[Daily morning study] ORM의 N+1 문제와 해결 방법

#daily morning study

Image


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 Join1번낮음컬렉션 다중 join 불가
EntityGraph1번낮음Fetch Join과 동일한 제약
Batch SizeN/size 번매우 낮음 (전역 설정)완전 해결은 아님
DTO Projection1번높음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을 사용하는 패턴이 일반적이다.