Skip to Content
Backend작은 fetch 전략 변경이 큰 쿼리 문제를 고쳤을 때
💻 Backend2025년 7월 15일

작은 fetch 전략 변경이 큰 쿼리 문제를 고쳤을 때

#identity-platform#hibernate#jpa#performance#orm
Identity Platform · Series 4 · Diagnostic & Performance13 / 15Backend
트랜잭션 경계 밖에서 연관 엔티티를 읽을 때 생긴 문제를 fetch 전략으로 해결한 과정.

Eager vs Lazy: 그것이 문제로다

Hibernate를 쓰는 프로젝트에서 한 번쯤은 마주치는 질문이 있다. 연관 엔티티를 **즉시 로드(eager)**할 것인가, **지연 로드(lazy)**할 것인가?

Eager Loading: 엔티티를 조회할 때 연관된 엔티티도 함께 JOIN해서 가져온다. 데이터는 한 번의 쿼리로 다 올라오지만, 필요 없는 데이터까지 가져올 수 있다.

Lazy Loading: 연관 엔티티에 실제로 접근하는 시점에 추가 쿼리를 실행한다. 초기 쿼리는 가볍지만, Hibernate 세션(영속성 컨텍스트)이 살아 있는 동안에만 동작한다.

대부분의 JPA 튜토리얼은 lazy loading을 권장한다. 필요한 데이터만 쿼리하니까. 하지만 실제 운영 코드에서는 이 선택이 그렇게 단순하지 않다.

무엇이 문제였나

이 서비스에서는 사용자 엔티티에 역할(Role) 컬렉션이 연관되어 있었다. 기본 설정은 lazy loading이었다.

java
// 엔티티 구조 개념 예시 @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 세션이 이미 닫혀 있으면 예외가 발생한다.

java
// 문제 상황 개념 예시 @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!

해결 방법들의 비교

이 문제를 해결하는 방법은 여러 가지가 있다.

FetchType.EAGER: 엔티티 매핑에서 즉시 로드로 변경

java
@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개가 발생할 수 있다.

sql
-- 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 절로 묶을 수도 있다.

java
@ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "user_roles") @BatchSize(size = 20) // 최대 20개씩 IN 절로 묶어서 조회 private Set<Role> roles;

여기서 얻은 기준

이 경험에서 정리한 eager/lazy 선택 기준:

조건EagerLazy
연관 크기소량 (1~10건)대량 또는 가변적
접근 빈도거의 항상 사용가끔 사용
트랜잭션 경계밖에서도 접근 필요안에서만 접근
쿼리 패턴단건 조회 중심목록 조회 중심
eager loading은 무조건 좋은 선택이 아니다. 관계 크기가 커지면 다른 쿼리 비용 문제를 부를 수 있다. 이 변경은 “항상 eager loading 하자”가 아니라, 이 관계와 이 사용 맥락에서는 이게 더 낫다는 구체적인 판단으로 남겨야 했다.

작은 ORM 수정이 기록할 가치가 있을 때는, 코드 양보다 판단 기준이 재사용 가능할 때다. 트랜잭션 경계, 연관 데이터 크기, 후속 로직의 실행 타이밍을 같이 보지 않으면 왜 문제가 생겼는지 설명하기 어렵다.

다음 글에서는 이처럼 개별 병목을 잡기 전에, 여러 서비스에 걸친 요청 흐름을 Splunk REST API로 어떻게 추적했는지 이어서 본다.

Last updated on