안녕하세요 자바를 공부하는 초보개발자 명아주입니다.
최근 JPA 를 공부하며 헷갈렸던 내용을 공유하려고 글을 적습니다.
JPA 에서 연관관계 Mapping 은 N + 1 이슈나 여러 성능개선을 위해 대부분 fetch 를 LAZY로 설정합니다.
LAZY로 설정하면 실질적으로 하이버네이트가 프록시객체를 만들어서 관리하다가, 조회가 필요할 때 쿼리가 날라가게 되는데요.
보통 LAZY로 해결이 되는데 OneToOne 관계에서 연관관계 주인의 상대 컬럼일때 알 수 없는 문제가 발생합니다. 분명 LAZY로 설정했는데 쿼리가 두번(또는 N+1번) 나가는 현상입니다.
예시를 위해 간단한 테이블을 만들어보겠습니다.
이런식으로 Order와 Delivery 테이블을 만들었습니다.
Java code는 아래와 같습니다.
@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {
@Id
@Column(name = "order_id")
private Long id;
private String name;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "delvery_id")
@Setter(AccessLevel.PRIVATE)
private Delivery delivery;
public void registerDelivery(Delivery delivery) {
this.delivery = delivery;
delivery.setOrder(this);
}
}
@Entity
@Getter @Setter
public class Delivery {
@Id
@Column(name = "delivery_id")
private Long id;
private String driver;
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;
}
이렇게 양방향 연관관계로 만들어놓고 연관관계의 주인은 Order에 있습니다.
테스트하기 편하게 GeneratedValue는 뺐습니다.
아래처럼 테스트할 수 있는 코드를 만들어보겠습니다.
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
// 5개씩 생성
for (long i = 1; i <= 5; i++) {
Delivery delivery = new Delivery();
delivery.setId(i);
delivery.setDriver("driver" + i);
em.persist(delivery);
Order order = new Order();
order.setId(i);
order.setName("order" + i);
order.registerDelivery(delivery);
em.persist(order);
}
em.flush();
em.clear();
System.out.println("====== ORDER 시작 ======");
Order findOrder = em.find(Order.class, 1L);
System.out.println("findOrder.getDelivery().getClass() = " + findOrder.getDelivery().getClass());
System.out.println("====== ORDER 끝 ======\n");
em.clear();
System.out.println("====== DELIVERY 시작 ======");
Delivery findDelivery = em.find(Delivery.class, 1L);
System.out.println("findDelivery.getOrder().getClass() = " + findDelivery.getOrder().getClass());
System.out.println("====== DELIVERY 끝 ======");
tx.commit();
} catch (Exception e) {
e.printStackTrace();
tx.rollback();
} finally {
em.close();
}
emf.close();
위처럼 간단한 테스트를 만들었습니다.
5개씩 생성하고 em.flush(), em.clear() 를 통해 Database에 반영하고 영속성 컨텍스트를 초기화했습니다. 그리고, Order를 조회한 후 다시 초기화한 후 Delivery를 조회해봤습니다.
DB 조회 쿼리는 몇번 나갔을까요?
LAZY로 설정했기에 기대한 바로는 Order에서 한번, Delivery에서 한번이 발생하길 기대했습니다.
그러나 실제로 실행해보면 Order에서는 한번만 나간 반면, Delivery에서는 두번 나간것을 확인할 수 있습니다.
또한, LAZY에서 엔티티 필드는 프록시객체로 생성될 것을 기대하지만 delivery.getOrder()는 프록시가 아닌 것을 확인할 수 있습니다.
====== ORDER 시작 ======
Hibernate:
select
order0_.order_id as order_id1_1_0_,
order0_.delvery_id as delvery_3_1_0_,
order0_.name as name2_1_0_
from
orders order0_
where
order0_.order_id=?
findOrder.getDelivery().getClass() = class hellojpa.Delivery$HibernateProxy$6txmPqTI
====== ORDER 끝 ======
====== DELIVERY 시작 ======
Hibernate:
select
delivery0_.delivery_id as delivery1_0_0_,
delivery0_.driver as driver2_0_0_
from
Delivery delivery0_
where
delivery0_.delivery_id=?
Hibernate:
/* load hellojpa.Order */ select
order0_.order_id as order_id1_1_0_,
order0_.delvery_id as delvery_3_1_0_,
order0_.name as name2_1_0_
from
orders order0_
where
order0_.delvery_id=?
findDelivery.getOrder().getClass() = class hellojpa.Order
====== DELIVERY 끝 ======
연관관계의 주인은 생각했던대로 동작하지만, 연관관계의 상대컬럼에서는 쿼리가 두번 나가고 프록시가 아닌 실제 객체가 나와있는것을 볼수 있습니다.
Order 테이블을 조회할땐, Order 테이블이 가지고 있는 delivery_id 값을 이용해서 프록시 객체를 생성할 수 있고 그 객체가 order.delivery에 들어갑니다. 하지만 Delivery 테이블을 조회할 때, 하이버네이트는 Delivery 테이블을 참조하는 Order가 있는지 확인해서 프록시 객체의 id를 채우던, Order 자체를 null로 만들던해야합니다. 그렇기 때문에 그것을 확인하기 위해 한번 더 쿼리를 날립니다.
이것이 프록시의 한계이고 OneToOne 관계에서 연관관계주인의 상대테이블에서 발생합니다.
JPQL 을 이용해서 불러봐도 마찬가지입니다. 아래는 JPQL을 이용한 테스트의 조회 부분입니다.
System.out.println("====== ORDER 시작 ======");
em.createQuery("select o From Order o", Order.class).getResultList();
System.out.println("====== ORDER 끝 ======\n");
em.clear();
System.out.println("====== DELIVERY 시작 ======");
em.createQuery("select d From Delivery d", Delivery.class).getResultList();
System.out.println("====== DELIVERY 끝 ======");
이것의 결과는 어떻게 될까요?
마찬가지로 위의 Order는 쿼리가 한번만 호출되지만 아래쪽에서는 Delivery를 한번 찾고, 각각의 Delivery에 해당하는 Order의 프록시객체를 확인할 수 없기에 쿼리를 한번씩 더 날리게 되서 1 + 5 = 6번의 쿼리가 발생합니다. 결국 N + 1 이슈가 발생하게 되는 것입니다.
실제 쿼리 결과는 아래와 같습니다.
====== ORDER 시작 ======
Hibernate:
/* select
o
From
Order o */ select
order0_.order_id as order_id1_1_,
order0_.delvery_id as delvery_3_1_,
order0_.name as name2_1_ from
orders order0_
====== ORDER 끝 ======
====== DELIVERY 시작 ======
Hibernate:
/* select
d
From
Delivery d */ select
delivery0_.delivery_id as delivery1_0_,
delivery0_.driver as driver2_0_
from
Delivery delivery0_
Hibernate:
/* load hellojpa.Order */ select
order0_.order_id as order_id1_1_0_,
order0_.delvery_id as delvery_3_1_0_,
order0_.name as name2_1_0_
from
orders order0_
where
order0_.delvery_id=?
Hibernate:
/* load hellojpa.Order */ select
order0_.order_id as order_id1_1_0_,
order0_.delvery_id as delvery_3_1_0_,
order0_.name as name2_1_0_
from
orders order0_
where
order0_.delvery_id=?
Hibernate:
/* load hellojpa.Order */ select
order0_.order_id as order_id1_1_0_,
order0_.delvery_id as delvery_3_1_0_,
order0_.name as name2_1_0_
from
orders order0_
where
order0_.delvery_id=?
Hibernate:
/* load hellojpa.Order */ select
order0_.order_id as order_id1_1_0_,
order0_.delvery_id as delvery_3_1_0_,
order0_.name as name2_1_0_
from
orders order0_
where
order0_.delvery_id=?
Hibernate:
/* load hellojpa.Order */ select
order0_.order_id as order_id1_1_0_,
order0_.delvery_id as delvery_3_1_0_,
order0_.name as name2_1_0_
from
orders order0_
where
order0_.delvery_id=?
====== DELIVERY 끝 ======
이를 해결하기 위한 방법을 고민해봤는데 찾은 방법은 아래와 같습니다.
1. OneToOne 연관관계의 상대테이블에서는 EAGER 로딩적용
2. JPQL 에서는 fetch join 을 활용
3. LAZY 를 사용하면서 default_batch_fetch_size 옵션을 이용하기
그를 위해 Delivery 코드를 아래와 같이 변경해보겠습니다.
@Entity
@Getter
@Setter
public class Delivery {
@Id
@Column(name = "delivery_id")
private Long id;
private String driver;
@OneToOne(mappedBy = "delivery", fetch = FetchType.EAGER)
private Order order;
}
위처럼 EAGER로 다시 변경해주고, 여러건 조회는 fetch join을 활용합니다.
테스트 코드는 아래와 같습니다.
System.out.println("====== Delivery 단건 조회 시작 ======");
em.find(Delivery.class, 1L);
System.out.println("====== Delivery 단건 조회 끝 ======\n");
em.clear();
System.out.println("====== DELIVERY 여러건 조회 시작 ======");
em.createQuery("select d From Delivery d join fetch d.order", Delivery.class).getResultList();
System.out.println("====== DELIVERY 여러건 끝 ======");
이렇게 하면 단건 조회시에는 EAGER로 조회하기때문에 조인이 발생하지만, 쿼리는 한번만 나갑니다.
여러건 조회 때도 마찬가지로 조인을 이용해서 처리하기때문에 한번만 나가게 됩니다.
결과는 아래와 같습니다.
====== Delivery 단건 조회 시작 ======
Hibernate:
select
delivery0_.delivery_id as delivery1_0_0_,
delivery0_.driver as driver2_0_0_,
order1_.order_id as order_id1_1_1_,
order1_.delvery_id as delvery_3_1_1_,
order1_.name as name2_1_1_
from
Delivery delivery0_
left outer join
orders order1_
on delivery0_.delivery_id=order1_.delvery_id
where
delivery0_.delivery_id=?
====== Delivery 단건 조회 끝 ======
====== DELIVERY 여러건 조회 시작 ======
Hibernate:
/* select
d
From
Delivery d
join
fetch d.order */ select
delivery0_.delivery_id as delivery1_0_0_,
order1_.order_id as order_id1_1_1_,
delivery0_.driver as driver2_0_0_,
order1_.delvery_id as delvery_3_1_1_,
order1_.name as name2_1_1_
from
Delivery delivery0_
inner join
orders order1_
on delivery0_.delivery_id=order1_.delvery_id
====== DELIVERY 여러건 끝 ======
이처럼 무조건 join 이 발생하지만, 쿼리의 요청 횟수자체는 줄어들게 됩니다.
default_batch_fetch_size 옵션을 주게되면 (100 ~ 1000 정도) LAZY로 설정하더라도 위에서 발생하는 여러쿼리들이 in 으로 묶여서 나가면서 성능최적화가 될 것입니다.
잘못된 설명이나 부연설명 또는 더 좋은 방법이 있다면 댓글 부탁드립니다.
감사합니다.