JPA N+1 문제 해결 전략

JPA N+1 문제란?

N+1 문제는 JPA에서 연관 엔티티를 조회할 때, 부모 1건 조회 쿼리 + 자식 N건 개별 조회 쿼리가 발생하는 현상입니다. 부모 100건을 조회하면 총 101개의 쿼리가 실행됩니다. 개발 환경에서는 눈에 띄지 않지만, 데이터가 쌓이면 심각한 성능 저하의 원인이 됩니다. Hibernate의 Lazy Loading이 기본 동작이기 때문에, 이를 이해하지 않으면 프로덕션에서 반드시 문제가 발생합니다.

N+1이 발생하는 메커니즘

엔티티 관계에서 N+1이 어떻게 발생하는지 정확히 이해해야 합니다.

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

    @OneToMany(mappedBy = "team", fetch = FetchType.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;
}
// N+1 발생 코드
List<Team> teams = teamRepository.findAll();  // 쿼리 1: SELECT * FROM team
for (Team team : teams) {
    team.getMembers().size();  // 쿼리 N: SELECT * FROM member WHERE team_id = ?
    // team마다 개별 쿼리 실행!
}
// 팀이 100개면 → 101개 쿼리 실행

FetchType.LAZY로 설정해도 실제로 getMembers()를 호출하는 순간 프록시가 초기화되면서 개별 쿼리가 발생합니다. FetchType.EAGER로 바꿔도 JPQL 사용 시 동일하게 N+1이 발생하므로 해결책이 아닙니다.

해결법 1: Fetch Join

가장 기본적이고 강력한 해결법입니다. JPQL의 JOIN FETCH로 연관 엔티티를 한 번에 가져옵니다.

public interface TeamRepository extends JpaRepository<Team, Long> {

    // JPQL Fetch Join
    @Query("SELECT t FROM Team t JOIN FETCH t.members")
    List<Team> findAllWithMembers();

    // 여러 연관관계 동시 Fetch
    @Query("SELECT t FROM Team t JOIN FETCH t.members JOIN FETCH t.coach")
    List<Team> findAllWithMembersAndCoach();
}

실행되는 SQL은 단 1개입니다:

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

주의사항:

  • 컬렉션 Fetch Join은 1개만 가능@OneToMany 2개를 동시에 Fetch Join하면 MultipleBagFetchException 발생
  • 페이징 불가 — 컬렉션 Fetch Join + Pageable은 메모리에서 페이징됨 (HHH000104 경고)
  • 중복 결과 — 1:N Join 특성상 부모 엔티티가 중복됨 → DISTINCT 필요
// DISTINCT로 중복 제거
@Query("SELECT DISTINCT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();
// Hibernate 6+에서는 자동으로 DISTINCT 처리

해결법 2: @EntityGraph

JPQL 없이 선언적으로 Fetch 전략을 지정합니다.

public interface TeamRepository extends JpaRepository<Team, Long> {

    // 어노테이션 방식
    @EntityGraph(attributePaths = {"members"})
    @Query("SELECT t FROM Team t")
    List<Team> findAllWithMembersGraph();

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

// 엔티티에 Named EntityGraph 정의
@Entity
@NamedEntityGraph(
    name = "Team.withMembersAndSkills",
    attributeNodes = {
        @NamedAttributeNode(value = "members", subgraph = "members.skills")
    },
    subgraphs = {
        @NamedSubgraph(name = "members.skills",
            attributeNodes = @NamedAttributeNode("skills"))
    }
)
public class Team { ... }

EntityGraph는 내부적으로 LEFT OUTER JOIN을 사용합니다. Fetch Join이 INNER JOIN인 것과 차이가 있으며, 멤버가 없는 팀도 결과에 포함됩니다. JPA EntityGraph 로딩 전략에서 더 자세한 활용법을 확인할 수 있습니다.

해결법 3: @BatchSize

N+1을 완전히 제거하지 않고 줄이는 방법입니다. N개 쿼리를 IN절로 묶어 배치 처리합니다.

@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    @BatchSize(size = 100)  // 100개씩 IN절로 묶음
    private List<Member> members;
}

// 또는 글로벌 설정 (application.yml)
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

팀 100개 조회 시:

  • BatchSize 없음: 1 + 100 = 101 쿼리
  • BatchSize(100): 1 + 1 = 2 쿼리 (WHERE team_id IN (1,2,...,100))
  • BatchSize(50): 1 + 2 = 3 쿼리 (50개씩 2번)

default_batch_fetch_size를 글로벌로 설정하면 모든 Lazy 관계에 적용되어, 별도 코드 변경 없이 N+1을 대폭 줄일 수 있습니다. 실무에서 가장 투자 대비 효과가 큰 설정입니다.

해결법 4: @Fetch(SUBSELECT)

서브쿼리로 연관 엔티티를 한 번에 로딩합니다.

@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    @Fetch(FetchMode.SUBSELECT)
    private List<Member> members;
}

