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

#daily morning study

Image


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_relatedJOIN (1번 쿼리)ForeignKey, OneToOne
prefetch_relatedIN 절 (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 FETCH1제한적낮음
@EntityGraph1제한적낮음
Batch Size2호환낮음
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로 실제 실행 계획을 확인하는 습관이 중요하다