본문 바로가기
Framework/Spring Boot

[Spring Boot] JPA N+1 문제의 완전한 종결 - Fetch Join의 함정과 EntityGraph, @BatchSize 심화 실무 튜닝 가이드

반응형

"어? 분명히 컨트롤러에서 teamRepository.findAll() 메서드 단 하나만 깔끔하게 호출했는데, 콘솔 창을 보니 SELECT 쿼리가 왜 101개나 미친 듯이 우수수 쏟아지는 거지?! 내 서버 해킹당했나?!" 스프링 데이터 JPA를 처음 접하는 1~2년 차 주니어 개발자들이 가장 먼저 마주하게 되는 거대한 절망의 벽. 그리고 백엔드 기술 면접관이 단골로 물어보지만, 대다수의 지원자가 "연관관계 매핑할 때 지연 로딩(LAZY)을 쓰면 해결되는 거 아니었나요?"라며 기초적인 헛소리를 하다가 광탈하게 만드는 최악의 성능 저하 원흉. 바로 전설적인 'N+1 문제(N+1 Problem)'입니다. 구글링을 해보면 수많은 기술 블로그들이 N+1 문제의 유일한 해결책으로 단순히 Fetch Join을 사용하라고만 적어놓고 글을 끝맺습니다. 하지만 실무의 거대한 데이터베이스 환경에서 데이터 볼륨(Volume)을 무시한 채 무지성으로 Fetch Join을 남발했다가는 'MultipleBagFetchException'이라는 에러가 터져 서버가 기절하거나, DB에 있는 100만 건의 데이터를 페이징 처리도 없이 한 번에 '인메모리(In-Memory)'로 싹 다 끌어올리다가 JVM 힙 메모리가 통째로 터져버리는 끔찍한 OOM(Out of Memory) 참사가 발생하게 됩니다.

이번 58단계 포스팅에서는 N+1의 근본적인 발생 원인부터 시작해, Fetch Join이 가지고 있는 치명적인 한계점들, @EntityGraph의 우아한 렌더링 방식, 그리고 컬렉션 페이징의 난제를 부숴버리는 궁극의 무기인 @BatchSize (default_batch_fetch_size)를 통한 실무 완벽 쿼리 튜닝 가이드까지 상세히 알아보겠습니다.

1. N+1 문제의 끔찍한 발생 원리 (지연 로딩의 치명적 함정)

축구팀을 나타내는 Team(부모)과 소속 선수를 나타내는 Member(자식) 엔티티가 1:N 양방향 관계를 맺고 있다고 가정해 봅시다. 성능 최적화를 위해 실무 가이드에서는 "무조건 모든 @OneToMany, @ManyToOne 연관관계를 FetchType.LAZY(지연 로딩)로 설정하라"고 배웁니다. 지연 로딩을 꼼꼼하게 걸어두고 teamRepository.findAll()을 호출하면, JPA는 DB에서 일단 Team 데이터 100개만 SELECT 해오고, 각 Team 내부에 List로 존재하는 Member 데이터들은 데이터베이스를 찌르지 않은 채 텅 빈 '가짜 객체(Proxy)' 껍데기로 채워 넣습니다. 여기까지는 완벽합니다. 무거운 쿼리가 단 1방(1)만 나갔으니까요.

JPA N+1 문제 발생 메커니즘과 Fetch Join, BatchSize 해결 원리를 비교하는 시각적 렌더링 다이어그램

대재앙은 그다음 렌더링 단계에서 벌어집니다. 프론트엔드(React/Vue)에 데이터를 JSON으로 내려주기 위해 TeamResponseDto로 변환하는 과정에서, for 루프를 돌면서 team.getMembers().get(0).getName()을 호출하여 선수의 이름을 꺼내려는 순간! 가짜 객체(Proxy)로 채워져 있던 Member를 진짜 데이터로 치환해야 하므로, JPA 영속성 컨텍스트가 다급하게 DB로 SELECT * FROM member WHERE team_id = ?라는 단건 쿼리를 날립니다. 만약 조회해 온 Team이 100개라면, 루프가 100번 돌면서 이 단건 추가 쿼리(N)가 정확히 100번 더 디스크로 날아갑니다.

