본문 바로가기
Framework and Tool/JPA

JPA - 객체지향 쿼리 언어 - JPQL Fetch Join

by ocwokocw 2021. 7. 31.

- 참조: 자바 ORM 표준 JPA 프로그래밍

- 페치 조인(Fetch Join)

페치 조인은 SQL 에서 이야기하는 조인의 종류를 말하는것은 아니다. 이전에 지연 로딩에 대해서 알아본적이 있는지 지연 로딩과 관련된 얘기이며 성능 최적화를 위해 제공하는 기능이다.

 

문법이 크게 어렵지는 않다. [ INNER | LEFT [OUTER] ] JOIN FETCH 와 같이 기존 Join 문법 뒤에 FETCH 를 붙여주기만 하면 된다.


- 엔티티 페치 조인

앞에서 User#1 과 주문 2 건을 사용하던 예제를 계속 사용해보자. Order 에서 Member 엔티티 @ManyToOne 맵핑시 FetchType 은 Lazy 라고 가정한다. 이전에 INNER JOIN 을 이용하여 Order 와 Member 엔티티를 Join 했을 때 아래와 같이 코드를 작성했었다.

 

String jpql = "select o "
		+ "from Order o "
		+ "inner join o.member m "
		+ "	where m.name = :username";

List<Order> orders = em.createQuery(jpql, Order.class)
	.setParameter("username", "User#1")
	.getResultList();

orders.forEach(order -> {
	System.out.println("orderId: " + order.getId() + 
			", member: " + order.getMember());
});

 

위의 코드를 실행하면 SQL 이 2 번에 걸쳐서 수행된다.

 

Hibernate: 
    /* select
        o 
    from
        
    Order o inner join
        o.member m  
    where
        m.name = :username */ select
            order0_.order_id as order_id1_6_,
            order0_.insert_datetime as insert_d2_6_,
            order0_.update_datetime as update_d3_6_,
            order0_.delivery_id as delivery6_6_,
            order0_.member_id as member_i7_6_,
            order0_.order_date as order_da4_6_,
            order0_.status as status5_6_ 
        from
            orders order0_ 
        inner join
            member member1_ 
                on order0_.member_id=member1_.member_id 
        where
            member1_.name=?
Hibernate: 
    select
        member0_.member_id as member_i1_4_0_,
        member0_.insert_datetime as insert_d2_4_0_,
        member0_.update_datetime as update_d3_4_0_,
        member0_.city as city4_4_0_,
        member0_.street as street5_4_0_,
        member0_.zipcode as zipcode6_4_0_,
        member0_.name as name7_4_0_ 
    from
        member member0_ 
    where
        member0_.member_id=?
orderId: 2, member: com.example.demo.member.Member@67f8f5a6
orderId: 4, member: com.example.demo.member.Member@67f8f5a6

 

우선 Member 의 이름 중 User#1 의 회원이 주문한 Order 정보들을 가져오는 SQL 이 수행된다. 그리고 forEach 에서 Member 의 정보를 print 할 때 실제 사용되므로 회원의 ID 로 Member 엔티티를 조회하는 SQL 이 수행된다. SQL 이 한번 수행되는것은 같은 회원이 2 건을 주문했기 때문에 조회하는 회원 ID 가 같기 때문이다.

 

fetch join 으로 변경해서 조회를 해보자. 하이버네이트는 fetch join 시 o.member 뒤에 m 별칭을 사용하지만 일반적인 JPQL 에서는 fetch join 에서 별칭을 사용할 수 없다.

 

String jpql = "select o "
		+ "from Order o "
		+ "inner join fetch o.member m"
		+ "	where m.name = :username";

List<Order> orders = em.createQuery(jpql, Order.class)
	.setParameter("username", "User#1")
	.getResultList();

orders.forEach(order -> {
	System.out.println("orderId: " + order.getId() + 
			", member: " + order.getMember());
});

 

실행되는 SQL 은 아래와 같다.

 

