[ORM/JPA] - N+1 문제
정의
연관관계가 있는 엔티티를 조회할 때, 목록을 가져오는 쿼리 1번 외에 조회된 행 수(N)만큼 연관 엔티티 조회 쿼리가 추가로 나가는 문제.
예시
Team(1) : Member(N) 관계에서 팀이 10개 있다고 하자.
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members = new ArrayList<>();
}
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
}
List<Team> teams = teamRepository.findAll(); // 쿼리 1번
for (Team team : teams) {
team.getMembers().size(); // 팀마다 쿼리 1번씩 → N번
}
select * from team; -- 1번
select * from member where team_id = 1; -- N번
select * from member where team_id = 2;
...
select * from member where team_id = 10;
쿼리 한 번이면 될 일을 총 1 + N = 11번 실행한다. 데이터가 늘수록 쿼리 수가 같이 늘어난다.
왜 발생하는가
JPQL은 연관관계의 fetch 전략을 무시하고, 작성된 그대로 SQL로 번역된다. select t from Team t는 team 테이블만 조회하는 SQL이 되고, 연관된 members는 결과를 받은 뒤에 따로 채워진다.
- 지연 로딩(LAZY): 연관 객체 자리에 프록시(컬렉션은
PersistentBag같은 래퍼)를 넣어 둔다. 실제로 접근하는 순간 초기화 쿼리가 나간다. - 즉시 로딩(EAGER): 목록 쿼리 직후, JPA가 fetch 전략을 맞추려고 엔티티마다 연관 조회 쿼리를 곧바로 날린다.
즉 객체는 team.getMembers()처럼 참조를 따라 탐색하지만 DB는 조인으로 한 번에 가져와야 하는데, 이 차이(패러다임 불일치)를 ORM이 "필요할 때마다 한 건씩 조회"로 메우면서 생기는 문제다.
자주 하는 오해
| 오해 | 실제 |
|---|---|
| EAGER로 바꾸면 해결된다 | 아니다. 쿼리가 나가는 시점만 앞당겨질 뿐 N+1은 그대로다. |
| LAZY면 N+1이 안 생긴다 | 아니다. 발생을 미룰 뿐이고, 연관 객체에 접근하면 발생한다. |
@OneToMany에서만 생긴다 |
아니다. @ManyToOne, @OneToOne에서도 똑같이 생긴다. |
findById도 N+1이다 |
em.find()는 EAGER일 때 조인으로 한 번에 가져온다. 문제는 JPQL(Spring Data JPA의 findAll, 쿼리 메서드 포함)에서 생긴다. |
EAGER는 필요 없는 연관까지 항상 끌고 오고 예상 못 한 쿼리를 만들기 때문에, 모든 연관관계는 LAZY로 두고 필요한 곳에서만 함께 조회하는 것이 기본이다. @ManyToOne, @OneToOne은 기본값이 EAGER라서 직접 LAZY로 지정해야 한다.
해결 방법
1. Fetch Join
JPQL에서 연관 엔티티를 조인해 한 번의 쿼리로 함께 가져온다. 가장 기본적인 해결책.
@Query("select distinct t from Team t join fetch t.members")
List<Team> findAllWithMembers();
- 일반
join은 조인만 하고 연관 엔티티를 영속성 컨텍스트에 올리지 않는다.join fetch여야 함께 로딩된다. - 컬렉션(
@OneToMany)을 fetch join하면 조인 결과만큼 행이 늘어 부모 엔티티가 중복된다.distinct로 제거한다(Hibernate 6부터는 자동 제거).
한계
- 컬렉션 fetch join + 페이징 불가: 행이 뻥튀기된 상태라 DB에서
limit을 걸 수 없다. Hibernate는 전체를 메모리로 읽어 와서 잘라내며(HHH000104경고), OOM 위험이 있다. - 컬렉션 둘 이상 fetch join 불가:
List두 개를 동시에 fetch join하면MultipleBagFetchException이 난다. 카테시안 곱이 생기기 때문이다.
2. @EntityGraph
fetch join을 JPQL 없이 애너테이션으로 선언한다. 내부적으로 left outer join으로 가져온다.
@EntityGraph(attributePaths = "members")
List<Team> findAll();
쿼리 메서드에 그대로 붙일 수 있어 간단하지만, 한계(페이징, 컬렉션 다중 조회)는 fetch join과 같다.
3. Batch Size
지연 로딩은 그대로 두되, 초기화 쿼리를 in 절로 묶어서 보낸다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
select * from team;
select * from member where team_id in (1, 2, ..., 10);
1 + N이 1 + ⌈N / 배치 크기⌉ 로 줄어든다. 쿼리가 1번은 아니지만, 행이 늘어나지 않아 컬렉션 페이징이 가능하고 컬렉션이 여러 개여도 문제없다. 개별 지정은 @BatchSize(size = 100).
4. DTO 직접 조회
엔티티가 아니라 필요한 컬럼만 조인해서 DTO로 바로 받는다. 지연 로딩 대상 자체가 없으니 N+1이 생길 여지가 없다. 조회 전용 화면·API에 적합하다.
@Query("select new com.example.MemberDto(m.name, t.name) from Member m join m.team t")
List<MemberDto> findMemberDtos();
정리
| 상황 | 선택 |
|---|---|
@ManyToOne, @OneToOne (ToOne) |
fetch join. 행이 늘지 않아 페이징도 가능 |
@OneToMany (컬렉션), 페이징 없음 |
fetch join + distinct |
| 컬렉션 + 페이징, 또는 컬렉션 여러 개 | ToOne만 fetch join하고 컬렉션은 Batch Size |
| 화면에 필요한 값만 조회 | DTO 직접 조회 |
어떻게 발견하는가
spring.jpa.show-sql,hibernate.format_sql또는 p6spy로 실행 쿼리를 로그로 확인한다.- 같은 형태의
select가where조건의 값만 바뀌며 반복되면 N+1이다. - 테스트에서 Hibernate
Statistics의 쿼리 실행 횟수를 검증해 회귀를 막을 수 있다.