1. 왜 N+1을 반드시 알아야 할까

JPA를 쓰다 보면 쿼리는 한 번 날렸는데 왜 DB 로그에 수십, 수백 개의 쿼리가 찍히는 상황을 반드시 겪게 된다.

하나의 쿼리로 데이터를 가져온 뒤 연관된 데이터를 조회하기 위해 추가로 n개의 쿼리가 실행되어 총 1+n개의 쿼리가 나가는 현상

이게 바로, N+1 문제고, 개발 환경에서는 티가 안 나다가 프로덕션 환경에서 트래픽이 몰리는 순간 DB 부하 -> 응답 지연 -> 장애로 직결될 수 있다는 점이다.

 

따라서 N+1 문제는 알면 좋은 개념이 아니라, ORM을 사용하는 개발자라면 반드시 이해하고 통제해야 하는 함정이다...


2. N+1 Select 문제

2.1 N+1 문제란?

  • 특정 엔티티를 조회하는 쿼리 1번
  • 해당 엔티티가 가진 연관관계를 조회하기 위한 쿼리 N번

1+N 문제라고 생각하는데 왜 N+1 인지..

2.2 발생하는 근본적인 이유

근본 원인은 객체지향 vs 관계형 DB의 패러다임 차이다.

객체에서는 team.getMember()처럼 참조만 있으면 즉시 접근 가능하지만

DB에서는 반드시 SQL을 실행해야 접근할 수 있다.

객체 RDB
참조만 있으면 언제든 접근 가능 쿼리를 날려야만 조회 가능
메모리 기반 I/O 기반

 

JPA는 이 간극을 메우기 위해 프록시 기반 지연 로딩이라는 전략을 사용한다.

2.3 N+1 문제 재현

1) 기본 데이터 및 요구 사항

  • 총 3개의 Team이 있으며 각 팀에는 Member가 4명씩 있다.
  • 각 팀에 속한 멤버들의 닉네임을 조회할 수 있다.

2) Service 코드

    // N+1 문제 발생 코드
    public List<String> findAllTeamMemberNicknames() {
        List<String> nicknames = new ArrayList<>();

        List<Team> teams = teamRepository.findAll();

        for (Team team : teams) {
            List<Member> members = team.getMemberList();

            for (Member member : members) {
                nicknames.add(member.getNickname());
            }
        }

        return nicknames;
    }

3) 실행된 쿼리 결과

 

결과만 본다면, 모든 팀 조회 쿼리 1회와 팀 별 멤버를 조회하는 쿼리 3개 총 4개의 쿼리가 조회됨을 알 수 있다.

  • 모든 팀 조회 쿼리 : 1
  • 각 팀에 속한 멤버 조회 : N (현재는 3)

2.4 원인 분석

1) 팀 조회 쿼리

 

select * from team;
  • 모든 팀을 조회하므로 쿼리 1회 발생은 자연스럽다.

2) 멤버 조회 쿼리

select * from team where team_id = ?
select * from team where team_id = ?
select * from team where team_id = ?

 

  • 이 쿼리들은 개발자가 명시적으로 호출하지 않았지만, 연관관계 컬렉션을 실제로 사용하는 시점에 JPA가 자동으로 실행한다.

3) 왜 이 쿼리가 발생할까?

JPA는 엔티티를 조회할 때 연관관계까지 항상 즉시 로딩하지 않는다. -> LAZY

  • select * from team 실행 시
    • Team 엔티티 자체는 생성 가능
    • memberList는 프록시 객체로 남아 있음
  • 이후 코드에서 memberList에 접근하는 순간
    • 실제 데이터를 채우기 위해 추가 쿼리 실행

즉, 내부적으로는 다음 과정이 일어난다.

  1. select * from team -> Team 엔티티 생성
  2. memberList는 LAZY 프록시 상태
  3. 컬렉션 접근 시 select * from member where team_id ? 실행

이로 인해 Team 개수만큼 Select가 반복되어 N+1 문제가 발생한다.


3. 그럼 Join으로 한번에 가져오면 해결되는 거 아닌가?

이번 문제를 접하다 보면, 이런 자연스러운 의문이 생기게 된다.

