JPA 기초 8 - JPQL

JPA 기초 8 - JPQL

JPQL은 JPA에서 사용하기 위해 추상화한 SQL입니다

 

📕 JPQL


SQL은 DB에 직접 조회를 하지만 JPA는 ORM이기 때문에 모든 쿼리의 대상이 엔티티 객체이다. 때문에 객체에 대한 쿼리를 직접 작성하기 위해 JPQL이라는 추상화된 SQL이 등장했다.

이전에 다룬 연관관계 매핑만으로도 많은 쿼리를 처리할 수 있지만, 정말 복잡한 쿼리들에는 분명한 한계가 존재한다.

대표적으로 통계성 쿼리같은 것들이 있을 수 있다.


결국 이런 아주 복잡한 쿼리들을 직접 작성해야만 하는 순간이 반드시 오는데, 이 때 SQL을 직접 작성하면 영속성 컨텍스트와 무관해지기 때문에 JPQL을 통해 SQL을 작성한다.

JPQL을 통해 쿼리를 하면 쿼리의 결과가 영속성 컨텍스트에 종속되기 때문에 JPA를 사용하는 이점을 모두 누릴 수 있게 된다.


하지만 이 JPQL도 결국 SQL을 문자열로 직접 작성해야 한다는 분명한 한계점이 존재하기 때문에 이를 해결하기 위해 CriteriaQuerydsl 등의 기술이 나왔다.

이 기술들은 JPQL을 문자열로 직접 작성하는게 아닌, 빌더를 통해 자바 컴파일러의 도움을 받아 JPQL을 작성할 수 있게 도와준다.

여기서 Criteria는 표준 기술이지만 아주 비효율적이므로 따로 공부하는데 시간을 할애하지 않는것을 추천하며, Querydsl을 배우는데 시간을 투자할 것을 추천드린다.

시간은 아주 귀중한 자원이기 때문이다.


하기의 모든 내용은 JPQL에 관한 것들이며, 이 대부분의 내용은 실제로 직접 사용하지 않는다.

그럼에도 불구하고 이러한 내용들을 학습해야 하는 이유는 JPA를 사용하는 경우 JPQL을 더 쉽게 사용할 수 있게 도와주는 도구 (Querydsl과 같은 것들)를 반드시 사용하게 될 것이며, 이것들을 잘 사용하기 위해서는 JPQL에 대한 이해가 필수적으로 동반되기 때문이다.


📕 JPQL 문법


SELECT, UPDATE, DELETE문을 지원하지만 특이하게도 JPQL 자체적으로 INSERT 문은 지원하지 않는다.

왜냐하면 EntityManager.persist() 로 저장할 수 있기 때문이다.

JPQL에 대해 공부하기 전 다음 사항들을 명심해야 한다.


  • JPQL은 테이블이 아닌 엔티티 객체를 대상으로 쿼리한다
  • JPQL은 특정 SQL에 의존하지 않는 추상화된 SQL이다
  • JPQL은 결국 특정 SQL로 변환되어 쿼리된다
  • JPQL은 엔티티에 관련된 것들에 대해 대소문자를 구분한다
  • JPQL은 엔티티 이름으로 쿼리한다 (@Entity(name = ‘name’), name 속성을 생략하면 기본값은 클래스 이름과 동일하다)
  • 별칭이 필수이며, as 키워드는 생략해도 무방하다


동적 쿼리의 경우 파라미터 바인딩을 할 수 있는데, 위치기반의 파라미터 바인딩이름기반의 파라미터 바인딩을 지원한다.

바로 아래에서 자세히 보겠지만 위치기반 파라미터 바인딩의 경우 물음표(?)를 사용하고, 이름기반 파라미터 바인딩의 경우 콜론(:)을 사용한다.

하지만 위치기반 방식의 경우 변경사항이 생기면 에러가 발생할 가능성이 매우 높기 때문에 아예 사용하지 않는다고 봐도 좋다.

이전에 학습한 @Enumerated에서 EnumType.ORDINAL을 쓰지 않고 EnumType.STRING만을 사용하는 이유와 동일하다.


JPQL 테스트를 위한 테스트 클래스는 다음과 같다.


@DataJpaTest
public class JpqlTest {
    private final TestEntityManager testEntityManager;

