Dev Log/Java & Spring

[ORM/JPA] - N+1 문제

eomoff 2026. 10. 5. 23:17

정의

연관관계가 있는 엔티티를 조회할 때, 목록을 가져오는 쿼리 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의 쿼리 실행 횟수를 검증해 회귀를 막을 수 있다.