연관 엔티티를 매번 추가 조회를 할 거면 처음부터 Join으로 한 번에 가져오면 되지 않나?

3.1. JPA는 Join을 구현할 수 없다.

  • memberList의 FetchType을 Eager로 하고 Team ID 값으로 찾는다.
@Entity
public class Team {

    @OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
    private List<Member> memberList = new ArrayList<>();

}
    @Test
    @DisplayName("Eager로 조회")
    void findEager(){
    
        Team team1= teamRepository.findById(1L)
                .orElseThrow(() -> new IllegalArgumentException("팀을 찾을 수 없습니다."));
                
    }

위 테스트 코드를 보면, Team 엔티티를 조회할 때 Member 테이블을 join하여 한번에 가져오는 것을 알 수 있다.


4. 그렇다면 Join이 기본 전략이 아닌 이유는 뭘까

4.1 Join이 더 비효율적인 상황이 많다.

1:N 관계에서 연관된 엔티티 정보를 가져오는 경우가 얼마나 될까? 라는 생각을 해보고, 연관 관계를 사용한 프로젝트 구성을 살펴 보니, 실제 서비스 코드에서는 연관된 N의 엔티티를 "항상" 사용하는 경우는 생각보다 많지 않다는 결론에 도달하게 되었다.

 

내가 진행했던 프로젝트의 조회 패턴을 보면 다음과 같다.

  • 화면 목록 조회에서는 루트 엔티티의 일부 필드만 필요
  • 상세 화면으로 진입해야만 연관 엔티티가 필요
  • 통계, 집계, 존재, 여부 판단 등에서는 연관 엔티티 자체가 필요 없는 경우도 많음

즉, 1:N 관계에서 1을 조회한다고 해서 항상 N이 필요한 것은 아니다.

4.2 FetchType 의미 정리

JPA의 FetchType 내용을 살펴보면 아래와 같다.

Defines strategies for fetching data from the database.
The EAGER strategy is a requirement on the persistence provider runtime that data must be eagerly fetched.
The LAZY strategy is a hint to the persistence provider runtime that data should be fetched lazily when it is first accessed.
  • FetchType은 데이터베이스에서 데이터를 가져오는 전략
  • EAGER 전략 : 데이터베이스에서 데이터를 즉시 가져와야한다는 "요구사항" -> 즉시 가져오는 전략
  • LAZY 전략 : 데이터를 처음 사용할 때 느리게 가져오라는 "힌트" -> 사용하는 시점에 가져오는 전략

 JPA의 Spect을 살펴보면, @OneToMany와 @ManyToOne의 default Fetch Type이 다르다.

  • @OneToMany : 기본 LAZY 로딩
  • @ManyToOne : 기본 EAGER 로딩

4.3 SQL문으로 비교

    @Test
    @DisplayName("Lazy로 조회")
    void findLazy(){

        List<Team> all = teamRepository.findAll();

        /**
         * select t1_0.id,t1_0.team_name from team t1_0
         */
    }

    @Test
    @DisplayName("Eager로 조회")
    void findEager(){

        List<Team> all = teamRepository.findAll();

        /**
         * select t1_0.id,t1_0.team_name from team t1_0
         * select ml1_0.team_id,ml1_0.id,ml1_0.nickname from member ml1_0 where ml1_0.team_id=?
         * select ml1_0.team_id,ml1_0.id,ml1_0.nickname from member ml1_0 where ml1_0.team_id=?
         * select ml1_0.team_id,ml1_0.id,ml1_0.nickname from member ml1_0 where ml1_0.team_id=?
         */
    }

5. N+1 문제 해결 방법들

위 내용을 본다면, N+1 문제는 JPA의 구조적 특성에서 비롯된 문제이기 때문에

무조건 피해야 할 버그라기보다는 의식적으로 제어해야 할 현상에 가깝다.

 

Hibernate(JPA 구현체)는 공식 문서에서 N+1 문제를 완화하거나 해결하기 위한 여러 전략을 제시하고 있다.

Hibernate provides several strategies for efficiently fetching associations and avoiding N+1 selects:
1. outer join fetching — where an association is fetched using a left outer join
2. batch fetching — where an association is fetched using a subsequent select with a batch of primary keys
3. subselect fetching —where an association is fetched using a subsequent select with keys re-queried in a subselect.