결과적으로 맨 처음 Team을 조회했던 1번의 쿼리로 인해, 뒤이어 100번의 Member 조회 쿼리가 쓸데없이 파생되는 이 극악무도한 네트워크 병목 현상을 1+N 문제 (개발자들 사이에서는 흔히 N+1 문제라고 부름)라고 합니다. 초당 1만 번 호출되는 티켓팅 API라면 단숨에 100만 번의 쓰레기 쿼리 폭격이 DB로 쏟아지며 커넥션 풀이 마르고 서버가 심정지(Death) 하게 됩니다.

2. 첫 번째 무기: Fetch Join (패치 조인)과 @EntityGraph

이 N+1 문제를 해결하는 가장 고전적이고 확실한 정공법은 Fetch Join(패치 조인)입니다. JPQL 쿼리를 짤 때 "팀을 조회할 때 어차피 선수 데이터도 꺼내 쓸 거니까, 나중에 프록시 호출로 귀찮게 하지 말고 한방에 싹 다 조인(Join)해서 진짜 데이터로 가져와버려!"라고 명시하는 것입니다.

// 💡 프록시(Proxy)를 생성하지 않고, 한 방 쿼리(Inner/Outer Join)로 부모와 자식을 모두 긁어옵니다.
@Query("SELECT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembersFetchJoin();

이 쿼리를 날리면 DB 엔진 내부에서 실제 물리적인 JOIN이 일어나며, 단 1방의 쿼리로 Team과 Member의 모든 데이터가 영속성 컨텍스트(1차 캐시)에 예쁘게 올라옵니다. 이후 DTO 변환을 위해 루프를 아무리 뱅뱅 돌아도 추가 쿼리가 전혀 발생하지 않습니다. 혹여나 JPQL을 하드코딩하기 싫다면, 스프링 데이터 JPA가 제공하는 @EntityGraph(attributePaths = {"members"}) 애노테이션을 리포지토리 인터페이스에 달아주어도 내부적으로 동일한 Outer Fetch Join 쿼리가 나갑니다.

⚠️ 무지성 Fetch Join의 2가지 끔찍한 부작용

이제 N+1을 잡았다고 기뻐하는 주니어 개발자는 모든 Repository 메서드에 Fetch Join을 무차별적으로 도배하기 시작합니다. 하지만 조만간 두 가지 거대한 통곡의 벽에 머리를 부딪히게 됩니다.

  1. 컬렉션 2개 이상 패치 조인 불가 (MultipleBagFetchException): Team 엔티티 안에 members(선수단) 리스트뿐만 아니라 sponsors(스폰서) 리스트라는 컬렉션이 하나 더 들어있을 때, 한방 쿼리를 위해 JOIN FETCH t.members JOIN FETCH t.sponsors를 날리는 순간! 스프링 부트는 "야! 컬렉션 2개를 동시에 다중 패치 조인하면 테이블 데이터가 카테시안 곱(Cartesian Product)으로 뻥튀기되어서 폭발해버려! 절대 안 돼!"라며 자비 없이 MultipleBagFetchException을 뱉어버립니다.
  2. 🔥 페이징(Paging) 불가 및 메모리 폭발 (OOM): 실무 게시판의 핵심인 페이징 처리를 위해, 1:N 관계를 패치 조인한 쿼리에 Pageable (Limit, Offset) 파라미터를 던져 페이징 조회를 시도해 봅니다. 쿼리가 잘 나갈까요? JPA는 "1:N 조인을 하면 자식 수만큼 데이터 row가 뻥튀기되기 때문에 DB 쿼리(Limit) 단에서 페이징을 할 수가 없네? 일단 Limit을 빼고 DB에서 100만 건 데이터를 다 긁어와서 자바(WAS) 메모리에 몽땅 올린 다음에, 내 메모리 안에서 자바 코드로 페이징 처리해야지!"라는 미친 짓을 저지릅니다. 이때 콘솔 창에는 'firstResult/maxResults specified with collection fetch; applying in memory!'라는 시뻘건 경고문이 뜨며, 조만간 서버 힙 메모리가 가득 차서(OOM) 톰캣 프로세스가 펑 하고 죽어버립니다.

3. 궁극의 아키텍처 해결책: @BatchSize (In 쿼리 튜닝)

다중 컬렉션을 두 개 이상 한꺼번에 가져오면서, 완벽하게 페이징(Paging)까지 DB 레벨에서 처리해야 하는 숨 막히는 딜레마. 이 실무 아키텍처의 난제를 해결하는 유일하고도 가장 아름다운 방법이 바로 Hibernate가 제공하는 default_batch_fetch_size (글로벌 배치 사이즈) 설정입니다.

프로젝트의 application.yml 파일 최상단 글로벌 설정에 아래 마법의 한 줄을 추가합니다.

spring:
  jpa:
    properties:
      hibernate:
        # 💡 지연 로딩 발생 시 지정된 숫자만큼 IN 쿼리로 모아서 묶어와라!
        default_batch_fetch_size: 1000 

이렇게 글로벌 세팅을 해 두고, 오히려 골칫덩어리였던 패치 조인(Fetch Join) 쿼리를 아예 빼버립니다. 그저 teamRepository.findAll(pageable)로 아무 Join 없는 평범한 지연 로딩 조회를 합니다 (페이징 쿼리 1방 발생). 이후 DTO로 변환하기 위해 루프를 도는 순간, 기적이 일어납니다. JPA는 "어? 지금 1번 팀의 Member, 2번 팀의 Member... 100번 팀의 Member를 루프 돌 때마다 각각 단건 쿼리로 가져오려니 예전처럼 N+1 쿼리가 발생하겠네? 잠깐, 글로벌 설정에 배치가 1000 사이즈로 설정되어 있잖아? 멍청하게 단건 쿼리 100번 날리지 말고 한 번에 ID를 수집(Collection)해서 모아서 가져오자!"라고 똑똑하게 내부적으로 연산을 수행합니다.

결과적으로 100번의 추가 쿼리가 콘솔에 난사되는 대신, SELECT * FROM member WHERE team_id IN (1, 2, 3, ... 100) 이라는 거대한 IN 쿼리 단 1방이 DB로 날아가며 100개 팀의 회원 데이터를 한꺼번에 몽땅 긁어옵니다. 쿼리가 1+N 방에서 1+1 방으로 마법처럼 획기적으로 줄어들었으며, Join으로 인한 데이터 뻥튀기도 전혀 없으므로 페이징(Limit, Offset)도 DB 단에서 완벽하게 작동합니다. 또한 MultipleBagFetchException도 신경 쓸 필요가 없습니다. 실무 환경에서는 이 batch_fetch_size를 너무 크지도, 작지도 않은 100 ~ 1000 사이의 값으로 세팅해 두는 것이 국룰이자 반드시 적용해야 할 필수 아키텍처입니다.

정리하며

이번 58단계 포스팅에서는 모든 백엔드 개발자를 고통에 빠뜨리는 JPA N+1 문제의 근원적 동작 원리부터 시작해, 해결책인 양 포장되어 있지만 치명적인 페이징 및 MultipleBagFetchException이라는 무서운 함정을 숨기고 있는 Fetch Join, 그리고 이를 IN 쿼리 한 방으로 우아하게 해결해 버리는 글로벌 default_batch_fetch_size 설정의 기적 같은 마법까지 뼈 때리게 파헤쳐 보았습니다. JPA의 프록시 개념과 엔티티 연관관계를 자유자재로 다루며 실무 쿼리를 최적화하는 이 능력이 바로 1년 차 주니어와 고액 연봉의 시니어 아키텍트를 가르는 가장 명확한 기술적 기준선입니다. 자, 이렇게 JPQL 성능 튜닝은 끝났습니다. 그런데 만약 검색 조건이 10개가 넘고 사용자가 체크박스에 뭘 입력할지 모르는 미친 듯이 복잡한 '동적 쿼리(Dynamic Query)'를 짜야한다면? JPQL을 String으로 더하기(+) 연산하면서 밤을 새우고 NullPointerException을 맞아야 할까요?

다음 59단계 포스팅에서는 "QueryDSL 심화: 복잡한 동적 쿼리 세팅, 서브 쿼리(Sub-Query) 작성법, 그리고 Tuple(튜플) 추출과 DTO 프로젝션 성능 최적화의 끝판왕 가이드"에 대해 아주 치밀하게 딥다이브 해보겠습니다!

반응형