[Daily morning study] ORM의 N+1 문제와 해결 방법
#daily morning study
N+1 문제란
ORM을 사용하다 보면 자주 마주치는 성능 문제가 N+1 문제다. 이름 그대로 1번의 쿼리가 N번의 추가 쿼리를 유발하는 상황이다.
예를 들어 게시글 목록을 가져오고, 각 게시글의 작성자 정보를 조회한다고 해보자.
- 게시글 전체 조회: 쿼리 1번
- 각 게시글마다 작성자 조회: N번 (게시글 수만큼)
- 총 쿼리 수: N+1
100개 게시글을 조회하면 101번의 쿼리가 실행되고, 1000개면 1001번이 된다. 쿼리 수가 데이터 수에 비례해서 늘어나니 당연히 성능에 치명적이다.
왜 발생하는가
N+1 문제는 주로 Lazy Loading 전략 때문에 발생한다.
ORM은 연관된 엔티티를 기본적으로 실제로 접근하는 시점에 가져온다(지연 로딩). 덕분에 불필요한 데이터를 미리 가져오지 않아 효율적이지만, 반복 순회 시 각 요소마다 개별 쿼리가 나가는 문제가 생긴다.
JPA 예시
// Post 엔티티
@Entity
public class Post {
@Id
Long id;
String title;
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩
User author;
}
// N+1 발생 코드
List<Post> posts = postRepository.findAll(); // 쿼리 1번
for (Post post : posts) {
System.out.println(post.getAuthor().getName()); // 각 게시글마다 쿼리 1번씩
}
실제 실행 SQL:
-- 게시글 조회 (1번)
SELECT * FROM post;
-- 각 post마다 반복 (N번)
SELECT * FROM user WHERE id = 1;
SELECT * FROM user WHERE id = 2;
SELECT * FROM user WHERE id = 3;
-- ...
해결 방법
1. JOIN FETCH (즉시 로딩 쿼리)
연관 엔티티를 한 번의 JOIN 쿼리로 함께 가져오는 방식이다.
// JPQL
@Query("SELECT p FROM Post p JOIN FETCH p.author")
List<Post> findAllWithAuthor();
실행 SQL:
SELECT p.*, u.*
FROM post p
JOIN user u ON p.author_id = u.id;
쿼리 1번으로 모든 데이터를 가져온다. 가장 직관적인 해결책이지만, 페이징과 함께 사용할 때 주의가 필요하다. @OneToMany와 함께 페이징을 사용하면 Hibernate가 모든 데이터를 메모리에 올려 페이징을 처리하는 문제(HHH90003004 경고)가 생긴다.
2. @EntityGraph
JPA 2.1에서 추가된 기능으로, 쿼리와 분리해서 로딩 전략을 지정할 수 있다.
@EntityGraph(attributePaths = {"author"})
@Query("SELECT p FROM Post p")
List<Post> findAllWithAuthor();
JOIN FETCH와 유사하게 동작하지만, 어노테이션으로 로딩 전략을 선언적으로 표현할 수 있어 재사용성이 좋다.
3. Batch Size 설정 (배치 로딩)
Lazy Loading을 유지하되, 쿼리를 개별이 아닌 IN 절로 묶어서 실행하는 방식이다.
@BatchSize(size = 100)
@OneToMany(mappedBy = "post")
List<Comment> comments;
또는 application.yml에서 전역 설정:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
실행 SQL:
-- 게시글 조회 (1번)
SELECT * FROM post;
-- 작성자를 한 번에 IN 절로 조회 (1번)
SELECT * FROM user WHERE id IN (1, 2, 3, ..., 100);
N+1이 → 2번의 쿼리로 줄어든다. 실무에서 가장 많이 쓰이는 방법 중 하나다.
4. Subquery Fetch (Django ORM)
Django에서는 prefetch_related를 사용한다. select_related는 JOIN, prefetch_related는 별도 쿼리로 한 번에 가져온다.
# N+1 발생
posts = Post.objects.all()
for post in posts:
print(post.author.name) # 각 post마다 쿼리
# select_related: JOIN으로 한 번에
posts = Post.objects.select_related('author').all()
# prefetch_related: 별도 쿼리로 IN 절 사용
posts = Post.objects.prefetch_related('comments').all()
| 메서드 | 방식 | 적합한 관계 |
|---|---|---|
select_related | JOIN (1번 쿼리) | ForeignKey, OneToOne |
prefetch_related | IN 절 (2번 쿼리) | ManyToMany, OneToMany |
5. DTO 프로젝션으로 필요한 데이터만 조회
엔티티 전체를 가져오는 대신, 필요한 컬럼만 선택해서 DTO로 받는 방법이다.
@Query("SELECT new com.example.PostDto(p.title, u.name) FROM Post p JOIN p.author u")
List<PostDto> findPostDtos();
연관 엔티티를 프록시로 가져오지 않기 때문에 Lazy Loading 자체가 발생하지 않는다. 읽기 전용 화면이나 API 응답에 특히 유용하다.
방법 비교
| 방법 | 쿼리 수 | 페이징 호환 | 복잡도 |
|---|---|---|---|
| JOIN FETCH | 1 | 제한적 | 낮음 |
| @EntityGraph | 1 | 제한적 | 낮음 |
| Batch Size | 2 | 호환 | 낮음 |
| DTO 프로젝션 | 1 | 호환 | 중간 |
실무에서의 주의점
페이징과 컬렉션 페치의 충돌
@OneToMany 관계를 JOIN FETCH하면서 페이징을 하면 Hibernate는 DB 레벨 LIMIT/OFFSET을 사용하지 않고 전체 데이터를 메모리로 읽어 페이징한다. 데이터가 많으면 OOM으로 이어진다.
해결책: 컬렉션 페치는 Batch Size로 처리하고, @ToOne 관계만 JOIN FETCH한다.
// 안전한 패턴
@Query("SELECT p FROM Post p JOIN FETCH p.author") // ToOne만 JOIN FETCH
List<Post> findAll(Pageable pageable);
// comments는 @BatchSize로 처리
@BatchSize(size = 100)
@OneToMany(mappedBy = "post")
List<Comment> comments;
다중 컬렉션 페치 금지
// 이렇게 하면 안 됨 — MultipleBagFetchException 발생
@Query("SELECT p FROM Post p JOIN FETCH p.comments JOIN FETCH p.tags")
컬렉션(Bag) 타입을 2개 이상 동시에 JOIN FETCH할 수 없다. 한 쪽은 JOIN FETCH, 나머지는 Batch Size로 처리해야 한다.
정리
- N+1 문제는 ORM의 Lazy Loading 전략으로 인해 발생한다
- 해결 방법은 JOIN FETCH / EntityGraph / Batch Size / DTO 프로젝션 등 여러 가지가 있다
- 페이징 여부, 관계 유형(ToOne vs ToMany), 쿼리 재사용 요건에 따라 적절한 방법을 선택해야 한다
- 실무에서는
default_batch_fetch_size를 글로벌 설정해두고, 필요한 곳에 JOIN FETCH / DTO 프로젝션을 추가하는 방식이 많이 쓰인다 EXPLAIN또는EXPLAIN ANALYZE로 실제 실행 계획을 확인하는 습관이 중요하다