Hibernate: 
    /* select
        o 
    from
        
    Order o inner join
        fetch o.member m  
    where
        m.name = :username */ select
            order0_.order_id as order_id1_6_0_,
            member1_.member_id as member_i1_4_1_,
            order0_.insert_datetime as insert_d2_6_0_,
            order0_.update_datetime as update_d3_6_0_,
            order0_.delivery_id as delivery6_6_0_,
            order0_.member_id as member_i7_6_0_,
            order0_.order_date as order_da4_6_0_,
            order0_.status as status5_6_0_,
            member1_.insert_datetime as insert_d2_4_1_,
            member1_.update_datetime as update_d3_4_1_,
            member1_.city as city4_4_1_,
            member1_.street as street5_4_1_,
            member1_.zipcode as zipcode6_4_1_,
            member1_.name as name7_4_1_ 
        from
            orders order0_ 
        inner join
            member member1_ 
                on order0_.member_id=member1_.member_id 
        where
            member1_.name=?
orderId: 2, member: com.example.demo.member.Member@4c6fc3e7
orderId: 4, member: com.example.demo.member.Member@4c6fc3e7

 

JPQL 의 select 절에서 Order 엔티티의 별칭인 o 만 주었는데도 Member 엔티티의 정보들까지 한꺼번에 조회하고 있다. 그래서 Member 엔티티를 실제 print 하여 객체 그래프 탐색을 수행할 때에도 별도 SQL 을 수행하지 않는다.


- 컬렉션 페치 조인(Collection Fetch Join)

JPQL Join 에서도 내부 조인, 외부 조인, 컬렉션 조인을 알아보았듯이 1 : N 의 관계인 컬렉션을 페치 조인한것을 컬렉션 페치 조인이라고 한다.

 

String jpql = "select m "
		+ "from Member m "
		+ "inner join fetch m.orders "
		+ "	where m.name = :username";

List<Member> members = em.createQuery(jpql, Member.class)
	.setParameter("username", "User#1")
	.getResultList();

members.forEach(member -> {
	System.out.println("member: " + member);
	
	member.getOrders().forEach(order -> {
		System.out.println("Order ID.: " + order.getId());
	});
});

 

위의 JPQL 에서 Member 에서 컬렉션 타입으로 참조하고 있는 Order 엔티티의 필드명인 orders 를 참조하였다. 이 JPQL 은 아래와 같은 SQL 로 변환된다.

 

Hibernate: 
    /* select
        m 
    from
        Member m 
    inner join
        fetch m.orders  
    where
        m.name = :username */ select
            member0_.member_id as member_i1_4_0_,
            orders1_.order_id as order_id1_6_1_,
            member0_.insert_datetime as insert_d2_4_0_,
            member0_.update_datetime as update_d3_4_0_,
            member0_.city as city4_4_0_,
            member0_.street as street5_4_0_,
            member0_.zipcode as zipcode6_4_0_,
            member0_.name as name7_4_0_,
            orders1_.insert_datetime as insert_d2_6_1_,
            orders1_.update_datetime as update_d3_6_1_,
            orders1_.delivery_id as delivery6_6_1_,
            orders1_.member_id as member_i7_6_1_,
            orders1_.order_date as order_da4_6_1_,
            orders1_.status as status5_6_1_,
            orders1_.member_id as member_i7_6_0__,
            orders1_.order_id as order_id1_6_0__ 
        from
            member member0_ 
        inner join
            orders orders1_ 
                on member0_.member_id=orders1_.member_id 
        where
            member0_.name=?
member: com.example.demo.member.Member@4213bc3e
Order ID.: 2
Order ID.: 4
member: com.example.demo.member.Member@4213bc3e
Order ID.: 2
Order ID.: 4

 

여기까지는 fetch join 에서 참조필드만 컬렉션으로 변경되었을 뿐 SQL 의 특이사항은 없다. 한 가지 눈여겨 볼점이 있는데 결과를 살펴보면 같은 회원이 2 건 조회된것을 확인할 수 있다.

 

SQL 입장에서는 User#1 회원이 주문을 2 건 했으므로 Join 을 하면 행이 늘어나는것은 당연하다. 그런데 객체 측면에서 생각해보면 같은 인스턴스 주소를 갖는 Member 엔티티가 2 번 나온것이다. 객체 그래프 탐색의 측면에서는 User#1 회원 1 명이 주문한 2 건을 알고 싶으므로 1 개의 행이 나오는게 더 타당하다고 얘기할 수 있다. 그래서 나오는것이 Distinct 이다.


