JPA를 사용하면서 가장 흔하게 마주치는 성능 문제가 바로 N+1이다. 쿼리 한 방이면 될 걸 수십, 수백 개의 쿼리로 쪼개서 날리는 현상인데, 모르고 넘어가면 운영 환경에서 DB가 터진다.
N+1 문제란
1개의 쿼리를 실행했는데, 그 결과 N개의 데이터 각각에 대해 추가 쿼리가 1개씩 발생하는 현상이다. 총 1 + N개의 쿼리가 실행된다.
팀 목록을 조회하는 상황을 생각해보자. 팀이 10개고, 각 팀에 소속된 멤버를 함께 보여줘야 한다.
List<Team> teams = teamRepository.findAll(); // 1번 쿼리: SELECT * FROM team
for (Team team : teams) {
List<Member> members = team.getMembers(); // 팀마다 1번씩: SELECT * FROM member WHERE team_id = ?
System.out.println(team.getName() + ": " + members.size());
}
findAll()로 팀 10개를 가져오는 쿼리 1개, 각 팀의 멤버를 가져오는 쿼리 10개. 총 11개의 쿼리가 실행된다. 팀이 1000개라면 1001개의 쿼리가 날아간다.
발생 원인
N+1의 근본 원인은 JPA의 지연 로딩(Lazy Loading) 메커니즘에 있다.
@OneToMany, @ManyToMany 같은 컬렉션 연관관계는 기본 페치 전략이 LAZY다. 엔티티를 조회할 때 연관된 컬렉션은 프록시 객체로 채워두고, 실제로 접근하는 시점에 쿼리를 날린다.
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team") // 기본값: FetchType.LAZY
private List<Member> members = new ArrayList<>();
}
team.getMembers()를 호출하는 순간 JPA는 "아, 이 데이터 아직 안 가져왔네"라고 판단하고 쿼리를 새로 날린다. 문제는 이게 루프 안에서 반복될 때다. JPA 입장에서는 각각의 getMembers() 호출이 독립적인 요청이라서, "한꺼번에 가져오자"는 판단을 하지 못한다.
FetchType.EAGER로 바꿔도 N+1은 사라지지 않는다. JPQL(findAll() 등)은 엔티티의 페치 전략을 무시하고 SQL을 그대로 번역하기 때문이다. 즉시 로딩이면 조회 직후에 N개의 추가 쿼리가 바로 발생하고, 지연 로딩이면 접근 시점에 발생할 뿐이다. 시점만 다르고 쿼리 수는 동일하다.
실제로 쿼리가 어떤 순서로 날아가는지 보면 문제가 명확해진다.
- 초록 영역 : 팀 목록을 가져오는 최초 쿼리 1개. 여기까진 정상이다.
- 분홍 영역 : 각 팀의 멤버에 접근할 때마다 쿼리가 1개씩 추가된다. 팀이 10개면 10개, 1000개면 1000개의 쿼리가 이 루프 안에서 발생한다.
확인 방법
먼저 문제가 있는지 눈으로 확인할 수 있어야 한다.
SQL 로그 활성화
application.yml에서 쿼리 로그를 켜자.
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
같은 패턴의 SELECT 쿼리가 반복적으로 찍히면 N+1을 의심해야 한다.
show-sql 대신 logging.level 설정만 사용한다. show-sql은 System.out으로 출력해서 로그 파일에 안 남을 수 있다.해결 전략
N+1을 해결하는 핵심 아이디어는 하나다. 연관 데이터를 한 번의 쿼리로 같이 가져오는 것. 방법은 여러 가지가 있고, 상황에 따라 선택이 달라진다.
Fetch Join
JPQL에서 JOIN FETCH를 사용해 연관 엔티티를 한 방 쿼리로 함께 가져온다. 가장 직관적이고 가장 많이 쓰이는 방법이다.
@Query("SELECT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();
이렇게 하면 실행되는 SQL은 딱 하나다.
SELECT t.*, m.*
FROM team t
INNER JOIN member m ON t.id = m.team_id
한 번의 쿼리로 팀과 멤버를 모두 가져온다. 위의 N+1 흐름과 비교하면 차이가 뚜렷하다.
- 초록 영역 : JOIN으로 팀과 멤버를 한 번에 가져온다. 쿼리 1개로 끝난다.
getMembers()호출 시점에 추가 쿼리가 발생하지 않는다. 이미 JOIN으로 가져왔기 때문이다.
주의할 점
Fetch Join에는 몇 가지 제약이 있다.
- 컬렉션 Fetch Join과 페이징을 함께 쓸 수 없다.
@OneToMany관계를 Fetch Join하면 데이터가 뻥튀기된다. 팀 1개에 멤버 3명이면 결과 row가 3개로 늘어난다. 여기에LIMIT을 걸면 잘못된 결과가 나오기 때문에, Hibernate는 메모리에서 페이징을 수행한다. 데이터가 많으면 OOM 위험이 있다. - 둘 이상의 컬렉션을 동시에 Fetch Join할 수 없다. 카테시안 곱이 발생해서 데이터가 기하급수적으로 늘어난다. Hibernate는
MultipleBagFetchException을 던진다. DISTINCT를 써야 할 수 있다. 데이터 뻥튀기 때문에 중복 엔티티가 반환될 수 있다. JPQL에SELECT DISTINCT t를 추가하면 JPA가 애플리케이션 레벨에서 중복을 제거한다.
DISTINCT를 자동 적용한다. Spring Boot 3.x를 쓰고 있다면 직접 붙이지 않아도 된다.EntityGraph
어노테이션 기반으로 Fetch Join과 같은 효과를 낸다. JPQL을 직접 작성하지 않아도 된다는 게 장점이다.
@EntityGraph(attributePaths = {"members"})
@Query("SELECT t FROM Team t")
List<Team> findAllWithMembers();
Spring Data JPA의 기본 메서드에도 적용할 수 있다.
@EntityGraph(attributePaths = {"members"})
List<Team> findAll();
내부적으로 LEFT JOIN을 생성하기 때문에 Fetch Join과 동일한 제약(페이징, 다중 컬렉션)이 적용된다.
BatchSize
지연 로딩을 유지하면서, 프록시를 초기화할 때 N개씩 묶어서 가져오는 방식이다. N+1을 완전히 없애진 못하지만, 1 + N을 1 + (N / batchSize)로 줄인다.
@Entity
public class Team {
@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
또는 글로벌 설정으로 전체 엔티티에 적용할 수 있다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
팀 50개를 조회하면, 멤버 조회 쿼리가 WHERE team_id IN (?, ?, ..., ?) 형태로 1개만 나간다. BatchSize가 100이니 50개는 한 번에 처리된다.
Fetch Join과 달리 페이징이 자유롭다는 게 큰 장점이다. 컬렉션 조회에서 페이징이 필요한 경우, BatchSize가 사실상 유일한 선택이다.
default_batch_fetch_size를 100~1000 사이로 설정해두고, 특별한 경우에만 Fetch Join을 쓰는 전략이 많다. 이렇게 하면 별도의 쿼리 수정 없이 N+1이 대부분 해소된다.DTO 프로젝션
애초에 엔티티가 아닌 DTO로 필요한 데이터만 조회하면 N+1이 발생할 여지가 없다.
@Query("SELECT new com.example.dto.TeamDto(t.name, m.name) " +
"FROM Team t JOIN t.members m")
List<TeamDto> findTeamMemberDtos();
조회 전용 화면에서 특정 컬럼만 필요할 때 효과적이다. 다만 엔티티를 반환하지 않으므로 영속성 컨텍스트의 관리를 받지 못하고, 변경 감지가 동작하지 않는다.
전략 선택 기준
어떤 전략을 쓸지는 "이 조회가 어떤 상황인가"로 결정한다.
어떻게 조회하나?} Start -->|항상 함께 사용| Together{페이징이
필요한가?} Together -->|No| FJ["Fetch Join"] Together -->|Yes| BS1["BatchSize"] Start -->|여러 컬렉션 동시 로딩| BS2["BatchSize"] Start -->|특정 필드만 필요
변경 감지 불필요| DTO["DTO 프로젝션"] Start -->|전역 기본 방어막| Global["글로벌 BatchSize"] style FJ fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style BS1 fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style BS2 fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style DTO fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style Global fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000
- Fetch Join (파랑) : 연관 엔티티를 항상 함께 쓰고, 페이징이 필요 없을 때. 가장 직관적.
- BatchSize (주황) : 페이징이 필요하거나, 여러 컬렉션을 동시에 로딩할 때. Fetch Join의 제약을 회피.
- DTO 프로젝션 (초록) : 조회 전용 화면에서 특정 컬럼만 필요할 때. 영속성 컨텍스트 관리를 포기하는 대신 효율적.
- 글로벌 BatchSize (보라) : 별도 쿼리 수정 없이 전역으로 N+1을 줄이는 기본 방어막.
| 상황 | 추천 전략 |
|---|---|
| 연관 엔티티를 항상 함께 사용 | Fetch Join |
| 페이징이 필요한 컬렉션 조회 | BatchSize |
| 여러 컬렉션을 동시에 로딩 | BatchSize |
| 조회 전용, 특정 필드만 필요 | DTO 프로젝션 |
| 기본 방어막으로 전역 적용 | 글로벌 BatchSize |
자주 하는 실수
EAGER로 바꾸기
앞서 설명했듯이 N+1 해결이 안 된다. 오히려 항상 연관 데이터를 로딩하게 되어 불필요한 쿼리가 늘어난다. 연관관계의 기본 전략은 LAZY로 두는 게 원칙이다.
양방향 연관관계에서 양쪽 모두 Fetch Join
Team → Member와 Member → Team을 동시에 Fetch Join하면 순환 참조가 발생할 수 있다. 특히 JSON 직렬화 시 무한 루프에 빠진다. 한 방향만 Fetch Join하고, 반대쪽은 필요할 때 별도 조회하자.
Open-in-View를 끄지 않기
spring.jpa.open-in-view의 기본값은 true다. 이 설정이 켜져 있으면 Controller까지 영속성 컨텍스트가 살아 있어서, View에서 지연 로딩이 발생해도 에러가 나지 않는다. 문제는 N+1이 Controller나 View 레이어에서 조용히 발생한다는 것이다. 운영 환경에서는 false로 설정하고, 필요한 데이터는 Service 계층에서 확실히 로딩하는 것이 좋다.
spring:
jpa:
open-in-view: false
면접 Q&A
1개의 쿼리로 N개의 엔티티를 조회한 뒤, 각 엔티티의 연관 데이터를 접근할 때 N개의 추가 쿼리가 발생하는 현상이다. 총 1+N개의 쿼리가 실행되어 성능이 급격히 저하된다.
[!QUESTION] FetchType.EAGER로 바꾸면 해결되나요?
아니다. JPQL은 엔티티의 페치 전략을 무시하고 SQL을 그대로 변환한다. EAGER는 조회 직후 즉시 N개의 추가 쿼리를 발생시킬 뿐, 쿼리 수는 동일하다. LAZY가 오히려 제어 가능한 시점을 제공한다.
[!QUESTION] Fetch Join과 EntityGraph의 차이는?
둘 다 연관 엔티티를 한 번에 조회하는 건 동일하다. Fetch Join은 JPQL에 직접 작성하고 INNER JOIN을 생성한다. EntityGraph는 어노테이션 기반이고 LEFT JOIN을 생성한다. OUTER JOIN이 필요하거나 JPQL을 쓰기 싫을 때 EntityGraph가 편하다.
[!QUESTION] 컬렉션 Fetch Join에서 페이징이 안 되는 이유는?
컬렉션 Fetch Join은 조인으로 인해 row 수가 뻥튀기된다. DB에서 LIMIT/OFFSET을 걸면 엔티티 단위가 아닌 row 단위로 잘리기 때문에 정확한 결과를 보장할 수 없다. Hibernate는 이를 감지하고 전체 데이터를 메모리로 올려서 페이징하는데, 데이터가 많으면 OOM이 발생한다.
[!QUESTION] 실무에서 어떤 전략을 주로 쓰나요?
글로벌 default_batch_fetch_size를 설정해서 기본 방어막을 깔고, 성능이 중요한 조회에는 Fetch Join을 적용하는 조합이 일반적이다. 조회 전용 API에서는 DTO 프로젝션으로 필요한 데이터만 가져오는 것도 좋은 전략이다.
관련 문서 : 1 JPA + Kotlin 엔티티 설계, 5 코드 일관성과 쿼리 최적화