    public JpqlTest(TestEntityManager testEntityManager) {
        this.testEntityManager = testEntityManager;
    }

    private EntityManager em;

    @BeforeEach
    void setUp() {
        em = testEntityManager.getEntityManager();
    }
}


💡 SELECT


@Test
@DisplayName("테스트를 실행하여 SELECT 쿼리를 확인한다")
void select() {
    em.createQuery("select m from Member m where m.name = 'siro'", Member.class)
      .getResultList();

    em.createQuery("select m from Member m where m.name = :name")
      .setParameter("name", "siro")
      .getResultList();
}


JPQL은 Member 객체에서 이름(name)이 siro인 것을 조회하고있다.

Hibernate는 이를 다음과 같은 SQL로 변환하여 쿼리했다. (DB는 H2이다.)

둘 모두 동일한 쿼리가 발생하며, 정적쿼리냐 파라미터를 받는 동적쿼리냐의 차이일 뿐이다.


select
    member0_.id as id1_2_,
    member0_.name as name2_2_
from
    member member0_ 
where
    member0_.name='siro'


💡 UPDATE


@Test
@DisplayName("테스트를 실행하여 UPDATE 쿼리를 확인한다")
void update() {
    em.createQuery("update Member m set m.name = 'siro' where m.id = 1")
      .executeUpdate();

    em.createQuery("update Member m set m.name = :name where m.id = :id")
      .setParameter("name", "siro")
      .setParameter("id", 1L)
      .executeUpdate();
}


update
    member 
set
    name='siro' 
where
    id=1


💡 DELETE


@Test
@DisplayName("테스트를 실행하여 DELETE 쿼리를 확인한다")
void delete() {
    em.createQuery("delete from Member m where m.id = 1")
      .executeUpdate();

    em.createQuery("delete from Member m where m.id = :id")
      .setParameter("id", 1L)
      .executeUpdate();
}


delete 
from
    member 
where
    id=1


👏 중간정리


보다시피 일반적인 ANSI SQL과 거의 동일하다.

다만 쿼리 대상이 테이블이 아닌 객체라는 점에서 아주 미세한 차이가 발생하며, 이 미세한 차이는 SQL에 익숙한 사람이라면 보자마자 바로 이해하고 사용할 수 있을 정도의 차이일 뿐이다.


💡 반환타입


TypedQueryQuery가 존재한다.


@Test
@DisplayName("TypedQuery, Query의 차이를 확인한다")
void typeTest() throws Exception {
    // TypedQuery: 반환 타입이 아주 명확한 경우
    TypedQuery<Member> typedQuery = em.createQuery("select m from Member m", Member.class);

    // 명확한 타입의 객체가 반환된다.
    List<Member> typedResultList = typedQuery.getResultList();

    for (Member member : typedResultList) {
        // Member 타입
    }

    // Query: 반환 타입이 명확하지 않은 경우
    Query query = em.createQuery("select m.id, m.name from Member m");

    // Object 타입의 객체가 반환된다. (타입 캐스팅을 한번 더 해야하므로 약간 더 불편하다)
    List<Object[]> queryResultList = query.getResultList();

    for (Object[] o : queryResultList) {
      System.out.println("id = " + (Long) o[0]); // 식별자
      System.out.println("name = " + (String) o[1]); // 이름
    }
}


💡 결과조회


  • getResultList
    • 결과를 List로 반환한다.
  • getSingleResult
    • 한개의 결과를 반환한다.
    • 결과가 없는 경우 NoResultException을 던진다
    • 결과가 둘 이상인 경우 NonUniqueResultException을 던진다
  • getResultStream
    • 결과를 List로 만들고 이에 대한 Stream 객체를 반환한다


여기서 getSingleResult는 결과가 정확히 한개가 아닐 경우 별도의 예외를 던지는 부분을 주의한다.


💡 프로젝션(Projection)


@Test
void typeTest() throws Exception{
    // Query: 반환 타입이 명확하지 않은 경우
    Query query = em.createQuery("select m.id, m.name from Member m");

    // Object 타입의 객체가 반환된다. (타입 캐스팅을 한번 더 해야하므로 약간 더 불편하다)
    List<Object[]> queryResultList = query.getResultList();

    for(Object[] o : queryResultList){
      System.out.println("id = " + (Long) o[0]); // 식별자
      System.out.println("name = " + (String) o[1]); // 이름
    }
}


