N+1 문제

Uhae·어제

자바 스프링 백엔드 개발자를 준비하며 정리한 JPA N+1 문제입니다.
"Fetch Join 쓰면 되죠?"에서 끝나지 않고, 왜 발생하는지, 각 해결책의 한계는 무엇인지까지 정리했습니다.


1. N+1 문제란?

연관된 엔티티를 조회할 때, 처음 1번의 쿼리 때문에 연관 데이터를 가져오기 위한 쿼리가 N번 추가로 실행되는 문제입니다.

예를 들어 팀(Team) 목록을 조회하고, 각 팀의 멤버(Member)에 접근하면 다음과 같이 됩니다.

SELECT * FROM team;                      -- 1번 (팀 N개 조회)
SELECT * FROM member WHERE team_id = 1;  -- N번 시작
SELECT * FROM member WHERE team_id = 2;
SELECT * FROM member WHERE team_id = 3;
...

팀이 100개면 쿼리가 101번 나갑니다. 데이터가 늘수록 DB 호출 횟수가 선형으로 늘어나 성능이 급격히 나빠집니다. 문제는 코드만 봐서는 눈치채기 어렵다는 점입니다.


2. 재현해 보기

엔티티

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

    @OneToMany(mappedBy = "team")   // 기본 fetch = LAZY
    private List<Member> members = new ArrayList<>();
}

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
}

문제 코드

List<Team> teams = teamRepository.findAll();   // 쿼리 1번

for (Team team : teams) {
    System.out.println(team.getMembers().size());  // 팀마다 쿼리 1번씩 → N번
}

findAll()로 팀만 가져오고, members는 프록시(지연 로딩) 상태입니다. 반복문에서 실제로 접근하는 순간마다 쿼리가 나갑니다.

쿼리 확인 방법

application.yml에 설정하면 SQL을 볼 수 있습니다.

spring:
  jpa:
    properties:
      hibernate:
        format_sql: true
logging:
  level:
    org.hibernate.SQL: debug

💡 개발 중에는 눈으로 확인하고, 테스트에서는 쿼리 횟수를 검증(Hibernate Statistics, p6spy 등)하면 N+1을 조기에 발견할 수 있습니다.


3. 왜 발생할까? (원인)

핵심은 JPQL(Spring Data JPA의 findAll(), 쿼리 메서드 포함)이 SQL로 바로 번역된다는 점입니다.

  1. findAll()은 SELECT t FROM Team t로 실행되어 팀 테이블만 조회합니다.
  2. 연관관계는 글로벌 fetch 전략(LAZY/EAGER)을 보지 않고 일단 무시합니다.
  3. 이후 연관 데이터에 접근할 때 영속성 컨텍스트가 필요한 만큼 추가 쿼리를 날립니다.

즉시 로딩(EAGER)으로 바꾸면 해결될까?

@OneToMany(mappedBy = "team", fetch = FetchType.EAGER)

해결되지 않습니다. JPQL은 fetch 전략을 무시하고 먼저 팀만 조회한 뒤, EAGER라서 곧바로 팀마다 멤버 조회 쿼리를 실행합니다. 오히려 필요 없는 곳에서도 항상 조회되어 더 위험합니다.

결론: 모든 연관관계는 LAZY로 설정하고, 필요한 곳에서만 한 번에 가져오는 전략을 쓴다.
(@ManyToOne, @OneToOne은 기본값이 EAGER이므로 명시적으로 LAZY 지정해야 합니다.)


4. 해결 방법

4-1. Fetch Join (가장 기본)

JPQL에서 join fetch를 쓰면 연관 엔티티를 한 번의 쿼리로 함께 조회합니다.

@Query("select t from Team t join fetch t.members")
List<Team> findAllWithMembers();
SELECT t.*, m.*
FROM team t
INNER JOIN member m ON t.id = m.team_id;   -- 쿼리 1번

장점: 쿼리 1번으로 해결, 직관적입니다.

주의할 점

  1. 컬렉션 페치 조인은 페이징이 안전하지 않습니다.
    • 1:N 조인은 결과 row가 N쪽 기준으로 늘어납니다. (팀 1개 + 멤버 3명 → 3 row)
    • 이 상태에서 Pageable을 쓰면 Hibernate가 경고(HHH000104: firstResult/maxResults specified with collection fetch; applying in memory)를 내고 모든 데이터를 메모리로 읽어서 페이징합니다. → OutOfMemory 위험
  2. 둘 이상의 컬렉션은 페치 조인할 수 없습니다. (MultipleBagFetchException) 카테시안 곱으로 데이터가 폭발하기 때문입니다.
  3. 중복 데이터: Hibernate 5 이하에서는 1:N 페치 조인 시 부모가 중복되어 distinct가 필요합니다. (Hibernate 6 / Spring Boot 3부터는 엔티티 중복을 자동 제거합니다.)
  4. 페치 조인 대상에는 별칭을 주고 조건을 거는 것을 지양합니다. 연관 데이터가 일부만 담긴 채로 영속성 컨텍스트에 올라가 일관성이 깨질 수 있습니다.

