JPA 컬렉션 N+1: fetch join이 막히는 세 지점과 batch_size

N+1 이야기는 대개 @ManyToOne으로 끝납니다. 회원을 조회하고 팀을 하나씩 더 읽는 그림이죠. 거기서는 fetch join을 걸면 해결됩니다.

문제는 반대쪽입니다. 팀을 조회하고 각 팀의 회원 목록을 읽는 경우, 그러니까 컬렉션 쪽은 fetch join이 세 군데에서 막힙니다. 페이징을 붙이면 경고가 뜨고, 컬렉션이 두 개면 예외가 나고, 억지로 통과시키면 DB가 만드는 행 수가 곱으로 늘어납니다.

전부 실제로 돌려서 쿼리 수를 셌습니다. Spring Boot 3.5.6 / Hibernate 6.6.29 / JDK 21 / H2 2.3 환경입니다.

측정 환경

팀 5개, 각 팀에 회원 4명과 프로젝트 3개를 넣었습니다. 회원 20명, 프로젝트 15개입니다.

@Entity
class Team {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
    String name;

    @OneToMany(mappedBy = "team", cascade = CascadeType.ALL)
    List<Member> members = new ArrayList<>();

    @OneToMany(mappedBy = "team", cascade = CascadeType.ALL)
    List<Project> projects = new ArrayList<>();
}

쿼리 수는 Hibernate의 generate_statistics를 켜고 getPrepareStatementCount()로 셌습니다.

spring.jpa.properties.hibernate.generate_statistics=true
spring.jpa.open-in-view=false

처음에 측정이 틀렸습니다

돌려보니 N+1이 재현되지 않았습니다. 팀 5개를 조회하고 회원 20명에 전부 접근했는데 쿼리가 1개에서 늘지 않더군요.

원인은 테스트 코드였습니다. @Transactional 안에서 데이터를 저장하고 바로 조회하니, 엔티티가 1차 캐시에 그대로 남아 있어서 DB에 다시 갈 일이 없었던 겁니다. 컨텍스트를 비우고 나서야 실제 조회 경로가 나왔습니다.

@BeforeEach
void setUp() {
    // ... 데이터 저장 ...
    em.flush();
    em.clear();     // 이게 없으면 N+1 이 재현되지 않는다
    stats.clear();
}

N+1을 재현하는 테스트를 짰는데 쿼리가 안 늘어난다면 대개 이 이유입니다.

컬렉션 N+1은 이렇게 나옵니다

@Query("select t from Team t")
List<Team> findPlain();
>>> [plain]  팀 목록 조회 직후: 1 / 팀 5개
>>> [plain]  회원 20명 접근 후: 6

1 + 5 = 6입니다. 회원이 20명이지만 쿼리는 21개가 아닙니다. 컬렉션을 초기화하는 쿼리가 팀마다 한 번씩 나가기 때문입니다. @ManyToOne 쪽에서 N이 왜 행 수가 아닌지는 Lazy Loading vs Eager Loading에 정리해 두었습니다. 먼저 보시면 이 글이 쉽습니다.

fetch join을 걸면 1개로 줄어듭니다.

@Query("select t from Team t join fetch t.members")
List<Team> findFetchMembers();
>>> [fetch join]  쿼리 1 / 반환된 Team 개수: 5

여기까지는 @ManyToOne과 같습니다. 문제는 지금부터입니다.

첫 번째 — 페이징을 붙이면 경고가 뜹니다

@Query(value = "select t from Team t join fetch t.members",
       countQuery = "select count(t) from Team t")
Page<Team> findFetchMembersPaged(Pageable pageable);

PageRequest.of(0, 2)로 2건만 요청했습니다.

WARN org.hibernate.orm.query : HHH90003004: firstResult/maxResults specified with
                               collection fetch; applying in memory
>>> [paging]  쿼리 2 / content=2 totalElements=5

결과는 2건으로 맞게 나옵니다. 그런데 경고를 보세요. applying in memory — DB에서 2건만 가져온 게 아니라 전부 읽어서 애플리케이션 메모리에서 잘랐습니다.

팀이 5개일 때는 아무 문제가 없습니다. 50만 개일 때는 50만 개를 전부 힙에 올리고 2개를 돌려줍니다. 개발 환경에서 잘 돌다가 운영에서 OOM이 나는 전형적인 경로가 이겁니다. 경고 로그라 조용히 지나가기도 쉽고요.

