본문 바로가기
Programming/Languages (Java, etc)

[Java] JPA N+1 원리 이해하기

by kghworks 2023. 11. 14.

목차

  • N+1 문제 정의
  • N+1 원인 1. 연관관계의 주인이 누구인가
  • N+1 원인 2. JPQL은 연관객체의 fetchType을 모른다
  • N+1 해결방법
  • 정리
  • 참고

 

 

포스팅에는 아주 간단히 아래 2개의 테이블을 사용한다.

1:N 관계의 team, member 테이블

@Entity
@Table(name = "team")
public class Team {

    @Id
    private String id;

    private String name;

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

 	// 생략
}

@Entity
@Table(name = "member")
public class Member {

    @Id
    private String id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "id_team")
    private Team team;
    
	// 생략
}

N+1 문제 정의

 N+1 문제를 정의하기에 앞서 연관 객체를 프록시로 가지는 JPA의 특징을 알아야 한다. fetch = FetchType.LAZY로 연관관계를 설정하면, 연관 객체는 select 시에 join 하지 않고 나중에 실제 참조 시에 SQL 질의하는 것을 말한다. (JPA 기초 상식이라 그냥 간단히 언급하고 넘김)

 

 이때 프락시는 연관객체의 아이디 (@Entity의 @Id 필드)만을 가져야 한다. (이 부분이 중요) 그렇기 때문에 프락시의 Id를 참조해도 sql 질의를 하지 않는다. 아래 테스트를 보면,

 

fetch = FetchType.LAZY 연관객체를 질의하지 않고 프락시로 가지고 있다

@Test
@DisplayName("FetchType.LAZY 는 연관객체를 proxy로 가지고 있다.")
@Transactional
public void lazy_loading() {

    // N+1 발생 안함
    Member karina = memberRepository.findById("ID_Karina")
            .orElseThrow(() -> new RuntimeException("Member not found"));

    // 연관 객체를 프록시로 가짐
    assertThat(Hibernate.isInitialized(karina.getTeam()), is(false));

    // 프록시 : 연관 객체의 ID만 가지고 있음
    karina.getTeam().getId();

    // 연관 객체를 프록시로 가짐
    assertThat(Hibernate.isInitialized(karina.getTeam()), is(false));

    // lazy loading 발생
    karina.getTeam().getName();

    // 연관 객체는 더이상 프록시가 아님
    assertThat(Hibernate.isInitialized(karina.getTeam()), is(true));
}

HibernateProxy에 id만을 가지고 있는 team 객체

 

 연관 객체를 프록시로 가지고 있다가 @Id 필드가 아닌 필드를 참조하자 lazy loading이 발생했다. 정상적인 JPA 동작이다.

 

 

N+1 문제의 정의

 이제 N+1 문제를 보자. 사실 개인적으로 N+1 문제는  "문제" 라기보다 당연히 발생하는 "현상"에 가깝다고 생각한다. 다들 N+1 "문제"라고 하니 나도 문제라고 하고 포스팅하겠다. 암튼 보자.

 

 먼저 가장 일반적인 N+1 문제다.

@Entity
@Table(name = "team")
public class Team {

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

@Test
@DisplayName("대표적인 N+1")
@Transactional
public void n_plus_one() {

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

}
Hibernate: select t1_0.id,t1_0.name from team t1_0
select t1_0.id,t1_0.name from team t1_0

Hibernate: select m1_0.id_team,m1_0.id,m1_0.name from member m1_0 where m1_0.id_team=?
select m1_0.id_team,m1_0.id,m1_0.name from member m1_0 where m1_0.id_team='ID_NewJeans'

Hibernate: select m1_0.id_team,m1_0.id,m1_0.name from member m1_0 where m1_0.id_team=?
select m1_0.id_team,m1_0.id,m1_0.name from member m1_0 where m1_0.id_team='ID_Aespa'

 

 team을 조회했는데, team의 결과 수만큼 추가 sql이 실행되었다. 이를 N+1 문제라고 한다. 1개의 질의에 따른 결과 set N개의 sql이 추가로 실행되었기 때문이다. FetchType을 Lazy로 바꿔보자

@Entity
@Table(name = "team")
public class Team {

    // ...
    
    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private List<Member> members = new ArrayList<>();
    
	// ...
}

@Test
@DisplayName("FetchType.LAZY여도 연관관계 주인이 아니면 N+1 발생")
@Transactional
public void n_plus_one_when_lazy() {

    // N+1 발생
    Team aespa = teamRepository.findById("ID_Aespa")
            .orElseThrow(() -> new RuntimeException("Team not found"));

    // 연관 객체를 프록시가 아닌 실제 객체로 가짐
    aespa.getMembers().stream().forEach(member -> {
        assertThat(Hibernate.isInitialized(member), is(true));
    });

}

 

Team을 조회했으니 연관 객체 members는 프락시 어야 하지만, 그렇지 않다. 로그를 보면,

Hibernate: select t1_0.id,t1_0.name from team t1_0 where t1_0.id=?
select t1_0.id,t1_0.name from team t1_0 where t1_0.id='ID_Aespa'

select m1_0.id_team,m1_0.id,m1_0.name from member m1_0 where m1_0.id_team=?
select m1_0.id_team,m1_0.id,m1_0.name from member m1_0 where m1_0.id_team='ID_Aespa'

 

 lazy loading으로 설정해 주었지만, team을 조회하는 쿼리 1개, member를 조회하는 쿼리 1개 총 2개의 쿼리가 실행되었다. N+1 문제이다. 

 

즉 N+1문제는 FetchType과 무관하게 발생할 수 있다.


N+1 원인 1. 연관관계의 주인이 누구인가

 

1:N 관계의 team, member 테이블

 

 

다시 테이블 E-R 모델로 돌아가서, FK를 가지는 테이블을 연관관계의 주인이라고 한다. 위 관계에서의 연관관계의 주인은 member 테이블이다. 

 

@Test
@DisplayName("N+1 발생 = One to Many, FK가 없는 쪽에 질의")
@Transactional
public void n_plus_one_when_lazy() {

    // N+1 발생
    Team aespa = teamRepository.findById("ID_Aespa")
            .orElseThrow(() -> new RuntimeException("Team not found"));

    // 연관 객체를 프록시가 아닌 실제 객체로 가짐
    aespa.getMembers().stream().forEach(member -> {
        assertThat(Hibernate.isInitialized(member), is(false));
    });

}

 

 위 테스트는 Team 테이블을 조회하면서 member 데이터를 proxy로 가지고 있기를 기대하는 테스트이다 즉 Member 테이블의 PK 데이터 (@Id 필드)를 가지고 있겠다는 뜻이다. 그것이 가능한가? 당연 불가능하다. 연관관계의 주인만이 연관관계를 설정할 수 있기 때문이다.

 더 쉽게 얘기해서 아래와 같은 SQL로는 Member의 PK값을 알지 못한다. -> Proxy로 가질 수 없다.

 

select t1_0.id,t1_0.name 
from team t1_0 
where t1_0.id='Aespa001'

 

 그렇기 때문에 fetchType 값에 상관없이 member 데이터를 알아내기 위해 한 번의 쿼리가 더 나가는 것이다. 이걸 이해했다면 아래 테스트의 성공 여부는 당연히 예측할 수 있다.

@Test
@DisplayName("N+1 발생 안함, FK가 있는 쪽에 질의")
@Transactional
public void n_plus_one_when_lazy_not() {

    // N+1 발생 안함
    Member karina = memberRepository.findById("ID_Karina")
            .orElseThrow(() -> new RuntimeException("Member not found"));

    // 연관 객체를 프록시로 가짐
    assertThat(Hibernate.isInitialized(karina.getTeam()), is(false));

    // 프록시 : 연관 객체의 ID만 가지고 있음
    karina.getTeam().getId();

    // 연관 객체를 프록시로 가짐
    assertThat(Hibernate.isInitialized(karina.getTeam()), is(false));
}

 

 테스트는 성공한다. Member 테이블은 연관관계의 주인이고, Member 테이블에 질의하는 것 만으로 Team 객체를 프락시로 가질 수 있다. 즉 Member 테이블에는 FK 값이 있다. 설명이 반복되는 것 같으니 그만 설명하겠다.

 


N+1 원인 2. JPQL은 연관객체의 fetchType을 모른다

@Entity
@Table(name = "team")
public class Team {
// ...
    @OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
    private List<Member> members = new ArrayList<>();

// ...
}

@Test
@DisplayName("FetchType.EAGER로 설정해도 N+1로 조회하지롱~")
@Transactional
public void n_plus_one_jpql_when_eager() {

    entityManager.createQuery("select t from Team t where t.id = :id", Team.class)
            .setParameter("id", "ID_Aespa")
            .getSingleResult()
            .getMembers()
            .stream()
            .forEach(member -> {
                // N+1 : 연관객체가 프록시가 아닌 실제 객체
                assertThat(Hibernate.isInitialized(member), is(true));
            });
}
select t1_0.id,t1_0.name from team t1_0 where t1_0.id=?
select t1_0.id,t1_0.name from team t1_0 where t1_0.id='ID_Aespa'

select m1_0.id_team,m1_0.id,m1_0.name from member m1_0 where m1_0.id_team=?
select m1_0.id_team,m1_0.id,m1_0.name from member m1_0 where m1_0.id_team='ID_Aespa'

 

 fetchType=EAGER임에도 2번의 쿼리가 발생했다. 참고로 Lazy로 설정하면 1번의 쿼리만 실행된다. (코드 생략)

원인은 JPQL은 Entity 연관관계의 fetchType을 모른다는 것이다. 즉 jpql이 실행될 때, Entity의 fetchType을 모르는 상태에서 일단 Entity만 조회하고, JPA global fetch 전략에 따라 추가로 조회해 나간다. (내가 Entity에 설정한 fetchType은 무시해 버림)


N+1 해결방법

 

1. FK가 없는 테이블에 질의할 때 lazy loading은 불가능하다

 먼저 연관객체의 주인이 아닌 쪽에 질의할 때다. (Team 측을 조회하는 경우) 이때는 fetchType=LAZY여도 N+1 쿼리가 실행될 수 있다. 따라서 fetch join을 JPQL에 명시적으로 걸어주는 방법이 있다. 아래처럼 말이다. 

 

@Test
@DisplayName("FetchType.Lazy로 설정하면 N+1로 없음")
@Transactional
public void n_plus_one_jpql_when_lazy() {

    entityManager.createQuery("select t from Team t where t.id = :id", Team.class)
            .setParameter("id", "ID_Aespa")
            .getResultList()
            .stream()
            .forEach(team -> {
                assertThat(Hibernate.isInitialized(team.getMembers()), is(false));
            });
}

 

Hibernate: select t1_0.id,m1_0.id_team,m1_0.id,m1_0.name,t1_0.name 
from team t1_0 join member m1_0 on t1_0.id=m1_0.id_team 
where t1_0.id=?

select t1_0.id,m1_0.id_team,m1_0.id,m1_0.name,t1_0.name 
from team t1_0 join member m1_0 on t1_0.id=m1_0.id_team 
where t1_0.id='ID_Aespa'

 

fetchType=Lazy인데 연관관계 주인이 아닌 쪽에 질의하면서 연관객체를 프락시로 갖는 방법은 없다. (앞서 말했듯이 당연히 불가능함)

 

 

2.  @org.hibernate.annotations.Batchsize

@Entity
@Table(name = "team")
public class Team {
	// ...
    
    @org.hibernate.annotations.BatchSize(size = 5)// in절에 5개씩 묶어서 쿼리를 날림
    @OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
    private List<Member> members = new ArrayList<>();
    
