8.3 KiB
JPA Fetch Strategy 예시
좋은 예시
예시 1. to-one 기본값도 명시적으로 LAZY로 바꾼다
@Entity
@Table(name = "sessions", schema = "auth")
public class Session {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "expires_at", nullable = false)
private OffsetDateTime expiresAt;
}
좋은 이유:
- JPA 기본값인 @ManyToOne EAGER에 기대지 않는다
- 세션 조회에서 항상 사용자 전체를 강제로 가져오지 않는다
- 필요한 경우 query 또는 entity graph에서만 user를 함께 가져오게 만들 수 있다
Jakarta Persistence는 @ManyToOne 기본 fetch가 EAGER라고 정의하고, Hibernate는 to-one EAGER 기본값을 그대로 쓰기보다 모든 association을 LAZY로 두는 편을 권장한다.
예시 2. 상세 조회의 to-one 연관은 join fetch로 가져온다
@Query("""
select s
from Session s
join fetch s.user u
where s.id = :id
""")
Optional<Session> findDetailById(@Param("id") Long id);
좋은 이유:
- 기본 매핑은 LAZY로 두고, 이 상세 조회에서만 user를 함께 가져온다
- to-one 연관 fetch join은 Hibernate가 권장하는 대표 패턴이다
- use case 단위로 eager 요구를 제어한다
Hibernate는 join fetch가 laziness를 override하여 같은 SQL join으로 데이터를 가져오게 하며, to-one association에 특히 적합하다고 설명한다.
예시 3. repository 메서드 단위 eager 요구는 EntityGraph로 선언한다
@Entity
@NamedEntityGraph(
name = "User.withRoles",
attributeNodes = {
@NamedAttributeNode("roles")
}
)
@Table(name = "users", schema = "auth")
public class User {
// ...
}
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(value = "User.withRoles", type = EntityGraph.EntityGraphType.FETCH)
Optional<User> findByEmail(String email);
}
좋은 이유:
- 정적 매핑을 EAGER로 바꾸지 않고, 특정 repository 메서드에서만 roles를 가져온다
- FETCH graph로 명시한 속성만 eager 범위에 넣는다
- entity graph를 fetch plan template로 사용하는 정석적인 방식이다
Jakarta Persistence는 fetchgraph와 loadgraph를 공식적으로 지원하고, Spring Data JPA는 repository method에서 @EntityGraph와 동적 attributePaths()를 지원한다.
예시 4. 여러 부모의 같은 LAZY 컬렉션 접근에는 BatchSize를 보조적으로 사용한다
@Entity
@Table(name = "users", schema = "auth")
public class User {
@Id
private Long id;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@BatchSize(size = 50)
private List<UserRole> roles = new ArrayList<>();
}
좋은 이유:
- 여러 사용자의 roles 컬렉션을 순차적으로 초기화할 때 round trip 수를 줄일 수 있다
- join fetch가 항상 적절하지 않은 경우의 보조 최적화다
- 기본 전략을 EAGER로 바꾸지 않는다
Hibernate는 @BatchSize가 여러 uninitialized association을 한 번에 가져오도록 도와주며, N+1보다 낫지만 대개 JOIN FETCH나 DTO projection이 더 좋다고 설명한다.
예시 5. 조회 전용 목록은 DTO projection으로 닫는다
public record UserSummary(Long id, String email, String status) {}
@Query("""
select new com.example.auth.user.UserSummary(u.id, u.email, u.status)
from User u
where u.deletedAt is null
order by u.id desc
""")
List<UserSummary> findActiveUserSummaries();
좋은 이유:
- 수정 목적이 아닌 조회에서 엔티티 그래프 전체를 관리하지 않는다
- 필요한 컬럼만 선택한다
- persistence context 부담을 줄이고 fetch 전략 고민도 단순화한다
Hibernate는 read-only transaction에는 DTO projection이 더 적합하고, 필요한 컬럼만 조회할 수 있어 persistence context 부담을 줄인다고 설명한다.
나쁜 예시
예시 1. to-one 기본값을 그대로 두어 암묵적 EAGER를 만든다
@Entity
@Table(name = "sessions", schema = "auth")
public class Session {
@Id
private Long id;
@ManyToOne
@JoinColumn(name = "user_id", nullable = false)
private User user;
}
나쁜 이유:
- @ManyToOne 기본값은 EAGER라서, 의도하지 않아도 user를 항상 가져오게 된다
- query별 fetch 정책을 세밀하게 제어하기 어려워진다
- JPQL에서 fetch join을 빠뜨리면 secondary select로 이어질 수 있다
Jakarta Persistence는 @ManyToOne 기본 fetch를 EAGER로 정의하고, Hibernate는 이런 EAGER 기본값을 피하라고 권장한다.
예시 2. 여러 컬렉션을 한 query에서 동시에 fetch join한다
@Query("""
select u
from User u
left join fetch u.roles
left join fetch u.sessions
left join fetch u.loginHistories
where u.id = :id
""")
Optional<User> findEverything(@Param("id") Long id);
나쁜 이유:
- 여러 to-many를 병렬 fetch join하면 Cartesian product가 발생할 수 있다
- row 폭발과 중복으로 성능이 매우 나빠질 수 있다
- “한 번에 다 가져오자”는 발상이 오히려 가장 위험하다
Hibernate는 여러 컬렉션/to-many를 병렬 fetch join하면 Cartesian product가 생겨 매우 나쁜 성능을 낼 수 있다고 명시한다.
예시 3. pagination query에 컬렉션 fetch join을 사용한다
@Query("""
select u
from User u
left join fetch u.roles
order by u.id desc
""")
Page<User> findAllWithRoles(Pageable pageable);
나쁜 이유:
- 컬렉션 fetch join과 pagination은 충돌하기 쉽다
- page boundary가 불안정해지고 row duplication 문제가 생길 수 있다
- 목록/페이지 조회는 다른 방식으로 설계해야 한다
Hibernate는 fetch join을 제한/페이징 query에서 보통 피해야 한다고 설명한다.
예시 4. 지연 로딩 문제를 presentation 계층에서 우연히 해결되길 기대한다
@Transactional(readOnly = true)
public User getUser(Long id) {
return userRepository.findById(id).orElseThrow();
}
// controller
@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable Long id) {
User user = userService.getUser(id);
return new UserResponse(
user.getId(),
user.getEmail(),
user.getRoles().stream().map(UserRole::getName).toList()
);
}
나쁜 이유:
- service 트랜잭션이 끝난 뒤 controller에서 lazy 컬렉션 접근을 시도한다
- 환경에 따라 LazyInitializationException 또는 우연한 추가 SQL에 의존하게 된다
- fetch plan 책임이 service/application 경계 밖으로 새어 나간다
Hibernate는 필요한 연관은 persistence context가 닫히기 전에 fetch해야 하며, 그렇지 않으면 LazyInitializationException이 발생한다고 설명한다.
예시 5. read-only 목록인데도 엔티티 전체를 억지로 로딩한다
@Query("""
select u
from User u
left join fetch u.department
left join fetch u.roles
where u.deletedAt is null
order by u.id desc
""")
List<User> findAllForAdminList();
나쁜 이유:
- 목록 화면에 필요한 필드보다 훨씬 많은 엔티티 상태를 가져오기 쉽다
- 관리 대상 엔티티 수와 SQL row 수가 불필요하게 커진다
- 이런 경우는 DTO projection이 더 적합할 가능성이 높다
Hibernate는 read-only transaction에서는 DTO projection이 더 적절하고, 필요한 컬럼만 선택할 수 있다고 설명한다.
예시 6. @BatchSize로 구조 문제를 덮는다
@Entity
public class User {
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@BatchSize(size = 1000)
private List<UserRole> roles;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@BatchSize(size = 1000)
private List<Session> sessions;
}
나쁜 이유:
- 왜 secondary select 구조가 필요한지 설명 없이 숫자만 크게 올린다
- batch fetching은 보조 최적화이지 1차 설계 수단이 아니다
- query 설계, DTO projection, entity graph 같은 더 직접적인 해법을 가릴 수 있다
Hibernate도 @BatchSize는 N+1보다 낫지만 대개 JOIN FETCH나 DTO projection이 더 좋은 선택이라고 설명한다.