N+1 문제 해결 - JPA Fetch Join으로 쿼리 최적화하기
서비스를 운영하다 보면 어느 순간 특정 API의 응답 속도가 눈에 띄게 느려지는 시점이 옵니다. 이번 글에서는 JPA를 사용하는 백엔드에서 가장 흔하게 발생하는 성능 문제인 N+1 문제를 실제 사례를 통해 진단하고 해결한 과정을 정리합니다.
1. N+1 문제란
N+1 문제는 연관 관계가 걸린 엔티티를 조회할 때, 처음 1번의 쿼리로 목록을 가져온 뒤 연관 엔티티를 가져오기 위해 목록의 개수(N)만큼 추가 쿼리가 발생하는 현상을 말합니다.
예를 들어 게시글(Post)과 작성자(Member)가 다대일 관계로 매핑되어 있다고 가정해봅니다.
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
}
이 상태에서 게시글 목록을 조회하고, 각 게시글의 작성자 이름을 함께 출력하는 코드를 작성하면 다음과 같은 문제가 발생합니다.
List<Post> posts = postRepository.findAll(); // 쿼리 1번
for (Post post : posts) {
System.out.println(post.getMember().getName()); // 쿼리 N번
}
게시글이 100건이면, 작성자 이름을 조회하기 위한 쿼리가 추가로 100번 실행됩니다. 총 101번의 쿼리가 발생하는 셈입니다.
2. 문제 확인하기
콘솔에 출력되는 쿼리 로그를 보면 다음과 같이 동일한 패턴의 쿼리가 반복적으로 나가는 것을 확인할 수 있습니다.
select * from post;
select * from member where id = 1;
select * from member where id = 2;
select * from member where id = 3;
-- 게시글 수만큼 반복
application.yml에 다음 옵션을 추가해두면 쿼리 발생 횟수를 눈으로 직접 확인할 수 있어 문제를 진단할 때 유용합니다.
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
3. Fetch Join으로 해결하기
가장 기본적인 해결 방법은 Fetch Join을 사용해 연관 엔티티를 한 번의 쿼리로 함께 조회하는 것입니다.
@Query("select p from Post p join fetch p.member")
List<Post> findAllWithMember();
이렇게 작성하면 다음과 같이 JOIN을 사용한 단 1번의 쿼리로 게시글과 작성자 정보를 함께 가져옵니다.
select p.*, m.*
from post p
join member m on p.member_id = m.id;
101번 발생하던 쿼리가 1번으로 줄어들면서, 목록 API의 응답 속도가 눈에 띄게 개선됩니다.
4. Fetch Join 사용 시 주의할 점
Fetch Join이 만능은 아닙니다. 실무에서 적용할 때 주의해야 할 부분들이 있습니다.
- 컬렉션을 Fetch Join할 경우 페이징이 불가능합니다. 일대다 관계를 Fetch Join하면 데이터가 뻥튀기되기 때문에, 이 경우
@BatchSize를 사용하거나 별도의 조회 쿼리로 분리하는 것이 안전합니다. - 두 개 이상의 컬렉션을 동시에 Fetch Join할 수 없습니다. 카테시안 곱으로 인해 데이터 정합성이 깨질 수 있어, JPA에서 예외를 던집니다.
- 화면에 필요한 데이터가 많지 않다면, Entity 전체를 Fetch Join하기보다 DTO로 필요한 컬럼만 조회하는 방식이 더 효율적일 수 있습니다.
@Query("select new com.example.dto.PostSummaryDto(p.id, p.title, m.name) " +
"from Post p join p.member m")
List<PostSummaryDto> findAllSummary();
5. 정리
N+1 문제는 개발 단계에서는 데이터가 적어 잘 드러나지 않다가, 운영 환경에서 데이터가 쌓이면서 뒤늦게 발견되는 경우가 많습니다. 그래서 목록 조회 API를 개발할 때는 처음부터 연관 관계 조회 쿼리가 몇 번 발생하는지 로그로 확인하는 습관을 들이는 것이 중요합니다.
다음 글에서는 Fetch Join만으로 해결이 어려운 경우, @BatchSize와 default_batch_fetch_size 옵션을 활용해 컬렉션 조회 성능을 개선하는 방법을 다뤄보겠습니다.
'성능 최적화' 카테고리의 다른 글
| 성능 최적화 STEP 6 - 분산 락(Distributed Lock)으로 동시성 문제 해결하기 (0) | 2026.07.06 |
|---|---|
| 성능 최적화 STEP 5 - 인덱스 설계와 튜닝으로 쿼리 속도 개선하기 (0) | 2026.07.06 |
| 성능 최적화 STEP 4 - Spring Batch 청크 지향 처리로 대량 데이터 안전하게 다루기 (0) | 2026.07.03 |
| 성능 최적화 STEP 3 - Redis를 활용한 캐싱 전략 (0) | 2026.07.03 |
| 성능 최적화 STEP 2 - N+1 문제 해결 2편 - @BatchSize와 default_batch_fetch_size로 컬렉션 조회 최적화하기 (0) | 2026.07.02 |