LIMIT을 붙일 수 없는 이유는 단순합니다. 조인 결과는 팀 하나가 여러 행으로 흩어져 있어서, SQL에서 2행을 자르면 팀 하나의 회원 목록이 잘려 나갑니다. Hibernate는 그걸 막으려고 전부 읽습니다.

두 번째 — 컬렉션이 두 개면 예외입니다

@Query("select t from Team t join fetch t.members join fetch t.projects")
List<Team> findFetchTwoBags();
>>> [two bags]  MultipleBagFetchException
>>> [two bags]  cannot simultaneously fetch multiple bags: [demo.Team.members, demo.Team.projects]

List로 매핑한 @OneToMany를 Hibernate는 bag으로 봅니다. 순서도 없고 중복도 허용하는 컬렉션이라, 두 개를 동시에 조인하면 어느 행이 어느 컬렉션 것인지 구분할 수 없습니다. 그래서 막아둔 겁니다.

검색하면 “List를 Set으로 바꾸면 된다”는 답이 제일 많이 나옵니다. 바꿔서 돌려봤습니다.

>>> [two bags]  성공. Team 5개

예외는 사라집니다. 그런데 DB가 만드는 행 수를 같이 재보면 이야기가 달라집니다.

>>> [cartesian]  team x member        = 20행
>>> [cartesian]  team x member x proj = 60행

팀 5 × 회원 4 × 프로젝트 3 = 60행입니다. 카티션 곱이 그대로 나옵니다. Set은 중복을 걷어내 보여줄 뿐, DB가 60행을 만들어 네트워크로 넘기는 건 그대로입니다.

지금은 60행이라 눈에 안 띕니다. 팀당 회원 100명, 프로젝트 50개면 팀 하나에 5,000행입니다. 예외가 사라졌다고 해결된 게 아닙니다.

세 번째 — @EntityGraph도 같은 제약입니다

@EntityGraph(attributePaths = {"members"})
@Query("select t from Team t")
List<Team> findEntityGraphMembers();
>>> [EntityGraph]  쿼리 1 / 반환된 Team 개수: 5

쿼리 수는 fetch join과 같습니다. 당연합니다. @EntityGraph는 JPQL을 대신 써주는 문법일 뿐 실행 계획은 같은 조인입니다.

그래서 페이징 문제도, 컬렉션 두 개 문제도 똑같이 겪습니다. fetch join이 싫어서 @EntityGraph로 갈아타는 건 해결이 아닙니다.

batch_size가 셋 다 풉니다

설정 한 줄입니다.

spring.jpa.properties.hibernate.default_batch_fetch_size=100

fetch join을 쓰지 않고, 원래의 LAZY 조회를 그대로 다시 측정했습니다.

>>> [batch]  팀 목록 조회 직후: 1
>>> [batch]  회원 20명 접근 후: 2
>>> [batch]  프로젝트 15개까지 접근 후: 3

컬렉션 두 개를 모두 읽었는데 쿼리가 3개입니다. 팀 목록 1번, 회원 한 번에 1번, 프로젝트 한 번에 1번.

실제로 나간 SQL을 보면 방식이 보입니다.

select m1_0.team_id, m1_0.id, m1_0.name
from member m1_0
where m1_0.team_id in (?,?,?,?,?, ... )

팀마다 따로 조회하는 대신 지금까지 모인 팀 ID를 IN 절로 묶습니다. 물음표가 100개인 게 눈에 띄는데, 팀은 5개뿐인데도 그렇습니다. Hibernate 6이 IN 절 크기를 배치 크기에 맞춰 채워서, 쿼리 문자열이 매번 달라지지 않게 만듭니다. 실행 계획 캐시가 재활용됩니다.

정리하면 이렇습니다.

방식 쿼리 수 페이징 컬렉션 2개 DB가 만드는 행
LAZY 그대로 6 정상 가능 최소
fetch join 1 메모리에서 처리 예외 20행 (2개면 60행)
@EntityGraph 1 메모리에서 처리 예외 fetch join과 같음
batch_size=100 3 정상 가능 최소

쿼리 수만 보면 fetch join이 이깁니다. 1개니까요. 그런데 나머지 세 칸이 전부 batch_size 쪽입니다.