이처럼 엔티티 객체 전체를 조회하는 것이 아니고 특정 필드만 조회하고 싶은 경우에 특정 필드만 선택해서 조회하는 것을 프로젝션이라고 한다.

프로젝션을 사용해 특정 필드만 조회하더라도 해당 필드를 갖고있는 엔티티 객체는 영속성 컨텍스트에 캐시되고 관리되며, 필드 자체는 이기 때문에 따로 관리되지는 않는다.

조금 더 쉽게 이해하기 위해 일반적인 옵티마이저의 SELECT의 논리적인 실행 순서에 대해 알면 좋을 것 같다. (사용하는 DB벤더의 옵티마이저마다 상이할 수 있다.)


  1. FROM
    • 쿼리가 시작되면 from절을 먼저 검사하여 해당 테이블이 정말로 존재하는지, 해당 유저가 해당 테이블에 접근할 권한이 있는지를 먼저 검사한다. 여기서 문제가 있으면 옵티마이저는 SEMANTIC ERROR를 발생시킨다.
  2. ON
    • join을 할 경우 on절이 추가되는데 이 때 on절이 where절보다 실행 우선순위가 더 높다. A테이블과 B테이블을 조인한다고 하면 on절의 조건을 먼저 검사하여 필터링한다.
  3. WHERE
    • from절이나 on절에서 문제가 없으면 옵티마이저는 where절에서 검색 조건을 확인하고 해당 row들을 조회하고 가져온다.
  4. GROUP BY
    • where절에서 가져온 row들을 그루핑한다.
  5. HAVING
    • 그루핑한 row에서 한번 더 조건을 확인하여 필터링한다.
  6. SELECT
    • having절에서 필터링된 row에서 select절에 명시된 컬럼만 덜어낸다. 이렇게 select절이 최후순위에서 실행되기 때문에 select * from memberselect name from member의 조회 비용은 거의 동일하다. (복합 인덱스가 존재한다면 동일하지 않을 수 있다)
  7. ORDER BY
    • select된 컬럼들을 대상으로 정렬을 한다. select보다 후순위에서 실행되기 때문에 select 절에 명시된 별칭(alias, as)을 여기서 사용할 수 있다.


결국 프로젝션이라는 것은 다음과 같이 생각할 수 있다.


JPQL을 SQL로 변환 -> SQL 실행 -> SQL결과를 엔티티 객체로 반환 -> 원하는 필드만 조회함(프로젝션)


따라서 프로젝션을 사용해서 특정 필드만 조회했다고 하더라도 이미 영속성 컨텍스트에는 해당 필드를 갖고있는 엔티티 객체가 캐시되어있으며, 영속성 컨텍스트에서 관리되고있는 상태임을 이해하자.


💥 임베디드 타입(=값 타입) 프로젝션


JPA에서 임베디드 타입은 엔티티와 거의 동일하게 취급되지만 JPQL에서의 임베디드 타입은 엔드포인트가 될 수 없다는 차이가 있다.

또한 임베디드 타입은 결국 어떠한 이기 때문에 JPQL에서는 엔티티 객체와 다르게 영속성 컨텍스트에서 관리되지 않는다.


em.createQuery("select a from Address a");


위 쿼리에서 Address가 임베디드 타입이라면 이는 잘못된 JPQL이다.

임베디드 타입으로는 쿼리를 시작할 수 없기 때문이다.

그렇다면 Address를 조회하려면 어떻게 해야 할까?

Address를 사용하고 있는 엔티티 객체를 대상으로 쿼리한 후 Address를 프로젝션해야 한다.


List<Address> addresses = em.createQuery("select m.address from Member m", Address.class).getResultList();


🤔 생성자 사용


@Test
@DisplayName("프로젝션")
void projection() throws Exception {
        List<Object[]> queryResultList = em.createQuery("select m.id, m.name from Member m")
                                            .getResultList();

    List<MemberDto> memberDtos = new ArrayList<>();
    for (Object[] o : queryResultList) {
      Long id = (Long) o[0];
      String name = (String) o[1];
    
      MemberDto memberDto = new MemberDto(id, name);
    
      memberDtos.add(memberDto);
    }
}


이런식으로 프로젝션을 할 수 있다.

하지만 보다시피 너무 장황하고 지루하다.

이를 다음과 같이 생성자를 사용하여 간결하게 변경할 수 있다.


@Test
@DisplayName("생성자_프로젝션")
void constructorProjection() throws Exception {
    em.createQuery("select new learn.jpa.dto.MemberDto(m.id, m.name) from Member m", MemberDto.class)
             .getResultList();
}


하지만 이 방식의 경우 다음과 같은 주의사항이 있다.

  • 패키지 명을 포함한 전체 클래스명을 오타 없이 입력해야 한다
  • 시그니처가 동일한 생성자가 반드시 필요하다

이러한 주의사항이 존재하기 때문에 실제로 이 방법을 직접 사용하는 경우는 없다고 봐도 무방하다.

대신 Querydsl에서 이 방식을 더욱 편리하게 사용할 수 있는 다음과 같은 방법을 지원한다.

이 외에도 여러가지 방법이 더 존재한다.


@Test
@DisplayName("Querydsl 생성자 프로젝션")
void querydslConstructorProjection() throws Exception {
    queryFactory.select(new QMemberDto(member.id, member.name))
                .from(member)
                .fetch();
}


💡 페이징


페이징 쿼리의 경우 DB벤더마다 모두 다르기 때문에 이런 차이를 모두 익히려는 것은 굉장한 고역이다.

JPQL은 SQL을 추상화하였기 때문에 JPQL로 페이징 쿼리를 작성할 줄 안다면 실제 DB벤더로 나가는 쿼리는 JPA가 알아서 번역해준다.

JPQL로 페이징 쿼리를 작성하기 위해서는 두가지 옵션을 주면 된다.

아주 간단하게 다음과 같이 생각하면 될 것 같다.


  • setFirstResult
    • 검색할 페이지
  • setMaxResults
    • 가져올 데이터의 개수


@Test
@DisplayName("페이징")
void paging() throws Exception {
    em.createQuery("select m from Member m order by m.name desc", Member.class)
            .setFirstResult(2)
            .setMaxResults(5)
            .getResultList();
}


위와 같은 코드가 있다면 이렇게 이해할 수 있다.

테이블에 총 20개의 row가 존재한다면 이를 페이지의 크기인 5로 나눈다.

그러면 해당 테이블에는 총 4개의 페이지가 존재하며, 한 페이지에 5개의 row를 가질 것이다.

그중 2번째 페이지를 조회하려고 하니 11~15번째의 row가 조회될 것이다 (JPQL 페이징은 1이 아닌, 0부터 시작한다.)


image


H2DB 기준으로 하기와 같은 쿼리가 발생한다.


select
    member0_.id as id1_2_,
    member0_.create_at as create_a2_2_,
    member0_.update_at as update_a3_2_,
    member0_.age as age4_2_,
    member0_.name as name5_2_,
    member0_.team_id as team_id6_2_ 
from
    member member0_ 
order by
    member0_.name desc


하지만 페이징을 위해 이러한 코드를 직접 작성하는 일은 없을것이다.

Spring Data를 사용한다면 Paging관련 API가 있기 때문에 훨씬 더 쉽고 편리하게 사용할 수 있으며, Querydsl에도 관련 API가 아주 잘 되어있기 때문이다.

그러니까 어떤 방식으로 돌아가는지에 대해서 이해만 해두면 될 것 같다.


💡 통계


일반적으로 집계함수들은 DB에 부하가 많이 들어가기 때문에 보통 런타임에 직접 사용하지는 않는다.

트래픽이 적은 시간대에 배치성으로 작업해서 따로 기록하는게 일반적이기 때문에(당일 매출집계 등), 어플리케이션 레벨에서 JPA로 직접 작성하는 일은 생각보다 많지는 않은 것 같다.

그러니 JPA를 쓰더라도 본인이 배치를 개발하는게 아니라면 생각보다 크게 비중이 있다고 보기는 어려울 것 같다.

만일 위 조건에 해당하지 않는다면 그냥 이런것도 있구나 하고 가벼운 마음으로 보면 될 것 같다.


