JPA에서의 N+1 문제

hznnoy·2025년 11월 2일

JPA에서의 N+1 문제

N+1 문제란 무엇인가

N+1 문제(N+1 Problem)는 ORM(Object Relational Mapping) 환경에서 자주 발생하는 비효율적인 쿼리 문제이다.

하나의 조회 쿼리(1번)로 시작했지만, 그 결과로 가져온 각 엔티티마다 추가적인 N개의 쿼리가 발생하면서 총 N+1번의 SQL이 실행되는 현상을 말한다.

즉, “한 번에 가져올 수 있는 데이터를 여러 번 나눠서 불필요하게 조회”하는 상황이다.


예시

다음과 같은 두 엔티티 관계를 가정하자.

@Entity
public class Member {
    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    private Team team;
}

@Entity
public class Team {
    @Id
    @GeneratedValue
    private Long id;

    private String name;
}

다음 코드를 실행한다고 가정하자.

List<Member> members = em.createQuery("select m from Member m", Member.class).getResultList();

for (Member member : members) {
    System.out.println(member.getTeam().getName());
}

이 경우 실행되는 쿼리는 다음과 같다.

// (1) Member 목록 조회
select * from member;

// (2) 각 Member의 Team 조회 (회원 수만큼 반복)
select * from team where id = 1;
select * from team where id = 2;
select * from team where id = 3;
...

결과적으로 1 + N번의 쿼리가 발생한다.

회원이 100명이라면 총 101개의 SQL이 실행되는 셈이다.


왜 발생하는가

JPA의 연관관계는 기본적으로 지연 로딩(LAZY Loading)으로 설정되어 있다.

즉, 연관된 엔티티를 처음부터 함께 가져오지 않고, 해당 필드가 실제로 접근될 때 추가 쿼리를 실행한다.

이는 성능 최적화를 위한 설계이지만, 다음과 같은 상황에서는 오히려 성능 저하를 초래한다.

  • 한 번의 트랜잭션에서 다수의 연관 엔티티에 접근할 때
  • 컬렉션이나 연관 객체를 반복적으로 순회할 때

해결 방법

Fetch Join 사용

JPQL에서 fetch join을 이용하면, 연관된 엔티티를 한 번의 쿼리로 함께 조회할 수 있다.

@Query("select m from Member m join fetch m.team")
List<Member> findAllWithTeam();

실행 쿼리:

select m.*, t.*
from member m
join team t on m.team_id = t.id;

이 방식은 가장 일반적이면서도 효과적인 해결책이다.

단, fetch join은 페이징(Pageable)과 함께 사용할 경우 주의가 필요하다.

(OneToMany 조인 시 중복된 row가 발생할 수 있음)

EntityGraph 사용

Spring Data JPA에서는 @EntityGraph를 사용하여

JPQL 작성 없이도 fetch join과 같은 효과를 낼 수 있다.

@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findAllEntityGraph();

결과적으로 동일하게 join fetch가 적용된 SQL이 실행된다.

EntityGraph의 장점은 재사용성과 선언적 구성이 용이하다는 것이다.

BatchSize 설정 (하이버네이트 기능)

@BatchSize를 설정하면, 지연 로딩 시 N개의 쿼리가 아니라 한 번에 묶어서 가져오도록 할 수 있다.

이는 N+1 문제를 완전히 제거하지는 않지만, 쿼리 호출 횟수를 크게 줄이는 완화책으로 활용할 수 있다.

@Entity
@BatchSize(size = 100)
public class Member { ... }

또는 전역 설정으로 적용할 수도 있다.

spring:
  jpa:
    properties:
      hibernate.default_batch_fetch_size: 100

이 설정 시, 100명의 Member가 서로 다른 Team을 참조하더라도

최대 2~3번의 쿼리로 모든 Team 정보를 가져올 수 있다.

즉시 로딩(EAGER)의 잘못된 사용

@ManyToOne(fetch = FetchType.EAGER)로 설정하면 N+1 문제를 방지할 수 있을 것 같지만, 이는 권장되지 않는다.

이유는 다음과 같다.

  • 즉시 로딩은 항상 조인을 수행하므로 불필요한 데이터까지 함께 로드한다.
  • 여러 연관관계가 존재할 경우 복잡한 조인 쿼리가 자동 생성되어 성능 저하를 일으킨다.
  • 제어 불가능한 로딩 전략으로 인해 예상치 못한 동작이 발생할 수 있다.

따라서, 즉시 로딩보다는 지연 로딩 + fetch join / EntityGraph 조합이 이상적이다.


N+1 문제 발생 위치 파악하기

N+1 문제는 대개 코드에서는 눈에 잘 띄지 않는다.

따라서 다음과 같은 방법으로 탐지한다.

  • Hibernate SQL 로그 출력 설정
    spring:
      jpa:
        properties:
          hibernate:
            show_sql: true
            format_sql: true
            use_sql_comments: true
    logging:
      level:
        org.hibernate.SQL: debug
        org.hibernate.type.descriptor.sql.BasicBinder: trace
  • 애플리케이션 실행 중 SQL 로그를 통해 불필요하게 반복되는 SELECT 문이 있는지 확인한다.

정리

구분내용
문제 정의한 번의 조회 쿼리로 인해 연관된 데이터가 N번 추가 조회되는 현상
원인지연 로딩(LAZY)으로 인한 개별 쿼리 실행
대표 해결법Fetch Join, EntityGraph, BatchSize
잘못된 해결법EAGER 로딩으로 강제 조인
탐지 방법Hibernate SQL 로그 분석

결론

JPA의 N+1 문제는 ORM 사용 시 거의 필연적으로 마주하게 되는 성능 이슈다.

단순히 발생 원인을 이해하는 것에서 나아가,

도메인 구조에 따라 최적의 로딩 전략을 설계하는 것이 중요하다.

  • 다대일 관계에서는 fetch join을 적극적으로 활용하고,
  • 일대다 컬렉션에서는 BatchSizeEntityGraph로 접근을 조절하며,
  • 항상 SQL 로그를 통해 실제 쿼리 패턴을 검증하는 습관이 필요하다.
profile
노력에는 지름길이 없으니까요

0개의 댓글