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
다음으로 읽기 좋은 글
MySQL 인덱스 최적화 전략 — EXPLAIN으로 쿼리 분석하기
MySQL 인덱스의 동작 원리와 최적화 전략을 EXPLAIN 분석과 함께 정리합니다. 복합 인덱스, 커버링 인덱스, 인덱스 힌트까지 실무 예제로 알아봅니다.
🗄️ DatabaseSQL 성능 최적화 실전 가이드
SQL 튜닝을 문장 다듬기가 아니라 workload 설계 문제로 봅니다. execution plan을 읽고, 데이터 접근량을 줄이고, 인덱스를 정직하게 설계하는 실무 기준을 정리합니다.
🧪 TestSpring Boot 테스트 슬라이스: @WebMvcTest, @DataJpaTest
Spring Boot 테스트 슬라이스를 단순 어노테이션 모음이 아니라 테스트 피라미드와 실행 비용 관점에서 정리합니다. @WebMvcTest, @DataJpaTest, @JsonTest, @RestClientTest를 언제 쓰고 언제 @SpringBootTest가 더 맞는지 설명합니다.
⚙️ BackendSpring Boot JPA + Hibernate 실전 가이드
엔티티 경계, 연관관계 비용, N+1, DTO 조회, 트랜잭션 설계, 운영상 함정까지 JPA를 실무 기준으로 정리합니다.
다음 탐색