본문 바로가기
JPA

N+1 문제 해결 전략 @BatchSize편

by sangyunpark99 2025. 2. 10.
"N+1문제를 해결하는 전략엔 어떤 것이 있을까요?"

 

이번글은 N+1 문제에 대한 소개와 이 문제를 해결할 수 있는 방법 중 하나인 @BatchSize 어노테이션을 활용하는 방법에 대해 알아보겠습니다.

 

N+1 문제란 무엇일까요?

 

N+1 문제

N+1 문제는 데이터베이스에서 연관된 엔티티를 조회할 때, 의도하지 않게 추가적인 쿼리가 발생하는 성능 이슈를 의미합니다.

하나의 엔티티를 조회하는 단일 쿼리에 대해, 추가로 N개의 쿼리가 실행되는 현상을 의미합니다.

 

의도하지 않게 추가적인 쿼리가 다량 발생한다는게 무슨 의미일까요?

 

JPA에서는 연관된 엔티티를 지연 로딩(Lazy Loading)으로 설정하는 경우 프록시 객체를 활용해서 해당 엔티티가 실제로 필요할 때 조회하도록 합니다.

하지만 이를 반복문에서 사용할 경우, 개별 엔티티마다 추가적인 쿼리가 발생합니다.

 

N+1문제를 예시 코드를 통해 알아보겠습니다.

 

 

N+1 문제 예시 코드

Member엔티티와 Team엔티티가 존재하고, 엔티티의 관계는 Team을 기준으로 일대다 관계입니다.

일대다 관계로 설정한 이유는 Team 하나에 여러명의 Member가 속할 수 있기 때문입니다.

 

MemberEntity

package com.example.spring_study_test.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MemberEntity {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)  // 지연 로딩 설정
    @JoinColumn(name = "team_id")
    private TeamEntity team;

    public MemberEntity(final String name) {
        this.name = name;
    }

    public void setTeam(TeamEntity team) {
        this.team = team;
    }

    public TeamEntity getTeam() {
        return team;
    }
}

 

TeamEntity

package com.example.spring_study_test.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.BatchSize;

import java.util.ArrayList;
import java.util.List;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TeamEntity {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team")
    private List<MemberEntity> members = new ArrayList<>();

    public TeamEntity(final String name) {
        this.name = name;
    }

    public void addMember(MemberEntity member) {
        this.members.add(member);
    }

    public List<MemberEntity> getMembers() {
        return this.members;
    }

    public String getName() {
        return this.name;
    }
}

 

 

N+1문제가 발생하는 테스트 코드를 만들어줍니다.

 

N+1 문제 테스트 코드

package com.example.spring_study_test;

import com.example.spring_study_test.entity.MemberEntity;
import com.example.spring_study_test.entity.TeamEntity;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Commit;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@SpringBootTest
public class NPlusOneProblemTest {

    @PersistenceContext
    private EntityManager em;

    @BeforeEach
    void init() {
        //given
        for(int i = 0; i < 10; i++) {
            MemberEntity member = new MemberEntity("test" + i);
            TeamEntity team = new TeamEntity("team" + i);
            member.setTeam(team);
            em.persist(team);
            em.persist(member);
        }

        em.flush();
        em.clear();
    }

    @Test
    void givenFiveMemberJoinedTeam_whenSelectTeamAndSelectMemberName_thenNPlusOneProblemOccur() throws Exception{

        //when
        List<MemberEntity> members = em.createQuery("select M from MemberEntity M", MemberEntity.class).getResultList();

        //then
        for(MemberEntity member: members) {
            System.out.println(member.getTeam().getName());
        }
    }
}

 

init() 메서드는 반복문을 사용해서, 10개의 팀에 각 한명의 유저를 할당합니다.

그 다음 10명의 Member를 조회한 후, 각 Member가 속해있는 Team의 이름을 출력합니다.

 

실행 결과는 어떻게 될까요? N+1 문제가 발생했을까요?

 

쿼리 결과 분석

Hibernate: 
    select
        me1_0.id,
        me1_0.name,
        me1_0.team_id 
    from
        member_entity me1_0
Hibernate: 
    select
        te1_0.id,
        te1_0.name 
    from
        team_entity te1_0 
    where
        te1_0.id=?
team0
Hibernate: 
    select
        te1_0.id,
        te1_0.name 
    from
        team_entity te1_0 
    where
        te1_0.id=?
team1
Hibernate: 
    select
        te1_0.id,
        te1_0.name 
    from
        team_entity te1_0 
    where
        te1_0.id=?
team2
Hibernate: 
    select
        te1_0.id,
        te1_0.name 
    from
        team_entity te1_0 
    where
        te1_0.id=?
team3
Hibernate: 
    select
        te1_0.id,
        te1_0.name 
    from
        team_entity te1_0 
    where
        te1_0.id=?
team4
Hibernate: 
    select
        te1_0.id,
        te1_0.name 
    from
        team_entity te1_0 
    where
        te1_0.id=?
team5
Hibernate: 
    select
        te1_0.id,
        te1_0.name 
    from
        team_entity te1_0 
    where
        te1_0.id=?
team6
Hibernate: 
    select
        te1_0.id,
        te1_0.name 
    from
        team_entity te1_0 
    where
        te1_0.id=?
team7
Hibernate: 
    select
        te1_0.id,
        te1_0.name 
    from
        team_entity te1_0 
    where
        te1_0.id=?
team8
Hibernate: 
    select
        te1_0.id,
        te1_0.name 
    from
        team_entity te1_0 
    where
        te1_0.id=?
team9

 

10번의 팀조회 쿼리가 추가로 요청되는 것을 확인할 수 있습니다.

만약 100만명의 Member가 존재하는 경우엔 Member가 속한 Team을 조회할때마다 1번의 추가 쿼리가 발생하므로, 총 100만번의 추가 쿼리가 발생하게 됩니다. Member가 많을수록 더 많은 추가 쿼리가 생기게 됩니다. 이는 성능에 큰 영향을 미칩니다.

 

N+1 문제를 어떻게 해결할 수 있을까요?

 

 

@BatchSize

하이버네이트에서 제공하는 @BatchSize 어노테이션을 사용해서 N+1 문제를 해결할 수 있습니다.

@BatchSize 어노테이션은 지정한 size만큼 SQL의 IN절을 사용해서 한번에 조회합니다.

 

@BatchSize를 10으로 지정해보겠습니다. 코드는 다음과 같습니다.

package com.example.spring_study_test.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.BatchSize;

import java.util.ArrayList;
import java.util.List;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@BatchSize(size = 10)
public class TeamEntity {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team")
    private List<MemberEntity> members = new ArrayList<>();

    public TeamEntity(final String name) {
        this.name = name;
    }

    public void addMember(MemberEntity member) {
        this.members.add(member);
    }

    public List<MemberEntity> getMembers() {
        return this.members;
    }

    public String getName() {
        return this.name;
    }
}

 

@BatchSize를 지정했으므로, 10번의 조회 쿼리가 날라가는 것이 아닌, SQL IN쿼리로 한번만 날라갈까요?

 

출력된 쿼리 결과는 다음과 같습니다.

Hibernate: 
    select
        te1_0.id,
        te1_0.name 
    from
        team_entity te1_0 
    where
        te1_0.id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
team0
team1
team2
team3
team4
team5
team6
team7
team8
team9

 

IN을 사용한 쿼리문이 한번만 나가는 것을 확인할 수 있습니다.

 

만약, @BatchSize를 5로 하면 어떻게 될까요?
Hibernate: 
    select
        te1_0.id,
        te1_0.name 
    from
        team_entity te1_0 
    where
        te1_0.id in (?, ?, ?, ?, ?)
team0
team1
team2
team3
team4
Hibernate: 
    select
        te1_0.id,
        te1_0.name 
    from
        team_entity te1_0 
    where
        te1_0.id in (?, ?, ?, ?, ?)
team5
team6
team7
team8
team9

 

5개씩 가져오기 때문에, 총 2번의 쿼리가 발생했습니다.

 

 

만개를 기준으로 조회를 할경우 @BatchSize를 사용하면, 얼마나 빨라지는지 확인해 보겠습니다.
(단, @BatchSize의 size는 10000으로 합니다.)

 

성능 비교 테스트 코드

먼저, @BatchSize를 사용하지 않은 경우입니다.

@Test
void givenFiveMemberJoinedTeam_whenSelectTeamAndSelectMemberName_thenNPlusOneProblemOccur() throws Exception{

    //when
    List<MemberEntity> members = em.createQuery("select M from MemberEntity M", MemberEntity.class).getResultList();

    //then
    long startTime = System.nanoTime();
    for(MemberEntity member: members) {
        System.out.println(member.getTeam().getName());
    }
    long endTime = System.nanoTime();
    System.out.println("걸린 시간 : " + (endTime - startTime) / 1_000_000_000.0 + "s");
 }

 

만개의 엔티티를 조회하는데 대략 10초정도 걸립니다.

 

@BatchSize를 사용하는 경우 다음과 같습니다.

 

만개의 엔티티를 조회하는데 대략 1초정도 걸립니다.

 

@BatchSize를 사용하니 약 10배정도 빨라지는 것을 확인할 수 있습니다.

 

정리

N+1 문제는 @BatchSize를 활용하여 해결할 수 있습니다.

'JPA' 카테고리의 다른 글

JPA 비관적 락  (0) 2025.02.07
JPA 낙관적 락  (0) 2025.02.06
JPA와 읽기 전용 쿼리  (0) 2025.02.05
find 메서드와 1차 캐시의 관계  (0) 2025.02.04
IDENTITY 식별자 생성 전략과 persist()의 관계  (0) 2025.02.03