TestForge | Aidevops | 📊 Plogger ✍️ Blog 📚 Docs
plogger

AI DevOps Korea

AI 서비스 개발, 운영, 성능개선을 하나의 루프로 연결합니다

aidevops.kr에서 LLMOps, RAG, AI Agent, 관측성, 평가, 비용-성능 최적화를 실전 운영 관점으로 정리합니다.

JPA N+1 문제 완벽 해결 가이드

JPA N+1 문제 완벽 해결 가이드 다이어그램
이 글에서 다루는 핵심 흐름, 아키텍처 구조, 주요 판단 포인트를 한눈에 이해할 수 있도록 정리한 그림입니다.
## 그림으로 보는 구조
[Load Orders]
     |
     +--> 1 query: select * from orders
     |
     +--> N queries: select * from member where id = ?
                     select * from member where id = ?
                     select * from member where id = ?

N+1 문제는 단순히 쿼리가 많아지는 현상이 아니라, 한 번의 화면 조회가 루프 안의 추가 쿼리로 증폭되는 구조 문제입니다. 처음 한 번은 괜찮아 보여도 데이터 수가 늘어날수록 네트워크 왕복과 DB 부하가 함께 커지기 때문에, 이 “1 + N” 흐름을 먼저 시각적으로 이해하는 것이 핵심입니다.

N+1 문제란?

1개의 쿼리로 N개의 결과를 가져온 뒤, 각 결과마다 추가 쿼리 N개가 발생하는 문제입니다.

// 게시글 10개 조회 → 1번 쿼리
List<Post> posts = postRepository.findAll();

// 각 게시글의 댓글 조회 → 10번 추가 쿼리 발생!
posts.forEach(p -> System.out.println(p.getComments().size()));
// 총 11번 쿼리 실행

해결 방법 1 — Fetch Join

@Query("SELECT DISTINCT p FROM Post p JOIN FETCH p.comments WHERE p.id IN :ids")
List<Post> findAllWithComments(@Param("ids") List<Long> ids);

단점: 페이징 불가 (컬렉션 Fetch Join 시)

해결 방법 2 — @EntityGraph

@EntityGraph(attributePaths = {"comments", "author"})
@Query("SELECT p FROM Post p")
List<Post> findAllWithGraph();

해결 방법 3 — Batch Size (권장)

# application.yml — 전역 설정
spring.jpa.properties.hibernate.default_batch_fetch_size: 100
@BatchSize(size = 100)
@OneToMany(mappedBy = "post")
private List<Comment> comments;

IN 쿼리로 한 번에 N개 조회 → SELECT * FROM comments WHERE post_id IN (1,2,...,100)

해결 방법 4 — DTO 직접 조회

@Query("""
    SELECT new com.example.dto.PostSummary(p.id, p.title, COUNT(c))
    FROM Post p LEFT JOIN p.comments c
    GROUP BY p.id, p.title
    """)
List<PostSummary> findPostSummaries();

해결책 선택 기준

상황추천 방법
단건 조회Fetch Join
목록 + 페이징Batch Size
복잡한 집계DTO 직접 조회
다중 컬렉션Batch Size

Hibernate Statistics로 쿼리 카운트

spring.jpa.properties.hibernate.generate_statistics: true
logging.level.org.hibernate.stat: DEBUG

운영 환경에서 어려워지는 지점

  • N+1 문제는 단순한 ORM 버그라기보다 읽기 모델 설계와 fetch 경계가 명시되지 않았다는 신호인 경우가 많다.
  • 지연 접근 패턴이 늘어나면 테스트를 통과하는 시스템도 운영 데이터 규모에서 무너질 수 있다.
  • 비용은 지연뿐 아니라 예측 불가능한 DB 부하다.

중요한 아키텍처 결정

  • 엔티티 탐색을 공짜 읽기 모델로 가정하지 말고, 쿼리 사용 사례를 명시적으로 설계한다.
  • 화면 요구에 맞춰 fetch join, entity graph, projection, 전용 query model을 선택한다.
  • 필요하면 쓰기 중심 aggregate 모델과 읽기 중심 query 경로를 분리한다.

실무 예시

쿼리는 범용 엔티티 순회보다 화면의 정확한 요구를 반영해야 한다.

select o from Order o
join fetch o.customer
where o.status = :status

피해야 할 안티패턴

  • 리스트 렌더링에서 루프 안으로 lazy relation 탐색을 넣는 것.
  • EAGER가 설계 문제를 해결한다고 생각하는 것.
  • 단일 요청 성공만 보고 총 쿼리 수를 보지 않는 것.

운영 체크리스트

  • 핵심 엔드포인트는 통합 테스트에서 SQL 개수를 잡는다.
  • 운영과 비슷한 데이터 규모로 느린 엔드포인트를 점검한다.
  • 읽기 중심 리스트 화면에는 projection을 우선 검토한다.
  • ORM 편의성보다 DB 접근 규율을 우선한다.

최종 판단

N+1은 fetch 옵션을 감으로 건드려 해결하는 문제가 아니다. 읽기 의도를 명시적으로 설계하는 것이 근본 해결책이다.

Continue Reading

다음으로 읽기 좋은 글

다음 탐색

이 주제를 시스템 관점으로 더 이어서 보기