이 전략들은 크게 보면 두 가지 방향으로 나눈다.

  • 추가 조회 쿼리를 아예 발생시키지 않는 방법
  • 추가 조회 쿼리를 줄이는 방법

5.1 Outer Join Fetching (Hibernate가 가장 권장하는 방법)

1) Fetch Join 문법으로 JPQL 작성

기본 동작은 inner join으로 동작하고, outer join으로 동작하려면 left join을 명시해야 한다.

@Repository
public interface TeamRepository extends JpaRepository<Team, Long> {

    // fetch join - Inner Join : 쿼리 1번으로 Team + Member 한 번에 조회
    @Query("select distinct t from Team t join fetch t.memberList")
    List<Team> findAllWithInnerFetchJoin();

    // fetch join - Outer Join : 쿼리 1번으로 Team + Member 한 번에 조회
    @Query("select t from Team t left join fetch t.memberList")
    List<Team> findAllWithOuterFetchJoin();

}
    @Test
    @DisplayName("Fetch Join 테스트")
    void testFetchJoin(){
        System.out.println("==========Fetch Join 테스트==========");

        // 결과 : select distinct t1_0.id,ml1_0.team_id,ml1_0.id,ml1_0.nickname,t1_0.team_name from team t1_0 join member ml1_0 on t1_0.id=ml1_0.team_id
        List<String> nicknames1 = teamService.findAllTeamMemberNicknames_FetchJoinI_InnerJoin();

        // 결과 : select t1_0.id,ml1_0.team_id,ml1_0.id,ml1_0.nickname,t1_0.team_name from team t1_0 left join member ml1_0 on t1_0.id=ml1_0.team_id
        List<String> nicknames2 = teamService.findAllTeamMemberNicknames_FetchJoinI_OuterJoin();

    }

 

 

Hibernate는 Fetch Join에 대해 다음과 같이 설명한다.

Unfortunately, by its very nature, join fetching simply can’t be lazy. So to make use of join fetching, we must plan ahead.
  • Fetch Join은 본질적으로 LAZY 할 수 없다.
  • 사용하는 순간 해당 연관관계는 EAGER 된다.

그래서 Fetch Join은 항상 쓰는 해결책이 아니라, 필요한 지점에 명확하게 쓰는 해결책이다.

 

Hibernate가 말하는 Fetch Join 사용 원칙

Our general advice is: Avoid the use of lazy fetching, which is often the source of N+1 selects.

Now, we’re not saying that associations should be mapped for eager fetching by default! Most associations should be mapped for lazy fetching by default.

It sounds as if this tip is in contradiction to the previous one, but it’s not. It’s saying that you must explicitly specify eager fetching for associations precisely when and where they are needed.
  • LAZY 로딩을 기본으로 사용하되, 필요한 곳에서만 명시적으로 EAGER를 사용해라.

2) Entity Graph 사용

EntityGraph는 Fetch Join과 결과는 동일하지만 표현 방식이 다른 방법이다.

JPQL을 수정하지 않고, 이번 조회에서 함께 가져올 연관관계를 선언적으로 지정한다.

내부적으로는 Left Join Fetch를 사용하고, Member를 가져오기 위한 추가 쿼리는 발생하지 않는다.

@Repository
public interface TeamRepository extends JpaRepository<Team, Long> {

    @Query("select t from Team t")
    @EntityGraph(attributePaths = "memberList")
    List<Team> findAllWithEntityGraph();

}
    @Test
    @DisplayName("EntityGraph 사용")
    void testEntityGraph(){
        System.out.println("==========EntityGraph 테스트==========");

        // 결과 : select t1_0.id,ml1_0.team_id,ml1_0.id,ml1_0.nickname,t1_0.team_name from team t1_0 left join member ml1_0 on t1_0.id=ml1_0.team_id
        List<String> nicknames = teamService.findAllTeamMemberNicknames_EntityGraph();
    }

3) Fetch Join VS EntityGraph

두 방법의 차이는 어디에서 명시하느냐에 가깝다.

  • Fetch Join : JPQL 레벨에서 명시
  • Entity Graph : 조회 메서드에 힌트 형태로 명시

