[Daily morning study] ORM의 N+1 문제와 해결 방법
#daily morning study
ORM의 N+1 문제와 해결 방법
N+1 문제란?
ORM(Object-Relational Mapping)을 사용할 때 자주 발생하는 성능 문제다. 1개의 쿼리로 N개의 레코드를 조회한 뒤, 각 레코드의 연관 데이터를 가져오기 위해 N번의 추가 쿼리가 실행되는 현상이다. 쿼리가 총 N+1번 실행되기 때문에 N+1 문제라고 부른다.
예를 들어 게시글(Post) 100개를 조회하고, 각 게시글의 작성자(User) 정보를 출력하는 상황을 생각해보자. 게시글 목록을 가져오는 쿼리 1번, 그 뒤 각 게시글마다 작성자를 조회하는 쿼리 100번 — 총 101번의 쿼리가 실행된다.
발생 원인: Lazy Loading
ORM은 기본적으로 Lazy Loading(지연 로딩) 전략을 사용한다. 연관 객체는 실제로 접근하는 시점에 별도 쿼리를 실행해서 가져온다. 이 방식은 불필요한 데이터를 미리 로드하지 않아 메모리를 절약하지만, 반복 접근이 발생하면 쿼리 수가 폭발적으로 늘어난다.
문제 예시 (JPA / Hibernate)
// Post와 User가 ManyToOne 관계
List<Post> posts = postRepository.findAll(); // 쿼리 1번
for (Post post : posts) {
System.out.println(post.getUser().getName()); // 매번 쿼리 1번씩 추가
}
실행되는 SQL:
-- 1번: 게시글 전체 조회
SELECT * FROM post;
-- N번: 각 게시글의 작성자 조회 (posts 수만큼 반복)
SELECT * FROM user WHERE id = 1;
SELECT * FROM user WHERE id = 2;
SELECT * FROM user WHERE id = 3;
-- ...
해결 방법
1. Fetch Join (JOIN FETCH)
JPQL에서 JOIN FETCH를 사용하면 연관 엔티티를 한 번의 쿼리로 함께 가져온다.
@Query("SELECT p FROM Post p JOIN FETCH p.user")
List<Post> findAllWithUser();
실행 SQL:
SELECT p.*, u.*
FROM post p
INNER JOIN user u ON p.user_id = u.id;
쿼리가 1번으로 줄어든다. 가장 직관적인 해결책이지만, 컬렉션(OneToMany) 관계에 여러 개를 동시에 fetch join하면 MultipleBagFetchException이 발생할 수 있다.
2. EntityGraph
@EntityGraph 어노테이션으로 Eager Loading 범위를 메서드 단위로 지정할 수 있다.
@EntityGraph(attributePaths = {"user"})
@Query("SELECT p FROM Post p")
List<Post> findAllWithUserGraph();
fetch join과 동일하게 동작하지만, JPQL 없이 선언적으로 설정할 수 있어서 코드가 간결해진다.
3. Batch Size (지연 로딩 최적화)
Hibernate의 @BatchSize를 사용하면 Lazy Loading 시 IN 절을 이용해 한 번에 여러 연관 객체를 묶어서 조회한다.
@Entity
public class User {
@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Post> posts;
}
또는 전역 설정:
# application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
100개의 게시글을 조회한다면:
-- 기존: 100번
SELECT * FROM user WHERE id = 1;
SELECT * FROM user WHERE id = 2;
-- ...
-- Batch Size 적용 후: 1~2번
SELECT * FROM user WHERE id IN (1, 2, 3, ..., 100);
N+1 문제를 완전히 없애지는 못하지만 쿼리 수를 대폭 줄일 수 있다. 컬렉션 fetch join과 함께 사용하기 좋다.
4. DTO Projection으로 직접 쿼리
연관 객체까지 한 번에 조회하는 DTO를 만들고 JPQL이나 QueryDSL로 직접 쿼리를 작성하는 방법이다.
@Query("SELECT new com.example.PostDto(p.id, p.title, u.name) " +
"FROM Post p JOIN p.user u")
List<PostDto> findAllPostDto();
엔티티를 그대로 반환하지 않고 필요한 필드만 조회하므로 성능이 가장 좋다. 다만 쿼리와 DTO를 직접 관리해야 해서 유지보수 비용이 올라간다.
각 방법 비교
| 방법 | 쿼리 수 | 적용 난이도 | 주의사항 |
|---|---|---|---|
| Fetch Join | 1번 | 낮음 | 컬렉션 다중 join 불가 |
| EntityGraph | 1번 | 낮음 | Fetch Join과 동일한 제약 |
| Batch Size | N/size 번 | 매우 낮음 (전역 설정) | 완전 해결은 아님 |
| DTO Projection | 1번 | 높음 | DTO 별도 관리 필요 |
Eager Loading은 해결책이 아니다
연관 관계를 FetchType.EAGER로 바꾸면 항상 함께 로드되므로 Lazy Loading에 의한 N+1은 사라진다. 하지만 필요하지 않은 상황에서도 항상 연관 데이터를 함께 가져오기 때문에 불필요한 쿼리가 늘어나고, 컬렉션의 경우 카테시안 곱(Cartesian Product) 문제가 발생할 수 있다. Eager Loading은 N+1의 근본 해결책이 아니다.
요약
- N+1 문제는 ORM의 Lazy Loading에서 비롯된 쿼리 폭발 문제다.
- Fetch Join / EntityGraph: 연관 데이터를 단일 쿼리로 함께 가져온다. 단순한 경우에 가장 적합하다.
- Batch Size: 전역 설정 한 줄로 쿼리 수를 크게 줄인다. 실용적이고 범용적이다.
- DTO Projection: 필요한 컬럼만 쿼리해서 성능을 극대화한다. 복잡한 조회에 적합하다.
- 상황에 맞게 조합해서 쓰는 게 현실적이다. Batch Size를 기본으로 깔고, 복잡한 조회는 Fetch Join이나 DTO Projection을 사용하는 패턴이 일반적이다.