JPA를 쓰다 보면 거의 반드시 한 번은 만나는 문제가 있다. 바로 N+1 쿼리 문제다. 개발 초기에는 괜찮다가, 데이터가 많아지면서 갑자기 수백, 수천 개의 쿼리가 DB로 날아가는 걸 보고 당황하게 된다.
이 글에서는 N+1 문제가 왜 생기는지 보고, Fetch Join, EntityGraph, Batch Size 세 가지 해결 방법을 실무 관점에서 비교한 뒤, 어떤 상황에서 어떤 방법을 쓰는지 정리한다.
N+1 문제는 1:N 관계에서 쿼리가 목록 개수만큼 늘어나는 문제다
게시글 하나에 댓글이 여러 개 딸린 구조를 예로 든다
N+1 문제는 주로 1:N 관계에서 생긴다. DB에는 게시글을 담는 post 테이블과 댓글을 담는 comment 테이블이 따로 있고, 댓글 행마다 post_id 컬럼에 “나는 몇 번 게시글의 댓글이다”가 적혀 있다고 하자(외래 키). 그러면 게시글(Post) : 댓글(Comment) = 1 : N 관계가 된다.
@Entity
class Post {
@Id
private Long id;
private String title;
@OneToMany(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private List<Comment> comments;
}
한 줄씩 따라가기. @Entity는 이 클래스가 DB 테이블과 짝이라고 JPA에 알려주는 애너테이션(@로 시작하는 표시)이다 — 이렇게 테이블 한 줄을 자바 클래스로 옮겨 그린 것을 Entity라고 부른다. @Id는 그 테이블에서 행 하나하나를 구별해 주는 값, 즉 PK가 어떤 필드인지 표시한다. @OneToMany(fetch = FetchType.LAZY)는 Post 하나에 Comment가 여러 개 딸려 있고(1:N), 그 댓글 목록을 조회 시점에 바로 가져오지 않고 실제로 쓰는 순간에 가져오겠다는 뜻이다 — 이 방식을 지연 로딩이라고 부른다. @JoinColumn(name = "post_id")는 “어느 댓글이 이 게시글 것인지는 comment 테이블의 post_id 컬럼을 보고 가려라”는 뜻이다 — 그래서 JPA가 이 게시글의 id와 post_id가 같은 댓글을 골라 comments에 채운다(Jakarta Persistence JoinColumn 문서: 단방향 OneToMany에서 외래 키는 대상 Entity의 테이블에 있다). 아래 예시들에서는 이 줄을 생략했지만 같은 연결이 있다고 보면 된다. Comment 클래스와 getComments() 같은 getter는 짧게 보이려고 생략했다.
게시글 10개를 돌면서 댓글을 쓰면 쿼리가 11번 나간다
// 게시글 10개 조회
List<Post> posts = postRepository.findAll();
// 반복문에서 댓글 사용
for (Post post : posts) {
System.out.println(post.getComments().size());
}
한 줄씩 따라가기. postRepository는 DB에서 게시글을 가져오는 창구 역할을 하는 Repository다. 어떻게 만드는지는 아래 EntityGraph 절에서 보고, 여기서는 Spring이 만들어서 이 코드가 있는 클래스의 필드에 넣어 준 객체(의존성 주입)라고만 생각하면 된다. findAll()을 부르면 Repository가 대신 SQL을 만들어 DB에 보내고, 그 결과를 Post 객체 목록으로 바꿔 돌려준다. 그다음 반복문에서 post.getComments()로 댓글 목록을 꺼내 .size()를 부르는 순간, 앞서 LAZY로 설정해 둔 댓글 목록을 그제서야 가져오는 쿼리가 각 게시글마다 하나씩 추가로 나간다.
getter 하나 불렀을 뿐인데 어떻게 쿼리가 나갈까. JPA는 규칙(인터페이스)이고, 그 규칙을 실제 코드로 채운 구현체가 Hibernate다. Hibernate는 조회할 때 comments 칸에 평범한 ArrayList가 아니라 PersistentBag이라는 자기 컬렉션을 넣어 둔다. 이 컬렉션은 아직 내용이 비어 있다가, size()처럼 내용을 읽는 메서드가 처음 불리면 그때 DB에서 댓글을 채워 온다(Hibernate 소스 collection/spi/PersistentBag.java의 size() → AbstractPersistentCollection.readSize()).
실행되는 쿼리:
- 1번째:
SELECT * FROM post;—post테이블에서(FROM post) 모든 컬럼(*)을 가진 행을 전부 가져온다. 게시글 10개가 온다 - 2~11번째: 각 게시글마다
SELECT * FROM comment WHERE post_id = ?—comment테이블에서post_id가 그 게시글 id인 행만(WHERE) 가져온다.?자리에는 실행할 때 게시글 id(1, 2, 3, …)가 하나씩 들어간다 - 총 11번의 쿼리 = 1 + 10 = 1 + N
데이터가 1000개라면 1001번의 쿼리가 실행된다. 이것이 N+1 문제의 정체다.
지연 로딩이 연관 데이터를 객체마다 따로 가져오기 때문에 생긴다
이 글에서 “연관 데이터”는 한 Entity에 관계로 이어진 다른 Entity, 여기서는 게시글에 딸린 댓글 목록(comments)을 말한다.
지연 로딩은 값을 쓰는 순간에 쿼리를 보낸다
@OneToMany는 fetch를 따로 적지 않아도 기본값이 FetchType.LAZY다(JPA 규칙의 현재 이름인 Jakarta Persistence의 OneToMany.java에 FetchType fetch() default FetchType.LAZY;라고 적혀 있다). 위 코드에서 LAZY를 적은 건 눈에 보이게 하려는 것이고, 아래 Batch Size 예시처럼 fetch를 생략해도 지연 로딩이 된다.
Post post = postRepository.findById(1L).orElseThrow();
System.out.println(post.getTitle()); // 이 시점: Post만 조회 (댓글 X)
post.getComments().size(); // 이 순간: 댓글 조회 쿼리 실행!
한 줄씩 따라가기. findById(1L)은 id가 1인 게시글을 찾는다. 없을 수도 있어서 Post를 바로 돌려주지 않고 Optional이라는 상자에 담아 돌려준다. .orElseThrow()는 상자가 비어 있으면 예외를 던지고, 차 있으면 안의 Post를 꺼낸다. 제목을 읽을 때는 게시글 쿼리만 나가 있고, getComments().size()를 부르는 순간 댓글 쿼리가 나간다.
JPA는 세 번의 호출이 같은 목록에서 나왔다는 걸 모른다
JPA는 각 객체의 연관 데이터를 독립적으로 처리한다.
post1.getComments() // "1번 게시글의 댓글 필요" → SELECT 쿼리
post2.getComments() // "2번 게시글의 댓글 필요" → SELECT 쿼리
post3.getComments() // "3번 게시글의 댓글 필요" → SELECT 쿼리
JPA는 이 세 개의 호출이 같은 맥락이라는 걸 모른다. 각각을 별개의 요청으로 처리한다. 따로 알려 주지 않으면 앞으로 필요할 데이터를 미리 예측해 한 번에 가져오지 못한다. 아래 세 방법은 전부 이 “따로 알려 주기”의 서로 다른 모양이다.
N+1 문제는 세 가지 방법으로 푼다
방법 1. Fetch Join은 SQL JOIN으로 한 번에 가져온다
JOIN은 두 테이블을 연결해 쿼리 한 번으로 같이 조회하는 SQL 문법이다. Fetch Join은 이걸 써서 게시글과 댓글을 한 번에 가져온다.
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("SELECT p FROM Post p JOIN FETCH p.comments")
List<Post> findAllWithComments();
}
한 줄씩 따라가기. 이 메서드는 Repository 인터페이스 안에 몸체 없이 선언만 한다. 구현은 내가 쓰지 않고 Spring Data가 대신 만들어 실행한다(인터페이스 자체는 아래 EntityGraph 절에서 따라간다). @Query는 Spring Data JPA(Spring Data를 JPA와 묶어 쓰는 것)에게 이 메서드는 직접 적은 조회문으로 실행하라고 알려주는 표시다. 안에 적은 SELECT p FROM Post p JOIN FETCH p.comments는 SQL이 아니라 JPQL이다 — 테이블이 아니라 Post라는 자바 클래스를 대상으로 적는다. FROM Post p의 p는 “Post를 앞으로 p라고 부르겠다”는 짧은 이름이라, SELECT p는 게시글 객체를, p.comments는 그 게시글의 comments 필드를 가리킨다. JOIN FETCH는 지연 로딩으로 나중에 따로 가져오지 않고 join fetch로 댓글까지 쿼리 한 번에 같이 가져오라는 뜻이다.
동작:
SELECT p.*, c.*
FROM post p
INNER JOIN comment c ON p.id = c.post_id;
post p, comment c의 p, c는 테이블을 짧게 부르는 별칭이다. INNER JOIN comment c ON p.id = c.post_id는 “게시글의 id와 댓글의 post_id가 같은 것끼리 짝지어 한 줄로 붙여라”는 뜻이고, 그렇게 붙인 줄에서 게시글 컬럼(p.*)과 댓글 컬럼(c.*)을 모두 가져온다. INNER JOIN은 짝이 있는 줄만 남긴다.
결과는 이런 모양이 된다. 게시글 1번에 댓글이 3개라면 짝이 3개라서 3줄이 나온다.
p.id | p.title | c.id | c.post_id
1 | 첫 글 | 11 | 1
1 | 첫 글 | 12 | 1
1 | 첫 글 | 13 | 1
장점:
- 쿼리 1번으로 해결 (가장 빠름)
단점:
- 댓글이 없는 게시글은 조회되지 않는다. INNER JOIN은 짝이 있는 줄만 남기는데, 댓글이 없으면 짝이 없다
- 위 표처럼 게시글에 댓글이 10개면 게시글 데이터가 10줄에 되풀이되어 온다
- DB에서 페이징(결과를 20개씩 끊어 받기)을 할 수 없다. 줄이 댓글 단위로 늘어나 있어서, DB에게 “20줄만”이라고 하면 게시글 20개가 아니라 댓글 20줄이 잘린다. 그래서 Hibernate는 DB에서 끊지 않고 전부 받아 온 뒤 메모리에서 끊는다(아래 「Fetch Join과 페이징을 같이 쓰면 메모리에서 페이징한다」). 짝 없는 게시글도 남기는 LEFT OUTER JOIN으로 바꿔도, 줄이 댓글 단위인 건 같아서 마찬가지다
방법 2. EntityGraph는 애너테이션 하나로 같은 일을 한다
public interface PostRepository extends JpaRepository<Post, Long> {
// Spring Data가 이름으로 만들어 주는 메서드에 바로 적용
@EntityGraph(attributePaths = {"comments"})
List<Post> findAll();
@EntityGraph(attributePaths = {"comments"})
List<Post> findByTitleContaining(String keyword);
@EntityGraph(attributePaths = {"comments"})
Optional<Post> findById(Long id);
}
한 줄씩 따라가기. PostRepository extends JpaRepository<Post, Long>은 “이 Repository는 Post Entity를 다루고, 그 PK 타입은 Long이다”라는 선언이다. 구현 클래스는 내가 쓰지 않는다 — Spring Data가 메서드 이름과 애너테이션을 읽어 쿼리를 대신 만들어 실행한다. 그래서 앞에서 postRepository.findAll()을 바로 부를 수 있었다. findAll()과 findById()는 JpaRepository에 이미 있는 메서드인데, 여기서 다시 선언한 건 그 위에 @EntityGraph를 붙이기 위해서다. findByTitleContaining(String keyword)는 이름 규칙대로 “제목(Title)에 keyword가 들어 있는(Containing) 게시글”을 찾는 쿼리가 된다. Spring Data JPA 문서에 따르면 Containing은 SQL의 LIKE 조건으로 바뀌고, 넘긴 값 앞뒤에 “아무 글자나 몇 글자든”을 뜻하는 %가 붙는다. findById가 Post가 아니라 Optional<Post>를 돌려주는 건 그 id의 게시글이 없을 수도 있어서다.
@EntityGraph(attributePaths = {"comments"})는 이 메서드로 조회할 때 comments까지 처음부터 같이 가져오라고 JPA에 지정하는 애너테이션이다. @Query에 JPQL을 직접 적지 않고도 Fetch Join과 비슷한 효과를 낸다. 다만 아래 ‘동작’처럼 LEFT OUTER JOIN으로 나간다. LEFT JOIN은 짝이 없는 게시글도 남기고 댓글 칸을 비워 두므로, 댓글 없는 게시글도 조회된다.
동작:
SELECT p.*, c.*
FROM post p
LEFT OUTER JOIN comment c ON p.id = c.post_id;
장점:
@Query없이, Spring Data가 이름으로 만들어 주는 메서드에 그대로 붙일 수 있다- 코드가 짧다
- 댓글이 없는 게시글도 조회된다 (LEFT OUTER JOIN)
단점:
- 결국 JOIN이라 Fetch Join과 마찬가지로 DB에서 페이징을 못 한다
- 게시글이 댓글 수만큼 되풀이되는 중복 문제도 같다
방법 3. Batch Size는 IN 쿼리로 묶어서 가져온다
@Entity
class Post {
@Id
private Long id;
private String title;
@OneToMany
@BatchSize(size = 10) // 핵심!
private List<Comment> comments;
}
한 줄씩 따라가기. @OneToMany에 fetch를 적지 않았으니 기본값인 지연 로딩이다. @BatchSize(size = 10)는 댓글을 게시글마다 한 건씩 따로 조회하지 않고, 설정한 수(여기서는 10)만큼 게시글 id를 묶어 IN 쿼리 한 번으로 가져오라는 뜻이다. 묶는 단위는 댓글 수가 아니라 게시글 수다. 게시글이 20개면 댓글 쿼리가 20번이 아니라 2번(게시글 10개씩 묶어서)만 나간다. 지연 로딩이라는 점은 그대로라서, 이 묶음 쿼리도 댓글을 처음 쓰는 순간에 나간다.
첫 게시글의 댓글을 쓰는 순간 나머지 9개 게시글 id는 어디서 알까. 앞의 findAll()로 게시글들이 이미 조회되어 있고, 그 게시글들의 comments는 아직 비어 있다. Hibernate는 이렇게 “아직 안 채운 컬렉션”의 게시글 id를 따로 적어 두었다가, 하나를 채울 때 거기서 size만큼 꺼내 같이 묶는다(Hibernate 소스 engine/spi/BatchFetchQueue.java의 getCollectionBatch).
동작:
-- 1번째: 게시글 조회
SELECT * FROM post;
-- 2번째: 게시글 10개의 댓글을 한 번에 조회
SELECT * FROM comment
WHERE post_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
post_id IN (1, 2, …, 10)은 “post_id가 이 목록 중 하나인 댓글”이라는 뜻이다. 게시글 10개의 댓글이 쿼리 한 번에 온다.
장점:
- 페이징이 된다. 게시글 쿼리에는 JOIN이 없어서 줄이 게시글 단위 그대로이고, DB가 게시글 20개를 정확히 끊을 수 있다
- 댓글이 없는 게시글도 조회된다
- 게시글이 되풀이되는 중복 줄이 없다
- 쿼리 수가 게시글 수만큼이 아니라 게시글 수의 1/size만큼만 는다. 게시글 1000개면 N+1은 1001번, size 10이면 1 + 100번이다
단점:
- 쿼리가 1번으로 끝나지 않는다. 게시글 1번 + 댓글 (게시글 수 ÷ size, 올림)번이다. 게시글 20개, size 10이면 1 + 2 = 3번이다 (Fetch Join보다는 느림)
세 방법은 쿼리 횟수 · 페이징 · 중복 데이터에서 갈린다
| 방법 | 쿼리 횟수 | 페이징 | 중복 데이터 | 코드 간결도 |
|---|---|---|---|---|
| Fetch Join | 1번 | 불가 (메모리에서) | 있음 | 중간 |
| EntityGraph | 1번 | 불가 (메모리에서) | 있음 | 우수 |
| Batch Size | 1 + (게시글 수 ÷ size)번 | 가능 | 없음 | 우수 |
상황마다 고르는 방법이 다르다
페이징이 필요한 목록 조회는 Batch Size를 쓴다
추천: Batch Size
이유: 게시글은 DB에서 페이징, 댓글은 게시글 10개씩 묶어 조회
사용 예:
// Repository
public interface PostRepository extends JpaRepository<Post, Long> {
Page<Post> findAll(Pageable pageable); // 게시글은 페이징됨
}
// Service - Batch Size 설정으로 댓글은 게시글 10개씩 IN 쿼리
List<Post> posts = postRepository.findAll(PageRequest.of(0, 20)).getContent();
posts.forEach(post -> System.out.println(post.getComments().size()));
// 쿼리 1개: 게시글 20개 조회
// 쿼리 2개: 댓글 IN 쿼리 (게시글 10개씩 묶어서 2번)
// 합계 3번
한 줄씩 따라가기. Pageable은 몇 번째 페이지를 몇 개씩 달라는 조건을 담은 타입이고, Page<Post>는 그 조건대로 조회한 결과를 담는 타입이다. // Service는 이 코드가 Repository를 불러 실제 일을 처리하는 Service 쪽에 있다는 표시다. PageRequest.of(0, 20)은 “0번째 페이지를 20개씩 달라”는 조건을 만들고, .getContent()는 돌아온 Page<Post>에서 게시글 목록(List<Post>)만 꺼낸다. posts.forEach(post -> ...)는 목록의 게시글을 하나씩 post에 담아 화살표 뒤를 실행하라는 뜻이다 — for (Post post : posts) { ... }와 같고, 이렇게 짧게 적는 문법을 람다라고 한다. 게시글만 Pageable로 페이징하고 댓글은 @BatchSize 설정대로 따로 묶어서 가져오기 때문에, 둘을 함께 써도 페이징이 깨지지 않는다.
게시글 하나와 댓글 전부를 보는 상세 조회는 EntityGraph를 쓴다
추천: EntityGraph
이유: 페이징 불필요, 코드 간결
사용 예:
@EntityGraph(attributePaths = {"comments"})
Optional<Post> findById(Long id);
게시글이 하나뿐이라 페이징할 일이 없고, 게시글이 댓글 수만큼 되풀이되는 것도 한 건 분량이라 부담이 작다.
성능이 가장 중요한 조회는 Fetch Join을 쓴다
추천: Fetch Join
이유: 쿼리 1번으로 최고 성능
사용 예:
@Query("SELECT p FROM Post p JOIN FETCH p.comments")
List<Post> findAllWithComments();
Fetch Join은 1:N 컬렉션 하나에만 건다. 컬렉션 두 개를 한꺼번에 Fetch Join하면 안 되는 이유는 아래 「1:N 관계가 둘 이상이면 Fetch Join은 하나만 건다」에 있다.
세 방법을 쓸 때 걸리는 함정이 있다
즉시 로딩(EAGER)은 피한다
@OneToMany(fetch = FetchType.EAGER) // 절대 금지!
즉시 로딩은 지연 로딩의 반대다. 게시글을 조회할 때마다 댓글이 필요하든 아니든 무조건 같이 가져오기 때문에 성능이 나빠진다.
Fetch Join과 페이징을 같이 쓰면 메모리에서 페이징한다
// 위험한 코드
@Query("SELECT p FROM Post p JOIN FETCH p.comments")
Page<Post> findAllWithComments(Pageable pageable);
DB에서 페이징하면 DB가 필요한 20줄만 골라 보낸다. 컬렉션 Fetch Join에서는 앞에서 본 대로 줄이 댓글 단위라 DB에서 끊을 수 없어서, Hibernate는 조건에 맞는 줄을 전부 받아 자바 메모리에 올린 뒤 그 안에서 20개를 고른다. Hibernate 소스에도 이때 남기는 경고가 있다 — firstResult/maxResults specified with collection fetch; applying in memory(query/QueryLogging.java). firstResult는 몇 번째 결과부터, maxResults는 몇 개까지 — 즉 페이징 값이고, 문장 전체는 “컬렉션 Fetch Join에 페이징이 걸려서 메모리에서 적용한다”는 뜻이다. 게시글이 10만 개면 10만 개와 그 댓글을 전부 메모리에 올리므로, 데이터가 많으면 심각한 성능 문제가 생긴다.
Fetch Join은 게시글을 댓글 수만큼 되풀이해 돌려준다
아래는 같은 이름의 메서드를 세 가지 모양으로 비교한 것이다. 한 인터페이스에 같은 이름·같은 매개변수의 메서드를 둘 이상 둘 수는 없으니, 실제로는 하나만 고른다.
// 문제: 댓글 10개가 있으면 게시글이 10번 중복으로 조회됨
@Query("SELECT p FROM Post p JOIN FETCH p.comments")
List<Post> findAllWithComments();
// 해결 1: JPQL의 DISTINCT 사용
@Query("SELECT DISTINCT p FROM Post p JOIN FETCH p.comments")
List<Post> findAllWithComments();
// 해결 2: Set 사용으로 중복 제거
Set<Post> posts = new HashSet<>(findAllWithComments());
한 줄씩 따라가기. 첫 번째는 앞의 표처럼 게시글 1번이 댓글 수만큼 목록에 여러 번 들어 있다. 해결 1의 DISTINCT는 “같은 것은 한 번만”이라는 뜻이고, 여기서는 JPQL에 적어 같은 게시글을 한 번만 돌려받는다. 해결 2는 목록을 Set에 옮겨 담아 중복을 없앤다. DB에서는 게시글 1번 줄이 여러 개 오지만, JPA는 같은 id의 게시글을 영속성 컨텍스트에 객체 하나로만 두고 다시 나오면 그 객체를 그대로 쓴다. 그래서 목록에 되풀이된 게시글 1번은 같은 Post 객체 하나를 여러 번 가리키는 것이고, equals를 따로 정의하지 않아도 Set이 같은 객체로 알아보고 하나만 남긴다(==와 equals).
1:N 관계가 둘 이상이면 Fetch Join은 하나만 건다
@Entity
class Post {
@OneToMany
private List<Comment> comments; // 1:N
@OneToMany
private List<Tag> tags; // 1:N
}
// 위험한 코드 - 곱셈 문제 발생!
@Query("SELECT p FROM Post p " +
"JOIN FETCH p.comments " +
"JOIN FETCH p.tags")
List<Post> findAllWithAll();
예시: 게시글 1개가 댓글 10개, 태그 5개를 가지고 있다면
- 예상: 1개 게시글 조회
- 실제: 1 × 10 × 5 = 50개의 중복 행 발생!
왜 곱이 될까. 댓글을 붙이면 게시글 1줄이 10줄이 된다. 그 10줄 각각에 다시 태그 5개를 짝지으면 줄마다 5줄씩 늘어나 50줄이 된다. JOIN을 한 번 할 때마다 줄 수가 짝의 개수만큼 곱해진다.
게다가 이 예시처럼 두 컬렉션이 모두 List이면 Hibernate는 이 쿼리를 실행하지 않고 MultipleBagFetchException을 던진다(Hibernate 소스 loader/MultipleBagFetchException.java, 메시지 cannot simultaneously fetch multiple bags). bag은 여기서 Hibernate가 List 컬렉션을 부르는 이름이고, 메시지는 “bag 여러 개를 한 번에 가져올 수 없다”는 뜻이다. Set으로 바꾸면 어떻게 되는지는 용어 페이지에 있다 — 예외는 사라지지만 곱해진 줄은 그대로 온다.
해결책:
// 방법 1: Batch Size 조합 (권장)
@Entity
class Post {
@OneToMany
@BatchSize(size = 10)
private List<Comment> comments;
@OneToMany
@BatchSize(size = 10)
private List<Tag> tags;
}
// 방법 2: Fetch Join은 하나만, 나머지는 Batch Size
// tags 필드에는 위처럼 @BatchSize(size = 10)를 붙여 둔다
@Query("SELECT p FROM Post p JOIN FETCH p.comments")
List<Post> findAllWithComments();
방법 1은 두 컬렉션 모두 JOIN하지 않고 게시글 id를 묶어 따로 가져온다. 방법 2는 댓글만 Fetch Join으로 한 번에 가져오고, 태그는 tags에 붙인 @BatchSize대로 쓰는 순간 묶어서 가져온다.
EntityGraph도 관계 여러 개를 한꺼번에 걸지 않는다
// 안 A — 주의: 여러 관계를 동시에 FETCH하면 중복 문제 발생 가능
@EntityGraph(attributePaths = {"comments", "tags"})
List<Post> findAll();
// 안 B — 해결: 한 번에 하나씩 로드하고 나머지는 Batch Size에 의존
@EntityGraph(attributePaths = {"comments"})
List<Post> findAll();
EntityGraph도 결국 JOIN이라 위의 곱셈 문제가 그대로 생길 수 있다. 두 안은 비교용이고, 한 인터페이스에는 하나만 둔다.
트랜잭션이 끝난 뒤에는 지연 로딩을 할 수 없다
// 위험한 코드: 트랜잭션 밖에서 지연 로딩 실행
// PostService
@Transactional(readOnly = true)
public List<Post> getPosts() {
return postRepository.findAll();
}
// 다른 클래스(예: 요청을 받는 Controller)에서 PostService를 부른다
public void processData() {
List<Post> posts = postService.getPosts(); // 여기서 트랜잭션 끝남
posts.forEach(p -> {
System.out.println(p.getComments().size()); // LazyInitializationException!
});
}
// 해결 1: Fetch Join 또는 EntityGraph로 처음부터 같이 가져오기
@Transactional(readOnly = true)
public List<Post> getPosts() {
return postRepository.findAllWithComments(); // 댓글까지 여기서 모두 로드됨
}
// 해결 2: 트랜잭션 안에서 명시적으로 로드
@Transactional(readOnly = true)
public List<Post> getPosts() {
List<Post> posts = postRepository.findAll();
posts.forEach(p -> p.getComments().size()); // 트랜잭션 내에서 미리 로드
return posts;
}
한 줄씩 따라가기. @Transactional이 걸린 메서드 안에서는 트랜잭션이 열려 있다. readOnly = true는 이 메서드가 읽기만 한다는 표시인데, 이 함정과는 관계가 없다. 트랜잭션이 열려 있는 동안에는 JPA가 DB와 이어 둔 작업 공간(영속성 컨텍스트)도 살아 있어서, 지연 로딩 쿼리가 그 안에서 정상적으로 나간다. 그런데 getPosts()가 끝나는 순간 트랜잭션도 함께 끝나고, 그 뒤에 processData()에서 getComments().size()를 부르면 댓글을 채워 올 통로가 이미 닫혀 있다. 이때 터지는 예외가 LazyInitializationException이다(Hibernate 소스에는 no session or session was closed라는 문구로 던진다. 여기서 session은 Hibernate가 영속성 컨텍스트를 들고 DB와 일하는 작업 단위를 부르는 이름이라, “작업 공간이 없거나 이미 닫혔다”는 뜻이다). processData()가 있는 Controller는 바깥 요청을 받아 Service에 일을 넘기는 클래스다.
해결 1은 트랜잭션이 열려 있는 getPosts() 안에서 Fetch Join(앞의 findAllWithComments())이나 EntityGraph로 댓글까지 처음부터 다 가져와 버리는 방법이다. 해결 2는 트랜잭션이 끝나기 전에 getComments().size()를 미리 한 번 불러 로딩을 끝내 두는 방법이다. 해결 2에도 @Transactional이 붙어 있어야 “트랜잭션 안”이 된다.
Batch Size는 해결 1이 되지 못한다. 앞에서 본 대로 Batch Size도 지연 로딩이라, 댓글을 처음 쓰는 순간에야 묶음 쿼리가 나간다. findAll()만 하고 돌아오면 트랜잭션 밖에서 처음 쓰게 되어 같은 예외가 난다. Batch Size를 쓸 때는 해결 2처럼 트랜잭션 안에서 한 번 건드려 둔다.
실전에서 쓰는 설정 세 가지
Batch Size를 전체 기본값으로 건다
# application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100 # 글로벌 설정
한 줄씩 따라가기. 이 설정은 application.yml에 적는다 — Spring Boot(Spring을 바로 쓸 수 있게 설정까지 미리 끝내 둔 묶음)가 켜질 때 가장 먼저 읽어 들이는 설정 파일이다. YAML은 들여쓰기로 계층을 적으므로, spring: 아래 jpa: 아래 properties: 아래 hibernate: 아래 default_batch_fetch_size는 점으로 이으면 spring.jpa.properties.hibernate.default_batch_fetch_size 한 줄과 같다. spring.jpa.properties. 아래에 적은 것은 Hibernate에게 그대로 넘어간다. 그래서 Hibernate가 보는 이름은 hibernate.default_batch_fetch_size이고, 이 값은 Hibernate에게 @BatchSize를 따로 안 붙인 모든 연관 관계에도 기본으로 이 크기만큼 묶어서 가져오라고 전역으로 지정하는 값이다.
나가는 쿼리를 로그로 본다
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
한 줄씩 따라가기. logging.level 아래에는 “어느 이름의 로그를 어느 수준부터 찍을지”를 적는다. org.hibernate.SQL: DEBUG는 Hibernate가 DB로 보내는 SQL 문장을 찍는다 — N+1이 생기면 같은 모양의 SELECT가 줄줄이 찍혀서 바로 보인다. 두 번째 줄은 SQL의 ? 자리에 실제로 들어간 값(게시글 id 등)까지 찍으라는 설정이고, TRACE는 DEBUG보다 더 자세한 수준이다.
여기 적는 이름은 로그를 남기는 쪽이 자기 로그에 붙인 이름이다. 두 번째 줄은 BasicBinder 클래스의 전체 이름(패키지 + 클래스)을 로그 이름으로 적은 것이다.
그런데 두 번째 줄의 이름은 옛 Hibernate 기준이다. 나는 Hibernate 소스를 이 블로그 저장소의 open-source/ 폴더에 받아 두고 동작을 확인할 때 읽는데, 그 7.4 소스에서는 BasicBinder가 org.hibernate.type.descriptor.jdbc 패키지로 옮겨 갔고, 값을 찍을 때 쓰는 로그 이름도 클래스 이름이 아니라 org.hibernate.orm.jdbc.bind다(type/descriptor/JdbcBindingLogging.java). 쓰는 Hibernate 버전에 맞는 이름을 적어야 찍힌다.
쿼리 통계를 Actuator로 본다
# application.yml
spring:
jpa:
properties:
"[hibernate.generate_statistics]": true
한 줄씩 따라가기. 이 줄만 이름을 "[...]"로 감싼 건 Spring Boot 문서가 그렇게 적기 때문이다. 문서의 예시를 그대로 옮겼고, 대괄호를 빼면 무엇이 달라지는지는 확인하지 않았다. Spring Boot Actuator는 Micrometer로 서버의 메트릭을 모은다. 프로젝트의 의존성(빌드 파일에 적어 내려받는 남의 코드)에 org.hibernate.orm:hibernate-micrometer(만든 곳:이름 형식)가 있고 이 설정으로 Hibernate 통계를 켜 두면, Spring Boot가 Hibernate의 쿼리 · 캐시 통계를 hibernate라는 이름의 메트릭으로 내보낸다(Spring Boot 문서 Actuator → Metrics). 쿼리 수가 갑자기 늘면 N+1을 의심할 수 있다.
이 절은 원래 SessionFactoryMetrics라는 클래스의 객체를 @Bean으로 Spring에 맡기는 코드였다. 받아 둔 Hibernate 소스에는 그런 클래스가 없고, hibernate-micrometer(Hibernate 소스 안의 하위 프로젝트 하나)에 있는 것은 HibernateMetrics다. 그래서 Spring Boot 문서에 적힌 설정 방식으로 바꿨다.
내가 가장 많이 고르는 것은 Batch Size다
실무 경험상 Batch Size가 가장 실용적인 해결책이다.
@Entity
class Post {
@Id
private Long id;
private String title;
@OneToMany
@BatchSize(size = 10)
private List<Comment> comments;
}
// Repository
public interface PostRepository extends JpaRepository<Post, Long> {
Page<Post> findAll(Pageable pageable);
}
// Service
List<Post> posts = postRepository.findAll(PageRequest.of(0, 20)).getContent();
posts.forEach(post -> {
System.out.println(post.getComments().size()); // N+1 문제 해결!
});
한 줄씩 따라가기. 앞의 「페이징이 필요한 목록 조회」와 같은 코드를 Entity까지 한곳에 모은 것이다. PageRequest.of(0, 20)으로 PageRequest 조건을 만들어 게시글 20개를 DB에서 끊어 가져오고, .getContent()로 그 목록을 꺼낸다. 반복문에서 댓글을 처음 쓸 때 @BatchSize(size = 10)대로 게시글 10개씩 묶은 IN 쿼리가 나가서, 쿼리는 1 + 2 = 3번으로 끝난다.
장점:
- 게시글을 DB에서 페이징할 수 있다
- 게시글 수가 늘어도 댓글 쿼리는 게시글 10개당 1번이라, N에 비례해 폭발하지 않는다
- 댓글이 없는 게시글도 조회된다
- 설정 하나로 묶는 크기를 바꿀 수 있다
- 코드가 간결하다
마치며
JPA의 N+1 문제는 ORM(자바 객체와 DB 테이블을 이어 주는 방식)의 근본적인 특성에서 나온 문제다. 객체를 따라가며 쓰는 순간에 DB를 부르기 때문이다. 중요한 건 상황에 맞는 해결책을 고르는 것이다.
- 페이징이 필요한 대량 데이터 조회: Batch Size
- 단일 상세 조회: EntityGraph
- 극도의 성능 최적화: Fetch Join
위 기준은 일반적인 경우의 추천이고, 실제 서비스마다 데이터 양이나 화면 구성이 달라 사정이 다를 수 있다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기