JPA N+1 문제 완전 정리
N+1 문제는 JPA를 실무에서 사용할 때 가장 자주 마주치는 성능 이슈다. 원인과 해결책을 정확히 이해하지 못하면 운영 환경에서 예상치 못한 쿼리 폭탄을 맞게 된다. 이번 포스트에서는 N+1 문제의 발생 원인부터 다양한 해결 방법과 극한 시나리오까지 상세히 정리한다.
Step 1: N+1 문제란?
1번의 쿼리를 실행했더니 결과 행의 수(N)만큼 추가 쿼리가 실행되는 현상이다.
예를 들어 팀 목록을 조회했더니 각 팀의 멤버를 가져오기 위해 팀 수만큼 추가 SELECT가 발생하는 것이다.
[기대하는 쿼리 흐름]
SELECT * FROM Team; <- 1번 쿼리
SELECT * FROM Member WHERE team_id IN (1, 2, 3, ...); <- 1번 쿼리
총 2번
[실제 N+1 발생 시]
SELECT * FROM Team; <- 1번 (N=5라 가정)
SELECT * FROM Member WHERE team_id = 1; <- 추가 1번
SELECT * FROM Member WHERE team_id = 2; <- 추가 1번
SELECT * FROM Member WHERE team_id = 3; <- 추가 1번
SELECT * FROM Member WHERE team_id = 4; <- 추가 1번
SELECT * FROM Member WHERE team_id = 5; <- 추가 1번
총 1 + N = 6번
팀이 100개면 101번, 1000개면 1001번의 쿼리가 실행된다. 각 쿼리는 DB 커넥션을 사용하고 네트워크 왕복이 발생하기 때문에 서비스 응답 시간이 급격히 느려지고 DB에 부하가 걸린다.
엔티티 구조 (이 포스트 전체에서 사용)
@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 username;
private int age;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
}
Step 2: 즉시 로딩(EAGER)에서의 N+1
FetchType.EAGER로 설정하면 연관 엔티티를 항상 즉시 조회한다.
@ManyToOne(fetch = FetchType.EAGER) // EAGER 설정
@JoinColumn(name = "team_id")
private Team team;
// JPQL로 Member 전체 조회
List<Member> members = em.createQuery(
"select m from Member m", Member.class)
.getResultList();
실행되는 SQL 로그
-- 1. Member 전체 조회 (JPQL 그대로 번역)
Hibernate:
select member0_.id, member0_.username, member0_.age, member0_.team_id
from Member member0_
-- 2. EAGER이므로 각 Member의 Team을 즉시 로딩 (N번 실행)
Hibernate:
select team0_.id, team0_.name
from Team team0_
where team0_.id=?
Hibernate:
select team0_.id, team0_.name
from Team team0_
where team0_.id=?
-- ... Member 수(N)만큼 반복
왜 JOIN으로 한 번에 가져오지 않는가?
JPQL은 작성한 쿼리를 그대로 SQL로 번역한다. "select m from Member m" 에는 JOIN이 없으므로 Member만 조회한다. 그런데 EAGER 설정 때문에 조회 후 연관 엔티티를 즉시 가져오기 위해 Team을 추가로 각각 조회하는 것이다.
em.find()는 JPA가 내부적으로 JOIN을 활용하여 한 번에 가져오지만, JPQL은 다르게 동작한다.
Step 3: 지연 로딩(LAZY)에서의 N+1
지연 로딩으로 설정해도 N+1 문제가 발생할 수 있다.
@ManyToOne(fetch = FetchType.LAZY) // LAZY 설정
@JoinColumn(name = "team_id")
private Team team;
List<Member> members = em.createQuery(
"select m from Member m", Member.class)
.getResultList();
// 이 시점에는 쿼리 1번만 실행됨 (Team은 프록시)
for (Member member : members) {
System.out.println(member.getTeam().getName()); // 여기서 N번 실행!
}
실행되는 SQL 로그
-- 1. Member 전체 조회
Hibernate:
select member0_.id, member0_.username, member0_.age, member0_.team_id
from Member member0_
-- 2. 루프에서 team.getName() 호출 시마다 각각 조회
Hibernate:
select team0_.id, team0_.name from Team team0_ where team0_.id=?
-- binding parameter [1] as [LONG] - [1]
Hibernate:
select team0_.id, team0_.name from Team team0_ where team0_.id=?
-- binding parameter [1] as [LONG] - [2]
-- ... N번 반복
LAZY라서 최초 조회 시에는 문제가 없어 보이지만, 실제로 연관 엔티티에 접근하는 시점에 N번의 쿼리가 발생한다. 개발 환경에서는 데이터가 적어 눈에 띄지 않다가 운영 환경에서 폭발하는 경우가 많다.
Step 4: 해결 방법 1 — Fetch Join (JPQL JOIN FETCH)
동작 원리
JPQL에 JOIN FETCH를 사용하면 연관 엔티티를 한 번의 쿼리로 함께 조회한다.
List<Member> members = em.createQuery(
"select m from Member m join fetch m.team", Member.class)
.getResultList();
for (Member member : members) {
System.out.println(member.getTeam().getName()); // 추가 쿼리 없음
}
실행되는 SQL 로그
-- 단 1번의 쿼리로 Member + Team 모두 조회
Hibernate:
select
member0_.id,
member0_.username,
member0_.age,
member0_.team_id,
team1_.id,
team1_.name
from Member member0_
inner join Team team1_ on member0_.team_id=team1_.id
컬렉션 Fetch Join (Team -> members 방향)
List<Team> teams = em.createQuery(
"select t from Team t join fetch t.members", Team.class)
.getResultList();
Hibernate:
select
team0_.id, team0_.name,
members1_.id, members1_.username, members1_.team_id
from Team team0_
inner join Member members1_ on team0_.id=members1_.team_id
Fetch Join 한계: 컬렉션 페치 조인 시 페이징 불가
컬렉션(OneToMany)에 Fetch Join을 사용하면서 동시에 페이징을 적용하면 심각한 문제가 발생한다.
// 위험! 메모리에서 페이징 처리됨
List<Team> teams = em.createQuery(
"select t from Team t join fetch t.members", Team.class)
.setFirstResult(0)
.setMaxResults(10)
.getResultList();
Hibernate 경고 로그
WARN o.h.h.internal.ast.QueryTranslatorImpl - HHH90003004:
firstResult/maxResults specified with collection fetch;
applying in memory!
[문제 발생 이유]
Team A -- Member 1
-- Member 2
Team B -- Member 3
JOIN 결과 (카테시안 곱 발생):
+--------+----------+
| Team A | Member 1 |
| Team A | Member 2 |
| Team B | Member 3 |
+--------+----------+
DB 레벨에서 LIMIT 10 적용 시:
-> "팀 기준 10개"가 아닌 "JOIN 결과 행 기준 10개"가 잘림
-> Team 기준 페이징 불가, 결과가 의도와 다르게 나옴
Hibernate의 선택: 전체 데이터를 메모리에 올린 후 페이징
-> 데이터가 많으면 OutOfMemoryError 위험
해결책: 컬렉션 페이징 + Fetch Join 조합은 피하고, 대신 @BatchSize나 별도 쿼리로 분리한다.
Step 5: 해결 방법 2 — @EntityGraph
Spring Data JPA에서 JPQL 없이 메서드 이름만으로 Fetch Join 효과를 낼 수 있다.
attributePaths 설정
public interface MemberRepository extends JpaRepository<Member, Long> {
@EntityGraph(attributePaths = {"team"}) // team을 함께 로딩
List<Member> findAll();
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findAllWithTeam();
}
실행되는 SQL 로그
-- LEFT OUTER JOIN으로 실행됨 (Fetch Join은 INNER JOIN)
Hibernate:
select
member0_.id, member0_.username, member0_.team_id,
team1_.id, team1_.name
from Member member0_
left outer join Team team1_ on member0_.team_id=team1_.id
Named EntityGraph
엔티티에 직접 정의해두고 여러 곳에서 재사용할 수 있다.
@Entity
@NamedEntityGraph(
name = "Member.withTeam",
attributeNodes = @NamedAttributeNode("team")
)
public class Member {
// ...
}
// Repository에서 이름으로 참조
public interface MemberRepository extends JpaRepository<Member, Long> {
@EntityGraph("Member.withTeam")
List<Member> findByUsername(String username);
}
@EntityGraph vs Fetch Join 차이점
| 구분 | Fetch Join | @EntityGraph |
|---|---|---|
| JOIN 방식 | INNER JOIN | LEFT OUTER JOIN |
| 사용 방식 | JPQL 직접 작성 | 어노테이션 |
| 동적 조건 | 자유롭게 작성 | 제한적 |
| 가독성 | JPQL 작성 필요 | 선언적, 직관적 |
Step 6: 해결 방법 3 — @BatchSize
IN 절 최적화 동작 원리
@BatchSize는 지연 로딩 시 프록시를 초기화할 때 개별 SELECT 대신 IN 절을 사용해 한 번에 여러 행을 조회하는 방식이다.
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@BatchSize(size = 100) // 최대 100개씩 IN 절로 조회
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members = new ArrayList<>();
}
List<Team> teams = teamRepository.findAll(); // SELECT * FROM Team
for (Team team : teams) {
team.getMembers().size(); // 첫 접근 시 배치 조회 발동
}
실행되는 SQL 로그 (N+1 없음)
-- 1. Team 전체 조회
Hibernate:
select team0_.id, team0_.name from Team team0_
-- 2. 첫 번째 members 접근 시 -> 최대 100개 team_id를 IN으로 한 번에 조회
Hibernate:
select members0_.team_id, members0_.id, members0_.username
from Member members0_
where members0_.team_id in (?, ?, ?, ?, ?, ...)
-- binding parameter [1] as [LONG] - [1]
-- binding parameter [2] as [LONG] - [2]
-- ...
[@BatchSize 동작 흐름]
team1.getMembers() 최초 접근
|
+---> 영속성 컨텍스트 내 프록시 상태인 Team들 수집
+---> 최대 100개씩 묶어 IN 절 쿼리 실행
+---> WHERE team_id IN (1, 2, 3, ..., 100)
+---> 결과를 각 Team의 members에 채움
N+1 -> N/BatchSize + 1 번으로 감소
(팀 1000개, batchSize=100 -> 최대 11번 쿼리)
글로벌 설정 (권장)
모든 연관관계에 일일이 @BatchSize를 붙이는 대신 전역 설정을 사용한다. 실무에서 가장 많이 사용하는 방식이다.
# application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
또는
<!-- persistence.xml -->
<property name="hibernate.default_batch_fetch_size" value="1000"/>
default_batch_fetch_size의 적정값은 보통 100~1000 사이다. DB와 애플리케이션 서버 사양, IN 절에서 사용되는 파라미터 수 제한을 고려해 설정한다.
Step 7: 해결 방법 4 — @Fetch(FetchMode.SUBSELECT)
IN 절 대신 서브쿼리를 사용해 한 번에 조회하는 방식이다.
@Entity
public class Team {
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
@Fetch(FetchMode.SUBSELECT) // Hibernate 전용 어노테이션
private List<Member> members = new ArrayList<>();
}
실행되는 SQL 로그
-- 1. Team 전체 조회
Hibernate:
select team0_.id, team0_.name from Team team0_
-- 2. members 접근 시 -> 서브쿼리로 한 번에 모두 조회
Hibernate:
select members0_.team_id, members0_.id, members0_.username
from Member members0_
where members0_.team_id in (
select team0_.id from Team team0_
)
@BatchSize와의 차이
| 구분 | @BatchSize | @Fetch(SUBSELECT) |
|---|---|---|
| 쿼리 방식 | IN (?, ?, …) | IN (SELECT …) |
| 쿼리 횟수 | N/size + 1 | 2번 (항상) |
| 메모리 사용 | 배치 단위 | 전체 한 번에 |
| 표준 여부 | JPA 표준 | Hibernate 전용 |
대용량 데이터에서는 서브쿼리가 부담이 될 수 있으므로 @BatchSize가 더 유연하다.
Step 8: 해결 방법 5 — QueryDSL + DTO 직접 조회
연관 엔티티를 엔티티 그래프로 로딩하는 대신, 필요한 데이터만 DTO로 직접 프로젝션하는 방식이다. 가장 성능이 뛰어난 방법이다.
@QueryProjection // 컴파일 타임에 QMemberTeamDto 생성
public class MemberTeamDto {
private Long memberId;
private String username;
private String teamName;
public MemberTeamDto(Long memberId, String username, String teamName) {
this.memberId = memberId;
this.username = username;
this.teamName = teamName;
}
}
// QueryDSL로 JOIN + DTO 직접 조회
JPAQueryFactory queryFactory = new JPAQueryFactory(em);
QMember m = QMember.member;
QTeam t = QTeam.team;
List<MemberTeamDto> result = queryFactory
.select(new QMemberTeamDto(
m.id,
m.username,
t.name))
.from(m)
.join(m.team, t)
.where(m.age.gt(20))
.fetch();
실행되는 SQL 로그
-- 단 1번의 쿼리, 필요한 컬럼만 조회
Hibernate:
select
member0_.id,
member0_.username,
team1_.name
from Member member0_
inner join Team team1_ on member0_.team_id=team1_.id
where
member0_.age>?
JPQL new 명령어로도 가능
List<MemberTeamDto> result = em.createQuery(
"select new com.example.dto.MemberTeamDto(m.id, m.username, t.name) " +
"from Member m join m.team t where m.age > :age",
MemberTeamDto.class)
.setParameter("age", 20)
.getResultList();
단점: 엔티티가 아닌 DTO를 반환하므로 JPA 변경 감지(Dirty Checking)가 동작하지 않는다. 조회 전용으로만 사용해야 한다.
Step 9: 각 해결 방법 비교
| 방법 | 성능 | 편의성 | 페이징 지원 | 제약사항 |
|---|---|---|---|---|
| Fetch Join | 높음 | 중간 | 컬렉션 불가 | JPQL 직접 작성 |
| @EntityGraph | 높음 | 높음 | 컬렉션 불가 | LEFT JOIN 고정 |
| @BatchSize | 중간~높음 | 높음 | 가능 | 쿼리 2번 이상 |
| SUBSELECT | 중간 | 중간 | 가능 | Hibernate 전용 |
| DTO 직접 조회 | 최고 | 낮음 | 가능 | 엔티티 아님, 코드량 多 |
선택 기준
단순 조회 + 페이징 필요?
-> @BatchSize (글로벌 설정) + LAZY 기본 전략
조인 대상이 1개 + 페이징 불필요?
-> Fetch Join 또는 @EntityGraph
성능이 최우선 + 조회 전용?
-> QueryDSL + DTO 직접 조회
복잡한 조건 + 여러 연관관계?
-> QueryDSL + DTO 또는 @BatchSize 조합
Step 10: 극한 시나리오
10-1. 다단계 연관관계에서의 N+1 (A → B → C)
@Entity
public class Order {
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> orderItems;
}
@Entity
public class OrderItem {
@ManyToOne(fetch = FetchType.LAZY)
private Item item;
}
List<Order> orders = orderRepository.findAll();
for (Order order : orders) {
for (OrderItem orderItem : order.getOrderItems()) { // N번 쿼리
System.out.println(orderItem.getItem().getName()); // N*M번 쿼리
}
}
Order 10개, 각 Order당 OrderItem 5개, 각 OrderItem당 Item 조회 시:
Order 조회: 1번
OrderItem 조회: 10번 (Order당 1번)
Item 조회: 10 * 5 = 50번
총 61번 쿼리 발생!
해결: 중첩 Fetch Join
List<Order> orders = em.createQuery(
"select distinct o from Order o " +
"join fetch o.orderItems oi " +
"join fetch oi.item", Order.class)
.getResultList();
또는 글로벌 default_batch_fetch_size 설정으로 각 단계에서 배치 조회를 적용한다.
10-2. MultipleBagFetchException
컬렉션(List) Fetch Join을 2개 이상 동시에 사용하면 예외가 발생한다.
@Entity
public class Team {
@OneToMany(mappedBy = "team")
private List<Member> members;
@OneToMany(mappedBy = "team")
private List<Coach> coaches;
}
// 예외 발생!
List<Team> teams = em.createQuery(
"select t from Team t " +
"join fetch t.members " +
"join fetch t.coaches", Team.class)
.getResultList();
// org.hibernate.loader.MultipleBagFetchException:
// cannot simultaneously fetch multiple bags: [Team.members, Team.coaches]
[원인: 카테시안 곱]
Team A -- Member 1
-- Member 2
-- Coach X
-- Coach Y
JOIN 결과:
Team A | Member 1 | Coach X
Team A | Member 1 | Coach Y
Team A | Member 2 | Coach X
Team A | Member 2 | Coach Y
-> 중복 데이터 폭발, 데이터 정합성 문제
해결 방법
// 방법 1: List -> Set으로 변경 (순서 없음 허용 시)
@OneToMany(mappedBy = "team")
private Set<Member> members = new HashSet<>(); // Set은 중복 제거 가능
// 방법 2: Fetch Join 1개 + @BatchSize 조합
// members는 Fetch Join, coaches는 @BatchSize로 배치 조회
List<Team> teams = em.createQuery(
"select t from Team t join fetch t.members", Team.class)
.getResultList();
// coaches는 @BatchSize로 자동 배치 조회
// 방법 3: 쿼리 분리
List<Team> teams = teamRepository.findAllWithMembers(); // members Fetch Join
List<Team> teams2 = teamRepository.findAllWithCoaches(); // coaches Fetch Join
10-3. 카테시안 곱 문제
컬렉션 Fetch Join을 사용하면 JOIN 결과에 중복 행이 생긴다.
List<Team> teams = em.createQuery(
"select t from Team t join fetch t.members", Team.class)
.getResultList();
System.out.println(teams.size()); // 팀 2개인데 5가 나올 수 있음!
// (Member 수만큼 Team이 중복 반환)
-- JOIN 결과
+--------+----------+
| TEAM | MEMBER |
+--------+----------+
| Team A | Member 1 |
| Team A | Member 2 | <- Team A 중복
| Team A | Member 3 | <- Team A 중복
| Team B | Member 4 |
| Team B | Member 5 | <- Team B 중복
+--------+----------+
List<Team> 에 Team A가 3개, Team B가 2개 담김!
해결: DISTINCT 사용
// JPQL에서 distinct
List<Team> teams = em.createQuery(
"select distinct t from Team t join fetch t.members", Team.class)
.getResultList();
// JPQL의 distinct는 애플리케이션 레벨에서도 중복 제거를 수행
// (SQL의 DISTINCT + 같은 식별자 엔티티 중복 제거)
// Spring Data JPA + @EntityGraph
@EntityGraph(attributePaths = {"members"})
@Query("select distinct t from Team t")
List<Team> findAllWithMembers();
10-4. 페이징 + 페치 조인 조합 시 메모리 이슈
앞서 언급했듯이 컬렉션 Fetch Join + 페이징은 위험하다.
// 위험한 코드: 데이터 전체를 메모리에 올린 후 페이징
List<Team> teams = em.createQuery(
"select t from Team t join fetch t.members", Team.class)
.setFirstResult(0)
.setMaxResults(10)
.getResultList();
// HHH90003004 경고: applying in memory!
// 팀이 10만 개면 10만 개를 메모리에 올려 자름 -> OOM 위험
올바른 해결: 페이징 시에는 컬렉션 Fetch Join 대신 @BatchSize 사용
// application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
// Repository
Page<Team> findAll(Pageable pageable);
// -> Team만 페이징 조회 (1번)
// -> members는 배치 조회 (IN 절, 1번)
// 총 2번 쿼리, 메모리 안전
Step 11: 실무 Best Practice
기본 전략: LAZY + 필요 시 Fetch Join
모든 연관관계는 기본적으로 FetchType.LAZY로 설정한다. 성능 이슈가 확인된 쿼리에만 Fetch Join을 적용한다.
// 모든 연관관계에 LAZY 설정
@ManyToOne(fetch = FetchType.LAZY)
@OneToOne(fetch = FetchType.LAZY)
@OneToMany(fetch = FetchType.LAZY) // @OneToMany 기본값이 LAZY이긴 함
글로벌 BatchSize 적용
default_batch_fetch_size를 전역 설정해두면 대부분의 N+1을 자동으로 완화할 수 있다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
이 설정 하나로 지연 로딩 시 IN 절 최적화가 자동으로 적용되어 대부분의 N+1 문제가 N/1000 + 1 수준으로 줄어든다.
DTO 프로젝션 전략
화면에 필요한 데이터만 조회할 때는 엔티티 대신 DTO를 직접 반환한다.
// 조회 전용 서비스: DTO 직접 반환
@Transactional(readOnly = true)
public List<MemberTeamDto> searchMembers(MemberSearchCondition condition) {
return queryFactory
.select(new QMemberTeamDto(
member.id,
member.username,
member.age,
team.name))
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe()))
.fetch();
}
Spring Data JPA에서의 적용
public interface TeamRepository extends JpaRepository<Team, Long> {
// 페이징: @BatchSize 글로벌 설정 활용
Page<Team> findAll(Pageable pageable);
// 단건 조회 시 Fetch Join: @EntityGraph 활용
@EntityGraph(attributePaths = {"members"})
Optional<Team> findWithMembersById(Long id);
// 복잡한 조건 조회: QueryDSL 사용 (별도 Custom Repository)
// TeamRepositoryCustom 인터페이스 + TeamRepositoryCustomImpl 구현
}
// Custom Repository 구현
public class TeamRepositoryCustomImpl implements TeamRepositoryCustom {
private final JPAQueryFactory queryFactory;
public List<TeamDto> searchTeams(TeamSearchCondition condition) {
return queryFactory
.select(new QTeamDto(team.id, team.name, member.count()))
.from(team)
.leftJoin(team.members, member)
.groupBy(team.id)
.where(teamNameContains(condition.getTeamName()))
.fetch();
}
}
요약 의사결정 흐름
연관 데이터가 필요한가?
|
+---> 단건 조회 (em.find, findById)?
| -> Fetch Join 또는 @EntityGraph
|
+---> 목록 조회 (findAll)?
| |
| +---> 페이징 필요?
| | -> 컬렉션 Fetch Join 금지
| | -> @BatchSize(global) + LAZY 기본 전략
| |
| +---> 페이징 불필요?
| -> Fetch Join (단, 컬렉션 1개만)
| -> 컬렉션 2개 이상: Set 또는 쿼리 분리
|
+---> 조회 전용 + 성능 최우선?
-> QueryDSL + DTO 직접 조회
참조 - 자바 ORM 표준 JPA 프로그래밍 By 김영한
참조 - 실전! 스프링 부트와 JPA 활용 By 김영한
참조 - 실전! Querydsl By 김영한