38. 객체지향 쿼리 언어(5)

38. 객체지향 쿼리 언어(5)

2. JPQL

7. 페치 조인

페치(fetch) 조인은 SQL에서 이야기하는 조인의 종류는 아니고 JPQL에서 성능 최적화를 위해 제공하는 기능이다. 이것은 연관된 엔티티나 컬렉션을 한 번에 같이 조회하는 기능인데 join fetch 명령어로 사용할 수 있다.

JPA 표준 명세에 정의된 페치 조인 문법은 다음과 같다.

페치 조인 ::= [ LEFT [OUTER] | INNER ] JOIN FETCH 조인경로

페치 조인에 대해 자세히 알아보자.

엔티티 페치 조인

페치 조인을 사용해서 회원 엔티티를 조회하면서 연관된 팀 엔티티도 함께 조회하는 JPQL을 보자.

 select m
 from Member m join fetch m.team

예제를 보면 join 다음에 fetch라 적었다. 이렇게 하면 연관된 엔티티나 컬렉션을 함께 조회하는데 여기서는 회원(m)과 팀(m.team)을 함께 조회한다. 참고로 일반적인 JPQL 조인과는 다르게 m.team 다음에 별칭이 없는데 페치 조인은 별칭을 사용할 수 없다.

하이버네이트는 페치 조인에도 별칭을 허용한다.

실행된 SQL은 다음과 같다.

SELECT
    M.*, T.*
FROM MEMBER M
INNER JOIN TEAM T ON M.TEAM_ID=T.ID

페치 조인을 사용하면 아래 그림처럼 SQL 조인을 시도한다.

아래 그림은 SQL에서 조인의 결과다.

엔티티 페치 조인 JPQL에서 select m으로 회원 엔티티만 선택했는데 실행된 SQL을 보면 SELECT M.*, T.*로 회원과 연관된 팀도 함께 조회된 것을 확인할 수 있다. 그리고 위의 그림을 보면 회원과 팀 객체가 객체 그래프를 유지하면서 조회된 것을 확인할 수 있다. 아래 예제는 이 JPQL을 사용하는 코드다.

String jpql = "select m from Member m join fetch m.team";

List<Member> members = em.createQuery(jpql, Member.class)
                         .getResultList();

for (Member member : members) {
    // 페치 조인으로 회원과 팀을 함께 조회해서 지연 로딩 발생 안 함
    System.out.println("username = " + member.getUsername() + ", " +
        "teamname = " + member.getTeam().getName());
}

출력 결과는 다음과 같다.

username = 회원1, teamname = 팀A
username = 회원2, teamname = 팀A
username = 회원3, teamname = 팀B

회원과 팀을 지연 로딩으로 설정했다고 가정해보자. 회원을 조회할 때 페치 조인을 사용해서 팀도 함께 조회했으므로 연관된 팀 엔티티는 프록시가 아닌 실제 엔티티다. 따라서 연관된 팀을 사용해도 지연 로딩이 일어나지 않는다. 그리고 프록시가 아닌 실제 엔티티다. 따라서 연관된 팀을 사용해도 지연 로딩이 일어나지 않는다. 그리고 프록시가 아닌 실제 엔티티이므로 회원 엔티티가 영속성 컨텍스트에서 분리되어 준영속 상태가 되어도 연관된 팀을 조회할 수 있다.

컬렉션 페치 조인

이번에는 일대다 관계인 컬렉션을 페치 조인해보자. 아래 예제를 보자.

select t
from Team t join fetch t.members
where t.name = '팀A'

아래 예제는 팀(t)을 조회하면서 페치 조인을 사용해서 연관된 회원 컬렉션(t.members)도 함께 조회한다.

SELECT
    T.*, M.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = '팀A'

위의 예제의 컬렉션을 페치 조인한 JPQL에서 select t로 팀만 선택했는데 실행된 SQL을 보면 T.*, M.*로 팀과 연관된 회원도 함께 조회한 것을 확인할 수 있다. 그리고 위의 그림의 TEAM 테이블에서 '팀A'는 하나지만 MEMBER 테이블과 조인하면서 결과가 증가해서 위의 그림의 조인 결과 테이블을 보면 같은 '팀A'가 2건 조회되었다. 따라서 위의 그림의 컬렉션 페치 조인 결과 객체에서 teams 결과 예제를 보면 주소가 0x100으로 같은 '팀A'를 2건 가지게 된다.

