N+1 문제
N+1 문제란?
N+1 문제는 연관 관계(1:N, N:1, 1:1)가 설정된 엔티티를 조회할 때, 의도하지 않은 추가 쿼리가 발생하는 성능 저하 현상을 의미합니다.
1. 엔티티 예시
@Entity
public class Team {
...
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY) // 지연 로딩 설정
private List<Member> members = new ArrayList<>();
...
}
@Entity
public class Member {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
...
}
2. 메서드 예시
public void printAllTeamMembers() {
// 1. 모든 팀을 조회 (쿼리 1번 실행)
List<Team> teams = teamRepository.findAll();
for (Team team : teams) {
// 2. 각 팀의 멤버를 참조하는 순간 지연 로딩 발생
// (조회된 팀의 개수 N만큼 멤버 조회 쿼리 N번 실행)
System.out.println("팀 이름: " + team.getName());
for (Member member : team.getMembers()) {
System.out.println("멤버 이름: " + member.getNickname());
}
}
}
처음 모든 팀을 조회할 땐 지연 로딩으로 설정되어 있기 때문에 members 필드에는 실제 객체 대신 프록시 객체가 채워져 있습니다. 프록시 객체는 실제 데이터가 필요한 시점(여기서는 멤버의 닉네임을 출력하려는 시점)에 DB에 쿼리를 날려 데이터를 가져옵니다.
3. 실제 실행되는 SQL 쿼리
-- (1) 모든 팀 조회 (1번)
SELECT * FROM team;
-- (2) Team A의 멤버 조회 (N-1번)
SELECT * FROM member WHERE team_id = 1;
-- (3) Team B의 멤버 조회 (N-2번)
SELECT * FROM member WHERE team_id = 2;
-- (4) Team C의 멤버 조회 (N-3번)
SELECT * FROM member WHERE team_id = 3;
결과적으로 우리는 전체 팀 조회(1번)를 원했을 뿐인데, 각 팀에 속한 멤버들을 조회하기 위한 추가 쿼리(N번)가 팀의 개수만큼 발생하게 되었습니다.
원인
- JPQL은 연관 관계를 고려하지 않고 대상 엔티티만 조회하는 SQL을 생성합니다. (1 발생)
- 이후 영속성 컨텍스트가 연관 데이터를 채우는 과정에서, 데이터가 없으면 엔티티 개수만큼 추가 쿼리를 날립니다. (N 발생)
FetchType이 eager이든 lazy든 대상 엔티티만 조회하는 SQL을 생성하기 때문에 문제는 동일하게 발생합니다.
해결방안
1. 조인을 통해 한 번에 가져오기
-
Fetch Join
@Query("select t from Team t join fetch t.members") List<Team> findAllFetchJoin();- 특징: join fetch 문법을 사용하며, SQL의 INNER JOIN 혹은 LEFT OUTER JOIN으로 실행됩니다.
- 장점: N+1을 해결하는 가장 표준적인 방법입니다.
- 단점: 페이징 API와 함께 사용 시 모든 데이터를 메모리에 올리고 페이징 처리를 하여 위험합니다(1:N 관계일 때). - (심화)
- 둘 이상의 컬렉션에 페치 조인을 적용할 수 없습니다. - (심화)
-
@EntityGraph
@EntityGraph(attributePaths = {"members"}) @Query("select t from Team t") List<Team> findAllWithEntityGraph();- 특징: 기본적으로 LEFT OUTER JOIN을 사용합니다.
- 장점: JPQL을 직접 길게 작성하지 않아도 되어 가독성이 좋습니다.
/* 두 방식 모두 결과적으로 조인 쿼리 1번만 나갑니다. */
SELECT t.id, t.name, m.id, m.nickname, m.team_id
FROM team t
LEFT OUTER JOIN member m ON t.id = m.team_id;
2. 쿼리 횟수를 줄여서 최적화하기
추가 쿼리를 아예 없애지는 못하지만, N번 날아갈 쿼리를 1번으로 줄이는 방식입니다. 페이징 처리가 필요할 때 사용할 수 있습니다.
-
@BatchSize (default_batch_fetch_size)
# application.yml 전역 설정 spring: jpa: properties: hibernate: default_batch_fetch_size: 100# 개별 어노테이션 사용 @BatchSize(size = 100) @OneToMany(mappedBy = "team") private List<Member> members = new ArrayList<>();-
연관된 엔티티를 조회할 때, 지정한 사이즈만큼 IN 절에 담아 한 번에 조회합니다.
-
특징: hibernate.default_batch_fetch_size 설정을 통해 전역적으로 적용 가능합니다.
-
장점: 1:N 관계의 페이징 문제(MultipleBagFetchException 등)를 해결할 수 있는 가장 깔끔한 대안입니다.
-
실제 실행 SQL
-- 1. 모든 팀 조회 (1번) SELECT * FROM team; -- 2. 조회된 팀 ID(1, 2, 3...)들을 모아서 멤버를 한 번에 조회 (1번) SELECT * FROM member WHERE team_id IN (1, 2, 3, 4, 5, ...);
-
-
FetchMode.SUBSELECT
@Fetch(FetchMode.SUBSELECT) @OneToMany(mappedBy = "team") private List<Member> members = new ArrayList<>();-
연관 데이터를 조회할 때 서브쿼리를 사용하여 조회하는 방식입니다.
-
특징: 해당 엔티티를 조회한 쿼리 자체를 서브쿼리로 넣어 IN 절에 활용합니다.
-
실제 실행 SQL
-- 1. 팀 조회 (1번) SELECT * FROM team WHERE region = 'SEOUL'; -- 2. 해당 팀에 속한 멤버들을 서브쿼리로 한 번에 조회 (1번) SELECT * FROM member WHERE team_id IN ( SELECT id FROM team WHERE region = 'SEOUL' );
-

