JPA N+1 문제, 왜 생기고 어떻게 해결할까?
많은 개발자들이 JPA를 쓰면서 한 번쯤은 겪는 고질병, 바로 N+1 문제다.
처음엔 성능 잘 나오다가도, 조회 건수가 많아지면 갑자기 쿼리 수가 폭증하고 응답 시간이 기하급수적으로 느려진다.
그리고 로그를 보면 이렇게 되어 있다.
SELECT * FROM member;
SELECT * FROM team WHERE team_id = 1;
SELECT * FROM team WHERE team_id = 2;
SELECT * FROM team WHERE team_id = 3;
...
한 번의 조회로 끝날 줄 알았던 쿼리가 무려 N+1번이나 나간다.
N+1 문제란 무엇인가?
N+1 문제는 JPA에서 1번의 쿼리로 N개의 결과를 가져온 후, 그 결과 각각에 대해 추가 쿼리가 1번씩 실행되는 현상을 말한다.
즉, 총 N+1번의 쿼리가 발생하는 상황이다.
예를 들어, Member와 Team이 다대일 관계일 때 다음과 같은 코드가 있다고 해보자.
List<Member> members = em.createQuery("SELECT m FROM Member m", Member.class)
.getResultList();
for (Member member : members) {
System.out.println(member.getTeam().getName());
}
- 첫 번째 쿼리: SELECT * FROM member
- 두 번째부터 N+1번째까지: 각 member의 team 정보를 조회 (SELECT * FROM team WHERE id = ?)
왜 이런 일이 벌어질까?
JPA의 지연 로딩(Lazy Loading) 때문이다.
JPA는 관계를 가진 엔티티를 기본적으로 필요할 때 로딩하도록 설정한다.
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
위 설정은 지연 로딩이며, team 객체는 getTeam() 호출 시점에 쿼리가 날아간다.
그리고 우리는 N개의 member를 조회하고, 각 member마다 getTeam()을 호출했기 때문에 쿼리도 N번 나가는 것이다.
그렇다면 EAGER로 바꾸면 되지 않나?
이론적으로는 맞지만, 실무에서는 매우 조심해야 한다.
즉시 로딩(EAGER)은 조회 시점에 항상 JOIN을 수행하기 때문에, 다음과 같은 문제가 생긴다.
- 불필요한 쿼리 증가
- 의도하지 않아도 항상 join 되므로 쿼리 복잡도 증가
- 양방향 관계일 때 순환 로딩 가능성
- Member → Team → Member → Team… 무한 루프 가능성
- JPQL로 여러 엔티티 조인 시 의도치 않은 데이터 중복 발생
그래서 실무에서는 대부분의 연관관계를 **지연 로딩(LAZY)**으로 설정하고, fetch join 또는 EntityGraph로 제어하는 방식을 선호한다.
N+1 문제 예제 정리
예제 1: 단방향 다대일
@Entity
public class Member {
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
}
List<Member> members = memberRepository.findAll();
for (Member member : members) {
System.out.println(member.getTeam().getName()); // N번 쿼리 발생
}
예제 2: 양방향 일대다
@Entity
public class Team {
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members;
}
List<Team> teams = teamRepository.findAll();
for (Team team : teams) {
for (Member member : team.getMembers()) {
System.out.println(member.getUsername()); // N번 쿼리 발생
}
}
N+1 문제 해결 방법
1. fetch join 사용
JPA는 JOIN FETCH 구문으로 한 번에 연관된 엔티티를 함께 가져올 수 있다.
@Query("SELECT m FROM Member m JOIN FETCH m.team")
List<Member> findAllWithTeam();
이렇게 하면 Member와 Team을 한 쿼리로 가져오고, N+1이 발생하지 않는다.
SELECT m.*, t.*
FROM member m
JOIN team t ON m.team_id = t.id
단점은 아래와 같다.
- 페이징이 불가능하거나 예측 불가능해진다는 점 (OneToMany일 때 특히)
- JOIN된 데이터가 많아지면 중복 row가 많아질 수 있음
2. EntityGraph 사용
Spring Data JPA에서 제공하는 방식으로, 쿼리 없이도 fetch join 효과를 낼 수 있다.
@EntityGraph(attributePaths = {"team"})
@Query("SELECT m FROM Member m")
List<Member> findAllWithTeam();
- 쿼리 재사용 가능
- 가독성이 좋고 선언적
- 동적 조합은 어려움
3. batch-size 설정 (하이버네이트 전용)
JPA 구현체인 Hibernate는 지연 로딩을 배치 단위로 모아서 한 번에 처리하는 기능을 제공한다.
spring.jpa.properties.hibernate.default_batch_fetch_size=100
- team_id가 1~100까지일 때, 다음 쿼리처럼 IN 절로 한 번에 처리됨
SELECT * FROM team WHERE team_id IN (1, 2, 3, ..., 100)
- 다대일 뿐 아니라 일대다에도 적용 가능
- 성능은 fetch join보다는 느리지만 페이징과 병행 가능
실무 적용 전략
상황 | 해결 방법 |
단순 조회, 연관 엔티티 1~2개 | fetch join |
페이징 필요 | batch-size + 지연 로딩 |
재사용성 높은 쿼리 | EntityGraph |
검색 조건 동적 | QueryDSL + fetch join |
조회 건수 수천 건 이상 | Projection (DTO 직접 조회) |
마무리
N+1 문제는 “설계상의 문제”가 아니라 “JPA의 동작 방식에 대한 이해 부족”에서 출발한다.
기본 fetch 전략을 이해하고, 상황에 맞는 적절한 우회 수단(fetch join, EntityGraph, batch size 등)을 활용하면 JPA는 강력한 무기가 된다.
“JPA는 성능이 나쁜 게 아니라, 잘못 썼을 뿐이다.”
'Dev Framework > Spring' 카테고리의 다른 글
[Spring][JPA] JPA QueryHint에 대해서 알아보자 (0) | 2025.05.01 |
---|---|
[Spring][JPA] 스프링 데이터 JPA Auditing 심화편 (1) | 2025.04.28 |
[Spring][JPA] 스프링 데이터 JPA Auditing 완전 정복 (0) | 2025.04.28 |
[Spring][JPA] Hibernate에서 FROM 절 서브쿼리를 만들 수 없는 이유 (0) | 2025.04.28 |
[Spring] 트랜잭션 예외에 따른 커밋(Commit)과 롤백(Rollback) (0) | 2025.04.22 |