JPA N+1, 왜 생기고 어떻게 줄이나?

Bien·2025년 9월 21일

실제 실무 도입은 아니고, 혼자 프로젝트를 만들어보면서 고민했던 포인트를 정리함. 언젠간 실무적으로도 깊게 고민할 날이 오길

시리즈(가안, 계속 손볼 예정)

  1. JPA N+1, 왜 생기고 어떻게 줄이나? ← (이번 글)
  2. 글로벌 예외 처리: 에러 모델·로그·관측성
  3. 통합검색 설계: 질의/색인/랭킹/페이징
  4. 테스트 준비 vs 테스트 헬퍼: setUp · 팩토리 · 빌더 · Reflection
  5. 단위 테스트의 범위와 경계: 무엇을 테스트할까
  6. 프론트 친화적 API 스펙 & Swagger 문서 전략

JPA N+1, 왜 생기고 어떻게 줄이나?

내가 이해한 핵심

  • 지연 로딩(LAZY) + 컬렉션(=to-many) 접근 타이밍이 겹치면 “쿼리 폭증(N+1)”이 터진다.
  • 해결의 핵심은 쿼리 수를 내가 통제하는 것:
    fetch join, 두 단계 로딩(IDs → fetch join), DTO 프로젝션(flat), @BatchSize, (필요하면 캐시/리드모델).

1. 문제 상황

도메인 (다대다를 중간 엔티티로 표현)

@Entity
class Post {
    @Id @GeneratedValue Long id;
    String title;
    String content;

    // Post ⟷ PostTag(다대일)
    @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
    Set<PostTag> tags = new HashSet<>();
}

@Entity
class PostTag {
    @Id @GeneratedValue Long id;

    @ManyToOne(fetch = FetchType.LAZY) Post post;
    @ManyToOne(fetch = FetchType.LAZY) Tag tag;
}

@Entity
class Tag {
    @Id @GeneratedValue Long id;
    String name;
}

검색 레포지토리 (제목, 태그명으로 검색, 페이징)

public interface PostRepository extends JpaRepository<Post, Long> {

    @Query(value = """
        SELECT DISTINCT p
        FROM Post p
        LEFT JOIN p.tags pt
        LEFT JOIN pt.tag t
        WHERE
            LOWER(function('REPLACE', p.title,  ' ', '')) LIKE LOWER(CONCAT('%', function('REPLACE', :keyword, ' ', ''), '%'))
         OR LOWER(t.name) LIKE LOWER(CONCAT('%', :keyword, '%'))
        """,
        countQuery = """
        SELECT COUNT(DISTINCT p.id)
        FROM Post p
        LEFT JOIN p.tags pt
        LEFT JOIN pt.tag t
        WHERE
            LOWER(function('REPLACE', p.title,  ' ', '')) LIKE LOWER(CONCAT('%', function('REPLACE', :keyword, ' ', ''), '%'))
         OR LOWER(t.name) LIKE LOWER(CONCAT('%', :keyword, '%'))
        """)
    Page<Post> searchAll(@Param("keyword") String keyword, Pageable pageable);
}

어디서 N+1이 터졌나?

// (가벼운 예시) DTO 변환 과정
PostResponse from(Post p) {
    // 여기서 p.getTags()를 건드리는 순간, Post마다 추가 SELECT 발생 → N+1
    List<String> tagNames = p.getTags().stream()
        .map(pt -> pt.getTag().getName()) // 여기도 LAZY 접근
        .toList();
    ...
}
  • 1번(검색) + 포스트 수만큼 N번(태그 로딩) (+ 태그 N번) → N+1
  • 참고: DISTINCT는 중복 제거 용도일 뿐, 연관 컬렉션 로딩 방식을 바꾸지 않는다.

2. 왜 생기나?

  • 지연 로딩(LAZY) = “필요해질 때 그때 가서 DB에서 가져오기”.
  • 컨트롤러에서 엔티티를 JSON으로 직렬화하거나, DTO로 만들려고 게터를 호출하는 순간
    → “어? 필요하네?” 하고 추가 SELECT가 튀어나온다.
  • 특히 컬렉션(to-many) 은 목록 화면에서 루프 돌리며 자주 건드리니까 더 잘 터진다.

    JOIN vs FETCH JOIN
    LEFT JOIN : 필터/조건을 위한 조인(로딩 시점은 그대로 LAZY)
    LEFT JOIN FETCH : “이번 쿼리에 같이 가져와” (해당 연관을 즉시 채움)

3. 그래서 어떻게 줄이나?

