작은 fetch 전략 변경이 큰 쿼리 문제를 고쳤을 때
Eager vs Lazy: 그것이 문제로다
Hibernate를 쓰는 프로젝트에서 한 번쯤은 마주치는 질문이 있다. 연관 엔티티를 **즉시 로드(eager)**할 것인가, **지연 로드(lazy)**할 것인가?
Eager Loading: 엔티티를 조회할 때 연관된 엔티티도 함께 JOIN해서 가져온다. 데이터는 한 번의 쿼리로 다 올라오지만, 필요 없는 데이터까지 가져올 수 있다.
Lazy Loading: 연관 엔티티에 실제로 접근하는 시점에 추가 쿼리를 실행한다. 초기 쿼리는 가볍지만, Hibernate 세션(영속성 컨텍스트)이 살아 있는 동안에만 동작한다.
대부분의 JPA 튜토리얼은 lazy loading을 권장한다. 필요한 데이터만 쿼리하니까. 하지만 실제 운영 코드에서는 이 선택이 그렇게 단순하지 않다.
무엇이 문제였나
이 서비스에서는 사용자 엔티티에 역할(Role) 컬렉션이 연관되어 있었다. 기본 설정은 lazy loading이었다.
// 엔티티 구조 개념 예시
@Entity
public class User {
@Id
private Long id;
private String email;
private String name;
@ManyToMany(fetch = FetchType.LAZY) // 기본값
@JoinTable(name = "user_roles")
private Set<Role> roles;
// ...
}조회 자체는 빠르게 동작했다. 문제는 조회 이후에 발생했다.
| 구간 | 기존 동작 | 문제 |
|---|---|---|
| 엔티티 조회 시점 | 연관 데이터를 바로 읽지 않음 (lazy) | 표면상 빠르게 보인다 |
| 후속 알림/부가 로직 | user.getRoles()에 접근 | 트랜잭션 밖이라 LazyInitializationException 발생 |
핵심은 lazy loading 자체가 아니라 언제 접근하느냐였다. 트랜잭션 안에서는 자연스럽게 동작하던 연관 데이터 접근이, 경계 밖으로 나가자 런타임 예외로 바뀌었다.
LazyInitializationException이란
Hibernate는 엔티티를 로드할 때 프록시(Proxy) 객체를 만든다. lazy 설정된 컬렉션은 실제 데이터가 아니라 프록시만 들어 있다가, 접근 시점에 DB 쿼리를 실행한다. 이때 Hibernate 세션이 이미 닫혀 있으면 예외가 발생한다.
// 문제 상황 개념 예시
@Service
public class UserService {
@Transactional(readOnly = true)
public User findUser(Long id) {
return userRepository.findById(id); // roles는 프록시 상태
}
}
// 컨트롤러 또는 후속 로직에서 (트랜잭션 밖)
User user = userService.findUser(123L);
user.getRoles(); // LazyInitializationException!해결 방법들의 비교
이 문제를 해결하는 방법은 여러 가지가 있다.
Eager Loading
FetchType.EAGER: 엔티티 매핑에서 즉시 로드로 변경
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "user_roles")
private Set<Role> roles;- 장점: 가장 간단한 수정
- 단점: 이 엔티티를 조회하는 모든 쿼리에서 roles를 함께 가져옴
왜 eager loading으로 풀었나
이 서비스에서는 관계 크기가 작고 접근 패턴이 비교적 일정했다. 사용자당 역할 수는 보통 1~3개이고, 사용자를 조회하면 거의 항상 역할 정보도 함께 필요했다.
| 판단 기준 | 이 케이스의 상황 | 결정 |
|---|---|---|
| 연관 데이터 크기 | 사용자당 역할 1~3개 | 작다 → eager 부담 적음 |
| 접근 빈도 | 거의 항상 함께 사용 | 높다 → eager가 합리적 |
| 쿼리 복잡도 | 단순 1:N | 낮다 → 별도 fetch join 불필요 |
| 후속 로직 | 트랜잭션 밖에서 접근 | lazy 불가 → eager 필수 |
Fetch Join이나 Entity Graph가 더 세밀한 제어를 제공하지만, 이 경우에는 전체 모델을 다시 설계하기보다 매핑 한 줄 바꾸는 것이 가장 빠르고 안전한 해결책이었다.
N+1 문제는 없었나
Eager loading을 쓰면 당연히 나오는 질문이 N+1 문제다. 목록 조회에서 사용자 100명을 가져오면, 각 사용자의 roles를 가져오기 위해 추가 쿼리 100개가 발생할 수 있다.
-- N+1 문제 발생 패턴
SELECT * FROM users WHERE ...; -- 1번 쿼리
SELECT * FROM user_roles WHERE user_id = 1; -- N번 쿼리
SELECT * FROM user_roles WHERE user_id = 2;
SELECT * FROM user_roles WHERE user_id = 3;
...이 서비스에서 목록 조회는 별도의 DTO projection이나 batch fetch를 사용하고 있었기 때문에, eager loading이 N+1 문제로 직접 이어지지는 않았다. Hibernate의 @BatchSize를 설정해 두면 N+1을 IN 절로 묶을 수도 있다.
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "user_roles")
@BatchSize(size = 20) // 최대 20개씩 IN 절로 묶어서 조회
private Set<Role> roles;여기서 얻은 기준
이 경험에서 정리한 eager/lazy 선택 기준:
| 조건 | Eager | Lazy |
|---|---|---|
| 연관 크기 | 소량 (1~10건) | 대량 또는 가변적 |
| 접근 빈도 | 거의 항상 사용 | 가끔 사용 |
| 트랜잭션 경계 | 밖에서도 접근 필요 | 안에서만 접근 |
| 쿼리 패턴 | 단건 조회 중심 | 목록 조회 중심 |
작은 ORM 수정이 기록할 가치가 있을 때는, 코드 양보다 판단 기준이 재사용 가능할 때다. 트랜잭션 경계, 연관 데이터 크기, 후속 로직의 실행 타이밍을 같이 보지 않으면 왜 문제가 생겼는지 설명하기 어렵다.
다음 글에서는 이처럼 개별 병목을 잡기 전에, 여러 서비스에 걸친 요청 흐름을 Splunk REST API로 어떻게 추적했는지 이어서 본다.