JPA 의 Fetch Type 과 친해지기

web | 06 August 2019

Tags | java spring jpa

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 이란

Fetch Type 은 JPA 가 하나의 Entity 를 조회할 때, 연관관계에 있는 객체들을 어떻게 가져올 것이냐를 나타내는 설정값입니다.

Fetch Type 은 크게 Eager 와 Lazy 두가지 전략이 있습니다. Fetch Type Issue 상황이라는 것은 하나의 Entity 를 로드할 때, 아래의 두가지 전략 중 고민하는 상황을 말합니다.

Fetch Type 어떻게 동작할까요?

ManyToOne 컬럼 있을 때 (주인일 때)

Order Entity 는 단일 Member Entity 를 가지는 ManyToOne 컬럼이 있습니다. ( member 의 PK 가 Foreign Key 로 실제로 order DB컬럼에 매핑되어있으므로 Order 가 주인입니다. )

FetchType=EAGER 기본

아래와같이, 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 에 캐싱이 되어있기 때문에 추가적인 쿼리는 나가지 않을 것입니다.

FetchType=LAZY

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;

OneToMany 컬럼 있을 때 (종일 때)

Member Entity 에는 Order Entity Collection (List 혹은 Set) 을 가지는 OneToMany 컬럼이 있습니다.

FetchType=EAGER

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 을 해야합니다.)

FetchType=LAZY 기본

Member List 를 가져오려고 해봅시다. 한번의 쿼리만 날라갑니다.

select
	member.id,
	member.name
from member;

FetchType 이 동작하는 시점

JPA Entity Manager 에 의해 관리되는 Persistence Context 에 Entity 가 Managed 상태로 올라올 때의 동작입니다.

39-persist-context

queryDSL 과 같은 쿼리 빌더를 이용해 아무리 Join 문을 짜도, (Fetch Join 을 하지 않는 이상) 메인 도메인의 엔티티만 Persistence Context 에 올라옵니다. 연관관계에 대한 Fetch 도 메인 도메인만 일어납니다.

N+1 문제는 이럴 때 발생합니다.

ManyToOne, OneToOne 컬럼의 FetchType 을 LAZY 로 하였을 경우 발생합니다.

N+1 은 어떻게 해결할까요?

N+1 이 발생하는 Entity 연관관계를 발견하였다면, 한 Entity 가 Managed 상태로 올라올 때, N+1 문제를 일으키는 Entity Collections 들도 동시에 Managed 상태로 올라오게 하면 됩니다.

지금까지 크게 3가지 방법을 발견했습니다.

  1. JPQL 의 Fetch Join 을 이용합니다. (QueryDSL 과 같은 쿼리빌더의 도움을 받을 수도 있습니다.)
  2. ManyToOne, OneToOne 의 FetchType = LAZY → EAGER 로 변경합니다.
  3. @EntityGraph 를 이용해, 한 쿼리에 대해서만 EAGER load 를 지정합니다.

OneToOne 의 FetchType LAZY 는 사용자가 의도한대로 동작하지 않습니다.

OneToOne 컬럼의 FetchType=LAZY 는 사용자가 의도한대로 동작하지도 않습니다! 아래의 글을 보시죠.

OneToOne 의 FetchType=LAZY 는 컬럼을 조회하지 않아도 무조건 쿼리가 날라갑니다. 1번 날라갈 쿼리를 무조건 날라가는 2번의 쿼리로 바꿔치기 하는 셈 뿐입니다.

hibernate OneToOne lazy 구현하기

Query Builder 를 이용하는 경우, OneToOne 은 Fetch Join 걸어주는게 좋습니다.

쿼리 빌더를 이용해서 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 을 이용하는 법을 소개하고 있습니다.

JPA N+1 문제 및 해결방안