3-1. 먼저, 쿼리 수를 눈으로 본다

  • 왜? 나도 처음에 눈으로 보기전에는 몰랐듯, N+1은 느낌으로 오지 않았다. 따라서 직접 눈으로 확인 할 수 있도록 사전 작업을 해두면 된다.하나의 API(혹은 서비스 메서드) 호출시 SELECT 가 몇번 나가는가? 이걸 확인하자
// 개발용 로그 보기
spring.jpa.show-sql=true
logging.level.org.hibernate.SQL=debug
// datasource-proxy로 테스트에 “쿼리 수 단언”
// 테스트에서
QueryCountHolder.clear();
service.searchAll("java", PageRequest.of(0, 10));
int selects = QueryCountHolder.getGrandTotal().getSelect();
assertThat(selects).isLessThanOrEqualTo(3); // 상한 고정

운영에서는 Actuator + Micrometer 를 사용해서 확인 하는 방법을 사용하는거 같은데, 일단 테스트 상한부터 걸어두면 체감이 온다. 이건 너무 어렵다... 추가적인 공부가 필요할듯🥲

3-2. 컬렉션을 화면에 뿌릴 땐: 목적에 맞는 방법을 고른다

옵션 A) 두 단계 로딩 (IDs → FETCH JOIN) — 페이징 + 자식 필요할 때 베스트

1. ID만 페이징해서 뽑고
2. 그 ID들만 대상으로 연관을 한 번에 적재(FETCH JOIN)

// 1) 부모 ID만 페이징(검색/필터는 여기에)
@Query(value = """
    SELECT DISTINCT p.id
    FROM Post p
    LEFT JOIN p.tags pt
    LEFT JOIN pt.tag t
    WHERE
        LOWER(function('REPLACE', p.title,  ' ', '')) LIKE LOWER(CONCAT('%', function('REPLACE', :kw, ' ', ''), '%'))
     OR LOWER(t.name) LIKE LOWER(CONCAT('%', :kw, '%'))
    """,
    countQuery = """
    SELECT COUNT(DISTINCT p.id)
    FROM Post p
    LEFT JOIN p.tags pt
    LEFT JOIN pt.tag t
    WHERE
        LOWER(function('REPLACE', p.title,  ' ', '')) LIKE LOWER(CONCAT('%', function('REPLACE', :kw, ' ', ''), '%'))
     OR LOWER(t.name) LIKE LOWER(CONCAT('%', :kw, '%'))
    """)
Page<Long> searchPostIds(@Param("kw") String keyword, Pageable pageable);

// 2) 그 ID들만 대상으로 연관까지 한 번에
@Query("""
    SELECT DISTINCT p
    FROM Post p
    LEFT JOIN FETCH p.tags pt
    LEFT JOIN FETCH pt.tag t
    WHERE p.id IN :ids
    """)
List<Post> findWithTagsByIdIn(@Param("ids") List<Long> ids);
// Service: 두 쿼리 오케스트레이션 + 순서 복원 + DTO 변환
Page<Long> idPage = repo.searchPostIds(keyword, pageable);
if (idPage.isEmpty()) return Page.empty(pageable);

List<Post> posts = repo.findWithTagsByIdIn(idPage.getContent());

// IN 절은 순서 보장이 안 됨 → 원래 순서 복원
Map<Long,Integer> order = new HashMap<>();
for (int i = 0; i < idPage.getContent().size(); i++) order.put(idPage.getContent().get(i), i);
posts.sort(Comparator.comparingInt(p -> order.get(p.getId())));

// 이미 컬렉션이 메모리에 채워져 있음 → 추가 SELECT 없이 DTO 변환
List<PostDto> content = posts.stream()
    .map(p -> new PostDto(
        p.getId(),
        p.getTitle(),
        p.getTags().stream().map(pt -> pt.getTag().getName()).distinct().toList()
    ))
    .toList();

return new PageImpl<>(content, pageable, idPage.getTotalElements());

결과적으로

  • 예전(N+1): 1번(목록) + 부모 수만큼 N번(자식 로딩) = N+1
  • 두 단계: 1번(ID 페이징) + 1번(FETCH JOIN) = 딱 2번

옵션 B) DTO 프로젝션(Flat) — 조회 전용, 가볍고 빠름

  • 한 번의 SELECT로 필요한 필드만 가져와서 코드에서 그룹핑.
public record PostRow(Long postId, String title, Long tagId, String tagName) {}

@Query("""
    SELECT new path.to.PostRow(p.id, p.title, t.id, t.name)
    FROM Post p
    LEFT JOIN p.tags pt
    LEFT JOIN pt.tag t
    WHERE
        LOWER(function('REPLACE', p.title,  ' ', '')) LIKE LOWER(CONCAT('%', function('REPLACE', :kw, ' ', ''), '%'))
     OR LOWER(t.name) LIKE LOWER(CONCAT('%', :kw, '%'))
    """)
