기본적으로 JPA 표준에서는 페치 조인 대상에 별칭을 주는 것을 지원하지 않는다. 그러나 하이버네이트를 포함한 몇몇 구현체들은 이를 지원한다.
페치 조인 대상에 별칭을 줄 수 없는 이유를 예제를 통해 알아보자. 예를 들어, 팀(teamA)과 회원(m1, m2)의 관계가 일대다인 경우 다음과 같이 쿼리문을 작성하였다.
List<Team> result = em.createQuery("select t from Team t join fetch t.members m where m.username=:username", Team.class)
.setParameter("username", "m1")
.getResultList();
애플리케이션에서 페치 조인의 결과를 담고 있는 result에는 teamA와 teamA에 소속된 모든 회원들이 담겨야하는데, m1만 담기게 된다. 즉 teamA의 member 컬렉션이 전부 조회되지 않고 필터 처리된다.
페치 조인 대상에 별칭을 주어 컬렉션 결과를 필터링해버리면, 객체 상태와 DB의 일관성이 깨진다. 애플리케이션에서 페치 조인의 결과는 연관된 모든 엔티티가 있을 것이라고 가정하고 사용해야 한다. 따라서 페치 조인 대상에 별칭을 주지 않고, 조회 데이터와 연관된 모든 컬렉션이 조회되도록 하자!
따라서 다음과 같이 페치 조인의 대상에 별칭을 주지 않는 방식으로 쿼리문을 작성하는게 좋다.
// 수정 전
select t from Team t join fetch t.members m where m.username=:username
// 수정 후
select m from Member m join fetch m.team where m.username=:username
그렇다면 페치 조인 대상을 별칭으로 사용해도 되는 경우는 언제일까? 하이버네이트가 이를 허용하는 이유가 무엇일까?
예를 들어 team -> member는 일대다, member -> order는 다대일 관계일 때 m.order
와 같이 추가적으로 회원의 주문 정보를 가져올 때 회원 컬렉션에 별칭을 줄 수 있다.
select t from Team t
join fetch t.members m
join fetch m.order
select m from Member m join fetch m.team t where t.name=:name
위 쿼리는 회원과 팀의 일관성을 해치지 않는다. 조회된 회원은 DB와 동일하게 일관성을 유지한 팀을 갖는다. 즉 일관성에 문제가 없을 때는 별칭을 주어도 된다. 결론적으로 애플리케이션의 페치 조인 결과와 DB가 일관성을 유지해야 한다는게 중요하다!
별칭 허용 유무를 정리해보면 다음과 같다.
select m from Member m join fetch m.team where m.username=:username
페치 조인의 주인(member)에게 별칭을 주어 필터링을 거는 것은 상관 없다. 원하는 member를 필터링하고 그에 속한 모든 team을 함께 조회하는 것이기 때문이다.
select m from Member m join fetch m.team t where t.name=:name
페치 조인의 대상이 단일 값이라면, 즉 일대일/다대일 조회인 경우 페치 조인의 대상에 별칭을 주어 필터링을 걸어도 일관성이 깨지지 않는다. 위 쿼리 실행 결과로 조회된 회원을 통해서 회원이 속한 팀을 정확하게 조회할 수 있다.
select t from Team t join fetch t.members m t where m.username=:username
반면 페치 조인의 대상이 컬렉션이라면, 즉 일대다/다대다 조회인 경우 페치 조인의 대상에 별칭을 줄 수 없다. 위 쿼리 실행 결과, 특정 팀을 통해서 해당 팀에 속한 모든 회원이 조회되는 것이 아니라, 필터링을 거쳤기 때문에 일부 회원들만 조회 가능하다. 즉 팀과 그에 소속된 회원의 데이터가 DB와 달라져 애플리케이션에서 활용하기에 문제가 된다.
join 할 때 on과 where의 차이
on: join 전에 조건을 필터링
where: join 후에 조건을 필터링
inner join시에는 on이나 where이나 차이가 없다. 그러나 left join 시에는 on과 where의 결과가 다르다.
예를 들어
select t from Team t join fetch t.member m on t.name=:teamName
위 쿼리는 조인과 무관하게 Team 자체의 데이터를 필터링하는 것이다. 따라서 on을 사용하는 것은 의도에 맞지 않으며, 다음과 같이 where을 사용하는 것이 적절하다.
select t from Team t join fetch t.member m where t.name=:teamName
단일 값 연관 필드(일대일, 다대일)는 페치 조인을 사용하면서 페이징 API를 사용할 수 있다. 그러나 컬렉션 페치 조인의 경우, 페이징 API를 사용하면 경고 로그를 남기면서 메모리 내에서 페이징 처리를 시도한다.
다음은 컬렉션 페치 조인을 사용하면서 페이징 API를 사용하는 예제이다.
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
public List<Order> findAllWithItem() {
return em.createQuery(
"select distinct o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d" +
" join fetch o.orderItems oi" +
" join fetch oi.item i", Order.class)
.setFirstResult(0)
.setMaxResults(10)
.getResultList();
}
}
실행된 SQL을 보면 limit, offset이 적용되지 않은 것을 확인할 수 있다.
그리고 다음과 같은 오류 로그가 남는 것을 확인할 수 있다.
firstResult/maxResults specified with collection fetch; applying in memory
즉 컬렉션 페치 조인과 함께 페이징 처리를 하려고 했기 때문에, DB가 아닌 메모리 내에서 페이징 처리를 한다는 의미이다. 데이터가 적으면 상관없겠지만 데이터가 많으면 모든 데이터를 메모리로 불러와 페이징 처리를 하기 때문에 성능 이슈와 메모리 초과 예외가 발생할 수 있어 위험하다.
그렇다면 컬렉션 페치 조인시 페이징 API를 사용할 수 없는 제약 조건이 있는 이유는 무엇일까?
예를 들어, order를 기준으로 페이징하고 싶은 경우, DB내에서 페이징 처리를 하면 일대다 페치 조인으로 인해 row 수가 증가하게 되고 결국 order가 아닌 orderItem을 기준으로 페이징 처리가 진행되기 때문이다. 즉 일대다에서 일(1)을 기준으로 페이징하는 것이 목적인데, DB의 데이터는 다(N)를 기준으로 row가 생성되고 결국 다(N)인 orderItem을 기준으로 페이징 처리가 진행된다. 즉 데이터 정합성이 깨질 수 있으며, 원하는 결과가 나오지 않을 수 있기 때문에 DB가 아닌 애플리케이션 메모리 안에서 진행되는 것이다.
다음과 같이 둘 이상의 컬렉션은 페치 조인 할 수 없다. 하나의 컬렉션만 페치 조인하더라도 조인 결과 데이터 수가 증가하는데, 둘 이상의 컬렉션을 페치 조인하면 데이터 수가 급격하게 증가하기 때문이다.
select t from Team t
join fetch t.members m
join fetch t.orders o
구현체에 따라 되기도 하는데, 데이터 수가 급격하게 증가하기 때문에 사용하지 않는 것이 안전하다.
Reference
잘 읽었습니다. 좋은 정보 감사드립니다.