// 실행되는 SQL:
// 1) SELECT * FROM team
// 2) SELECT * FROM member WHERE team_id IN (SELECT id FROM team)
// → 항상 2개 쿼리로 고정

SUBSELECT는 데이터 건수와 관계없이 항상 2개 쿼리로 고정됩니다. BatchSize와 달리 배치 크기를 고민할 필요가 없지만, 원래 쿼리를 서브쿼리로 재실행하므로 원래 쿼리가 복잡하면 성능이 오히려 저하될 수 있습니다.

상황별 최적 전략 비교

전략 쿼리 수 페이징 다중 컬렉션 추천 상황
Fetch Join 1개 ❌ (1개만) 소량 데이터, 단일 연관관계
EntityGraph 1개 ⚠️ (Bag 주의) 선언적 Fetch, 다중 경로
BatchSize 1+⌈N/size⌉ 글로벌 기본 설정, 페이징
SUBSELECT 2개 고정 단순 쿼리, 전체 조회

실전 조합 패턴

실무에서는 여러 전략을 조합합니다.

// application.yml - 글로벌 BatchSize로 기본 방어
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

// Repository - 특정 조회에서 Fetch Join
public interface OrderRepository extends JpaRepository<Order, Long> {

    // 주문 상세 조회: Fetch Join (페이징 불필요)
    @Query("SELECT DISTINCT o FROM Order o " +
           "JOIN FETCH o.orderItems oi " +
           "JOIN FETCH oi.product " +
           "WHERE o.id = :id")
    Optional<Order> findByIdWithItems(@Param("id") Long id);

    // 주문 목록: 페이징 필요 → BatchSize에 의존
    Page<Order> findByUserId(Long userId, Pageable pageable);
    // members는 BatchSize(100)으로 IN절 배치 처리됨
}

이 패턴의 핵심: 글로벌 BatchSize로 기본 방어하고, 성능이 중요한 특정 쿼리에만 Fetch Join을 적용합니다. JPA 커서 페이지네이션과 함께 사용하면 대용량 데이터에서도 일관된 성능을 유지할 수 있습니다.

N+1 탐지 자동화

개발 단계에서 N+1을 자동으로 탐지하는 설정입니다.

// 1. 쿼리 카운트 검증 (테스트)
// spring-data-jpa-query-counter 또는 직접 구현
@Test
void shouldNotCauseNPlusOne() {
    // SQLStatementCountValidator 활용
    SQLStatementCountValidator.reset();

    List<Team> teams = teamRepository.findAllWithMembers();
    teams.forEach(t -> t.getMembers().size());

    SQLStatementCountValidator.assertSelectCount(1);  // 1개 쿼리만 허용
}

// 2. Hibernate 통계 활성화
spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true    # 개발 환경만!

// 3. 로그로 쿼리 수 확인
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.stat: DEBUG

generate_statistics: true는 쿼리 실행 횟수, 시간 등을 로그로 출력합니다. CI 파이프라인에서 쿼리 카운트 테스트를 돌리면 N+1이 코드 리뷰를 통과하는 것을 방지할 수 있습니다.

정리

JPA N+1 문제는 ORM을 사용하는 한 피할 수 없지만, 전략적으로 관리할 수 있습니다. default_batch_fetch_size를 글로벌로 설정해 기본 방어하고, 핵심 쿼리에 Fetch Join을 적용하세요. 페이징이 필요한 컬렉션 조회는 BatchSize에 의존하고, 단건 상세 조회는 Fetch Join으로 최적화하는 것이 실무 표준 패턴입니다. 테스트에서 쿼리 카운트를 검증하면 N+1이 프로덕션에 배포되는 것을 원천 차단할 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux