JPA 무조건 LAZY로 설정하시나요?

명아주·2022년 1월 21일
1

java

목록 보기
1/1
post-thumbnail

안녕하세요 자바를 공부하는 초보개발자 명아주입니다.

최근 JPA 를 공부하며 헷갈렸던 내용을 공유하려고 글을 적습니다.

JPA 에서 연관관계 Mapping 은 N + 1 이슈나 여러 성능개선을 위해 대부분 fetch 를 LAZY로 설정합니다.
LAZY로 설정하면 실질적으로 하이버네이트가 프록시객체를 만들어서 관리하다가, 조회가 필요할 때 쿼리가 날라가게 되는데요.
보통 LAZY로 해결이 되는데 OneToOne 관계에서 연관관계 주인의 상대 컬럼일때 알 수 없는 문제가 발생합니다. 분명 LAZY로 설정했는데 쿼리가 두번(또는 N+1번) 나가는 현상입니다.
예시를 위해 간단한 테이블을 만들어보겠습니다.

이런식으로 Order와 Delivery 테이블을 만들었습니다.
Java code는 아래와 같습니다.

Order

@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);
	}
}

Delivery

@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는 뺐습니다.
아래처럼 테스트할 수 있는 코드를 만들어보겠습니다.

Main

		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 으로 묶여서 나가면서 성능최적화가 될 것입니다.

잘못된 설명이나 부연설명 또는 더 좋은 방법이 있다면 댓글 부탁드립니다.
감사합니다.

profile
풀스택 개발자 꿈나무

0개의 댓글