- 페치 조인과 DISTINCT

SQL 에서 DISTINCT 구문을 사용하면 중복을 제거한다. 하지만 이 구문을 수행한다고 해서 앞에서 살펴본 User#1 - 주문1, User#1 - 주문2 의 조회 결과는 주문이 다르기 때문에 그대로 2 건이 조회된다. 

 

JPQL 에서는 DISTINCT 를 하면 SQL 의 DISTINCT 를 수행함과 동시에 엔티티의 중복도 없애준다.

 

String jpql = "select distinct m "
		+ "from Member m "
		+ "inner join fetch m.orders "
		+ "	where m.name = :username";

List<Member> members = em.createQuery(jpql, Member.class)
	.setParameter("username", "User#1")
	.getResultList();

members.forEach(member -> {
	System.out.println("member: " + member);
	
	member.getOrders().forEach(order -> {
		System.out.println("Order ID.: " + order.getId());
	});
});

 

위 처럼 Member 엔티티의 별칭 m 조회시 distinct 를 붙여주면 실행결과는 아래와 같다.

 

Hibernate: 
    /* select
        distinct m 
    from
        Member m 
    inner join
        fetch m.orders  
    where
        m.name = :username */ select
            distinct member0_.member_id as member_i1_4_0_,
            orders1_.order_id as order_id1_6_1_,
            member0_.insert_datetime as insert_d2_4_0_,
            member0_.update_datetime as update_d3_4_0_,
            member0_.city as city4_4_0_,
            member0_.street as street5_4_0_,
            member0_.zipcode as zipcode6_4_0_,
            member0_.name as name7_4_0_,
            orders1_.insert_datetime as insert_d2_6_1_,
            orders1_.update_datetime as update_d3_6_1_,
            orders1_.delivery_id as delivery6_6_1_,
            orders1_.member_id as member_i7_6_1_,
            orders1_.order_date as order_da4_6_1_,
            orders1_.status as status5_6_1_,
            orders1_.member_id as member_i7_6_0__,
            orders1_.order_id as order_id1_6_0__ 
        from
            member member0_ 
        inner join
            orders orders1_ 
                on member0_.member_id=orders1_.member_id 
        where
            member0_.name=?
member: com.example.demo.member.Member@97beeaf
Order ID.: 2
Order ID.: 4

 

SQL 은 크게 달라지는것이 없지만 결과를 보면 DISTINCT 를 사용하지 않은 fetch join 은 Member 가 2 건이 조회되었는데 1 건만 조회된것을 알 수 있다.


- 페치 조인의 특정 한계

이전에 지연 로딩과 관련된 글을 작성했을 때 "글로벌 전략은 Lazy 로 설정하고 필요한 곳에서만 즉시 로딩으로 조회한다." 라고 책에서 나온 내용을 쓴적이 있다. JPQL 에서 fetch join 문법은 연관관계 어노테이션 맵핑 속성의 FetchType.Lazy 설정보다 우선하기 때문에 필요한 곳에서 JPQL fetch join 으로 조회하면 SQL로 한번에 연관된 엔티티들을 조회할 수 있다.

 

위의 예제 중 페치 조인 별칭을 준 JPQL 예제가 있는데 이는 하이버네이트를 포함한 몇몇 구현체들만 제공하는 기능이다. 별칭을 사용하지 못하는게 뭐 대수냐 사용하지 않으면 되지라고 생각할 수 있지만 별칭을 사용하지 못한다는건 WHERE 절이나 서브쿼리에 페치 조인 대상을 사용할 수 없다는 의미이다. 또한 2차 캐시와 함꼐 사용할 때에도 문제가 될 수 있는데 이는 나중에 알아보도록 한다.

 

둘 이상의 컬렉션을 페치 조인할 경우 예외가 발생한다. 이를 지원하는 구현체라고 하더라도 컬렉션 * 컬렉션 카테시안 곱이 일어나므로 가능하면 이런 방식으로는 사용하지 않도록 하자.

 

컬렉션 페치 조인을 사용할 시 단 ManyToOne 이나 OneToOne 연관 필드는 사용가능하지만 컬렉션 형으로 참조하는 연관성에 대해서는 페이징 API 를 사용할 수 없다.

댓글