JPA N+1 문제란?
N+1 문제는 JPA에서 가장 흔하고 치명적인 성능 이슈입니다. 연관 엔티티를 조회할 때 1번의 쿼리로 부모 N건을 가져온 뒤, 각 부모마다 1번씩 자식 쿼리가 추가 실행되어 총 N+1번의 쿼리가 발생합니다.
이 글에서는 N+1이 발생하는 정확한 메커니즘, 5가지 해결 전략(Fetch Join, EntityGraph, BatchSize, Subselect, DTO Projection), 각 전략의 트레이드오프, 그리고 실전 모니터링 방법을 다룹니다.
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마다 추가 쿼리 발생!
System.out.println(team.getName() + ": " + team.getMembers().size());
// 쿼리 N회: SELECT * FROM member WHERE team_id = ?
}
// 총 쿼리: 1 + N회
FetchType.LAZY여도 EAGER여도 N+1은 발생합니다. EAGER는 findAll() 시점에 즉시 N번 추가 쿼리를 실행하고, LAZY는 컬렉션 접근 시점에 실행합니다. EAGER가 더 위험한 이유는 사용하지 않는 경우에도 항상 쿼리가 실행되기 때문입니다.
해결 전략 1: Fetch Join (JPQL)
가장 기본적이고 강력한 해결책입니다.
public interface TeamRepository extends JpaRepository<Team, Long> {
@Query("SELECT DISTINCT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();
// 단 1번의 쿼리: SELECT t.*, m.* FROM team t
// INNER JOIN member m ON t.id = m.team_id
}
주의사항:
DISTINCT필수 — Fetch Join은 카테시안 곱으로 결과가 부풀어남- 페이징 불가 —
Pageable과 함께 사용하면 메모리에서 페이징 (HHH000104 경고) - 2개 이상의 컬렉션 Fetch Join 불가 —
MultipleBagFetchException발생
// ❌ MultipleBagFetchException 발생
@Query("SELECT t FROM Team t JOIN FETCH t.members JOIN FETCH t.sponsors")
List<Team> findAllWithMembersAndSponsors();
// ✅ 해결: 하나는 Set으로 변경
@OneToMany(mappedBy = "team")
private Set<Sponsor> sponsors = new HashSet<>(); // List → Set
해결 전략 2: @EntityGraph
JPQL 없이 선언적으로 Fetch 전략을 지정합니다.
public interface TeamRepository extends JpaRepository<Team, Long> {
// 방법 1: attributePaths로 직접 지정
@EntityGraph(attributePaths = {"members"})
@Query("SELECT t FROM Team t")
List<Team> findAllWithMembers();
// 방법 2: Named EntityGraph
@EntityGraph(value = "Team.withMembersAndSponsors")
List<Team> findAll();
}
// Named EntityGraph 정의
@Entity
@NamedEntityGraph(
name = "Team.withMembersAndSponsors",
attributeNodes = {
@NamedAttributeNode("members"),
@NamedAttributeNode("sponsors")
}
)
public class Team { ... }
EntityGraph는 내부적으로 LEFT OUTER JOIN을 사용합니다. Fetch Join(INNER JOIN)과 다르게 연관 데이터가 없는 부모도 결과에 포함됩니다.
해결 전략 3: @BatchSize
N+1을 1+⌈N/size⌉로 줄입니다. Fetch Join이 불가능한 상황(페이징 등)에서 유용합니다.
@Entity
public class Team {
@OneToMany(mappedBy = "team")
@BatchSize(size = 100) // IN 절로 최대 100개씩 묶어서 조회
private List<Member> members;
}
// 실행되는 쿼리:
// SELECT * FROM team (1회)
// SELECT * FROM member WHERE team_id IN (1,2,3,...,100) (1회)
// SELECT * FROM member WHERE team_id IN (101,102,...,200) (1회)
// → 팀 500개일 때: 1 + 5 = 6회 (N+1이면 501회)
글로벌 설정도 가능합니다:
# application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
장점: 페이징과 완벽 호환, 기존 코드 수정 불필요. Transaction 전파 전략과도 충돌 없음.
해결 전략 4: @Fetch(FetchMode.SUBSELECT)
@Entity
public class Team {
@OneToMany(mappedBy = "team")
@Fetch(FetchMode.SUBSELECT)
private List<Member> members;
}
// 실행되는 쿼리:
// SELECT * FROM team WHERE ... (1회)
// SELECT * FROM member WHERE team_id IN (1회)
// (SELECT id FROM team WHERE ...) ← 서브쿼리로 한번에!
BatchSize와 달리 항상 2번의 쿼리로 해결됩니다. 단, 원본 쿼리를 서브쿼리로 재실행하므로 원본이 복잡하면 성능이 떨어질 수 있습니다.
해결 전략 5: DTO Projection
엔티티 대신 필요한 필드만 DTO로 직접 조회하면 N+1 자체가 발생하지 않습니다.
// Interface Projection
public interface TeamSummary {
String getName();
int getMemberCount();
}
public interface TeamRepository extends JpaRepository<Team, Long> {
@Query("SELECT t.name AS name, SIZE(t.members) AS memberCount FROM Team t")
List<TeamSummary> findAllSummaries();
}
// Class DTO Projection
public record TeamMemberDto(String teamName, String memberName) {}
@Query("SELECT new com.example.dto.TeamMemberDto(t.name, m.name) " +
"FROM Team t JOIN t.members m")
List<TeamMemberDto> findAllTeamMembers();
DTO Projection은 영속성 컨텍스트를 거치지 않으므로 가장 성능이 좋습니다. 읽기 전용 API에 적극 활용하세요.
전략 비교
- Fetch Join: 1회 쿼리, 페이징 불가, 컬렉션 2개 이상 불가. 단순 조회에 최적
- EntityGraph: LEFT JOIN 기반, 선언적, Fetch Join과 같은 제약. Spring Data와 궁합 좋음
- BatchSize: 1+⌈N/size⌉회, 페이징 호환, 글로벌 설정 가능. 범용적 해결책
- Subselect: 항상 2회, 서브쿼리 기반. 단순 쿼리에 효과적
- DTO Projection: 1회, N+1 원천 차단, 읽기 전용. 성능 최우선 시 선택
N+1 탐지 및 모니터링
Hibernate 쿼리 로깅
# application.yml — 개발 환경 전용
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
# 쿼리 통계 활성화
generate_statistics: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.stat: DEBUG
쿼리 카운트 테스트
// datasource-proxy 라이브러리 활용
@SpringBootTest
class NPlus1Test {
@Autowired EntityManager em;
@Test
void 팀_목록_조회시_쿼리_2회_이내() {
// given
QueryCountHolder.clear();
// when
List<Team> teams = teamRepository.findAllWithMembers();
teams.forEach(t -> t.getMembers().size()); // 강제 초기화
// then
QueryCount count = QueryCountHolder.getGrandTotal();
assertThat(count.getSelect()).isLessThanOrEqualTo(2);
}
}
실전 권장 조합
# 1. 글로벌 BatchSize 설정 (안전망)
spring.jpa.properties.hibernate.default_batch_fetch_size=100
# 2. 모든 연관관계 LAZY 기본
# → @ManyToOne, @OneToOne도 LAZY로 명시
# 3. 핫 쿼리는 Fetch Join 또는 EntityGraph
# 4. API 응답용은 DTO Projection
# 5. 쿼리 카운트 테스트로 회귀 방지
마무리
JPA N+1 문제는 “발생하지 않게 하는 것”이 아니라 “발생했을 때 빠르게 탐지하고 적절한 전략으로 해결하는 것”이 핵심입니다. 글로벌 BatchSize로 안전망을 깔고, 핫 쿼리에는 Fetch Join을, 읽기 전용 API에는 DTO Projection을 적용하세요. Actuator + Micrometer로 슬로우 쿼리를 모니터링하고, R2DBC로의 마이그레이션도 장기적으로 고려해 보세요.