😱 집합, 정렬


함수설명
count결과 개수를 구한다. 반환타입은 Long
max최대 값을 구한다.
min최소 값을 구한다.
sum합을 구하며 숫자타입만 사용 할 수 있다. 반환 타입은 Long, Double, BigInteger, BigDecimal
avg평균값을 구하며 숫자타입만 사용 할 수 있다. 반환 타입은 Double


@Test
@DisplayName("집계함수")
void aggregate() throws Exception {
    em.createQuery("select count(m) from Member m").getSingleResult();
    em.createQuery("select max(m.id) from Member m").getSingleResult();
    em.createQuery("select min(m.id) from Member m").getSingleResult();
    em.createQuery("select avg(m.age) from Member m").getSingleResult();
    em.createQuery("select sum(m.age) from Member m").getSingleResult();
}


발생한 쿼리는 다음과 같다.


select
    count(member0_.id) as col_0_0_ 
from
    member member0_


select
    max(member0_.id) as col_0_0_ 
from
    member member0_


select
    min(member0_.id) as col_0_0_ 
from
    member member0_


select
    avg(cast(member0_.age as double)) as col_0_0_ 
from
    member member0_


select
    sum(member0_.age) as col_0_0_ 
from
    member member0_


😱 GROUP BY, HAVING


@Test
@DisplayName("집계함수")
void aggregate() throws Exception {
    em.createQuery("select m.name as aa from Member m group by m.team having m.age > 20").getResultList();
}


Member의 전체 row를 team별로 그루핑하고, 각 그룹별로 나이가 21살 이상인 Member들의 name을 출력하는 쿼리다.

이는 아주아주 간단한 예제이며, 실무에서는 이와 비교해 상상할 수 없을 만큼 훨씬 더 복잡한 통계성 쿼리를 작성한다.

위 코드로 인해 발생한 쿼리는 다음과 같다.


select
    member0_.name as col_0_0_ 
from
    member member0_ 
group by
    member0_.team_id 
having
    member0_.age>20


😱 ORDER BY


자주 사용하는 정렬 쿼리이다.

사용방법은 아주 간단하지만, 무조껀 인덱스를 사용하게끔 작성하는게 더 중요한 쿼리라고 볼 수 있다.

왜냐하면 인덱스는 이미 정렬되어 있기 때문에 정렬 쿼리가 인덱스를 사용하게 되면 옵티마이저가 정렬 연산을 스킵하기 때문이다.


@Test
@DisplayName("정렬")
void sort() throws Exception {
    em.createQuery("select m from Member m order by m.age desc").getResultList();
}


위 코드는 Member의 나이를 기준으로 내림차순 정렬(desc)을 한다.

만약 오름차순으로 정렬하고 싶다면 asc를 주면 된다.

발생한 쿼리는 다음과 같다.


select
    member0_.id as id1_2_,
    member0_.create_at as create_a2_2_,
    member0_.update_at as update_a3_2_,
    member0_.age as age4_2_,
    member0_.name as name5_2_,
    member0_.team_id as team_id6_2_ 
from
    member member0_ 
order by
    member0_.age desc


💡 조인


조인에 대해서는 필자가 잘 모른다.

조인이라는게 조인순서를 어떻게 하는지, 컬럼을 어떻게 조회하는지 등의 사소한 차이에 따라 속도가 천차만별로 변하기 때문이다.

아주아주 깊고 어려운 주제라고 생각한다.


조인에 대해 정말 잘 모르기 때문에 필자는 다음과 같은 규칙하나만 세워두고 개발한다.


무조건 inner join이 먼저, outer join은 뒤로 뺀다.


경험상 이렇게 하는게 항상 성능이 더 잘나왔다.

언젠가 이런 주제에 대해서도 깊게 공부해봐야 겠지만 당장은 우선순위가 밀린다.

시간을 만만찮게 잡아먹을 것 같은 분야여서…


이러하니 그냥 문법에 대해서만 정리해보고자 한다.


🤔 내부조인


내부 조인은 문법상 inner join 이며 inner를 생략할 수 있다.


em.createQuery("select m from Member m join m.team t on m.createAt = t.createAt").getResultList();


문법은 일반적인 SQL과 크게 다를게 없지만 조인 시 위처럼 별칭을 사용하게 된다.


select
  member0_.id as id1_2_,
  member0_.create_at as create_a2_2_,
  member0_.update_at as update_a3_2_,
  member0_.age as age4_2_,
  member0_.name as name5_2_,
  member0_.team_id as team_id6_2_
from
  member member0_
    inner join
  team team1_
  on member0_.team_id=team1_.id
    and (
       member0_.create_at=team1_.create_at
       )


보다시피 Member가 fk로 갖고 있는 Team의 id와 Team의 pk를 기준으로 조인한다.

추가적으로 create_at 컬럼도 같이 조건에 넣고 있다.


하기와 같이 사용할 경우 SYNTAX ERROR가 발생한다.


em.createQuery("select m from Member m join Team t).getResultList();


다만 다음과 같이 할 경우에는 문제가 생기지 않는다.


em.createQuery("select m from Member m join Team t on m.createAt = t.createAt").getResultList();


select
    member0_.id as id1_2_,
    member0_.create_at as create_a2_2_,
    member0_.update_at as update_a3_2_,
    member0_.age as age4_2_,
    member0_.name as name5_2_,
    member0_.team_id as team_id6_2_ 
from
    member member0_ 
inner join
    team team1_ 
        on (
            member0_.create_at=team1_.create_at
        )


차이라면 fk로 조인하지 않고 create_at 컬럼으로만 조인하고 있기 때문에 성능 저하가 예상된다.


하지만 일반적으로 내부조인시에는 on절을 잘 사용하지 않는다.

on절을 생략하면 기본적으로 fk를 이용하여 조인되기 때문이기도 하고, 어차피 where절을 사용하면 on절과 동일하게 동작하기 때문이다.

그냥 이런것도 있구나~ 하고 넘어가면 될 것 같다.


🤔 외부조인


잘 모르기 때문에 참 사용하기가 꺼려지는 조인이다.

기본적으로 성능저하를 달고오고, 조건을 어떻게 주느냐에 따라 또 이 성능저하가 천차만별이기 때문이다.

역시 잘 모르므로 어떻게 사용하는지 문법 정도만 정리한다.


문법상 left outer join 이지만 outer는 생략 가능하기 때문에 left join으로 사용하는게 일반적이다.


@Test
@DisplayName("외부조인")
void leftOuterJoin() throws Exception {
    em.createQuery("select m from Member m left join m.team t").getResultList();
}


select
    member0_.id as id1_2_, 
    member0_.create_at as create_a2_2_,
    member0_.update_at as update_a3_2_,
    member0_.age as age4_2_,
    member0_.name as name5_2_,
    member0_.team_id as team_id6_2_ 
from
    member member0_ 
left outer join
    team team1_ 
        on member0_.team_id=team1_.id


🤔 세타조인


연관관계가 없는 엔티티여도 조인할 수 있다.

일명 막조인이라고 부르기도 한다.

다음과 같이 where절을 사용한다.


@Test
@DisplayName("세타조인")
void thetaJoin() throws Exception {
    em.createQuery("select m, t from Member m, Team t where m.name = t.name").getResultList();
}


select
    member0_.id as id1_2_0_,
    team1_.id as id1_4_1_,
    member0_.create_at as create_a2_2_0_,
    member0_.update_at as update_a3_2_0_,
    member0_.age as age4_2_0_,
    member0_.name as name5_2_0_,
    member0_.team_id as team_id6_2_0_,
    team1_.create_at as create_a2_4_1_,
    team1_.update_at as update_a3_4_1_,
    team1_.name as name4_4_1_ 
from
    member member0_ cross 
join
    team team1_ 
where
    member0_.name=team1_.name


👏 페치조인


페치조인은 join fetch를 명시적으로 걸어 연관된 엔티티를 함께 조회하는 것을 말한다.

문법은 다음과 같다.


@Test
@DisplayName("페치조인")
void fetchJoin() throws Exception {
    em.createQuery("select m from Member m join fetch m.team").getResultList();
}


select
    member0_.id as id1_2_0_,
    team1_.id as id1_4_1_,
    member0_.create_at as create_a2_2_0_,
    member0_.update_at as update_a3_2_0_,
    member0_.age as age4_2_0_,
    member0_.name as name5_2_0_,
    member0_.team_id as team_id6_2_0_,
    team1_.create_at as create_a2_4_1_,
    team1_.update_at as update_a3_4_1_,
    team1_.name as name4_4_1_ 
from
    member member0_ 
inner join
    team team1_ 
        on member0_.team_id=team1_.id


만약 Member와 Team을 모두 사용해야 하는데 지연로딩 + 객체 그래프 탐색을 사용한다면 Member에 대한 select 쿼리가 먼저 발생하고, 이후 Team에 대한 select 쿼리가 발생했을 것이다.

하지만 이렇게 페치조인을 사용하면 두번의 쿼리가 아닌 한번의 쿼리만 발생시키면 되기 때문에 효율적이다.


단점도 있다.

위처럼 Member와 Team을 모두 사용할 게 아니고 Member만 필요한 상황에서 페치조인을 사용한다면 필요없는 Team까지 조회하기 때문에 낭비다.

이런 경우에는 오히려 페치조인을 사용하지 않는게 좋다.


즉, 페치조인이 JPA에서 성능 최적화에 큰 기여를 하는것도 분명한 사실이지만, 제대로 알고 사용하지 않는다면 오히려 성능을 저하시킬수도 있는 trade-off가 존재한다는 점을 잘 이해하고 사용해야 하겠다.


💡 경로 표현식


경로 표현식이라는 것은 점(.)을 찍어 객체 그래프를 탐색하는 것이다.


@Test
@DisplayName("경로표현식")
void pathExpression() throws Exception {
    em.createQuery("select m.team from Member m").getResultList();
}


위 코드가 바로 경로표현식을 사용한 예다.

위 JPQL이 실행되면 어떤 쿼리가 발생할까?


select
    team1_.id as id1_4_,
    team1_.create_at as create_a2_4_,
    team1_.update_at as update_a3_4_,
    team1_.name as name4_4_ 
from
    member member0_ 
inner join
    team team1_ 
        on member0_.team_id=team1_.id


예상하신분도, 예상하지 못하신 분도 있겠지만 이렇게 Tema에 대한 내부조인 쿼리가 발생했다.

책에서는 이를 경로 표현식에 의한 묵시적 조인이라고 표현하고 있다.

이렇게 무분별한 조인이 발생할 수 있기 때문에 JPQL을 사용하면 상당히 주의해서 사용해야 될 기능이라고 볼 수 있겠다.

조인은 데이터베이스에 부하를 주기 때문이다.

묵시적 조인은 기본적으로 내부조인으로 발생하며, 외부조인을 하고 싶다면 명시적으로 외부조인 쿼리를 작성해줘야만 한다.


경로 표현식은 다음과 같은 용어를 사용한다.

  • 상태 필드(state field): 값을 저장하기 위한 필드(일반적인 값 타입)
  • 연관 필드(association field): 연관관계를 위한 필드, 임베디드 타입(값 타입)을 포함한다
    • 단일 값 연관 필드: @~ToOne의 관계
    • 컬렉션 값 연관 필드: @~ToMany의 관계


또한 경로 표현식은 다음과 같은 특징을 갖는다.

  • 상태 필드: 상태 필드가 선언되면 경로 탐색의 끝을 의미하며, 여기서 더 탐색할 수 없다.
  • 단일 값 연관 필드: 묵시적 조인이 발생하며, 계속 이어서 탐색할 수 있다.
  • 컬렉션 값 연관 필드: 묵시적 조인이 발생하며, 더이상 탐색할 수 없다. 단 from절에서 명시적 조인을 통해 별칭(as)을 얻으면 더 탐색할 수 있다.


필자는 @~ToMany의 관계를 최대한 사용하지 않으려고 하기 때문에 컬렉션 관련해서는 사용해본적이 전무하며, 경로 표현식을 통한 묵시적 조인이 발생하는것도 별로 좋다고 생각하지 않기 때문에 조인이 필요하다면 항상 명시적 조인을 사용하고 있다.

위의 내용을 제대로 이해하면 어떤 문제가 발생할 수 있는지 알 수 있기 때문에 이런 내용은 최대한 이해해두는게 도움이 될 거라고 생각한다.



© 2022. All rights reserved.