4-2. @EntityGraph

메서드에 애노테이션만 붙여서 페치 조인과 같은 효과를 냅니다. (내부적으로 LEFT OUTER JOIN으로 동작)

@EntityGraph(attributePaths = {"members"})
@Query("select t from Team t")
List<Team> findAllWithMembers();

// 쿼리 메서드에도 적용 가능
@EntityGraph(attributePaths = {"members"})
List<Team> findByName(String name);

장점: JPQL을 직접 쓰지 않아도 되고 간결합니다.
한계: 본질은 페치 조인과 같아서 컬렉션 페이징 문제도 동일하게 존재합니다.

4-3. Batch Size (컬렉션 N+1의 실무 해법)

지연 로딩 시 연관 데이터를 IN 쿼리로 묶어서 한 번에 가져옵니다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

또는 개별로 지정할 수도 있습니다.

@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();

결과 쿼리는 다음과 같습니다.

SELECT * FROM team;                                   -- 1번
SELECT * FROM member WHERE team_id IN (1, 2, 3, ... 100);  -- 팀 100개씩 묶어서 1번

팀이 100개면 101번 → 2번으로 줄어듭니다. (N+1 → 1+⌈N/size⌉)

장점

  • 컬렉션이 있어도 페이징이 정상 동작합니다. (팀을 DB에서 페이징 → 가져온 팀들의 멤버만 IN으로 조회)
  • 둘 이상의 컬렉션도 문제없습니다.
  • 설정 한 줄로 전체 적용이 가능합니다.

팁: 사이즈는 보통 100~1000 사이에서 DB의 IN 절 한계와 WAS 메모리를 고려해 정합니다.

4-4. DTO 직접 조회

엔티티가 아닌 필요한 컬럼만 DTO로 바로 조회합니다.

@Query("select new com.example.dto.TeamMemberDto(t.name, m.name) " +
       "from Team t join t.members m")
List<TeamMemberDto> findTeamMemberDtos();

장점: 필요한 컬럼만 가져와 성능이 좋고, 영속성 컨텍스트를 거치지 않아 N+1 자체가 일어나지 않습니다.
단점: 엔티티를 재사용하기 어렵고 조회 전용에 한정됩니다. 화면에 딱 맞춘 조회 API에 적합합니다.


5. 그래서 뭘 써야 할까?

상황추천
ToOne (@ManyToOne, @OneToOne) 연관Fetch Join (페이징 영향 없음)
ToMany (@OneToMany) 연관 + 페이징default_batch_fetch_size + 필요 시 ToOne만 fetch join
컬렉션을 한 번에, 페이징 없음Fetch Join / EntityGraph
조회 전용 복잡한 화면DTO 직접 조회 (QueryDSL 등)

실무의 정석 패턴

  1. 모든 연관관계를 LAZY로 설정한다.
  2. default_batch_fetch_size를 전역으로 설정해 둔다. (컬렉션 N+1 방어선)
  3. ToOne 관계는 Fetch Join으로 한 번에 가져온다.
  4. 복잡한 조회는 DTO 조회로 분리한다.
// ToOne은 fetch join, ToMany는 batch size로 처리
@Query("select o from Order o join fetch o.member join fetch o.delivery")
List<Order> findAllWithMemberDelivery(Pageable pageable);
// orderItems 같은 컬렉션은 LAZY + batch size가 IN 쿼리로 자동 처리

6. 정리

  • N+1은 연관 엔티티를 지연 로딩으로 접근할 때 반복적으로 쿼리가 실행되는 문제입니다.
  • EAGER로 바꾼다고 해결되지 않습니다. (오히려 악화) 항상 LAZY를 기본으로 합니다.
  • 해결책은 Fetch Join, EntityGraph, Batch Size, DTO 조회입니다.
  • 컬렉션 페치 조인 + 페이징 = 메모리 위험, 이 경우엔 Batch Size를 사용합니다.
  • 성능 문제는 쿼리 로그를 직접 눈으로 보고 검증하는 습관이 중요합니다.

7. 면접 대비 질문과 모범 답안

Q1. N+1 문제가 무엇이고 왜 발생하나요?

A. 연관 관계가 있는 엔티티를 조회할 때, 처음 조회 쿼리 1번 이후에 연관된 엔티티를 가져오기 위한 쿼리가 N번 추가로 실행되는 문제입니다. 예를 들어 팀 100개를 조회한 뒤 각 팀의 멤버에 접근하면 쿼리가 101번 나갑니다.

