ORM을 쓰다 보면 반드시 한 번은 만나는 문제가 있다.
바로 N+1 문제다.
처음에는 잘 모르고 지나치다가 운영 환경에서 성능 문제로 터지는 경우가 많다.
1. N+1 문제란?
쿼리 1번으로 N개의 데이터를 가져온 뒤, 각 데이터마다 추가 쿼리가 실행되는 현상이다.
총 1 + N번의 쿼리가 발생한다.
예를 들어 게시글 100개를 조회하면, 게시글 조회 1번 + 작성자 조회 100번 = 101번의 쿼리가 나간다.
2. 왜 생기는가?
LAZY 로딩 때문이다.
@Entity
public class Post {
@ManyToOne(fetch = FetchType.LAZY)
private Member author;
}
JPA는 기본적으로 연관 엔티티를 프록시로 두고, 실제 접근할 때 DB에서 가져온다.
그래서 각 Post마다 author를 호출하면 그때그때 SELECT가 발생한다.
3. 실제 쿼리 흐름
List<Post> posts = postRepository.findAll(); // 1번 쿼리
for (Post post : posts) {
String name = post.getAuthor().getName(); // N번 쿼리
}
첫 번째 줄에서 Post 목록만 가져온다.
두 번째 반복문에서 각 Post의 author에 접근할 때마다 SELECT가 나간다.
결과적으로 1 + N번의 쿼리가 발생한다.
4. 언제 문제가 되는가?
데이터가 적을 때는 티가 안 난다.
하지만 목록 조회가 빈번하거나 데이터가 많아지면 심각해진다.
대표적인 케이스:
- 게시판 목록 + 작성자
- 댓글 목록 + 작성자
- 주문 목록 + 주문 상품
- 알림 목록 + 관련 엔티티
5. 해결 방법
5.1 Fetch Join
가장 기본적인 방법이다.
@Query("SELECT p FROM Post p JOIN FETCH p.author")
List<Post> findAllWithAuthor();
SQL 레벨에서 JOIN을 걸어서 한 번에 가져온다.
N+1이 완전히 사라진다.
장점:
- 쿼리 1번으로 해결
- 명확한 의도 표현
단점:
- JPQL을 직접 작성해야 함
- 페이징 시 컬렉션 조인은 주의 필요
5.2 EntityGraph
Fetch Join의 선언적 버전이라고 보면 된다.
@EntityGraph(attributePaths = {"author"})
List<Post> findAll();
메서드에 어노테이션만 붙이면 내부적으로 JOIN이 걸린다.
JPQL 없이 깔끔하게 처리 가능하다.
장점:
- 선언적이라 깔끔함
- 메서드 네이밍 쿼리와 함께 사용 가능
단점:
- Fetch Join과 동일한 제약
- 복잡한 조건은 표현 어려움
5.3 Batch Size
완전히 막는 건 아니고, N+1을 완화하는 방식이다.
@BatchSize(size = 100)
@ManyToOne(fetch = FetchType.LAZY)
private Member author;
또는 전역 설정:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
IN 절로 묶어서 가져온다.
N개의 쿼리가 N/batch_size개로 줄어든다.
장점:
- 페이징과 함께 사용 가능
- 설정만으로 적용
- 컬렉션 조회에도 안전
단점:
- N+1이 완전히 사라지지는 않음
- 추가 쿼리는 여전히 발생
6. 어떤 방법을 선택할까?
상황에 따라 다르다.
Fetch Join / EntityGraph:
- 목록 + 단일 연관은 항상 함께 필요할 때
- toOne 관계
- N+1 완전 제거가 목표
Batch Size:
- 페이징이 필수
- 컬렉션 조회
- N+1 완화로도 충분
보통 기본은 Batch Size를 켜두고, 핵심 목록 조회는 Fetch Join이나 EntityGraph로 최적화한다.
7. 실무에서 자주 놓치는 부분
@Transactional 범위를 잘못 잡으면 다시 N+1이 생긴다.
public List<PostDto> getPosts() {
List<Post> posts = postRepository.findAll(); // 트랜잭션 밖
return posts.stream()
.map(post -> new PostDto(
post.getTitle(),
post.getAuthor().getName() // 여기서 쿼리 발생
))
.collect(toList());
}
트랜잭션이 끝난 뒤에 LAZY 로딩을 시도하면 각각 쿼리가 나간다.
@Transactional(readOnly = true)를 붙이거나 DTO 변환을 트랜잭션 안에서 해야 한다.
8. 정리
N+1 문제는 ORM의 숙명이다.
LAZY 로딩의 편리함과 성능 사이의 트레이드오프다.
해결 방법은 여러 가지지만, 상황에 맞게 선택하는 게 중요하다.
핵심:
- Fetch Join: 완전 제거
- Batch Size: 완화
- 트랜잭션 범위 주의
모니터링으로 쿼리 수를 확인하면서 하나씩 최적화하는 게 답이다.