JPA를 처음 배우고 신나게 프로젝트를 진행하다 보면, 어느 순간 이상한 현상을 마주하게 됩니다. 분명히 데이터를 한 번만 조회했는데, 콘솔에 SQL 쿼리가 수십 개씩 찍혀있는 거죠. "어? 나는 쿼리 하나만 날렸는데...?" 하고 당황하신 경험, 한 번쯤 있으실 겁니다.
이것이 바로 그 유명한 N+1 문제입니다. JPA를 사용하는 개발자라면 반드시 이해하고 넘어가야 할 핵심 주제이기도 하죠. 오늘은 이 N+1 문제가 정확히 무엇인지, 왜 발생하는지, 그리고 어떻게 해결할 수 있는지 차근차근 알아보겠습니다.
N+1 문제란?
이름의 의미부터 이해하기
N+1이라는 이름은 발생하는 쿼리의 개수에서 유래했습니다.
- 1: 처음에 데이터 목록을 가져오는 쿼리 1개
- N: 목록의 각 항목(N개)마다 연관된 데이터를 가져오는 쿼리 N개
즉, 총 N+1개의 쿼리가 실행된다는 뜻입니다.
일상적인 비유로 이해하기
회사에서 팀원들의 정보를 조회하는 상황을 생각해봅시다.
N+1 문제가 있는 상황 (비효율적)
1. 인사팀에 전화: "우리 팀 직원 명단 좀 주세요" → 김철수, 이영희, 박지민 (1번 통화)
2. 인사팀에 전화: "김철수 소속 부서 정보 주세요" (2번 통화)
3. 인사팀에 전화: "이영희 소속 부서 정보 주세요" (3번 통화)
4. 인사팀에 전화: "박지민 소속 부서 정보 주세요" (4번 통화)
직원이 3명이니까 총 4번(1+3) 전화를 했습니다. 직원이 100명이면? 101번 전화해야 합니다.
N+1 문제를 해결한 상황 (효율적)
1. 인사팀에 전화: "우리 팀 직원 명단이랑 각자 소속 부서 정보 한번에 주세요" (1번 통화)
한 번에 필요한 정보를 모두 요청하면 되는 거죠. 데이터베이스도 마찬가지입니다.
코드로 보는 N+1 문제
엔티티 구조
먼저 간단한 예제 엔티티를 만들어보겠습니다. 팀(Team)과 회원(Member)의 관계입니다.
@Entity
public class Team {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
// getter, setter 생략
}
@Entity
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
// getter, setter 생략
}
하나의 팀에 여러 회원이 소속될 수 있는 1:N 관계입니다.
N+1 문제 발생 시나리오
모든 회원을 조회하고, 각 회원이 속한 팀 이름을 출력하는 코드를 작성해보겠습니다.
@Test
void N플러스1_문제_발생() {
// 회원 전체 조회
List<Member> members = memberRepository.findAll();
// 각 회원의 팀 이름 출력
for (Member member : members) {
System.out.println("회원: " + member.getName()
+ ", 팀: " + member.getTeam().getName());
}
}
이 코드를 실행하면 콘솔에 어떤 SQL이 찍힐까요?
-- 1. 회원 전체 조회 (1번 쿼리)
SELECT * FROM member;
-- 2. 첫 번째 회원의 팀(A) 조회
SELECT * FROM team WHERE id = 1;
-- 3. 두 번째 회원의 팀(B) 조회
SELECT * FROM team WHERE id = 2;
-- 4. 세 번째 회원의 팀(A) 조회
-- → 이미 팀A가 영속성 컨텍스트에 있으므로 쿼리 생략 (1차 캐시 히트)
-- ... 영속성 컨텍스트에 없는 팀이면 추가 쿼리 발생
물론 영속성 컨텍스트의 1차 캐시 덕분에 이미 조회된 팀은 다시 쿼리가 나가지 않습니다. 하지만 최악의 경우(모든 회원이 서로 다른 팀에 속한 경우), 회원이 100명이면 101번, 1000명이면 1001번의 쿼리가 실행될 수 있습니다.
왜 이런 일이 발생할까?
핵심은 지연 로딩(Lazy Loading) 때문입니다.
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩 설정
private Team team;
지연 로딩은 연관된 엔티티를 실제로 사용할 때 조회합니다. 즉:
findAll()로 Member만 조회 (Team은 아직 조회 안 함)member.getTeam().getName()호출 시점에 Team 조회 쿼리 실행- 각 Member마다 Team을 사용하니까, 각각 쿼리가 나감
"그럼 즉시 로딩(EAGER)으로 바꾸면 되지 않나요?"라고 생각하실 수 있습니다. 하지만 즉시 로딩도 N+1 문제가 발생할 수 있고, 불필요한 데이터까지 항상 가져오는 문제가 있습니다. 그래서 기본적으로 지연 로딩을 사용하되, 필요한 시점에 적절한 해결책을 적용하는 것이 권장됩니다.
해결책 1: Fetch Join
Fetch Join이란?
Fetch Join은 JPQL에서 연관된 엔티티를 한 번의 쿼리로 함께 조회하는 방법입니다. SQL의 JOIN과 비슷하지만, JPA가 연관 엔티티까지 영속성 컨텍스트에 로딩해준다는 차이가 있습니다.
사용 방법
Repository에 Fetch Join을 사용하는 메서드를 추가합니다.
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("SELECT m FROM Member m JOIN FETCH m.team")
List<Member> findAllWithTeam();
}
핵심은 JOIN FETCH 키워드입니다.
실행 결과
@Test
void Fetch_Join으로_해결() {
List<Member> members = memberRepository.findAllWithTeam();
for (Member member : members) {
System.out.println("회원: " + member.getName()
+ ", 팀: " + member.getTeam().getName());
}
}
이제 실행되는 SQL을 확인해보면:
-- 단 1번의 쿼리로 Member와 Team을 함께 조회
SELECT
m.id, m.name, m.team_id,
t.id, t.name
FROM member m
INNER JOIN team t ON m.team_id = t.id;
회원이 몇 명이든 딱 1번의 쿼리로 모든 데이터를 가져옵니다.
Fetch Join의 주의사항
Fetch Join은 강력하지만, 몇 가지 주의할 점이 있습니다.
1. 컬렉션(OneToMany) Fetch Join 시 데이터 뻥튀기
1:N 관계를 Fetch Join하면 데이터가 뻥튀기됩니다. 예를 들어, 팀이 1개이고 소속 멤버가 3명이면 결과 row가 3개가 됩니다.
-- 팀A에 멤버가 3명이면, 결과가 3줄로 나옴
SELECT t.*, m.* FROM team t JOIN member m ON t.id = m.team_id;
-- 결과:
-- 팀A | 멤버1
-- 팀A | 멤버2
-- 팀A | 멤버3
이 때문에 애플리케이션에서 중복된 Team 객체가 생길 수 있습니다. 이를 방지하려면 DISTINCT를 사용합니다.
@Query("SELECT DISTINCT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();
참고로 Hibernate 6부터는 컬렉션 Fetch Join 시 DISTINCT가 자동 적용되어, 별도로 명시하지 않아도 됩니다.
2. 컬렉션 Fetch Join 시 페이징 불가
// 이렇게 하면 경고가 발생하고, 메모리에서 페이징 처리됨 (위험!)
@Query("SELECT t FROM Team t JOIN FETCH t.members")
Page<Team> findAllWithMembers(Pageable pageable);
Team을 기준으로 Member를 Fetch Join하면, 데이터 row가 Member 수만큼 늘어나기 때문에 정확한 페이징이 어렵습니다. JPA는 이 경우 모든 데이터를 메모리에 올린 후 페이징 처리하는데, 데이터가 많으면 OutOfMemoryError가 발생할 수 있습니다.
3. 둘 이상의 컬렉션은 Fetch Join 불가
// 이렇게는 사용할 수 없음
@Query("SELECT t FROM Team t JOIN FETCH t.members JOIN FETCH t.projects")
List<Team> findAllWithMembersAndProjects();
하나의 엔티티에서 두 개 이상의 컬렉션을 동시에 Fetch Join하면 Cartesian Product(곱집합)가 발생하여 데이터가 기하급수적으로 늘어납니다.
해결책 2: EntityGraph
EntityGraph란?
EntityGraph는 JPA 2.1부터 추가된 기능으로, 엔티티를 조회할 때 함께 조회할 연관 엔티티를 지정하는 방법입니다. Fetch Join과 비슷한 효과를 내지만, JPQL 없이 애노테이션만으로 사용할 수 있습니다.
사용 방법
public interface MemberRepository extends JpaRepository<Member, Long> {
// 방법 1: attributePaths 사용 (간단)
@EntityGraph(attributePaths = {"team"})
@Query("SELECT m FROM Member m")
List<Member> findAllWithTeamByEntityGraph();
// 방법 2: Spring Data JPA 메서드 이름에 적용
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();
}
attributePaths에 함께 조회할 연관 엔티티의 필드명을 지정하면 됩니다.
실행 결과
-- LEFT OUTER JOIN으로 실행됨
SELECT
m.id, m.name, m.team_id,
t.id, t.name
FROM member m
LEFT OUTER JOIN team t ON m.team_id = t.id;
Fetch Join과 마찬가지로 1번의 쿼리로 데이터를 가져옵니다.
Fetch Join vs EntityGraph
| 구분 | Fetch Join | EntityGraph |
|---|---|---|
| JOIN 방식 | 기본 INNER JOIN (LEFT 가능) | LEFT OUTER JOIN |
| 사용 방법 | JPQL 작성 필요 | 애노테이션으로 간편 |
| 유연성 | 복잡한 쿼리 작성 가능 | 단순한 연관관계에 적합 |
| 재사용성 | 메서드별 JPQL 작성 | Named EntityGraph로 재사용 가능 |
INNER JOIN vs LEFT OUTER JOIN 차이
- Fetch Join: 기본은 INNER JOIN이지만,
LEFT JOIN FETCH로 명시하면 OUTER JOIN도 가능 - EntityGraph: 항상 LEFT OUTER JOIN으로 동작
// Fetch Join에서 LEFT OUTER JOIN 사용하기
@Query("SELECT m FROM Member m LEFT JOIN FETCH m.team")
List<Member> findAllWithTeamLeftJoin();
INNER JOIN은 team이 없는 Member가 조회되지 않고, LEFT OUTER JOIN은 team이 없는 Member도 조회됩니다. 상황에 맞게 선택하시면 됩니다.
해결책 3: Batch Size 설정
Batch Size란?
지금까지의 해결책은 "한 방 쿼리"로 모든 것을 가져오는 방식이었습니다. Batch Size는 조금 다른 접근입니다. N+1 문제의 N개 쿼리를 묶어서 처리하는 방식입니다.
일상적인 비유
다시 인사팀 전화 예시로 돌아가보면:
Batch Size 적용 전
1번 통화: "김철수 부서 정보 주세요"
2번 통화: "이영희 부서 정보 주세요"
3번 통화: "박지민 부서 정보 주세요"
... (100번 반복)
Batch Size = 10 적용 후
1번 통화: "김철수, 이영희, 박지민, ... (10명) 부서 정보 한번에 주세요"
2번 통화: "다음 10명 부서 정보 한번에 주세요"
... (10번만 반복)
100번 통화가 10번으로 줄어드는 효과입니다.
설정 방법
방법 1: application.yml 전역 설정
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
이렇게 설정하면 애플리케이션 전체에 적용됩니다.
방법 2: 개별 엔티티에 설정
@Entity
public class Team {
@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
특정 연관관계에만 적용하고 싶을 때 사용합니다.
실행 결과
@Test
void Batch_Size로_해결() {
List<Team> teams = teamRepository.findAll(); // 팀 10개 조회
for (Team team : teams) {
System.out.println("팀: " + team.getName()
+ ", 회원 수: " + team.getMembers().size());
}
}
Batch Size를 100으로 설정했다면:
-- 1. 팀 전체 조회
SELECT * FROM team;
-- 2. IN 쿼리로 한번에 여러 팀의 회원 조회
SELECT * FROM member WHERE team_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
팀이 10개일 때, 기존에는 11번(1+10) 쿼리가 나갔지만, 이제는 2번의 쿼리로 해결됩니다.
Batch Size의 장단점
장점
- 기존 코드 수정 없이 설정만으로 적용 가능
- Fetch Join의 제약(페이징 불가, 다중 컬렉션 불가)이 없음
- 메모리 사용량 조절 가능 (size 값으로)
단점
- 완벽한 1번 쿼리는 아님 (데이터 양에 따라 여러 번 실행될 수 있음)
- 적절한 size 값을 찾아야 함 (너무 크면 메모리 문제, 너무 작으면 효과 미미)
어떤 해결책을 선택해야 할까?
각 해결책은 장단점이 있어서, 상황에 맞게 선택해야 합니다.
Fetch Join을 사용하면 좋은 경우
- ToOne 관계(ManyToOne, OneToOne)에서 연관 엔티티가 반드시 필요할 때
- 조회할 데이터 양이 많지 않을 때
- 페이징이 필요 없을 때
// 회원 조회 시 팀 정보가 반드시 필요한 경우
@Query("SELECT m FROM Member m JOIN FETCH m.team")
List<Member> findAllWithTeam();
EntityGraph를 사용하면 좋은 경우
- JPQL 작성 없이 간단하게 해결하고 싶을 때
- team이 없는 Member도 조회해야 할 때 (LEFT OUTER JOIN)
- Spring Data JPA의 기본 메서드에 적용하고 싶을 때
@EntityGraph(attributePaths = {"team"})
List<Member> findByNameContaining(String name);
Batch Size를 사용하면 좋은 경우
- ToMany 관계(OneToMany)에서 페이징이 필요할 때
- 여러 컬렉션을 동시에 조회해야 할 때
- 기존 코드를 수정하기 어려울 때 (전역 설정으로 해결)
# 전역 설정으로 프로젝트 전체에 적용
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
실무에서 자주 사용하는 조합
실무에서는 보통 다음과 같이 조합해서 사용합니다:
- 기본 설정:
default_batch_fetch_size: 100전역 적용 - ToOne 관계: Fetch Join 사용
- ToMany 관계 + 페이징: Batch Size 활용
- 간단한 조회: EntityGraph 사용
정리
N+1 문제와 해결책을 정리하면 다음과 같습니다.
N+1 문제란?
- 1번의 쿼리로 N개의 데이터를 조회한 후, 연관 데이터를 조회하기 위해 N번의 추가 쿼리가 발생하는 현상
- 지연 로딩(Lazy Loading)을 사용할 때 주로 발생
- 데이터가 많아질수록 성능 저하가 심각해짐
해결책 비교
| 해결책 | 핵심 원리 | 장점 | 주의점 |
|---|---|---|---|
| Fetch Join | JOIN으로 한 방 쿼리 | 가장 직관적, 쿼리 1번 | 컬렉션 페이징 불가, 다중 컬렉션 불가 |
| EntityGraph | 애노테이션으로 함께 조회할 엔티티 지정 | JPQL 없이 간편, LEFT JOIN | Fetch Join과 유사한 제약 |
| Batch Size | IN 쿼리로 묶어서 조회 | 페이징 가능, 설정만으로 적용 | 완벽한 1번 쿼리는 아님 |
실무 권장사항
- 기본적으로 모든 연관관계는 지연 로딩으로 설정
- 전역 Batch Size(100~1000)를 설정해두고 시작
- 성능이 중요한 조회는 Fetch Join으로 최적화
- 상황에 따라 적절한 해결책 조합
N+1 문제는 JPA를 사용하면서 반드시 마주치게 되는 문제입니다. 처음에는 복잡하게 느껴질 수 있지만, 원리를 이해하고 나면 상황에 맞는 해결책을 적용하는 것이 어렵지 않습니다. 실제 프로젝트에서 SQL 로그를 켜두고, 쿼리가 몇 번 나가는지 확인하는 습관을 들이시면 좋겠습니다.
'프레임워크 > 스프링' 카테고리의 다른 글
| Spring 예외 처리 전략: @ControllerAdvice와 @ExceptionHandler 활용법 (0) | 2026.01.04 |
|---|---|
| @RestController vs @Controller: API 서버와 화면 서버의 차이 (0) | 2025.12.30 |
| JPA 영속성 컨텍스트(Persistence Context): 1차 캐시와 쓰기 지연이 주는 이점 (0) | 2025.12.28 |
| @Transactional의 동작 원리: 트랜잭션은 어떻게 시작되고 롤백될까? (0) | 2025.12.26 |
| AOP(관점 지향 프로그래밍): 어떻게 메소드 실행 전후에 로그를 남길까? (0) | 2025.12.24 |