JPA

JPA N+1 문제 완벽 정리 - 원인부터 해결까지 실무 가이드

백엔드 개발자 김승원 2026. 3. 24. 16:27

들어가며

JPA를 사용하는 백엔드 개발자라면 한 번쯤은 마주치게 되는 문제가 있습니다. 바로 N+1 문제입니다. 분명 하나의 쿼리로 끝날 것 같았는데, 콘솔 로그를 열어보면 수십, 수백 개의 쿼리가 쏟아지는 경험을 해보셨을 겁니다. 이 글에서는 N+1 문제의 원인을 정확히 이해하고, 실무에서 바로 적용할 수 있는 해결법을 정리하겠습니다.

1. N+1 문제란 무엇인가

N+1 문제는 연관 관계가 설정된 엔티티를 조회할 때, 1번의 쿼리로 N개의 엔티티를 가져온 뒤, 각 엔티티의 연관된 데이터를 가져오기 위해 추가로 N번의 쿼리가 실행되는 현상입니다. 총 1 + N번의 쿼리가 발생하기 때문에 N+1 문제라고 부릅니다.

간단한 예시로 이해하기

팀(Team)과 회원(Member)이 일대다 관계라고 가정하겠습니다.

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

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

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

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
}

이제 모든 팀을 조회하고, 각 팀의 회원 목록에 접근한다고 해봅시다.

List<Team> teams = teamRepository.findAll(); // 쿼리 1번: SELECT * FROM team

for (Team team : teams) {
    System.out.println(team.getMembers().size()); 
    // 팀마다 쿼리 1번씩: SELECT * FROM member WHERE team_id = ?
}

팀이 10개라면 총 11번의 쿼리(1 + 10)가 발생합니다. 팀이 100개면 101번, 1000개면 1001번입니다. 데이터가 많아질수록 성능이 급격히 저하되는 치명적인 문제입니다.

2. 즉시 로딩(EAGER)과 지연 로딩(LAZY)의 차이와 N+1 발생 시점

즉시 로딩 (FetchType.EAGER)

연관된 엔티티를 즉시 함께 로딩하는 전략입니다. 엔티티를 조회하는 시점에 연관 데이터도 함께 가져옵니다.

@OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
private List<Member> members;

EAGER 로딩은 JPQL을 사용할 때 N+1 문제가 발생합니다. findAll()이 내부적으로 SELECT t FROM Team t라는 JPQL을 실행하면, JPA는 먼저 Team을 모두 가져온 뒤 EAGER 설정을 보고 각 Team의 members를 즉시 추가 쿼리로 가져옵니다.

지연 로딩 (FetchType.LAZY)

연관된 엔티티를 실제로 사용하는 시점에 로딩하는 전략입니다. 프록시 객체를 반환해 두었다가, 해당 데이터에 접근할 때 쿼리를 실행합니다.

@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members;

LAZY 로딩은 연관 데이터에 실제로 접근하는 시점에 N+1 문제가 발생합니다. team.getMembers().size()처럼 프록시를 초기화하는 순간 추가 쿼리가 나갑니다.

핵심 정리

EAGER든 LAZY든 N+1 문제는 발생합니다. 차이는 발생 시점뿐입니다. EAGER는 조회 즉시, LAZY는 접근 시점에 발생합니다. 그래서 JPA 공식 권장 사항은 모든 연관 관계를 LAZY로 설정하고, 필요한 시점에 적절한 해결법을 적용하는 것입니다.

3. 해결법 1: Fetch Join (JPQL JOIN FETCH)

가장 널리 사용되는 해결법입니다. JPQL에서 JOIN FETCH 키워드를 사용하면, 연관된 엔티티를 한 번의 쿼리로 함께 조회합니다.

public interface TeamRepository extends JpaRepository<Team, Long> {

    @Query("SELECT t FROM Team t JOIN FETCH t.members")
    List<Team> findAllWithMembers();
}

실행되는 SQL은 다음과 같습니다.

SELECT t.*, m.*
FROM team t
INNER JOIN member m ON t.id = m.team_id

단 한 번의 쿼리로 팀과 회원 데이터를 모두 가져옵니다.

주의사항: 데이터 중복과 DISTINCT

일대다 관계에서 Fetch Join을 사용하면 조인으로 인해 데이터가 뻥튀기됩니다. 팀 1개에 회원 3명이면 같은 팀 데이터가 3줄로 나옵니다. 이를 방지하려면 DISTINCT를 사용합니다.

