JPA 를 이용한 개발을 하다보면 자주 접하는 프로그래밍적인 이슈가 있습니다. 바로 Fetch Type (Fetch 전략) 입니다.
Fetch Type 속성은 언제 동작하고 어떻게 동작하는지, 그리고 어떨 때 Fetch Type 때문에 N+1 문제가 발생하는지, 또 어떻게 해결하는지 알아봅니다.
추가적으로, 이상하게 동작하는 OneToOne 의 FetchType LAZY 설정도 짚고 넘어갑니다.
이 글을 이끌어가는데 도움을 주는 두 엔티티를 소개합니다. “주문” 과 “멤버” 라는 친구들입니다. 주문과 멤버는 N:1 의 관계에 있습니다.
// 주문 엔티티
@Entity
@Table(name="order")
class Order {
@Id
private Long id;
@ManyToOne(name="member_id")
private Member member;
}
// 멤버 엔티티
@Entity
@Table(name="member")
class Member {
@Id
private Long id;
@Column
private String name;
@OneToMany
private HashSet<Order> orders;
}
Fetch Type 은 JPA 가 하나의 Entity 를 조회할 때, 연관관계에 있는 객체들을 어떻게 가져올 것이냐를 나타내는 설정값입니다.
Fetch Type 은 크게 Eager 와 Lazy 두가지 전략이 있습니다. Fetch Type Issue 상황이라는 것은 하나의 Entity 를 로드할 때, 아래의 두가지 전략 중 고민하는 상황을 말합니다.
Order Entity 는 단일 Member Entity 를 가지는 ManyToOne 컬럼이 있습니다. ( member 의 PK 가 Foreign Key 로 실제로 order DB컬럼에 매핑되어있으므로 Order 가 주인입니다. )
기본
아래와같이, Join 을 통해 한번에 모든 쿼리를 가져옵니다.
select
order.id,
order.member_id,
member.id,
member.name
from order
outer join member
on order.member_id=member.id;
order 에 대한 for 문이 돌면서 order.getMember
를 호출했다고 합시다.
이미 member 관련 정보가 Entity Manager 에 캐싱이 되어있기 때문에 추가적인 쿼리는 나가지 않을 것입니다.
Order List 를 가져오는 상황에서 N+1 Problem 이 발생할 수 있습니다. Order List 를 불러온 뒤, 각 Member 에 대해 무언가를 하는 For Loop 가 코드에 있다면 말입니다.
select
order.id,
order.member_id
from order;
위의 쿼리 결과가 아래와 같다고 합니다.
order.id | order.member_id |
---|---|
1001 | 999 |
1002 | 888 |
1003 | 777 |
order 에 대한 for 문이 돌면서 order.getMember
를 호출했다고 합시다. 그럼 아래와 같이 3번의 쿼리가 더 발생할 것입니다.
select
member.id,
member.name
from member
where member.id=999;
select
member.id,
member.name
from member
where member.id=888;
select
member.id,
member.name
from member
where member.id=777;
Member Entity 에는 Order Entity Collection (List 혹은 Set) 을 가지는 OneToMany 컬럼이 있습니다.
Member List 를 가져오려고 해봅시다.
select
member.id,
member.name
from member;
// member 가 2개 라면
// order set 땡겨오는 쿼리도 바로 2번 날라갑니다.
select
order.id,
order.member_id
from order
where member_id=999;
select
order.id,
order.member_id
from order
where member_id=888;
member 를 가져오는 동시에 쿼리가 2방 더 날라갑니다. (이를 한번의 쿼리로 해결하려면 직접 JPQL 을 작성하거나 Query Builder 를 이용해 Fetch Join 을 해야합니다.)
기본
Member List 를 가져오려고 해봅시다. 한번의 쿼리만 날라갑니다.
select
member.id,
member.name
from member;
JPA Entity Manager 에 의해 관리되는 Persistence Context 에 Entity 가 Managed 상태로 올라올 때의 동작입니다.
queryDSL 과 같은 쿼리 빌더를 이용해 아무리 Join 문을 짜도, (Fetch Join 을 하지 않는 이상) 메인 도메인의 엔티티만 Persistence Context 에 올라옵니다. 연관관계에 대한 Fetch 도 메인 도메인만 일어납니다.
ManyToOne, OneToOne 컬럼의 FetchType 을 LAZY 로 하였을 경우 발생합니다.
N+1 이 발생하는 Entity 연관관계를 발견하였다면, 한 Entity 가 Managed 상태로 올라올 때, N+1 문제를 일으키는 Entity Collections 들도 동시에 Managed 상태로 올라오게 하면 됩니다.
지금까지 크게 3가지 방법을 발견했습니다.
@EntityGraph
를 이용해, 한 쿼리에 대해서만 EAGER load 를 지정합니다.OneToOne 컬럼의 FetchType=LAZY 는 사용자가 의도한대로 동작하지도 않습니다! 아래의 글을 보시죠.
OneToOne 의 FetchType=LAZY 는 컬럼을 조회하지 않아도 무조건 쿼리가 날라갑니다. 1번 날라갈 쿼리를 무조건 날라가는 2번의 쿼리로 바꿔치기 하는 셈 뿐입니다.
쿼리 빌더를 이용해서 Order List 를 가져오는 쿼리를 짠다고 합시다. 예를 들면 queryDSL 기준으로 아래와 같이 짭니다.
List<Order> orders = jpaQueryFactory.select(qOrder).from(qOrder).fetch();
개발자는 Order 의 Member 연관관계가 FetchType EAGER 로 설정되어있으므로, Order 와 Member 를 조인에서 한번에 가져와주는 쿼리가 나가길 기대할 수 있습니다.
하지만 쿼리 빌더를 이용하면, 사실 순수한 Order 에 대한 쿼리만 나가는 경우가 많습니다. 아래와 같이 말이죠.
select
order.id,
order.member_id
from order;
이렇게 쿼리가 나가면, 복수개의 Order Entity 가 영속성 컨텍스트로 로드될 때 EAGER 로 설정된 Member 연관관계의 Fetch Type 이 동작하여 바로 N개의 단일 Member 쿼리가 나가게 됩니다. (FetchType 이 동작하는 시점은 영속성 컨텍스트로 로드될 때입니다.)
따라서 Query Builder 를 이용해 JPQL 쿼리를 직접 작성할 경우, One To One 관계의 Entity 에 대해선 Fetch Join 을 걸어주는 것이 좋습니다.
N+1 문제를 해결하기 위한 방법들을 잘 정리해놓은 글입니다. Fetch Join
을 이용하는 방법과 @EntityGraph
Annotation 을 이용하는 법을 소개하고 있습니다.