Page<PostRow> searchAllFlat(@Param("kw") String keyword, Pageable pageable);
  • 납작(flat)의미: DB 조인 결과가
    post_id | title | tag_id | tag_name 식으로 부모가 행으로 반복되는 모양.화면에서는 postId 기준으로 묶어서 PostDto { id, title, tags:[...] }로 조립.
  • 주의(페이징 의미)
    Page의 페이지 크기는 행(row) 기준.
    “포스트 10개를 정확히” 보여주고 싶다면 ID 페이지 + flat 조회로 바꿔서,
    코드에서 그룹핑 후 응답을 만드는 편이 정확함.

옵션 C) @BatchSize — 빠른 완화책(완전 제거 아님)

  • 코드를 크게 바꾸기 어렵고, 임시로라도 쿼리 수를 줄이고 싶을 때.
  • LAZY 로딩이 터지는 “그 순간”에, 같은 종류의 프록시들을 묶어서 IN (...) 한 방으로 가져와 왕복 횟수를 줄인다.
  • N+1을 완전 제거하는 게 아니라 N/size + 1로 줄여 준다.(작은 페이지는 레이어당 보통 1쿼리)
// (1) 컬렉션 배치: 여러 부모의 "컬렉션 프록시"를 묶어서 초기화
@Entity
class Post {
    @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
    @BatchSize(size = 100) // ← post.getTags()를 '처음' 건드릴 때,
    Set<PostTag> tags;     //    세션에 있는 다른 Post들의 tags까지 최대 100개를
}                          //    IN (post_id...) 한 방 SELECT로 채움.

// (2) to-one 타겟 배치: 여러 "엔티티 프록시"를 묶어서 초기화
@Entity
@BatchSize(size = 100)     // ← pt.getTag().getName()을 '처음' 읽을 때,
class Tag {                //    아직 초기화 안 된 Tag 프록시들의 id를 모아
    @Id Long id;           //    IN (tag_id...) 한 방 SELECT로 초기화.
    String name;
}

“처음 건드리는 순간” Hibernate가 아직 초기화 안 된 동일 종류의 프록시들을 최대 size개까지 모아
… WHERE … IN ( … ) 한 방 SELECT로 초기화한다.

  • 컬렉션 프록시 초기화(여러 부모의 컬렉션을 묶음):
    SELECT * FROM post_tag WHERE post_id IN ( ... 최대 size개 ... )
  • 엔티티 프록시 초기화(여러 to-one 타겟을 묶음):
    SELECT * FROM tag WHERE id IN ( ... 최대 size개 ... )
    즉, N+1 → N/size + 1 근사로 감소.
  • 장점: 적용 쉬움, 기존 코드 영향 적음
  • 단점:
    • 여전히 여러 번의 SELECT (고QPS/대용량엔 부족)
    • 세션 범위 안에서만 묶임(중간 flush/clear 등 하면 이점 감소).

3-3. 페이징 + 컬렉션 fetch join은 피한다

  • 컬렉션(to-many) fetch join을 걸면 행 수가 자식 수만큼 뻥튀기 → DB 페이징 왜곡 or 메모리 페이징(느림).
  • 대안은 A(두 단계) 또는 B(Flat).
  • 참고: to-one(ManyToOne/OneToOne)은 fetch join + 페이징이 비교적 안전(행 수가 크게 늘지 않음). 하지만 루트에서 컬렉션을 fetch하는 순간 위험해진다.

4. 용어를 아주 쉽게 한 번 더

  • 쿼리: DB에 “질문 1번”
  • N+1: 목록 1번 + 항목마다 추가 질문 N번 ⇒ 총 N+1번
  • LAZY(지연 로딩): “필요해질 때 그때 가져오기”
  • FETCH JOIN: “이번 질문에 같이 가져와”
  • 두 단계 로딩: “먼저 누구(ID)인지 고르고 → 그 애들만 한 번에 자세히 가져와”
  • Flat(납작): “부모+자식 일부 칼럼을 행(row)으로 펼쳐 받기”
  • 그룹핑: “같은 postId를 한 덩어리로 합치기”
  • @BatchSize: “질문을 묶음으로 줄이기(완전 제거는 아님)”

처음엔 “fetch = LAZY가 성능 최적화라며? 근데 왜 느려지지?” 싶었는데, 결국 문제는 로딩 방식 자체가 아니라 “언제 어떻게 접근하느냐”였음.
내가 원하는 화면 모양(부모 10개 + 태그 뱃지들)을 기준으로 쿼리 수를 내가 설계해 버리면,
느림/폭증/페이징 왜곡이 다 설명되고, 해결법도 일관되게 정리됐다. 끝!

profile
🙀

0개의 댓글