본문 바로가기
Jpa/series

[Jpa 4편] n+1 정리

by 무대포 개발자 2022. 7. 7.
728x90
반응형

목차는 jpa series 목차 에 있습니다.

source 는 Github 에 있습니다.

[Jpa 4편] n+1 정리

1. Jpa n+1 문제란?


  • 상위 엔티티에서 하위 엔티티를 조회할 때마다 하위 엔티티 갯수만큼 쿼리가 더 수행되는 현상입니다. 데이터가 많을 때는 하위 엔티티 만큼 쿼리가 발생하게 되니 성능 문제가 발생할 수 있습니다.

내부 동작

  • n+1 에 관련해서 알아보기 전에 jpa 가 동작하는 방식에 대해서 간략히 정리했습니다.

  • repository 인터페이스에 선언한 메서드를 호출하면 JPA 엔진에서는 메서드명을 분석해서 JPQL을 생성합니다.

  • JPQL은 SQL을 추상화한 쿼리 언어입니다. 특정 SQL에 종속되지 않고, 엔티티 객체와 필드명을 맵핑해서 쿼리를 수행합니다.

  • 예를 들어, findById 라는 함수를 호출한다면 아래와 같이 쿼리를 만들고 수행을 할 것입니다.

select * from test where id = ?
  • 문제는 test 라는 Entity 에 하위 엔티티도 조회를 해야하는데 jpa 엔진 입장에서는 그렇게 쿼리를 실행시키기 위해서는 조인을 하거나 서브쿼리를 날려야하는데 그렇게 경우의 수를 따지기에는 많은 케이스가 있어 힘들다고 생각합니다.
  • 그렇기에 JPA 기본 default 쿼리 수행 동작은 인터페이스에 선언된 메소드명을 분석해서 JPQL 을 만들고, JPQL 을 실행시켜서 데이터를 가져온 후, 하위 엔티티 데이터를 또 조회해서 가져오게 됩니다. 그렇기에 n+1 이 합니다.

예시

  • 아래 예시는 Member : Account = 1 : n 관계입니다.
  • Member 데이터가 2개 있고, Member 데이터 1개당 Account 데이터가 2개 있다고 가정을 해보겠습니다.
  • 아래와 같이 MemberService 에서 member 를 가져와서 account 를 조회하면 n+1 문제가 발생합니다. 이는 Fetch type 이 lazy, eager 관계 없습니다.
  • MemberService 를 호출하면 Member 를 조회하는 쿼리 1개, Account 를 조회하는 쿼리 2개가 발생합니다.

@Slf4j
@RequiredArgsConstructor
@Service
public class MemberService {

    private final MemberRepository memberRepository;

    @Transactional(readOnly = true)
    public List<Member> getMembers2() {
        List<Member> members = memberRepository.findAll();
        members.forEach(member -> {
            log.info("member : {}, account.size : {}", member, member.getAccounts().size());
        });
        return members;
    }
}


@Entity
@Getter
@NoArgsConstructor
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    @Setter
    private Long id;

    @Column
    private String name;

    @Column
    private String telNo;

    @Column
    private int age;

    @JsonIgnore
    @OneToMany(mappedBy = "member")
    private List<Account> accounts = new ArrayList<>();
}


@Entity
@Getter
@NoArgsConstructor
public class Account {

    @Id
    @GeneratedValue
    @Column(name = "account_id")
    private Long id;

    @Column @Setter
    private String accountNo;   // 계좌번호

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
}


2. n+1 해결 방안


2.1 join fetch

join fetch 는 연관된 엔티티나 컬렉션을 한 번에 조회하는 기능입니다. join fetch 를 통해 데이터를 한 번에 읽어오면 n+1 문제는 발생하지 않습니다.

문제는 join fetch 를 통해 여러 하위 엔티티를 조회할 때 MultipleBagFetchException 가 발생하게 됩니다.



MultipleBagFetchException 가 발생하는 이유는 다음과 같습니다.

Member Entity 가 있다고 가정하고, Member 엔티티는 Account, Car 하위 엔티티를 가지고 있다고 가정하겠습니다.
Member : Account = 1 : n , Member : Car = 1: n
Member 데이터 1개당 Account, Car 데이터를 2개씩 가지고 있다고 가정하겠습니다.
Member 와 account 를 fetch join 하면 데이터를 2개를 가지고 올 것 입니다. (Member + Account)
이 상태에서 Car 를 fetch join 하면 이미 가져온 2개 데이터에서 어떤 기준을 가지고 Car 를 가져와야할지 판단을 할 수 없습니다.
그렇기에 MultipleBagFetchException 발생합니다.

2.2 Entity Graph

쉽게 설명하면 조회할 때, 한 번에 조회하는 기능입니다. (그러나 사용하기 좀 어렵습니다.)

2.3 default_batch_fetch_size 또는 @BatchSize

default_batch_fetch_size 는 상위 엔티티 안에 포함돼있는 하위 엔티티를 조회할 때, In Query 로 한 번에 조회하는 기능입니다.

예를 들면, default_batch_fetch_size 가 100 이고, Account, Car 데이터를 조회할 때 최대 100개까지 member_id 를 InQuery 로 가져옵니다.

default_batch_fetch_size 는 전역 설정이며, @BatchSize 는 Entity 로 설정할 수 있습니다. 아래 예시에 사용법이 있습니다.


@Entity
public class Member {

    @OneToMany(mappedBy = "member")
    @BatchSize(size = 100)
    private List<Account> accounts = new ArrayList<>();
}


spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

    select
        accounts0_.member_id as member_i3_0_1_,
        accounts0_.account_id as account_1_0_1_,
        accounts0_.account_id as account_1_0_0_,
        accounts0_.account_no as account_2_0_0_,
        accounts0_.member_id as member_i3_0_0_ 
    from
        account accounts0_ 
    where
        accounts0_.member_id in (
            ?, ?
        )

reference

'Jpa > series' 카테고리의 다른 글

[Jpa 2편] OneToMany, ManyToOne 정리  (0) 2022.07.07
[jpa 3편] OneToOne 정리  (0) 2022.06.16
[jpa 1편] jpa 개념 정리  (0) 2022.06.16

댓글