@Query("SELECT DISTINCT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();

참고로 Hibernate 6(Spring Boot 3.x)부터는 JPQL의 DISTINCT가 자동으로 엔티티 중복을 제거해주므로, 명시적으로 작성하지 않아도 됩니다.

Fetch Join의 한계

  • 컬렉션 Fetch Join은 1개만 가능합니다. 둘 이상의 컬렉션을 동시에 Fetch Join하면 MultipleBagFetchException이 발생합니다.
  • 페이징 API 사용 불가입니다. 컬렉션 Fetch Join 시 setFirstResult, setMaxResults를 적용하면 메모리에서 페이징 처리가 되어 매우 위험합니다. Hibernate가 경고 로그를 남깁니다: HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

4. 해결법 2: @EntityGraph

@EntityGraph는 Spring Data JPA에서 제공하는 어노테이션으로, JPQL을 직접 작성하지 않고도 Fetch Join과 동일한 효과를 낼 수 있습니다.

public interface TeamRepository extends JpaRepository<Team, Long> {

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

    // 메서드 이름 기반 쿼리에도 사용 가능
    @EntityGraph(attributePaths = {"members"})
    List<Team> findAll();
}

@EntityGraph는 내부적으로 LEFT OUTER JOIN을 사용합니다. Fetch Join이 기본적으로 INNER JOIN을 사용하는 것과 차이가 있으므로, 회원이 없는 팀도 조회 결과에 포함됩니다.

여러 연관 관계를 동시에 로딩

@EntityGraph(attributePaths = {"members", "sponsors"})
@Query("SELECT t FROM Team t")
List<Team> findAllWithMembersAndSponsors();

attributePaths에 여러 필드를 지정하면 됩니다. 다만 이 역시 컬렉션이 2개 이상이면 MultipleBagFetchException 위험이 있으므로, 컬렉션 타입을 Set으로 변경하는 것을 고려해야 합니다.

5. 해결법 3: @BatchSize

@BatchSize는 N+1 문제를 완전히 제거하지는 않지만, N+1을 N/BatchSize + 1로 최적화합니다. SQL의 IN 절을 사용하여 여러 엔티티의 연관 데이터를 한꺼번에 가져옵니다.

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

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

이렇게 설정하면 회원 데이터를 가져올 때 다음과 같은 쿼리가 실행됩니다.

SELECT * FROM member WHERE team_id IN (?, ?, ?, ... , ?)

팀이 100개 이하라면 딱 2번의 쿼리(팀 조회 1번 + 회원 일괄 조회 1번)로 끝납니다.

글로벌 설정

엔티티마다 어노테이션을 붙이는 대신, application.yml에서 전체 애플리케이션에 일괄 적용할 수 있습니다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

이 설정 하나로 모든 연관 관계에 BatchSize가 적용됩니다. 실무에서 매우 유용한 설정입니다.

6. 각 해결법의 장단점 비교

구분 Fetch Join @EntityGraph @BatchSize
쿼리 수 1번 1번 1 + N/size번
JPQL 작성 필요 (JOIN FETCH 명시) 불필요 (어노테이션) 불필요
페이징 지원 컬렉션 조회 시 불가 컬렉션 조회 시 불가 가능
다중 컬렉션 1개만 가능 Set으로 변경 시 가능 제한 없음
글로벌 적용 불가 (쿼리별 설정) 불가 (메서드별 설정) 가능 (yml 설정)
조인 방식 INNER JOIN LEFT OUTER JOIN 조인 없음 (IN 절)
데이터 중복 발생 가능 (DISTINCT 필요) 발생 가능 발생하지 않음
적용 난이도 중간 쉬움 매우 쉬움

7. 실무에서의 베스트 프랙티스

원칙 1: 모든 연관 관계는 LAZY로 설정하라

@ManyToOne@OneToOne은 기본값이 EAGER이므로 반드시 fetch = FetchType.LAZY를 명시하세요. @OneToMany@ManyToMany는 기본값이 LAZY이므로 그대로 두면 됩니다.

@ManyToOne(fetch = FetchType.LAZY) // 반드시 LAZY로!
@JoinColumn(name = "team_id")
private Team team;

원칙 2: 글로벌 BatchSize를 기본으로 깔아라

default_batch_fetch_size: 100application.yml에 설정하면, 별도의 코드 수정 없이도 N+1 문제의 충격을 크게 줄일 수 있습니다. 이것은 안전망 역할을 합니다.

원칙 3: 성능이 중요한 곳은 Fetch Join으로 최적화하라

API 응답 속도가 중요한 화면이나 대량 데이터 처리 로직에서는 Fetch Join을 적용하여 쿼리를 1번으로 줄이세요. 단, 페이징이 필요한 곳에서는 BatchSize가 더 적합합니다.

원칙 4: 컬렉션 페이징은 BatchSize로 해결하라

일대다 관계에서 페이징이 필요하다면 Fetch Join 대신 BatchSize를 사용하세요. Fetch Join + 페이징 조합은 메모리 내 페이징이라는 치명적인 문제를 일으킵니다.

// 올바른 방법: ToOne 관계만 Fetch Join + BatchSize로 컬렉션 해결
@Query("SELECT t FROM Team t JOIN FETCH t.league") // ManyToOne은 Fetch Join
Page<Team> findAllWithLeague(Pageable pageable);
// members(OneToMany)는 BatchSize로 자동 최적화

원칙 5: 쿼리 로그를 반드시 확인하라

개발 환경에서 SQL 로그를 켜두고 예상대로 쿼리가 나가는지 항상 확인하세요.

spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace

정리

JPA N+1 문제는 ORM의 편리함 뒤에 숨어 있는 대표적인 성능 함정입니다. 하지만 원리를 이해하고 적절한 해결법을 적용하면 충분히 제어할 수 있습니다.

  • LAZY 로딩을 기본으로 설정하고
  • 글로벌 BatchSize로 안전망을 깔고
  • 성능이 중요한 곳에서는 Fetch Join으로 정밀 최적화하세요

이 세 가지만 기억하면 실무에서 N+1 문제로 고생하는 일은 크게 줄어들 것입니다. 쿼리 로그를 확인하는 습관과 함께, JPA를 더 안전하고 효율적으로 사용하시길 바랍니다.