5.2 Batch Fetching & Subselect Fetching

앞선 5.1 Outer Join Fetching은 추가 조회 쿼리를 아예 없애는 방식이었다.

반면 5.2 Batch Fetching & Subselect Fetching은 추가 조회 쿼리를 줄이는 방향의 방식이다.

 

즉, N+1 -> 1+1, 1+2 수준으로 완화하는 방법이다.

 

완전한 제거는 아니지만, Join Fetch가 부담스러운 상황에서는 매우 유용한 대안이 된다.

1) Batch Fetching (@BatchSize)

Batch Fetching은 연관 엔티티를 조회할 때 여러 부모 ID를 묶어서 한 번에 조회하는 방식이다.

Entity에서 @BatchSize(int size)를 사용해서 한 번에 조회하는 개수를 조절할 수 있다.

  • Team이 3개라면
    • Member 조회 쿼리 3번 X
    • Member 조회 쿼리 1번 (IN절) O

즉, 어차피 여러 개를 조회할 거면 한 번에 가져오자 전략이다.

@Entity
public class Team {

    @OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
    @BatchSize(size = 5)
    private List<Member> memberList = new ArrayList<>();

}
    @Test
    @DisplayName("Batch Fetching으로 조회")
    void testBatchFetching(){
        System.out.println("==========Batch Fetching 테스트==========");

        /**
         * team = 3팀
         * batchsize = 5
         * select t1_0.id,t1_0.team_name from team t1_0
         * select ml1_0.team_id,ml1_0.id,ml1_0.nickname from member ml1_0 where ml1_0.team_id in (?,?,?,?,?)
         */
        List<Team> teams = teamRepository.findAll();
    }
    @Test
    @DisplayName("Batch Fetching으로 조회")
    void testBatchFetching(){
        System.out.println("==========Batch Fetching 테스트==========");

        /**
         * team = 3팀
         * batchsize = 2
         * select t1_0.id,t1_0.team_name from team t1_0
         * select ml1_0.team_id,ml1_0.id,ml1_0.nickname from member ml1_0 where ml1_0.team_id in (?,?)
         * select ml1_0.team_id,ml1_0.id,ml1_0.nickname from member ml1_0 where ml1_0.team_id=?
         */
        List<Team> teams = teamRepository.findAll();
    }

팀이 3팀인데 배치사이즈가 2일 때는 마지막 쿼리가 1줄 더 추가된다.

그 이유는 Batch에서 남은 마지막 Team을 조회해야되기 때문이다.

 

특징

  • N+1 문제를 완전히 제거하지는 않음
  • 하지만 쿼리 폭증 문제는 크게 완화
  • 설정만으로 적용 가능
  • LAZY 로딩 유지 가능

2) Subselect Fetching

Subselect Fetching은 부모 엔티티를 조회한 쿼리를 서브쿼리로 재사용하여 연관 엔티티를 한 번에 조회하는 방식이다.

가져올 Member 컬렉션 엔티티 위에 @Fetch에 FetchMode.SUBSELECT를 설정하면 된다.

이를 사용하면 Member 엔티티가 조회될 때 IN절과 서브 쿼리를 사용하여 한 번에 조회할 수 있다.

@Entity
public class Team {

    @OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
    @Fetch(value = FetchMode.SUBSELECT)
    private List<Member> memberList = new ArrayList<>();

}
    @Test
    @DisplayName("Subselect Fetching으로 조회")
    void testSubselectFetching(){
        System.out.println("==========Subselect Fetching 테스트==========");

        /**
         * select t1_0.id,t1_0.team_name from team t1_0
         * select ml1_0.team_id,ml1_0.id,ml1_0.nickname from member ml1_0 where ml1_0.team_id in (select t1_0.id from team t1_0)
         */
        List<Team> teams = teamRepository.findAll();
    }

3) Batch Fetching / Subselect Fetching 공통점

Batch Fetching과 Subselect Fetching은 동작 방식에는 차이가 있지만, Hibernate가 이 두 전략을 묶어서 설명하는 중요한 공통점이 하나 있다.

바로 둘 다 LAZY 로딩 기반에서 동작한다는 점이다.

 

