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개만 가능 —
@OneToMany2개를 동시에 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이 프로덕션에 배포되는 것을 원천 차단할 수 있습니다.