💣 1. “쿼리가 왜 100번 나가죠?”

[!WARNING] N+1 문제란? 하버드 대학생(1)을 조회했는데, 학생들의 수강신청 목록(N)을 가져오기 위해 추가 쿼리가 N번 더 실행되는 현상입니다.

  • 결과: DB 부하 급증, 응답 속도 저하.

findAll() 하나 불렀을 뿐인데, 콘솔에 SQL이 폭포수처럼 쏟아집니다. 이것이 바로 N+1 문제입니다.

sequenceDiagram
    participant S as App Service
    participant DB as Database
    
    S->>DB: 1. findAll Teams (쿼리 1번)
    DB-->>S: 100 Teams
    
    loop For each Team
        S->>DB: 2. SELECT Members where team_id=? (쿼리 N번)
        DB-->>S: Members
    end
    
    Note right of S: 총 101번 쿼리 실행! 😱

1개의 쿼리(Team 조회)를 날렸는데, 결과 개수(N)만큼 추가 쿼리(Members 조회)가 나가는 현상입니다.

발생 원인

JPA는 기본적으로 연관된 엔티티를 진짜 쓸 때(getMembers()) 가져오려고 합니다(Lazy Loading). 그래서 루프를 돌면서 team.getMembers()를 호출할 때마다 SELECT * FROM member WHERE team_id = ?를 날리는 것입니다.


🛠️ 2. 해결책 3대장

2-1. Fetch Join (가장 확실함)

“가져올 때 한방에 Join해서 다 가져와!”

graph LR
    App[Service] --"1. Join Query"--> DB[(Database)]
    DB --"Teams + Members (한방에)"--> App
    
    style App fill:#e3f2fd
    style DB fill:#f3e5f5
SELECT t FROM Team t JOIN FETCH t.members
  • 장점: 쿼리 1방으로 끝남.
  • 단점: 페이징(Paging) 시 메모리 이슈 발생 가능. (DB에서 페이징 안 하고 다 퍼올려서 메모리에서 자름 😱)

2-2. @EntityGraph (간편함)

JPQL 짜기 귀찮을 때 애노테이션으로 해결.

@EntityGraph(attributePaths = {"members"})
List<Team> findAll();
  • 특징: LEFT OUTER JOIN을 사용합니다.

2-3. @BatchSize (in 쿼리)

“1개씩 가져오지 말고 100개씩 묶어서(in) 가져와.”

spring.jpa.properties.hibernate.default_batch_fetch_size: 100
SELECT * FROM member WHERE team_id IN (1, 2, 3, ... 100)
  • 장점: 페이징 문제 해결! Fetch Join과 다르게 데이터 뻥튀기가 없음.
  • 실무 꿀팁: 컬렉션 조회 + 페이징이 필요하면, Fetch Join 대신 Batch Size가 답입니다.

📊 3. 비교 요약

방법쿼리 수페이징 가능?데이터 중복권장 상황
Just Lazy1 + NOX단건 상세 조회
Fetch Join1X (List)O (Distinct 필요)목록 전체 조회
Batch Size1 + 1OX페이징 목록 조회

요약: Best Practice

[!TIP] 실무 JPA 최적화 공식:

  1. 기본은 Lazy Loading (Fetch=LAZY)으로 설정한다.
  2. 목록 조회(List)가 필요하면 Fetch Join으로 N+1을 잡는다.
  3. 페이징이 필요하면 default_batch_fetch_size(100~1000)를 켠다.
  1. 원인: Lazy Loading 때문에 루프 돌 때마다 쿼리가 나간다.
  2. 해결 1: 목록 조회는 Fetch Join이 기본.
  3. 해결 2: 페이징이 필요하면 default_batch_fetch_size를 켜라.