본문 바로가기

경험과 지식

JPA N+1문제와 해결방안(1)

ORM 기술을 사용한 애플리케이션에서 흔히 겪는 현상으로 N+1 문제가 있습니다.
N+1 문제는 1개의 조회 쿼리에 N개의 추가 쿼리가 발생하는 현상으로 애플리케이션 성능저하의 원인입니다.
당시 수백개의 비교적 적은 레코드의 조회 쿼리 실행 시간이 1초 이상 걸리던 레거시 코드를 개선했던 경험을 정리해 보겠습니다.
1편에서는 여러 가지 현상들을 확인하고, 2편에서 해결했던 방식을 소개하겠습니다. 모든 코드는 예시로 작성되었습니다.

 

- JAVA 17

- Spring boot 3.1.0

1. 엔티티 정의

1:N 관계의 Team,Member 엔티티를 구성합니다.

Member 는 연관관계의 주인으로 멤버변수 중 하나로 Team이 있습니다. 로딩 전략으로는 즉시로딩(Eager)을 사용합니다.

Team.java

package com.npo.npo.entities;

import jakarta.persistence.*;

@Entity
public class Team {

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

    @Column
    private String name;

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

}

Member.java

package com.npo.npo.entities;

import jakarta.persistence.*;

@Entity
public class Member {

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

    @Column
    private String name;

    @ManyToOne // default EAGER
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    public Long getId() {
        return id;
    }

    public Team getTeam() {
        return team;
    }

    public String getName() {
        return name;
    }
}

2. 데이터 초기화용 sql 파일 생성과 repository 레이어

각 엔티티에 맵핑된 h2 db 테이블에 다음과 같은 데이터가 초기화를 하려고합니다.

이 sql 파일은 테스트코드에서 @Sql 어노테이션과 함께 테스트시 데이터를 초기화합니다.

team에는 3개의 팀이 존재하고, member 테이블에는 총 10개 레코드에 1,2,3 팀이 골고루 있습니다.

init.sql

INSERT INTO team (name)
VALUES
    ('Team A'),
    ('Team B'),
    ('Team C');

INSERT INTO member (name, team_id)
VALUES
    ('김', 1),
    ('이', 1),
    ('박', 1),
    ('최', 1),
    ('정', 1),
    ('강', 2),
    ('조', 1),
    ('윤', 1),
    ('장', 3),
    ('임', 3);

 

 

Repository 레이어에 해당하는 인터페이스를 생성해줍니다. JpaRepository 에서 기본적으로 제공하는 메서드만 사용합니다.

MemberRepository.java

package com.npo.npo.repository;

import com.npo.npo.entities.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
}

3. 테스트 작성

실제 jpa가 실행하는 쿼리가 무엇인지, 어느시점에 실행하는지를 자체검증하는 테스트코드는 작성하기 까다롭다고 판단하였습니다.

그래서 jpa 에서 제공하는 show-sql 옵션을 true 로 설정하여 콘솔 출력을 통해 검증하고자 합니다.

MemberRepositoryTest.java

package com.npo.npo.repository;

import com.npo.npo.entities.Member;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.jdbc.Sql;

import java.util.List;

@DataJpaTest
@Sql("/init.sql")
public class MemberRepositoryTest {

    @Autowired
    private MemberRepository memberRepository;

    @Test
    public void findAllTest() {
        List<Member> all = memberRepository.findAll();
    }
}

 

위 findAllTest() 테스트를 실행한 결과, 콘솔에는 다음과같은 Hibernate 로그가 출력됩니다.

Hibernate: select m1_0.id,m1_0.name,m1_0.team_id from member m1_0
Hibernate: select t1_0.id,t1_0.name from team t1_0 where t1_0.id=?
Hibernate: select t1_0.id,t1_0.name from team t1_0 where t1_0.id=?
Hibernate: select t1_0.id,t1_0.name from team t1_0 where t1_0.id=?