이 두 전략은 연관 엔티티를 즉시 Join해서 가져오지 않는다.

루트 엔티티를 먼저 조회한 뒤, 연관 컬렉션이 실제로 사용되는 시점에 추가 쿼리를 실행한다는 점에서 기본적인 철학은 LAZY 로딩과 동일하다.

 

다만 차이점은, 기존의 LAZY 로딩이 부모 엔티티 개수만큼 개별 쿼리를 반복 실행했다면,
Batch Fetching과 Subselect Fetching은 여러 부모 ID를 한 번에 묶어서 조회함으로써 쿼리 개수를 줄인다는 데 있다.

즉, 이 방식들은 N+1 문제를 완전히 제거하는 해결책이라기보다는, N+1이 불가피한 구조에서 쿼리 폭증을 완화하는 전략에 가깝다.

 

Hibernate 역시 이 점을 명확하게 언급한다.
Batch Fetching과 Subselect Fetching은 편리하지만, 이는 개발자가 제어를 일부 포기하는 대가로 얻는 편의성이라는 것이다.
실제로 어떤 시점에 어떤 쿼리가 실행되는지 명확히 알기 어려워지고, 복잡한 조회 시 성능 예측이 어려워질 수 있다.

그래서 Hibernate는 이러한 편리함에 대해 다음과 같이 말한다.

“It turns out that this is a convenience we’re going to have to surrender.”

 

이는 Batch Fetching이나 Subselect Fetching이 나쁘다는 의미가 아니라,
궁극적인 해결책으로 의존해서는 안 된다는 경고에 가깝다.


6. 결론

N+1 문제는 JPA를 잘못 사용해서 생기는 단순한 실수가 아니다.
객체지향과 관계형 데이터베이스라는 서로 다른 패러다임을 연결하는 ORM 구조 자체에서 필연적으로 발생할 수 있는 문제다.

 

그렇기 때문에 N+1 문제는 “피해야 할 버그”라기보다는 반드시 이해하고, 상황에 따라 의도적으로 제어해야 하는 현상에 가깝다.

 

Hibernate가 제시하는 여러 해결 전략을 정리해보면 방향은 명확하다.

 

Fetch Join과 EntityGraph는
추가 조회 쿼리를 아예 발생시키지 않는 가장 명시적이고 예측 가능한 해결책이다.
대신 LAZY 로딩을 포기해야 하므로, 필요한 지점에서만 신중하게 사용해야 한다.

 

Batch Fetching과 Subselect Fetching은
LAZY 로딩을 유지하면서도 쿼리 수를 줄일 수 있는 완화 전략이다.
설정만으로 적용할 수 있어 편리하지만, 실행 시점과 쿼리를 명확히 통제하기 어렵다는 한계를 가진다.

 

결국 핵심은 하나다.

LAZY 로딩을 기본으로 두되, 필요한 조회 시점에만 명시적으로 EAGER 전략을 선택하라는 것이다.

 

즉, N+1 문제를 해결하는 정답은 하나의 기술이 아니라, 조회 목적과 데이터 사용 패턴을 고려한 의도적인 선택에 있다.

 

이 점을 이해하는 순간, N+1 문제는 더 이상 두려운 장애 원인이 아니라 ORM을 제대로 다루고 있다는 신호가 된다.


7. 느낀점

이번 글을 정리하면서 N+1 문제를 단순히 “피해야 할 성능 문제”가 아니라, 객체지향과 관계형 데이터베이스 사이의 간극에서 자연스럽게 발생하는 ORM의 구조적 특성으로 이해하게 되었다.


Fetch Join, Batch Fetching, Subselect Fetching을 비교하며, 성능 최적화에는 항상 명확한 트레이드오프가 존재하고, 편리한 설정이 반드시 최선의 선택은 아니라는 점을 체감했다.


특히 LAZY 로딩을 기본으로 두되, 필요한 시점에만 명시적으로 로딩 전략을 선택하는 것이 ORM을 제대로 사용하는 방법이라는 기준을 세울 수 있었다.


이제는 N+1 문제를 단순히 제거하는 데 집중하기보다, 조회 목적과 데이터 사용 패턴을 고려해 의도적으로 설계하는 관점을 갖게 되었다.