distinct는 이제 필요 없습니다

select distinct t from Team t join fetch t.members. 컬렉션 fetch join을 다룬 글이면 거의 빠짐없이 나오는 문장입니다. 조인 때문에 팀이 4번씩 중복되니 걷어내라는 이야기죠.

Hibernate 6부터는 필요 없습니다. 붙이든 안 붙이든 결과가 같습니다.

>>> [fetch join]  반환된 Team 개수: 5
>>> [distinct]    반환된 Team 개수: 5
>>> [raw SQL]     조인 결과 행 수: 20

SQL은 여전히 20행을 돌려줍니다. 그런데 Hibernate가 엔티티 결과를 자동으로 중복 제거해서 List에는 5개만 담깁니다. Hibernate 5 시절에 쓰던 습관이 아직도 예제로 돌아다니는 겁니다.

중복 제거가 Hibernate 쪽에서 일어난다고 해서 distinct가 무해한 건 아닙니다. 실제로 나간 SQL을 찍어보면 그대로 실려 나갑니다.

-- distinct 없이
select t1_0.id, m1_0.team_id, m1_0.id, m1_0.name, t1_0.name
from team t1_0 join member m1_0 on t1_0.id = m1_0.team_id

-- distinct 붙이면
select distinct t1_0.id, m1_0.team_id, m1_0.id, m1_0.name, t1_0.name
from team t1_0 join member m1_0 on t1_0.id = m1_0.team_id

결과는 똑같은데 DB만 정렬이나 해시로 중복을 걸러내는 일을 더 합니다. 지금은 빼는 쪽이 낫습니다.

그래서 무엇을 쓰나

기본은 batch_size를 전역으로 켜두는 것입니다. default_batch_fetch_size를 100~1000 사이로 잡아두면 대부분의 N+1이 조용히 사라집니다. 코드를 건드리지 않아도 되고, 페이징과 컬렉션 개수 제약이 없습니다.

그 위에 fetch join은 단건 조회에 씁니다. 팀 하나를 상세 페이지에 뿌리는 경우처럼 페이징이 없고 컬렉션이 하나면 fetch join이 가장 적은 쿼리로 끝납니다.

@ManyToOne 쪽은 여전히 fetch join이나 @EntityGraph가 답입니다. bag이 아니라 단일 엔티티라 위의 제약이 전부 없습니다.

목록에 컬렉션 두 개 이상이 필요하면 fetch join으로 풀려고 하지 마세요. 하나만 fetch join하고 나머지는 batch_size에 맡기거나, 전부 batch_size로 가는 게 낫습니다.

마무리

컬렉션 fetch join은 페이징에서 전체를 메모리에 올리고, 두 개를 쓰면 MultipleBagFetchException이 나고, Set으로 우회하면 카티션 곱이 그대로 옵니다. @EntityGraph도 같은 조인이라 같은 제약을 받습니다.

default_batch_fetch_size 한 줄이면 셋 다 피하면서 쿼리 3개로 끝납니다.

그리고 N+1을 재현하는 테스트가 재현되지 않는다면 em.clear()를 확인해 보세요. 제가 여기서 한참 헤맸습니다.

서울·경기 아파트 실거래 주간 브리핑

국토교통부 실거래가 공개 데이터를 모아 서울·경기 관심 지역 17곳의 아파트 매매 거래를 매주 정리합니다. 거래 건수와 평당가 추이, 신고가 갱신 단지, 거래 위치 지도를 한 편에 담습니다. 무료입니다.

공개 데이터를 정리한 통계 정보이며 특정 단지의 매수·매도를 권유하지 않습니다.

이 주제를 더 깊게 보려면

인프런 고성능 JPA & Hibernate (High-Performance Java Persistence) (Vlad Mihalcea)

이 링크를 통해 수강 신청하면 운영자에게 소정의 수수료가 지급될 수 있습니다. 강의 가격은 달라지지 않습니다.

“JPA 컬렉션 N+1: fetch join이 막히는 세 지점과 batch_size”에 대한 1개의 생각

  1. 핑백: JPA 성능 최적화의 핵심: Lazy Loading(지연 로딩) vs Eager Loading(즉시 로딩) 완벽 비교와 실무 가이드 - loghubs

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