 	// ...
}

@Test
@DisplayName("@BatchSize 활용")
@Transactional
public void use_batch_size() {

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

    allTeams.stream()
            .flatMap(team -> team.getMembers().stream())
            .forEach(member -> {
                assertThat(Hibernate.isInitialized(member), is(true));
            });
}
Hibernate : select t1_0.id,t1_0.name from team t1_0
select t1_0.id,t1_0.name from team t1_0

Hibernate: select m1_0.id_team,m1_0.id,m1_0.name from member m1_0 where m1_0.id_team in (?,?,?)
select m1_0.id_team,m1_0.id,m1_0.name from member m1_0 where m1_0.id_team in ('ID_NewJeans','ID_Aespa',NULL)

 

@BatchSize를 활용하면 SQL in 절을 사용하여 한 번의 쿼리를 더 실행한다. 이때 in 절 안의 파라미터 수는 @BatchSize의 size 속성을 통해 지정할 수 있다. (개수가 size 이상이면 size씩 chunk 하여 실행한다)

 

 비단 N+1 문제에서만이 아니라 @OneToMany 연관 객체를 Eager로 가져올 수 있는 전략으로 택할 수 있는 좋은 방법이 될 수 있다.

 

 

3. @org.hibernate.annotations.Fetch(FetchMode.SUBSELECT) 

@Entity
@Table(name = "team")
public class Team {
	// ...
    
    @org.hibernate.annotations.Fetch(FetchMode.SUBSELECT)
    @OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
    private List<Member> members = new ArrayList<>();
    
 	// ...
}

@Test
@DisplayName("FetchMode.SUBSELECT 활용")
@Transactional
public void use_batch_size() {

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

    allTeams.stream()
            .flatMap(team -> team.getMembers().stream())
            .forEach(member -> {
                assertThat(Hibernate.isInitialized(member), is(true));
            });
}
Hibernate: select t1_0.id,t1_0.name from team t1_0
select t1_0.id,t1_0.name from team t1_0

Hibernate: select m1_0.id_team,m1_0.id,m1_0.name from member m1_0 where m1_0.id_team in(select t1_0.id from team t1_0)
select m1_0.id_team,m1_0.id,m1_0.name from member m1_0 where m1_0.id_team in(select t1_0.id from team t1_0)

 

추가질의를 in 절에 서브쿼리로 넣어서 해결한다. 

 

4. fetch join (추천)

가장 추천하는 방법은 JPQL을 직접 명시해서 fetch join 구문으로 join을 명시하는 방법이다. 연관관계 조회까지 1번의 쿼리로 실행된다.

@Test
@DisplayName("fetch join으로 N+1 해결하기")
@Transactional
public void n_plus_one_jpql_fetch_join() {

    entityManager.createQuery("select t from Team t join fetch t.members", Team.class)
            .getResultList()
            .stream().flatMap(team -> team.getMembers().stream())
            .forEach(member -> {
                assertThat(Hibernate.isInitialized(member), is(true));
            });
}
Hibernate: select t1_0.id,m1_0.id_team,m1_0.id,m1_0.name,t1_0.name from team t1_0 join member m1_0 on t1_0.id=m1_0.id_team
select t1_0.id,m1_0.id_team,m1_0.id,m1_0.name,t1_0.name from team t1_0 join member m1_0 on t1_0.id=m1_0.id_team

정리

N+1문제는 FetchType과 무관하게 발생한다. 문제가 발생했을 때 먼저 연관관계의 주인이 누구인지를 살펴보자 (=해당 테이블에 FK가 있는지 확인) FK가 없는 테이블에 lazy loading은 불가능하다. fetch join을 통해 해결하거나, @BatchSize와 같은 어노테이션으로 성능을 최적화해 볼 수 있다.

 

 N+1 문제에 silver bullet이 없다. 경우에 따라 해결할 수 있는 방법이 많으므로 적절히  해결하면 되겠다. 방법보다는 N+1 문제가 발생하는 이유를 살펴보는 것이 더 중요하다.

 


참고

https://product.kyobobook.co.kr/detail/S000000935744

 

자바 ORM 표준 JPA 프로그래밍 | 김영한 - 교보문고

자바 ORM 표준 JPA 프로그래밍 |

product.kyobobook.co.kr

 

댓글