원인은 JPQL이 글로벌 fetch 전략을 고려하지 않고 SQL로 그대로 번역되기 때문입니다. 먼저 팀만 조회하고, 이후 지연 로딩된 연관 데이터에 접근할 때 건건이 추가 쿼리가 실행됩니다.

Q2. 즉시 로딩(EAGER)으로 바꾸면 N+1이 해결되나요?

A. 해결되지 않습니다. JPQL은 fetch 전략을 무시하고 우선 본 엔티티만 조회한 뒤, EAGER 설정 때문에 즉시 연관 엔티티를 추가 쿼리로 조회하므로 오히려 N+1이 발생합니다. 또한 연관 데이터가 필요 없는 상황에서도 항상 조회되어 불필요한 쿼리가 생깁니다. 그래서 모든 연관관계는 LAZY로 두고, 필요한 시점에 Fetch Join 등으로 함께 조회하는 것이 좋습니다.

Q3. N+1을 해결하는 방법에는 어떤 것이 있나요?

A. 네 가지를 주로 씁니다.
1. Fetch Join: JPQL에서 연관 엔티티를 한 번의 쿼리로 함께 조회합니다.
2. @EntityGraph: 애노테이션으로 페치 조인과 같은 효과를 냅니다. (LEFT OUTER JOIN)
3. Batch Size: 지연 로딩 시 연관 데이터를 IN 쿼리로 묶어서 조회해 쿼리 수를 1+N에서 1+N/size로 줄입니다.
4. DTO 직접 조회: 필요한 컬럼만 DTO로 조회해 엔티티 연관 로딩 자체를 피합니다.

Q4. Fetch Join의 단점이나 주의할 점은 무엇인가요?

A. 가장 큰 문제는 컬렉션(1:N) 페치 조인과 페이징을 함께 쓸 수 없다는 점입니다. 1:N 조인은 결과 row가 늘어나기 때문에, Hibernate가 DB에서 페이징하지 못하고 모든 데이터를 메모리로 가져와 페이징합니다. 데이터가 많으면 OutOfMemory 위험이 있습니다.

또한 둘 이상의 컬렉션은 페치 조인할 수 없고(MultipleBagFetchException), 별칭을 사용해 조건을 거는 것은 영속성 컨텍스트의 일관성을 해칠 수 있어 지양합니다.

Q5. 컬렉션 조회에서 페이징이 필요하면 어떻게 하나요?

A. 두 단계로 접근합니다. 먼저 ToOne 관계는 Fetch Join으로 가져오고(row가 늘어나지 않아 페이징에 영향이 없습니다), 컬렉션은 LAZY로 두고 default_batch_fetch_size(또는 @BatchSize)를 설정합니다. 그러면 본 엔티티는 DB에서 정상적으로 페이징되고, 조회된 엔티티들의 컬렉션만 IN 쿼리로 한 번에 가져오므로 쿼리 수도 크게 줄어듭니다.

Q6. Fetch Join과 @EntityGraph의 차이는 무엇인가요?

A. 둘 다 연관 엔티티를 한 번의 쿼리로 함께 가져온다는 점에서 목적은 같습니다. 차이는 사용 방식과 조인 종류입니다. Fetch Join은 JPQL에 직접 join fetch를 작성하고 기본이 INNER JOIN이며, EntityGraph는 애노테이션으로 선언하고 LEFT OUTER JOIN으로 동작합니다. 쿼리를 직접 쓰지 않아도 된다는 편의성은 있지만, 컬렉션 페이징 문제 같은 한계는 동일합니다.

Q7. N+1 문제를 어떻게 발견하고 예방하나요?

A. 개발 단계에서 SQL 로그를 켜서 실행되는 쿼리 개수를 직접 확인하고, p6spy나 Hibernate Statistics로 쿼리 횟수를 테스트로 검증하는 방법이 있습니다. 예방 차원에서는 모든 연관관계를 LAZY로 설정하고, default_batch_fetch_size를 전역으로 지정해 두며, ToOne은 Fetch Join, 복잡한 조회는 DTO로 분리하는 원칙을 세워 둡니다. 운영 환경에서는 APM(예: Pinpoint)으로 API당 쿼리 수를 모니터링하는 것도 좋습니다.

Q8. @ManyToOne의 기본 fetch 전략은 무엇이고, 왜 LAZY로 바꿔야 하나요?

A. @ManyToOne과 @OneToOne은 기본이 EAGER입니다. (@OneToMany, @ManyToMany는 LAZY) EAGER는 연관 엔티티가 필요 없는 경우에도 항상 조회하고, JPQL에서 N+1을 유발하며, 연관관계가 얽히면 예상치 못한 쿼리가 많이 발생합니다. 그래서 fetch = FetchType.LAZY로 명시적으로 지연 로딩을 지정하고, 필요한 시점에 Fetch Join으로 가져오는 것이 권장됩니다.


0개의 댓글