[Jpa 4편] n+1 정리
목차는 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 (
?, ?
)