위 로그를 통해 확인할 수 있는점은 다음과 같습니다.

  1. @XXXToOne 어노테이션의 default 페치 전략은 즉시로딩(EAGER)입니다.
    테스트 코드 로직 내에서 team 엔티티에 접근하지 않았음에도, 추가적인 team 조회 쿼리를 실행합니다. (N+1 문제 발생)
  2. member에는 총 10개의 레코드가 있어서 1+10개 쿼리가 발생할 것으로 예상하기 쉽지만 한 번 조회된 entity 는 같은 트랜잭션 내에서 1차캐시에 올라가있기 때문에 실제로 DB에 쿼리를 수행하는 횟수는 조회된 결과 집합에서 team 컬럼의 카디널리티(이 경우 3)와 같습니다. 1번, 6번, 9번 레코드에서 새로운 teamId가 등장하므로 총 1+3번 입니다.

이제 Member Entity 의 페치 전략을 지연로딩(LAZY)으로 변경하여 동일한 테스트를 진행해보겠습니다.

Member.java 일부

public class Foo {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
}

동일한 테스트 실행시 아래와 같이 예상가능한 결과가 출력되었습니다.

Hibernate: select m1_0.id,m1_0.name,m1_0.team_id from member m1_0

Lazy 로딩 전략에서 조회된 Member 오브젝트 내의 Team은 프록시 상태로 존재하게됩니다.

아래 테스트코드는 EntityManager 클래스의 getReference() 메서드를 통해 기본키 필드만 초기화된 프록시 오브젝트를 만들고,

해당 오브젝트의 멤버들에 접근하면서 실제 수행되는 쿼리를 보기 위한 테스트코드입니다.

Hibernate 쿼리 콘솔 출력정보와 시스템 프린트를 활용하여 어느 시점에 쿼리가 실행되는지 확인합니다.

MemberRepositoryTest.java 일부

@Autowired
private EntityManager entityManager;

@Test
public void proxyReferenceTest() {
    Member member = entityManager.getReference(Member.class, 1l);
    System.out.println("============1============");
    System.out.println(member.getClass().getName());
    System.out.println("============2============");
    System.out.println(member.getName());
    System.out.println("============3============");
    System.out.println(member.getTeam().getClass().getName());
    System.out.println("============4============");
    System.out.println(member.getTeam().getName());
    System.out.println("============5============");
}

 

결과는 다음과 같습니다.

============1============
com.npo.npo.entities.Member$HibernateProxy$QKs8sOEW
============2============
Hibernate: select m1_0.id,m1_0.name,m1_0.team_id from member m1_0 where m1_0.id=?
김
============3============
com.npo.npo.entities.Team$HibernateProxy$uP67XGyX
============4============
Hibernate: select t1_0.id,t1_0.name from team t1_0 where t1_0.id=?
Team A
============5============

 

결과를 통해 얻을 수 있는 정보는 다음과 같습니다.

[1-2] getReference() 메소드를 통해 프록시 오브젝트를 생성합니다. h2에 쿼리는 아직 실행되지 않습니다.

[2-3] name 멤버변수에 접근할 때(실제 오브젝트의 정보가 필요한 순간) 쿼리가 실행되고 리턴 값인 ‘김’ 을 출력합니다.

[3-4] team 역시 프록시 오브젝트입니다. 현재는 호출된 적이 없으므로 target이 null인 상태입니다.

[4-5] 실제 team의 name 멤버변수 접근할 때 쿼리가 실행되고 리턴 값인 ‘Team A’ 를 출력합니다.

지연로딩 전략에서는 프록시 객체를 영속성 컨텍스트에 저장하고(실제 오브젝트인 target은 null인 상태) 실제 멤버변수에 접근할 때 DB I/O가 발생하게 됩니다.

추가적으로, 즉시로딩 전략의 경우 위 테스트코드 [2-3] 에서 left join 으로 모든 연관 엔티티의 정보까지 로드합니다.

============2============
Hibernate: 
    select
        m1_0.id,m1_0.name,t1_0.id,t1_0.name 
    from
        member m1_0 
    left join
        team t1_0 
            on t1_0.id=m1_0.team_id 
    where
        m1_0.id=?
김
============3============

따라서 Member 의 정보만 필요한 기능의 경우 지연로딩 전략으로 변경하였을 때 N+1 문제를 해결할 수 있습니다.
그러나 Team 정보까지 필요한 요구사항에서는 여전히 N번의 쿼리가 더 발생하므로 다른 해결방식이 필요합니다.

이어서 JPA N+1문제와 해결방안(2) 에서 살펴보도록 하겠습니다.