JPA의 N+1문제

썬쑨·2025년 4월 20일

Spring

목록 보기
7/12
post-thumbnail

N+1 문제는 JPA 또는 Hibernate 같은 ORM(Object-Relational Mapping) 프레임워크에서 연관된 엔티티를 조회할 때 자주 발생하는 성능 문제이다. N+1의 개념부터 원인, 구체적인 예시, 그리고 해결 방안까지 설명하려한다.

✅ 1. N+1 문제란?

N+1문제: 하나의 쿼리(1)로 데이터를 조회한 후, 연관된 엔티티를 조회하기 위해 N개의 추가 쿼리가 실행되는 상황.

결과적으로 총 N + 1번의 쿼리가 실행되며, 이는 성능을 크게 저하시킬 수 있음.

<예시 상황>
Member와 Team이 다대일(N:1) 관계라고 가정:

@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;
}

위처럼 있을 때, 아래같은 모든 Member를 조회하는 코드를 작성한다면 발생하는 쿼리 수는?

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

for (Member member : members) {
    System.out.println(member.getTeam().getName());
}
  1. 첫 번째 쿼리: SELECT * FROM Member;
  2. 그 후, member.getTeam().getName()이 호출될 때마다 추가 쿼리: SELECT * FROM Team WHERE id = ?;
    → Member가 N명이라면, Team을 N번 조회함 → 총 N + 1 쿼리 발생

✅ 2. N+1문제가 발생하는 이유

JPA에서 연관된 엔티티를 매핑할 때, 기본적으로 다음 전략을 따른다:

✔️ 지연 로딩 (Lazy Loading)

-> @ManyToOne, @OneToMany 등의 연관 관계는 기본적으로 LAZY를 사용한다.
즉, 연관된 엔티티를 실제로 접근하는 시점에 쿼리를 날려서 로딩되는 것임.

위의 예시에선 member.getTeam()을 호출하는 시점에 DB 쿼리가 실행된다.

✔️ 프록시 객체

LAZY 설정된 필드는 프록시 객체(가짜 객체)로 대체된다!

프록시가 실제 사용될 때 → JPA가 DB에 쿼리를 보내 데이터를 채운다.

결과적으로 루프 내부에서 매번 getTeam()을 호출하면 그때마다 DB로부터 데이터를 불러오게 되어 N개의 쿼리가 추가 발생하게 되는 것.

✅ 3. 해결 방안

✔️ 1) Fetch Join 사용하기 (JPQL)

연관된 엔티티를 한 번에 조인해서 함께 조회하는 방법이다.

List<Member> members = em.createQuery(
    "SELECT m FROM Member m JOIN FETCH m.team", Member.class)
    .getResultList();

한 번의 쿼리로 Member + Team을 모두 가져와 N+1 문제 방지할 수 있지만, 컬렉션(@OneToMany) 을 fetch join할 경우, 페이징이 불가능하다. 또한, 한 쿼리로 너무 많은 데이터가 한 번에 로딩될 수 있다. (조인으로 인해 중복) 그러니 필요한 상황에만 사용하는 것이 좋음.

✔️ 2) EntityGraph 사용하기 (JPA 표준 방식)

@EntityGraph(attributePaths = {"team"})
@Query("SELECT m FROM Member m")
List<Member> findAllWithTeam();

EntityGraph는 어떤 연관 엔티티를 로딩할지 명시적으로 지정한다. 내부적으로 fetch join과 유사한 방식으로 동작한다. Spring Data JPA에서 많이 사용하는 방식이다.

  • attributePaths: 로딩할 연관 엔티티의 경로 (필드 이름) 지정
  • type (선택): FETCH (기본값), LOAD 등 지정 가능

✔️ 3) Batch Size 설정 (Hibernate 옵션)

Hibernate에 설정을 추가하여 연관된 엔티티들을 IN 절로 한 번에 조회할 수 있도록 하는 방법

-properties-

spring.jpa.properties.hibernate.default_batch_fetch_size=100
-코드 작성!-
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 100)
private Team team;

위처럼 해두면, member.getTeam()을 반복하는 경우, JPA가 100개씩 묶어서 아래와 같이 실행한다.

SELECT * FROM Team WHERE id IN (1, 2, 3, ..., 100);

각 연관 엔티티를 미리 가져온다 (지연 로딩이지만 성능 개선)
fetch join처럼 조인으로 인한 중복 데이터가 없고, 페이징에도 사용이 가능하다.


  • 조회 쿼리에 포함할 연관 엔티티가 명확하면 fetch join 를 사용
  • 페이징 + 연관 관계가 필요하다면 batch size 활용
  • 반복 쿼리 발생 여부는 SQL 로그로 항상 확인
  • Spring Data JPA의 경우 @EntityGraph를 통해 명확한 연관 로딩 제어 가능

<참고 자료>
https://www.baeldung.com/spring-hibernate-n1-problem
https://programmer93.tistory.com/83#google_vignette

profile
천천히 굴러갑니다!

0개의 댓글