컬렉션 페치 조인을 사용하는 아래 예제를 보자.

일대다 조인은 결과가 증가할 수 있지만 일대일, 다대일 조인은 결과가 증가하지 않는다.

String jpql = "select t from Team t join fetch t.members where t.name = '팀A'";
List<Team> teams = em.createQuery(jpql, Team.class).getResultList();

for (Team team : teams) {

    System.out.println("teamname = " + team.getName() + ",
      team = " + team);
    
    for (Member member : team.getMembers()) {
        
        // 페치 조인으로 팀과 회원을 함께 조회해서 지연 로딩 발생 안함
        System.out.println(
            "->username = " + member.getUsername() + ",
            member = " + member);
    }
}

출력 결과는 다음과 같다.

teamname = 팀A, team = Team@0x100
->username = 회원1, member = Member@0x200
->username = 회원2, member = Member@0x300
teamname = 팀A, team = Team@0x100
-> username = 회원1, member = Member@0x200
-> username = 회원2, member = Member@0x300

출력 결과를 보면 같은 '팀A'가 2건 조회된 것을 확인할 수 있다.

페치 조인과 DISTINT

SQL의 DISTINCT는 중복된 결과를 제거하는 명령이다. JPQL의 DISTINCT 명령어는 SQL에 DISTINCT를 추가하는 것은 물론이고 애플리케이션에서 한 번 더 중복을 제거한다.

바로 직전에 컬렉션 페치 조인은 팀A가 중복으로 조회된다. 다음처럼 DISTINCT를 추가해보자.

select distinct t
from Team t join fetch t.members
where t.name = '팀A'

먼저 DISTINCT를 사용하면 SQL에 SELECT DISTINCT가 추가된다. 하지만 지금은 각 로우의 데이터가 다르므로 아래 표처럼 SQL의 DISTINCT는 효과가 없다.

데이터가 달라 다음처럼 SQL의 DISTINCT 효과가 없다.

로우 번호 회원
1 팀A 회원1
2 팀A 회원2

다음으로 애플리케이션에서 distinct 명령어를 보고 중복된 데이터를 걸러낸다. select distinct t의 의미는 팀 엔티티의 중복을 제거하라는 것이다. 따라서 중복인 팀A는 아래 그림처럼 하나만 조회된다.

컬렉션 페치 조인 사용 예제에 distinct를 추가하면 출력 결과는 다음과 같다.

teamname = 팀A, team = Team@0x100
-> username = 회원1, member = Member@0x200
-> username = 회원2, member = Member@0x300

페치 조인과 일반 조인의 차이

페치 조인을 사용하지 않고 조인만 사용하면 어떻게 될까?

select t
from Team t join t.members m
where t.name = '팀A'
SELECT
    T.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = '팀A'

위의 예제의 JPQL에서 팀과 회원 컬렉션을 조인했으므로 회원 컬렉션도 함께 조회할 것으로 기대해선 안 된다. 위의 에제에 실행된 SQL의 SELECT 절을 보면 팀만 조회하고 조인했던 회원은 전혀 조회하지 않는다.

JPQL은 결과를 반환할 때 연관관계까지 고려하지 않는다. 단지 SELECT 절에 지정한 엔티티만 조회할 뿐이다. 따라서 팀 엔티티만 조회하고 연관된 회원 컬렉션은 조회하지 않는다. 만약 회원 컬렉션을 지연 로딩으로 설정하면 아래 그림처럼 프록시나 아직 초기화되지 않은 컬렉션 래퍼를 반환한다. 즉시 로딩으로 설저하면 회원 컬렉션을 즉시 로딩하기 위해 쿼리를 한 번 더 실행한다.

반면에 페치 조인을 사용하면 연관된 엔티티도 함께 조회한다. 아래 예제를 보자.

select t
from Team t join fetch t.members
where t.name = '팀A'

아래 예제를 보면 SELECT T.*, M.*로 팀과 회원을 함께 조회한 것을 알 수 있다.

SELECT
    T.*, M.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = '팀A'

페치 조인의 특징과 한계

페치 조인을 사용하면 SQL 한 번으로 연관된 엔티티들을 함께 조회할 수 있어서 SQL 호출 횟수를 줄여 성능을 최적화할 수 있다.

다음처럼 엔티티에 직접 적용하는 로딩 전략은 애플리케이션 전체에 영향을 미치므로 글로벌 로딩 전략이라 부른다. 페치 조인은 글로벌 로딩 전략보다 우선한다. 예를 들어 글로벌 로딩 전략은 지연 로딩으로 설정해도 JPQL에서 페치 조인을 사용하면 페치 조인을 적용해서 함께 조회한다.

@OneToMany(fetch = FetchType.LAZY)  // 글로벌 로딩 전략

최적화를 위해 글로벌 로딩 전략을 즉시 로딩으로 설정하면 애플리케이션 전에체서 항상 즉시 로딩이 일어난다. 물론 일부는 빠를 수는 있지만 전체로 보면 사용하지 않는 엔티티를 자주 로딩하므로 오히려 성능에 악영향을 미칠 수 있다. 따라서 글로벌 로딩 전략은 될 수 있으면 지연 로딩을 사용하고 최적화가 필요하면 페치 조인을 적용하는 것이 효과적이다.

또한 페치 조인을 사용하면 연관된 엔티티를 쿼리 시점에 조회하므로 지연 로딩이 발생하지 않는다. 따라서 준영속 상태에서도 객체 그래프를 탐색할 수 있다.

페치 조인은 다음과 같은 한계가 있다.

  • 페치 조인 대상에는 별칭을 줄 수 없다.
    - 문법을 자세히 보면 페치 조인에 별칭을 정의하는 내용이 없다. 따라서 SELECT, WHERE 절, 서브 쿼리에 페치 조인 대상을 사용할 수 없다.
    - JPA 표준에서는 지원하지 않지만 하이버네이트를 포함한 몇몇 구현체들은 페치 조인에 별칭을 지원한다. 하지만 별칭을 잘못 사용하면 연관된 데이터 수가 달라져서 데이터 무결성이 깨질 수 있으므로 조심해서 사용해야 한다. 특히 2차 캐시와 함께 사용할 때 조심해야 하는데, 연관된 데이터 수가 달라진 상태에서 2차 캐시에 저장되면 다른 곳에서 조회할 때도 연관된 데이터 수가 달라지는 문제가 발생할 수 있다.

  • 둘 이상의 컬렉션을 페치할 수 없다. 구현체에 따라 되기도 하는데 컬렉션 * 컬렉션의 카테시안 곱이 만들어지므로 주의해야 한다. 하이버네이트를 사용하면 "javax.persistence.PersistenceException: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags" 예외가 발생한다.

  • 컬렉션을 페치 조인하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다.
    - 컬렉션(일대다)이 아닌 단일 값 연관 필드(일대일, 다대일)들은 페치 조인을 사용해도 페이징 API를 사용할 수 있다.
    - 하이버네이트에서 컬렉션을 페치 조인하고 페이징 API를 사용하면 경고 로그를 남기면서 메모리에서 페이징 처리를 한다. 데이터가 적으면 상관없겠지만 데이터가 많으면 성능 이슈와 메모리 초과 예외가 발생할 수 있어서 위험하다.

페치 조인은 SQL 한 번으로 연관된 여러 엔티티를 조회할 수 있어서 성능 최적화에 상당히 유용하다. 그리고 실무에서 자주 사용하게 된다. 하지만 모든 것을 페치 조인으로 해결할 수는 없다. 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다. 반면에 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 한다면 억지로 페치 조인을 사용하기보다는 여러 테이블에서 필요한 필드들만 조회해서 DTO로 반환하는 것이 더 효과적일 수 있다.

참고

  • 자바 ORM 표준 JPA 프로그래밍

좋은 웹페이지 즐겨찾기