Spring JPA를 사용하다 보면 반드시 마주치는 성능 함정이 있습니다. 바로 N+1 문제입니다. 목록 조회 API 하나에서 데이터베이스 쿼리가 수백, 수천 개씩 나가는 현상으로, 대규모 서비스에서는 DB 부하를 폭증시켜 장애를 유발하는 치명적인 문제입니다. 이 글에서는 N+1 문제가 발생하는 원리를 명확하게 이해하고, **페치 조인(Fetch Join)**과 @EntityGraph를 통한 해결 방법을 실전 코드와 함께 완벽하게 정리합니다.
1. N+1 문제란 무엇인가?
N+1 문제는 연관 관계가 있는 엔티티를 조회할 때, 1번의 쿼리로 N개의 엔티티를 가져온 뒤, 각 엔티티의 연관 데이터를 조회하기 위해 N번의 추가 쿼리가 발생하는 현상입니다. 총 1 + N번의 쿼리가 실행됩니다.
도메인 모델 예시
@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;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
}
N+1 발생 코드
// 팀 목록 10개를 조회
List<Team> teams = em.createQuery("select t from Team t", Team.class).getResultList();
// 각 팀의 members에 접근할 때마다 추가 쿼리 발생!
for (Team team : teams) {
System.out.println(team.getMembers().size()); // 여기서 SELECT 쿼리 발생
}
실행되는 쿼리:
-- 1번: 팀 목록 조회
SELECT * FROM team;
-- N번: 각 팀의 멤버 조회 (팀이 10개면 10번 추가 실행!)
SELECT * FROM member WHERE team_id = 1;
SELECT * FROM member WHERE team_id = 2;
SELECT * FROM member WHERE team_id = 3;
-- ... (팀 수만큼 반복)
팀이 100개면 총 101번, 1,000개면 1,001번의 쿼리가 실행되어 DB 서버가 순식간에 마비될 수 있습니다.
2. 왜 N+1이 발생하는가? — LAZY 로딩의 원리
N+1 문제는 주로 **지연 로딩(LAZY Loading)**에서 발생합니다.
FetchType.LAZY: 연관 엔티티는 처음에 로딩하지 않고, 실제로 접근하는 순간 쿼리를 실행합니다.FetchType.EAGER: 연관 엔티티를 즉시 함께 조회합니다. EAGER도 JPQL 사용 시 N+1 문제가 발생할 수 있습니다.
결론:
FetchType.EAGER로 바꾸는 것은 N+1 문제의 해결책이 아닙니다. 오히려 불필요한 쿼리 실행이 늘어날 수 있습니다. LAZY를 기본으로 설정하고, 필요한 경우에만 명시적으로 즉시 로딩을 지정하는 것이 올바른 접근법입니다.
3. 해결책 1: JPQL 페치 조인(Fetch Join)
가장 강력하고 명확한 해결 방법입니다. JPQL에서 JOIN FETCH 키워드를 사용하여 연관 엔티티를 한 번의 SQL JOIN 쿼리로 함께 조회합니다.
// 페치 조인 JPQL
String jpql = "select t from Team t join fetch t.members";
List<Team> teams = em.createQuery(jpql, Team.class).getResultList();
// 이제 members에 접근해도 추가 쿼리가 발생하지 않음!
for (Team team : teams) {
System.out.println(team.getMembers().size()); // 추가 쿼리 없음!
}
실행되는 쿼리 (단 1번):
SELECT t.*, m.*
FROM team t
INNER JOIN member m ON t.id = m.team_id;
Spring Data JPA의 리포지토리에서는 다음과 같이 사용합니다:
public interface TeamRepository extends JpaRepository<Team, Long> {
@Query("select distinct t from Team t join fetch t.members")
List<Team> findAllWithMembers();
}
⚠️ 주의: 일대다(OneToMany) 페치 조인 시 데이터가 중복될 수 있습니다.
DISTINCT키워드를 함께 사용하거나,Set을 반환 타입으로 활용하세요.
4. 해결책 2: @EntityGraph
@EntityGraph는 JPQL을 직접 작성하지 않고, 어노테이션 선언만으로 즉시 로딩 범위를 지정할 수 있는 스프링 데이터 JPA의 편리한 기능입니다.
public interface TeamRepository extends JpaRepository<Team, Long> {
// attributePaths에 즉시 로딩할 연관 엔티티 경로 지정
@EntityGraph(attributePaths = {"members"})
List<Team> findAll();
// 특정 조건 조회에도 적용 가능
@EntityGraph(attributePaths = {"members"})
Optional<Team> findByName(String name);
}
내부적으로 SQL LEFT OUTER JOIN이 생성되어 연관 데이터를 한 번에 가져옵니다.
5. 해결책 비교 및 선택 기준
| 방법 | 장점 | 단점 | 권장 상황 |
|---|---|---|---|
| JPQL 페치 조인 | 쿼리 완전 제어, 복잡한 조건 처리 가능 | JPQL 작성 필요, 유지보수 복잡 | 복잡한 조인, 다중 연관 최적화 |
| @EntityGraph | 코드 간결, Spring Data JPA 메서드와 궁합 좋음 | JPQL 대비 세밀한 제어 어려움 | 단순 조회, 빠른 적용이 필요할 때 |
| @BatchSize | 연관 엔티티를 IN절 배치로 조회 | N+1 완전 해소는 아님 (N/배치크기 + 1) | 페이징과 함께 사용할 때 |
@BatchSize 예시:
@BatchSize(size = 100)
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members;
-- IN절로 한 번에 100개씩 조회 (쿼리 횟수 대폭 감소)
SELECT * FROM member WHERE team_id IN (1, 2, 3, ..., 100);
6. 자주 묻는 질문 (FAQ)
Q1. 페치 조인과 일반 조인의 차이는 무엇인가요?
일반 JPQL JOIN은 SQL JOIN을 실행하여 조건 필터링에는 사용하지만, 연관 엔티티의 데이터를 실제 JPA 영속성 컨텍스트에 로딩하지 않습니다. 반면 JOIN FETCH는 조인된 연관 엔티티의 데이터를 영속성 컨텍스트에 함께 로딩하여 지연 로딩 시점의 추가 쿼리를 막습니다.
Q2. 컬렉션 페치 조인은 페이징이 불가능한가요?
@OneToMany 같은 컬렉션 페치 조인에서 limit/offset 페이징을 적용하면, Hibernate가 경고 로그를 출력하고 메모리에서 모든 데이터를 다 불러온 뒤 페이징을 처리합니다. 이는 OOM(OutOfMemoryError)을 유발할 수 있는 매우 위험한 상황입니다. 컬렉션 페이징은 @BatchSize나 카운트 쿼리를 별도로 작성하는 방식으로 해결해야 합니다.
Q3. N+1 문제를 개발 중에 미리 탐지하는 방법이 있나요?
Spring Boot의 application.yml에서 spring.jpa.show-sql: true와 함께 hibernate.format_sql: true를 설정하면 실행 SQL을 콘솔에서 확인할 수 있습니다. 더 체계적인 모니터링을 위해서는 P6Spy 라이브러리나 Micrometer + Prometheus 조합으로 DB 쿼리 횟수를 메트릭으로 수집하는 것을 권장합니다.
7. 마무리 및 권장사항
N+1 문제는 JPA를 처음 사용하는 개발자들이 가장 흔히 겪는 성능 함정입니다. 개발 초기부터 SQL 로그를 켜두고 쿼리 횟수를 의식하는 습관이 중요합니다. 원칙은 기본은 LAZY 로딩, 필요한 연관 데이터는 페치 조인이나 @EntityGraph로 명시적으로 한 번에 가져오는 것입니다. 페이징이 필요한 컬렉션 조회에는 @BatchSize를 활용하여 안전하게 처리하세요.