[자바 ORM 표준 JPA 프로그래밍] Chapter 10

YUSHIN KIM·2025년 11월 11일

Chapter 10. 객체지향 쿼리 언어

10.1 객체지향 쿼리 소개

EntityManager.find() 메서드를 사용하면 식별자로 단일 엔터티를 조회할 수 있고, 조회한 엔터티에 객체 그래프 탐색을 사용하여 연관된 엔터티들을 조회할 수 있다. 이것은 가장 단순한 조회 방법이다.

  • 식별자로 조회(EntityManager.find())
  • 객체 그래프 탐색(Member.getTeam())

하지만 조건에 따라 엔터티를 필터링해야 하는 경우 데이터베이스에서는 단순 조회만 수행하고 애플리케이션에서 필터링을 수행하는 것은 매우 비효율적이다. 데이터를 조회할 때부터 SQL로 필터링을 수행하는 것이 훨씬 효율적이며 그것이 데이터베이스의 존재 이유이기도 하다. 하지만 ORM은 데이터베이스 테이블이 아닌 엔터티 객체를 활용한 기술이므로 검색 또한 엔터티 객체를 대상으로 이루어져야 한다. 이러한 불일치를 해결하기 위해 JPQL이 고안되었다.

JPQL의 특징은 다음과 같다.

  • 테이블이 아닌 객체를 대상으로 검색하는 객체지향 쿼리이다.
  • SQL을 추상화해서 특정 데이터베이스 SQL에 의존하지 않는다.

SQL은 데이터베이스 테이블을 대상으로 하는 데이터 중심 쿼리, JPQL은 엔터티 객체를 대상으로 하는 객체지향 쿼리이다. JPQL 사용 시 JPA는 이를 분석하여 적절한 SQL을 생성해 데이터베이스를 조회한다. 그리고 조회한 결과로 엔터티 객체를 생성해 반환한다.

JPA는 다음과 같은 다양한 검색 방법을 제공한다.

  • JPQL(Java Persistence Query Language)
  • Criteria Query: JPQL을 편하게 작성하도록 도와주는 API, 빌더 클래스 모음
  • Native SQL: JPA를 통해 직접 사용할 수 있는 SQL

JPA가 공식 지원하는 기능은 아니지만 다음과 같은 유용한 기능도 있다.

  • QueryDSL: JPQL을 편하게 작성하도록 도와주는 빌더 클래스 모음, 비표준 오픈소스 프레임워크
  • JDBC를 직접 사용하면서 MyBatis 같은 SQL 매퍼 프레임워크 활용

Criteria, QueryDSL도 결국 JPQL을 편하게 작성하도록 도와주는 빌더 클래스일 뿐이다. 그러므로 이 기술들을 이해하려면 JPQL에 대한 이해가 선행되어야 한다.

10.1.1 JPQL 소개

JPQL(Java Persistence Query Language)의 특징은 다음과 같다.

  • 엔터티 객체를 조회하는 객체지향 쿼리 언어이다.
  • 문법이 SQL과 유사하고 ANSI 표준 SQL이 제공하는 기능을 유사하게 지원한다.
  • SQL을 추상화하여 특정 데이터베이스에 의존하지 않는다.
  • 데이터베이스 방언(Dialect)만 변경하면 JPQL을 수정하지 않고도 데이터베이스를 변경할 수 있어 이식성이 뛰어나다.
  • 엔터티 직접 조회, 묵시적 조인, 다형성 지원으로 SQL보다 간결하다.

예제 10.1. 회원 엔터티

@Entity(name = "Member")
public class Member {

    @Id
    @GeneratedValue
    private Long id;

    @Column(name = "age")
    private Integer age;

    @Column(name = "name")
    private String username;

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
    
    // ...
}

예제 10.2. JPQL 사용

@Test
@Transactional
void jpqlExample() {
    String jpql = "SELECT m FROM Member AS m WHERE m.username = 'kim'";
    List<Member> resultList = em.createQuery(jpql, Member.class).getResultList();

    Assertions.assertEquals(2, resultList.size());
}

EntityManager.createQuery() 메서드의 매개변수로 JPQL과 반환될 엔터티의 클래스 타입을 전달하고 getResultList() 메서드를 실행하면 JPA는 JPQL을 SQL로 변환해서 데이터베이스를 조회한다. 그리고 조회 결과로 Member 엔터티를 생성해서 반환한다.

하이버네이트가 생성한 쿼리

Hibernate: 
    /* 
    SELECT
        m 
    FROM
        Member AS m 
    WHERE
        m.username = 'kim'
    */
    	
        select
            m1_0.id,
            m1_0.age,
            m1_0.team_id,
            m1_0.name 
        from
            member m1_0 
        where
            m1_0.name='kim'

주석 처리된 부분이 JPA가 실행한 JPQL, 그 아래는 하이버네이트 구현체가 생성한 SQL이다.

10.1.2 Criteria Query 소개

Criteria Query의 특징은 다음과 같다.

  • Criteria는 JPQL을 생성하는 빌더 클래스이다.
  • 문자열이 아닌 메서드 체이닝 방식으로 JPQL을 작성할 수 있다.
    • 컴파일 시점에 오류를 발견할 수 있다.
    • IDE 사용 시 코드 자동완성을 지원한다.
    • 동적 쿼리를 작성하기 편하다.
  • 모든 장점을 상쇄할 정도로 복잡하고 장황하여 사용 편의성과 가독성이 떨어진다.

예제 10.3. Criteria Query

@Test
@Transactional
void criteriaExample() {
    // prepare for using Criteria Query
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<Member> query = cb.createQuery(Member.class);

    // Root class (like FROM clause)
    Root<Member> m = query.from(Member.class);

    // generate query
    CriteriaQuery<Member> cq = query.select(m).where(cb.equal(m.get("username"), "kim"));
    List<Member> resultList = em.createQuery(cq).getResultList();

    Assertions.assertEquals(2, resultList.size());
}

앞선 예제를 Criteria Query를 사용하여 작성한 것이다.

하이버네이트가 생성한 쿼리

Hibernate: 
    /* <criteria> */
    select
        m1_0.id,
        m1_0.age,
        m1_0.team_id,
        m1_0.name 
    from
        member m1_0 
    where
        m1_0.name=?

Criteria Query 역시 m.get("username")과 같이 컬럼 이름을 하드코딩해야 하는 경우가 생기는데, 이 부분도 프로그래밍 방식으로 개선하고자 한다면 메타 모델(MetaModel)을 사용하면 된다.

메타 모델 API는 Java의 애너테이션 프로세서(Annotation Processor) 기능을 사용하여 Member 클래스로부터 Criteria 전용 메타 모델 클래스인 Member_을 생성한다. 이를 활용하면 m.get(Member_.username)과 같이 프로그래밍 방식으로 JPQL 쿼리를 작성할 수 있다.

10.1.3 QueryDSL 소개

QueryDSL의 특징은 다음과 같다.

  • JPQL 빌더 역할을 한다.
  • 프로그래밍 기반이면서도 단순하고 사용하기 쉽다.
  • 작성한 코드가 JPQL과 유사해 가독성이 좋다.
  • JPA 표준이 아닌 오픈소스 프로젝트이다.

QueryDSL은 의존성 스타터와 함께 제공되지 않기 때문에 먼저 의존성을 설치와 Q-클래스 컴파일이 필요하다.

pom.xml

...
        <dependency>
            <groupId>com.querydsl</groupId>
            <artifactId>querydsl-jpa</artifactId>
            <version>5.1.0</version>
            <classifier>jakarta</classifier>
        </dependency>
        <dependency>
            <groupId>com.querydsl</groupId>
            <artifactId>querydsl-apt</artifactId>
            <version>5.1.0</version>
            <scope>provided</scope>
            <classifier>jakarta</classifier>
        </dependency>
...
            <plugin>
                <groupId>com.mysema.maven</groupId>
                <artifactId>apt-maven-plugin</artifactId>
                <version>1.1.3</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>process</goal>
                        </goals>
                        <configuration>
                            <outputDirectory>target/generated-sources/java</outputDirectory>
                            <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
...

이후 메이븐 컴파일을 통해 Q-클래스 파일을 생성해야 애플리케이션 코드에서 사용 가능하다.

예제 10.4. QueryDSL 코드

@Test
@Transactional
void queryDSLExample() {
    JPAQueryFactory queryFactory = new JPAQueryFactory(em);
    QMember member = QMember.member;

    List<Member> members = queryFactory.selectFrom(member).where(member.username.eq("kim")).fetch();

    Assertions.assertEquals(2, members.size());
}

QueryDSL도 애너테이션 프로세서를 사용하여 쿼리 전용 클래스인 Q-클래스를 생성해야 한다. 때문에 초기 설정이 복잡하다.

10.1.4 네이티브 SQL 소개

네이티브 SQL의 특징은 다음과 같다.

  • SQL을 직접 작성할 수 있는 기능으로 JPA에서 직접 지원한다.
  • 특정 데이터베이스에 의존하는 기능이나 SQL에서만 지원되는 기능을 사용해야 할 때 필요하다.
  • JPQL에 비해 이식성은 부족하다.

예제 10.5. 네이티브 SQL

@Test
@Transactional
void nativeSQLExample() {
    String sql = "SELECT ID, AGE, TEAM_ID, NAME FROM MEMBER WHERE NAME = 'kim'";
    List<Member> resultList = em.createNativeQuery(sql, Member.class).getResultList();

    Assertions.assertEquals(2, resultList.size());
}

10.1.5 JDBC 직접 사용, MyBatis 같은 SQL 매퍼 프레임워크 사용

JDBC 커넥션에 직접 접근하고 싶으면 JPA 구현체가 제공하는 방법을 통해 JDBC 커넥션을 획득해야 한다.

예제 10.6. 하이버네이트 JDBC 획득

@Test
@Transactional
void jdbcExample() {
    Session session = em.unwrap(Session.class);
    session.doWork(new Work() {
        @Override
        public void execute(Connection connection) throws SQLException {
            // work ...
        }
    });
}

JDBC나 MyBatis를 JPA와 함께 사용하면 적절한 시점에 영속성 컨텍스트를 강제 플러시해야 한다. JDBC를 사용하든 MyBatis 같은 SQL 매퍼를 사용하든 모든 SQL은 JPA를 우회하게 되는데 JPA는 이를 인식하지 못한다. 그러므로 최악의 경우 영속성 컨텍스트와 데이터베이스 간 상태가 불일치하여 데이터 무결성이 훼손될 수 있다.

가장 간단한 해결 방법은 JPA를 우회하여 SQL을 실행하기 전 영속성 컨텍스트를 수동으로 플러시하여 데이터베이스와 영속성 컨텍스트를 동기화하는 것이다. 이때 스프링 프레임워크의 AOP를 적절히 활용하여 JPA를 우회하는 영속성 계층 접근 메서드를 호출할 때마다 영속성 컨텍스트를 플러시하게 할 수 있다.

10.2 JPQL

JPQL의 특징은 다음과 같다.

  • 객체지향 쿼리 언어이기 때문에 엔터티 객체를 대상으로 쿼리한다.
  • SQL을 추상화해서 특정 데이터베이스에 의존하지 않는다.
  • JPQL 쿼리는 결국 SQL 쿼리로 변환된다.

10.2.1 기본 문법과 쿼리 API

JPQL도 SQL과 유사하게 SELECT, UPDATE, DELETE 문을 사용할 수 있다. INSERT 문은 EntityManager.persist() 메서드에 해당한다.

예제 10.7. JPQL 문법

select_query :: =
	select_clause
    from_clause
    [where_clause]
    [groupby_clause]
    [having_clause]
    [orderby_clause]
    
update_query :: = update_clause [where_clause]
delete_query :: = delete_clause [where_clause]

SELECT 문

SELECT 문은 다음과 같이 사용한다.

SELECT m FROM Member AS m WHERE m.username = 'Hello'
  • 대소문자 구분: 엔터티와 속성 이름은 대소문자를 구분한다. JPQL 키워드는 대소문자를 구분하지 않는다.
  • 엔터티 이름: JPQL에서 사용한 Member는 엔터티 이름으로, @Entity(name = "Member")과 같이 애너테이션으로 지정한다. 기본 값은 클래스 이름이다.
  • 별칭(식별 변수)은 필수: JPQL은 m과 같은 별칭을 생략하면 문법 오류가 발생한다. AS 키워드는 생략할 수 있다.

TypeQuery, Query

JPQL을 실행하려면 쿼리 객체를 생성해야 한다. 반환 타입을 명확히 지정할 수 있으면 TypedQuery 객체를, 그렇지 않으면 Query 객체를 사용한다.

예제 10.8. TypedQuery 사용

@Test
@Transactional
void typedQueryExample() {
    TypedQuery<Member> query = em.createQuery("SELECT m FROM Member m", Member.class);
    List<Member> resultList = query.getResultList();

    Assertions.assertEquals(3, resultList.size());
}

EntityManager.createQuery() 메서드의 두 번째 매개변수로 반환 타입을 지정하면 TypedQuery 객체를 반환하고, 지정하지 않으면 Query 객체를 반환한다.

예제 10.9. Query 사용

@Test
@Transactional
void queryExample() {
    Query query = em.createQuery("SELECT m.username, m.age FROM Member m");
    List resultList = query.getResultList();

    for (Object o : resultList) {
        Object[] result = (Object[]) o;
        logger.info("m.username = " + result[0]);
        logger.info("m.age = " + result[1]);
    }

    Assertions.assertEquals(3, resultList.size());
}

Query 객체는 SELECT 절의 조회 대상이 하나이면 Object, 둘 이상이면 Object[] 타입의 객체를 반환한다. 타입이 명확하지 않으면 사용자가 직접 변환을 수행해야 하기 때문에 처리가 번거롭다.

결과 조회

다음 메서드는 실제로 쿼리를 실행하여 데이터베이스를 조회한다.

  • query.getResultList()
    • 결과를 List 컬렉션으로 반환한다.
    • 결과가 없으면 빈 컬렉션이 반환된다.
  • query.getSingleResult()
    • 기대되는 결과가 정확히 한 건일 때 사용한다.
    • 결과가 없으면 jakarta.persistence.NoResultException 예외가 발생한다.
    • 결과가 2건 이상이면 jakarta.persistence.NonUniqueResultException 예외가 발생한다.

10.2.2 파라미터 바인딩

JDBC는 위치 기준 파라미터 바인딩만 지원하고, JPQL은 이름 기준 파라미터 바인딩까지 지원한다.

파라미터 바인딩 방식의 특징은 다음과 같다.

  • SQL Injection Attack을 예방할 수 있어 보안상 압도적인 이점이 있다.
  • JPA가 파라미터 값이 달라도 같은 쿼리로 인식해서 JPQL을 SQL로 파싱한 결과를 재사용할 수 있어 재사용성 및 성능상의 이점이 있다.

이름 기준 파라미터

이름 기준 파라미터(Named parameters)는 파라미터를 이름으로 구분하는 방법으로, : 문자를 사용하여 파라미터를 이름으로 지정한다.

예제 10.10. 이름 기준 파라미터 사용

@Test
@Transactional
void namedParameterExample() {
    String usernameParam = "kim";

    List<Member> resultList =
            em.createQuery("SELECT m FROM Member m WHERE m.username = :username", Member.class)
                    .setParameter("username", usernameParam)
                    .getResultList();

    Assertions.assertEquals(2, resultList.size());
}

위와 같이 메서드 체이닝으로 JPQL API를 사용할 수도 있다.

위치 기준 파라미터

위치 기준 파라미터(Positional parameters)? 문자 다음에 위치 값을 주는 방식으로 파라미터를 위치로 지정한다.

예제 10.11. 위치 기준 파라미터 사용

@Test
@Transactional
void positionalParameterExample() {
    String usernameParam = "kim";

    List<Member> members =
            em.createQuery("SELECT m FROM Member m WHERE m.username = ?1", Member.class)
                    .setParameter(1, usernameParam)
                    .getResultList();

    Assertions.assertEquals(2, members.size());
}

10.2.3 프로젝션

SELECT 절에 조회할 대상을 지정하는 것을 프로젝션(projection, π\pi)이라 하고, [SELECT {프로젝션 대상} FROM] 문법으로 대상을 선택한다. 프로젝션의 대상으로는 엔터티, 임베디드 타입, 스칼라 타입(기본 데이터 타입)이 선택될 수 있다.

엔터티 프로젝션

SELECT m FROM Member m
SELECT m.team FROM Member m

엔터티 프로젝션은 원하는 객체를 바로 조회하는 것이므로 조회 대상 컬럼을 나열해야 하는 SQL과는 차이가 있다. 엔터티 프로젝션으로 조회한 엔터티는 영속성 컨텍스트에서 관리된다.

임베디드 타입 프로젝션

JPQL에서 임베디드 타입은 엔터티와 비슷하게 사용되지만 조회의 시작점이 될 수 없다는 제약이 있다.

엔터티를 통한 임베디드 타입 조회

@Test
@Transactional
void selectEmbeddedObject() {
    String query = "SELECT o.address FROM Orders o";
    List<Address> addresses = em.createQuery(query, Address.class).getResultList();

    Assertions.assertEquals(0, addresses.size());
}

하이버네이트가 생성한 쿼리

Hibernate: 
    /* SELECT
        o.address 
    FROM
        Orders o */
        select
            o1_0.city,
            o1_0.street,
            o1_0.zipcode 
        from
            orders o1_0

임베디드 타입은 엔터티 타입이 아닌 값 타입이기 때문에 직접 조회한 임베디드 타입은 영속성 컨텍스트에서 관리되지 않는다.

스칼라 타입 프로젝션

숫자, 문자, 날짜와 같은 기본 데이터 타입들을 스칼라 타입이라고 한다.

여러 값 조회

엔터티에서 꼭 필요한 데이터들만 선택해서 조회해야 할 때도 있다. 프로젝션에 여러 값을 선택할 시 TypedQuery 객체는 사용할 수 없고 Query 객체를 사용해야 한다.

예제 10.12. 여러 프로젝션

@Test
@Transactional
void selectPartialColumns() {
    Query query = em.createQuery("SELECT m.username, m.age FROM Member m");
    List resultList = query.getResultList();

    Iterator iterator = resultList.iterator();
    while (iterator.hasNext()) {
        Object[] row = (Object[]) iterator.next();
        String username = (String) row[0];
        Integer age = (Integer) row[1];
    }
}

제네릭에 Object[] 타입을 명시하면 코드를 조금 더 간결하게 작성할 수 있다.

예제 10.13. 여러 프로젝션 Object[]로 조회

@Test
@Transactional
void selectPartialColumns() {
    List<Object[]> resultList = em.createQuery("SELECT m.username, m.age FROM Member m").getResultList();

    for (Object[] row : resultList) {
        String username = (String) row[0];
        Integer age = (Integer) row[1];
    }
}

엔터티 타입도 여러 값을 함께 조회할 수 있다. 이때 조회된 엔터티들은 영속성 컨텍스트에서 관리된다.

예제 10.14. 여러 프로젝션 엔터티 타입 조회

@Test
@Transactional
void selectSeveralEntities() {
    List<Object[]> resultList = em.createQuery("SELECT o.member, o.product, o.orderAmount FROM Orders o").getResultList();

    for (Object[] row : resultList) {
        Member member = (Member) row[0];
        Product product = (Product) row[1];
        Integer orderAmount = (Integer) row[2];
    }
}

NEW 명령어

실제 애플리케이션 개발 시에는 데이터 전송 객체(DTO, Data Transfer Object)를 사용한다. 그러므로 Query 객체 사용 시 결과를 Object[] 타입의 제네릭으로 처리하는 것이 아니라 즉시 DTO 객체에 대입할 수도 있다. 이때 NEW 명령어가 사용된다.

예제 10.15. NEW 명령어 사용 전

@Test
@Transactional
void unuseNewKeyword() {
    List<Object[]> resultList = em.createQuery("SELECT m.username, m.age FROM Member m").getResultList();

    List<UserDTO> userDTOs = new ArrayList<>();
    for (Object[] row : resultList) {
        UserDTO userDTO = new UserDTO((String) row[0], (Integer) row[1]);
        userDTOs.add(userDTO);
    }

    Assertions.assertEquals(3, userDTOs.size());
}

예제 10.16. UserDTO

public class UserDTO {

    private String username;
    private Integer age;

    public UserDTO(String username, Integer age) {
        this.username = username;
        this.age = age;
    }
    
    // ...
}

예제 10.17. NEW 명령어 사용 후

@Test
@Transactional
void useNewKeyword() {
    TypedQuery<UserDTO> query =
            em.createQuery("SELECT NEW jpabook.entity.dto.UserDTO(m.username, m.age) FROM Member m", UserDTO.class);

    List<UserDTO> resultList = query.getResultList();

    Assertions.assertEquals(3, resultList.size());
}

SELECT 키워드 다음 NEW 키워드를 사용하여 DTO 클래스의 생성자를 지정할 수 있고, 반환된 결과를 생성자 매개변수로 즉시 전달할 수 있다. 또한 이 경우 TypedQuery 객체를 사용할 수 있어 객체 변환 코드가 짧아진다는 장점이 있다.

NEW 명령어 사용 시엔 다음 2가지를 주의해야 한다.

  • 패키지 명을 포함한 전체 클래스 명을 입력해야 한다.
  • 대상 클래스에 순서와 타입이 일치하는 생성자가 정의되어 있어야 한다.

10.2.4 페이징 API

페이징 처리용 SQL을 작성하는 것은 반복적이며 데이터베이스마다 문법이 다르다. JPA는 페이징을 다음 2개의 API로 추상화했다.

  • setFirstResult(int startPosition): 조회 시작 위치(0부터 시작)
  • setMaxResults(int maxResult): 조회할 데이터 수

예제 10.18. 페이징 사용

@Test
@Transactional
void pagingAPI() {
    TypedQuery<Member> query =
            em.createQuery("SELECT m FROM Member m ORDER BY m.username DESC", Member.class);

    query.setFirstResult(10);
    query.setMaxResults(20);

    List<Member> members = query.getResultList();
    Assertions.assertEquals(0, members.size());
}

위 쿼리는 데이터베이스 방언(Dialect)마다 상이한 SQL을 생성한다. 이를 더 최적화하고 싶다면 JPA가 제공하는 페이징 API가 아닌 네이티브 SQL을 직접 사용해야 한다.

10.2.5 집합과 정렬

집합 함수

함수설명
COUNT결과 수를 구한다.
반환 타입: Long
MAX, MIN최대, 최소 값을 구한다. 문자, 숫자, 날짜 등에 사용한다.
AVG평균 값을 구한다. 숫자 타입만 사용할 수 있다.
반환 타입: Double
SUM합을 구한다. 숫자 타입만 사용할 수 있다.
반환 타입 - 정수합: Long, 소수합: Double, BigInteger합: BigInteger, BigDecimal합: BigDecimal

집합 함수 사용 시 참고사항

  • NULL 값은 무시된다.
  • 인정되는 값이 한 건도 없는데 집합 함수 사용 시 NULL 값이 반환된다. COUNT 함수는 예외적으로 0을 반환한다.
  • DISTINCT 키워드를 집합 함수 안에 사용할 수 있다.
  • DISTINCT 키워드를 COUNT 함수 안에 사용 시 임베디드 타입은 지원하지 않는다.

GROUP BY, HAVING

groupby_clause ::= GROUP BY {단일_값_경로 | 별칭}+
having_clause ::= HAVING {조건_식}

GROUP BY, HAVING 절도 사용 가능하지만 실시간 환경에서는 통계 쿼리가 효율적으로 부담되기 때문에 보통 통계 결과 테이블을 별도로 유지하는 방법을 많이 사용한다.

정렬(ORDER BY)

orderby_clause ::= ORDER BY {상태_필드_경로 | 결과_변수 [ASC | DESC]}+

10.2.6 JPQL 조인

내부 조인

(INNER) JOIN 키워드로 내부 조인을 수행할 수 있다.

예제 10.24. 내부 조인 사용 예

@Test
@Transactional
void innerJoin() {
    String teamName = "Team 1";
    String query = "SELECT m FROM Member m INNER JOIN m.team t WHERE t.name = :teamName";

    List<Member> members = em.createQuery(query, Member.class)
            .setParameter("teamName", teamName)
            .getResultList();

    Assertions.assertEquals(3, members.size());
}

하이버네이트가 생성한 쿼리

Hibernate: 
    /* SELECT
        m 
    FROM
        Member m 
    INNER JOIN
        m.team t 
    WHERE
        t.name = :teamName */
        select
            m1_0.id,
            m1_0.age,
            m1_0.team_id,
            m1_0.username 
        from
            member m1_0 
        join
            team t1_0 
                on t1_0.id=m1_0.team_id 
        where
            t1_0.name=?

JPQL 조인은 연관 필드(m.team)를 사용한다는 점이 가장 큰 특징이다.

외부 조인

LEFT OUTER JOIN 키워드로 외부 조인을 수행할 수 있다. OUTER 키워드를 생략하여 LEFT JOIN만 사용할 수도 있다.

예제 10.25. 외부 조인 JPQL

SELECT m
FROM Member m LEFT (OUTER) JOIN m.team t
WHERE t.name = :teamName

하이버네이트가 생성한 쿼리

Hibernate: 
    /* SELECT
        m 
    FROM
        Member m 
    LEFT JOIN
        m.team t 
    WHERE
        t.name = :teamName */
        select
            m1_0.id,
            m1_0.age,
            m1_0.team_id,
            m1_0.username 
        from
            member m1_0 
        left join
            team t1_0 
                on t1_0.id=m1_0.team_id 
        where
            t1_0.name=?

컬렉션 조인

일대다 관계나 다대다 관계처럼 조인하는 연관 필드가 컬렉션 타입인 것을 컬렉션 조인이라고 한다.

  • 회원에서 팀 방향으로의 조인은 다대일 조인이면서 단일 값 연관 필드(m.team)를 사용한다.
  • 팀에서 회원 방향으로의 조인은 일대다 조인이면서 컬렉션 값 연관 필드(m.members)를 사용한다.

컬렉션 조인 사용 예

@Test
@Transactional
void collectionJoin() {
    List<Object[]> resultList =
            em.createQuery("SELECT t, m FROM Team t LEFT JOIN t.members m")
            .getResultList();

    for (Object[] row : resultList) {
        Team team = (Team) row[0];
        Member member = (Member) row[1];
        Assertions.assertTrue(team.getMembers().contains(member));
    }
}

하이버네이트가 생성한 쿼리

Hibernate: 
    /* SELECT
        t,
        m 
    FROM
        Team t 
    LEFT JOIN
        t.members m */
        select
            t1_0.id,
            t1_0.name,
            m1_1.id,
            m1_1.age,
            m1_1.team_id,
            m1_1.username 
        from
            team t1_0 
        left join
            team_members m1_0 
                on t1_0.id=m1_0.team_id 
        left join
            member m1_1 
                on m1_1.id=m1_0.members_id

세타 조인

WHERE 절을 사용해 세타 조인이 가능하다. 세타 조인은 내부 조인만 지원한다. 전혀 관계 없는 엔터티 필드 간에도 조인이 가능하다.

예제 10.26. 회원 이름이 팀 이름과 똑같은 사람 수를 구하는 예

// JPQL
SELECT COUNT(m) FROM Member m, Team t
WHERE m.username = t.name

// SQL
SELECT COUNT(M.ID)
FROM
	MEMBER M CROSS JOIN TEAM T
WHERE
	M.USERNAME=T.NAME

JOIN ON 절(JPA 2.1)

JPA 2.1부터는 조인 시 ON 절을 지원한다. 내부 조인의 ON 절은 WHERE 절을 사용할 때와 결과가 같으므로 ON 절은 보통 외부 조인에서 사용한다.

예제 10.27. JOIN ON 사용 예

// JPQL
SELECT m, t FROM Member m
LEFT JOIN m.team t ON t.name = 'A'

// SQL
SELECT m.*, t.* FROM Member m
LEFT JOIN Team t ON m.TEAM_ID=t.id AND t.name='A'

10.2.7 페치 조인

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

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

엔터티 페치 조인

다음 JPQL은 회원 엔터티 조회 시 연관된 팀 엔터티도 함께 조회한다.

예제 10.28. 페치 조인 사용

@Test
@Transactional
void fetchJoin() {
    String jpql = "SELECT m FROM Member m JOIN FETCH m.team";
    
    List<Member> members =
            em.createQuery(jpql, Member.class)
            .getResultList();

    Assertions.assertEquals(3, members.size());
    for (Member member : members) {
        Assertions.assertNotNull(member.getTeam());
    }
}

하이버네이트가 생성한 쿼리

Hibernate: 
    /* SELECT
        m 
    FROM
        Member m 
    JOIN
        
    FETCH
        m.team */
        select
            m1_0.id,
            m1_0.age,
            t1_0.id,
            t1_0.name,
            m1_0.username 
        from
            member m1_0 
        join
            team t1_0 
                on t1_0.id=m1_0.team_id

페치 조인을 사용하면 다음과 같이 SQL 조인을 시도한다(데이터는 코드와 약간 다르다).

SQL 조인의 결과는 다음과 같을 것이다.

애플리케이션 레벨에서는 페치 조인 결과를 다음과 같이 이해할 수 있다.

페치 조인 시 조회되는 연관 엔터티는 실제 엔터티 객체이므로 회원과 팀 간 페치 전략이 지연 로딩으로 설정되었더라도 나중에 객체 그래프 탐색 시 추가적인 데이터베이스 호출이 발생하지 않는다. 그러므로 페치 조인은 선택적으로 즉시 로딩이 필요할 때 사용할 수 있다.

컬렉션 페치 조인

예제 10.29. 컬렉션 페치 조인 JPQL

SELECT t FROM Team t JOIN FETCH t.members WHERE t.name = 'Team 1'

예제 10.30. 실행된 SQL

Hibernate: 
    /* SELECT
        t 
    FROM
        Team t 
    JOIN
        
    FETCH
        t.members 
    WHERE
        t.name = :teamName */
        select
            t1_0.id,
            m1_0.team_id,
            m1_1.id,
            m1_1.age,
            m1_1.team_id,
            m1_1.username,
            t1_0.name 
        from
            team t1_0 
        join
            team_members m1_0 
                on t1_0.id=m1_0.team_id 
        join
            member m1_1 
                on m1_1.id=m1_0.members_id 
        where
            t1_0.name=?

컬렉션 페치 조인 역시 다음과 같은 SQL 조인이 시도된다(데이터는 코드와 약간 다르다).

SQL 조인 결과는 다음과 같을 것이다.

애플리케이션 레벨에서는 컬렉션 페치 조인의 결과를 다음과 같이 이해할 수 있다.

위 그림은 레거시한 하이버네이트의 동작으로, 하이버네이트 6 버전부터는 다음과 같이 중복 제거가 자동적으로 발생한다.

예제 10.31. 컬렉션 페치 조인 사용

@Test
@Transactional
void collectionFetchJoin() {
    String jpql = "SELECT t FROM Team t JOIN FETCH t.members WHERE t.name = :teamName";

    Team team = em.createQuery(jpql, Team.class)
            .setParameter("teamName", "Team 1")
            .getSingleResult();

    Assertions.assertNotNull(team);
    Assertions.assertEquals(3, team.getMembers().size());
}

페치 조인과 DISTINCT

SQL의 DISTINCT 명령어는 중복된 결과를 제거한다. JPQL의 DISTINCT 명령어는 이와 더불어 애플리케이션에서 한 번 더 중복을 제거한다. 직전의 컬렉션 페치 조인 예제에서는 레거시한 하이버네이트 버전을 기준으로 팀 엔터티 객체가 중복으로 조회되지만 DISTINCT 명령어를 추가하여 이를 방지할 수 있다.

DISTINCT 페치 조인 JPQL

SELECT DISTINCT t FROM Team t JOIN FETCH t.members WHERE t.name = 'Team 1'

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

예제 10.32. 내부 조인 JPQL

@Test
@Transactional
void nonFetchJoin() {
    String jpql = "SELECT t FROM Team t JOIN t.members m WHERE t.name = :teamName";

    Team team = em.createQuery(jpql, Team.class)
            .setParameter("teamName", "Team 1")
            .getSingleResult();

    Assertions.assertNotNull(team);
    Assertions.assertEquals(3, team.getMembers().size());
}

예제 10.33. 실행된 SQL

Hibernate: 
    /* SELECT
        t 
    FROM
        Team t 
    JOIN
        t.members m 
    WHERE
        t.name = :teamName */
        select
            t1_0.id,
            t1_0.name 
        from
            team t1_0 
        join
            team_members m1_0 
                on t1_0.id=m1_0.team_id 
        where
            t1_0.name=?
Hibernate: 
    select
        m1_0.team_id,
        m1_1.id,
        m1_1.age,
        t1_0.id,
        t1_0.name,
        m1_1.username 
    from
        team_members m1_0 
    join
        member m1_1 
            on m1_1.id=m1_0.members_id 
    left join
        team t1_0 
            on t1_0.id=m1_1.team_id 
    where
        m1_0.team_id=?

JPQL은 결과 반환 시 연관 관계까지 고려하지 않는다. 단지 SELECT 절에 지정한 엔터티만 조회할 뿐이다. 그러므로 페치 조인을 사용하지 않을 경우 연관 관계 엔터티와 조인한다고 해서 조회된 연관 엔터티들이 영속성 컨텍스트에 영속화되지는 않고 조인 쿼리가 두 번 호출된다.

예제 10.34. 컬렉션 페치 조인 JPQL

@Test
@Transactional
void collectionFetchJoin() {
    String jpql = "SELECT t FROM Team t JOIN FETCH t.members WHERE t.name = :teamName";

    Team team = em.createQuery(jpql, Team.class)
            .setParameter("teamName", "Team 1")
            .getSingleResult();

    Assertions.assertNotNull(team);
    Assertions.assertEquals(3, team.getMembers().size());
}

예제 10.35. 실행된 SQL

Hibernate: 
    /* SELECT
        t 
    FROM
        Team t 
    JOIN
        
    FETCH
        t.members 
    WHERE
        t.name = :teamName */
        select
            t1_0.id,
            m1_0.team_id,
            m1_1.id,
            m1_1.age,
            m1_1.team_id,
            m1_1.username,
            t1_0.name 
        from
            team t1_0 
        join
            team_members m1_0 
                on t1_0.id=m1_0.team_id 
        join
            member m1_1 
                on m1_1.id=m1_0.members_id 
        where
            t1_0.name=?

반면 페치 조인 시 연관된 엔터티도 함께 영속성 컨텍스트에 영속화된다. 그러므로 페치 조인은 특정 상황에서 페치 전략이 지연 로딩으로 설정된 연관 관계의 N+1 문제를 예방하기 위해 사용될 수 있다.

페치 조인의 특징과 한계

@OneToMany(fetch = FetchType.LAZY)와 같이 엔터티에 직접 적용하는 로딩 전략은 애플리케이션 전체에 영향을 미치므로 글로벌 로딩 전략이라 부른다. 페치 조인은 글로벌 로딩 전략보다 우선한다. 그러므로 글로벌 로딩 전략이 지연 로딩이더라도 페치 조인을 통해 즉시 로딩처럼 동작하게 할 수 있다.

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

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

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

  • 페치 조인 대상에는 별칭을 부여할 수 없다(JPA 표준).
    • SELECT 절, WHERE 절, 서브쿼리에 페치 조인 대상을 사용할 수 없다.
    • 하이버네이트를 포함한 몇몇 구현체들은 페치 조인에 별칭을 지원한다. 하지만 별칭을 잘못 사용하면 연관된 데이터 건수가 달라져 데이터 무결성이 파괴될 수 있어 주의해야 한다. 특히 2차 캐시와 함께 사용할 때 연관된 데이터 건수가 달라진 상태에서 캐싱되는 상황에 주의해야 한다.
  • 둘 이상의 컬렉션을 페치할 수 없다.
    • 하이버네이트에서는 예외가 발생한다.
    • 둘 이상의 컬렉션을 페치할 수 있는 구현체에서도 카티션 곱이 발생하므로 주의해야 한다.
  • 컬렉션을 페치 조인하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다.
    • 컬렉션(일대다)이 아닌 단일 값 연관 필드(일대일, 다대일)들은 페치 조인을 사용해도 페이징 API를 사용할 수 있다.
    • 하이버네이트에서 컬렉션 페치 조인 후 페이징 API를 사용하면 경고 로그가 발생하며 메모리상에서 페이징 처리를 수행한다. 데이터의 용량이 크면 애플리케이션 레벨에서 성능 이슈와 메모리 문제가 발생할 수 있어 위험하다.

페치 조인은 주로 객체 그래프를 유지할 때 사용하면 효과적이고, 여러 테이블의 조인 결과를 활용해야 한다면 페치 조인보다는 요구사항에 맞는 SQL의 호출 결과를 DTO로 변환하는 것이 더 효과적일 수 있다.

10.2.8 경로 표현식

JPQL의 경로 표현식(Path Expression).을 통해 객체 그래프를 탐색하는 것이다.

경로 표현식을 사용한 JPQL

SELECT m.username
FROM Member m
	JOIN m.team t
    JOIN m.orders o
WHERE t.name = 'Team 1'

위 JPQL에서 m.username, m.team, m.orders, t.name이 모두 경로 표현식을 사용한 예이다.

경로 표현식의 용어 정리

  • 상태 필드(state field): 단순히 값을 저장하기 위한 필드(필드 or 프로퍼티)
  • 연관 필드(association field): 연관 관계를 위한 필드, 임베디드 타입 포함(필드 or 프로퍼티)
    • 단일 값 연관 필드: @ManyToOne, @OneToOne, 대상이 엔터티
    • 컬렉션 값 연관 필드: @OneToMany, @ManyToMany, 대상이 컬렉션

상태 필드는 단순히 값을 저장하는 필드, 연관 필드는 객체 간 연관 관계를 맺기 위해 사용하는 필드이다.

예제 10.36. 상태 필드, 연관 필드 설명 예제 코드

@Entity
public class Member {

	@Id
    @GeneratedValue
    private Long id;

    @Column(name = "name")
    private String username;	// 상태 필드
    private Integer age;		// 상태 필드

    @ManyToOne(...)
    private Team team;			// 연관 필드(단일 값 연관 필드)

    @OneToMany(...)
    private List<Order> orders;	// 연관 필드(컬렉션 값 연관 필드)
  • 상태 필드: m.username, m.age
  • 단일 값 연관 필드: m.team
  • 컬렉션 값 연관 필드: m.orders

경로 표현식과 특징

JPQL의 경로 표현식은 종류마다 다음과 같은 특징이 있다.

  • 상태 필드 경로: 경로 탐색의 끝이다. 더는 탐색할 수 없다.
  • 단일 값 연관 경로: 묵시적으로 내부 조인이 일어난다. 단일 값 연관 경로는 계속 탐색할 수 있다.
  • 컬렉션 값 연관 경로: 묵시적으로 내부 조인이 일어난다. 더는 탐색할 수 없다. 단, FROM 절에서 조인을 통해 별칭을 부여하면 별칭을 통해 탐색할 수 있다.
상태 필드 경로 탐색

다음 JPQL은 상태 필드 경로 탐색이다.

SELECT m.username, m.age FROM Member m

JPQL의 실행 결과 생성되는 SQL은 다음과 같다.

SELECT m.name, m.age
FROM Member m
단일 값 연관 경로 탐색

다음 JPQL은 단일 값 연관 경로 탐색이다.

SELECT o.member FROM Orders o

JPQL의 실행 결과 생성되는 SQL은 다음과 같다.

SELECT m.*
FROM Orders o
	INNER JOIN Member m ON o.member_id=m.id

단일 값 연관 필드로 경로 탐색을 하면 SQL에서 내부 조인이 일어나는데 이것을 묵시적 조인이라 한다. 묵시적 조인은 모두 내부 조인이다. 외부 조인 사용 시엔 명시적으로 JOIN 키워드를 사용해야 한다.

  • 명시적 조인: JOIN 절을 직접 명시하는 것, SELECT m FROM Member m JOIN m.team t
  • 묵시적 조인: 경로 표현식에 의해 묵시적으로 조인이 발생하는 것, 내부 조인(INNER JOIN)만 가능, SELECT m.team FROM Member m

예제 10.37. 복잡한 JPQL

SELECT o.member.team
FROM Orders o
WHERE o.product.name = 'productA' AND o.address.city = 'JINJU'

예제 10.38. 실행된 SQL

SELECT t.*
FROM Orders o
INNER JOIN Member m ON o.member_id=m.id
INNER JOIN Team t ON m.team_id=t.id
INNER JOIN Product p ON o.product_id=p.id
WHERE p.name='productA' AND o.city='JINJU'

하나의 JPQL 문장에서 여러 개의 단일 값 연관 필드에 대한 경로 탐색을 수행하면 그만큼 많은 묵시적 조인이 발생한다. o.address와 같이 임베디드 타입에 접근하는 것도 단일 값 연관 경로 탐색이지만 이 경우에는 테이블에 이미 포함되어 있는 컬럼이므로 조인은 발생하지 않는다.

컬렉션 값 연관 경로 탐색

t.members.username처럼 컬렉션에서 경로 탐색을 시작하는 것은 허용되지 않는다. 만약 이러한 동작이 필요하다면 SELECT m.username FROM Team t JOIN t.members m과 같이 별칭을 사용한 조인이 필요하다. 단, size라는 특별한 기능을 사용하여 SELECT t.members.size FROM Team t와 같은 JPQL을 작성하면 COUNT 집합 함수를 사용한 쿼리로 변환해 준다.

경로 탐색을 사용한 묵시적 조인 시 주의사항

  • 항상 내부 조인이다.
  • 컬렉션은 경로 탐색의 끝이다. 컬렉션에서 경로 탐색을 하려면 명시적 조인 후 별칭을 부여해야 한다.
  • 경로 탐색은 주로 SELECT, WHERE 절에서 사용하지만 묵시적 조인으로 인해 SQL의 FROM 절에 영향을 준다.
  • 성능이 중요한 상황에서는 묵시적 조인보다는 명시적 조인을 사용하는 것이 좋다

10.2.9 서브쿼리

JPA 표준에 따르면 JPQL의 서브쿼리는 WHERE, HAVING 절에만 사용할 수 있고 SELECT, FROM 절에는 사용할 수 없다. 하지만 구현체에 따라서는 SELECT, FROM 절에도 사용할 수 있고 하이버네이트는 SELECT 절의 서브쿼리를 허용한다.

서브쿼리 함수

  • [NOT] EXISTS (subquery)
  • {ALL | ANY | SOME} (subquery)
  • [NOT] IN (subquery)

10.2.10 조건식

타입 표현

JPQL에서 사용하는 타입은 다음 표와 같이 표시한다. 대소문자는 구분하지 않는다.

종류설명예제
문자작은 따옴표 사이에 표현
작은 따옴표를 표현하고 싶으면 '' 사용
'HELLO'
'She''s'
숫자L(Long 타입 지정)
D(Double 타입 지정)
F(Float 타입 지정)
10L
10D
10F
날짜DATE {d 'yyyy-mm-dd'}
TIME {t 'hh-mm-ss'}
DATETIME {ts 'yyyy-mm-dd hh:mm:ss.f'}
{d '2012-03-24'}
{t '10-11-11'}
{ts '2012-03-24 10-11-11.123'}
m.createDate = {d '2012-03-24'}
BooleanTRUE, FALSE
Enum패키지 이름을 포함한 전체 이름을 사용해야 한다.jpabook.MemberType.Admin
엔터티 타입엔터티의 타입을 표현한다. 주로 상속과 관련해 사용한다.TYPE(m) = Member

연산자 우선순위

  1. 경로 탐색 연산: .
  2. 수학 연산: +, -, *, /
  3. 비교 연산: =, >, >=, <, <=, <>, [NOT] BETWEEN, [NOT] LIKE, [NOT] IN, IS [NOT] NULL, IS [NOT] EMPTY, [NOT] MEMBER [OF], [NOT] EXISTS
  4. 논리 연산: NOT, AND, OR

논리 연산과 비교식

논리 연산
AND, OR, NOT
비교식
=, >, >=, <, <=, <>

BETWEEN, IN, LIKE, NULL 비교

  • X [NOT] BETWEEN A AND B
  • X [NOT] IN (...)
  • 문자_표현식 [NOT] LIKE 패턴_값 [ESCAPE 이스케이프_문자]
  • {단일_값_경로 | 입력_파라미터} IS [NOT] NULL

예제 10.39. LIKE 식 예제

SELECT m FROM Member m

1. WHERE m.username LIKE '%원%'
2. WHERE m.username LIKE '회원%'
3. WHERE m.username LIKE '%회원'
4. WHERE m.username LIKE '회원_'
5. WHERE m.username LIKE '__3'
6. WHERE m.username LIKE '회원\%' ESCAPE '\'

컬렉션 식

{컬렉션_값_연관_경로} IS [NOT] EMPTY

예제 10.40. 빈 컬렉션 비교 예제

// JPQL: 주문이 하나라도 있는 회원 조회
SELECT m FROM Member m
WHERE m.orders IS NOT EMPTY

// 실행된 SQL
SELECT m.* FROM Member m
WHERE
	EXISTS (
    	SELECT o.id
        FROM Orders o
        WHERE m.id=o.member_id
    )

컬렉션을 대상으로는 컬렉션 식만 사용 가능하다. 위 예제에서 IS NOT EMPTYIS NOT NULL로 변경하면 오류가 발생한다.

컬렉션의 멤버 식
{엔터티|값} [NOT] MEMBER [OF] {컬렉션_값_연관_경로}

SELECT t FROM Team t WHERE :memberParam MEMBER OF t.members와 같이 사용한다.

스칼라 식

스칼라는 숫자, 문짜, 날짜, CASE, 엔터티 타입과 같은 가장 기본적인 타입들을 말한다. 스칼라 식은 스칼라 타입에 사용하는 식이다.

수학 식
+, -, *, /
문자 함수
CONCAT, SUBSTRING, TRIM, LOWER, UPPER, LENGTH, LOCATE
수학 함수
ABS, SQRT, MOD, SIZE, INDEX
날짜 함수
CURRENT_DATE, CURRENT_TIME, CURRENT_TIMESTAMP, YEAR, MONTH, DAY, HOUR, MINUTE, SECOND

CASE 식

CASE 식은 4가지 종류가 있다.

  • 기본 CASE
  • 심플 CASE
  • COALESCE
  • NULLIF
기본 CASE
CASE
    {WHEN <조건_식> THEN <스칼라_식>}+
    ELSE <스칼라_식>
END
심플 케이스

조건 식을 사용할 수 없지만 문법이 단순하다. 자바의 switch case 문과 유사하다.

CASE <조건_대상>
	{WHEN <스칼라_식_1> THEN <스칼라_식_2>}+
    ELSE <스칼라_식>
END
COALESCE

NULL이 아닌 첫 번째 값을 반환한다.

COALESCE(<스칼라_식> {,<스칼라_식>}+)
NULLIF

두 값이 같으면 NULL, 다르면 첫 번째 값을 반환한다.

NULLIF(<스칼라_식>, <스칼라_식>)

10.2.11 다형성 쿼리

JPQL로 부모 엔터티를 조회하면 자식 엔터티도 함께 조회한다.

예제 10.41. 다형성 쿼리 엔터티

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {

    @Id
    @GeneratedValue
    private Long id;

    private String name;
    private Integer price;
    private Integer stockQuantity;
    ...
}

@Entity
@DiscriminatorValue("B")
public class Book extends Item {

    private String author;
    private String isbn;
    ...
}

@Entity
@DiscriminatorValue("A")
public class Album extends Item {

    private String artist;
    private String etc;
    ...
}

@Entity
@DiscriminatorValue("M")
public class Movie extends Item {

    private String actor;
    private String director;
    ...
}

위와 같은 클래스가 정의되어 있을 때 List resultList = em.createQuery("SELECT i FROM Item i").getResultList(); 코드를 실행시키면 테이블 생성 전략별로 다음과 같은 쿼리가 생성된다.

단일 테이블 전략 사용 시 생성되는 쿼리

Hibernate: 
    /* SELECT
        i 
    FROM
        Item i */
        select
            i1_0.id,
            i1_0.dtype,
            i1_0.name,
            i1_0.price,
            i1_0.stock_quantity,
            i1_0.artist,
            i1_0.etc,
            i1_0.author,
            i1_0.isbn,
            i1_0.actor,
            i1_0.director 
        from
            item i1_0

조인 전략 사용 시 생성되는 쿼리

Hibernate: 
    /* SELECT
        i 
    FROM
        Item i */
        select
            i1_0.id,
            i1_0.dtype,
            i1_0.name,
            i1_0.price,
            i1_0.stock_quantity,
            i1_1.artist,
            i1_1.etc,
            i1_2.author,
            i1_2.isbn,
            i1_3.actor,
            i1_3.director 
        from
            item i1_0 
        left join
            album i1_1 
                on i1_0.id=i1_1.id 
        left join
            book i1_2 
                on i1_0.id=i1_2.id 
        left join
            movie i1_3 
                on i1_0.id=i1_3.id

TYPE

TYPE은 엔터티 상속 구조에서 조회 대상을 특정 자식 타입으로 한정할 때 주로 사용한다.

Item 중 Book, Movie만 조회하는 쿼리

// JPQL
SELECT i FROM Item i
WHERE TYPE(i) IN (Book, Movie)

// SQL
SELECT i FROM Item i
WHERE i.DTYPE IN ('B', 'M')

TREAT(JPA 2.1)

TREAT는 상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용한다. JPA 표준은 FROM, WHERE 절에서만 사용할 수 있지만 하이버네이트는 SELECT 절에서도 사용할 수 있다.

Item 타입을 Book 타입처럼 다루는 쿼리

// JPQL
SELECT i FROM Item i WHERE TREAT(i AS Book).author = 'kim'

// SQL
SELECT i.* FROM Item i
WHERE
	i.DTYPE='B'
    AND i.author='kim'

10.2.12 사용자 정의 함수 호출(JPA 2.1)

10.2.13 기타 정리

  • enum= 비교 연산만 지원한다.
  • 임베디드 타입은 비교를 지원하지 않는다.

EMPTY STRING

JPA 표준은 ''을 길이 0인 Empty String으로 지정했지만 데이터베이스에 따라 이를 NULL로 취급하는 경우도 있으므로 확인해야 한다.

NULL 정의

  • 조건을 만족하는 데이터가 하나도 없으면 NULL이다.
  • NULL은 알 수 없는 값(unknown value)이다. NULL과의 모든 수학적 연산 결과는 NULL이 된다.
  • NULL == NULL은 알 수 없는 값이다.
  • NULL IS NULL은 참이다.

ANDTFNULL
TTFNULL
FFFF
NULLNULLFNULL

ORTFNULL
TTTT
FTFNULL
NULLTNULLNULL

NOT
TF
FT
NULLNULL

10.2.14 엔터티 직접 사용

기본 키 값

객체 인스턴스는 참조 값으로 식별하고 테이블의 행은 기본 키 값으로 식별한다. 그러므로 JPQL에서 엔터티 객체를 직접 사용하면 SQL에서는 해당 엔터티의 기본 키 값을 사용한다.

엔터티를 직접 사용하는 JPQL

-- 엔터티의 기본 키를 사용
SELECT COUNT(m.id) FROM Member m
-- 엔터티를 직접 사용
SELECT COUNT(m) FROM Member m

JPQL이 SQL로 변환될 때 엔터티를 직접 사용하면 해당 엔터티의 기본 키를 사용하기 때문에 위 두 JPQL은 다음과 같이 똑같은 SQL로 변환된다.

변환된 SQL

SELECT COUNT(m.id) AS cnt
FROM Member m

예제 10.44. 엔터티를 파라미터로 직접 받는 코드

@Test
@Transactional
void entityParameter() {
    Member member = em.find(Member.class, 1L);
    String qlString = "SELECT m FROM Member m WHERE m = :member";
    List resultList = em.createQuery(qlString)
            .setParameter("member", member)
            .getResultList();

    Assertions.assertEquals(1, resultList.size());
    Assertions.assertEquals(member, resultList.get(0));
}

예제 10.45. 식별자 값을 직접 사용하는 코드

@Test
@Transactional
void valueParameter() {
    Member member = em.find(Member.class, 1L);
    String qlString = "SELECT m FROM Member m WHERE m.id = :memberId";
    List resultList = em.createQuery(qlString)
            .setParameter("memberId", member.getId())
            .getResultList();

    Assertions.assertEquals(1, resultList.size());
    Assertions.assertEquals(member, resultList.get(0));
}

하이버네이트가 생성한 쿼리(공통)

Hibernate: 
    /* SELECT
        m 
    FROM
        Member m 
    WHERE
        m = :member */
        select
            m1_0.id,
            m1_0.age,
            m1_0.team_id,
            m1_0.username 
        from
            member m1_0 
        where
            m1_0.id=?

JPQL에 엔터티를 직접 사용하더라도 m1_0.id=?와 같이 식별자 값을 비교하도록 SQL로 변환된다.

외래 키 값

예제 10.46. 외래 키 대신에 엔터티를 직접 사용하는 코드

@Test
@Transactional
void foreignKeyEntityParameter() {
    Team team = em.find(Team.class, 1L);
    String qlString = "SELECT m FROM Member m WHERE m.team = :team";
    List resultList = em.createQuery(qlString)
            .setParameter("team", team)
            .getResultList();

    Assertions.assertEquals(3, resultList.size());
}

예제 10.47. 외래 키에 식별자를 직접 사용하는 코드

@Test
@Transactional
void foreignKeyValueParameter() {
    Team team = em.find(Team.class, 1L);
    String qlString = "SELECT m FROM Member m WHERE m.team.id = :teamId";
    List resultList = em.createQuery(qlString)
            .setParameter("teamId", team.getId())
            .getResultList();

    Assertions.assertEquals(3, resultList.size());
}

하이버네이트가 생성한 쿼리

Hibernate: 
    /* SELECT
        m 
    FROM
        Member m 
    WHERE
        m.team.id = :teamId */
        select
            m1_0.id,
            m1_0.age,
            m1_0.team_id,
            m1_0.username 
        from
            member m1_0 
        where
            m1_0.team_id=?

m.team.id와 같은 경로 표현식을 사용해도 Member, Team 간 묵시적 조인이 발생하지는 않는다. 애초에 회원 엔터티가 데이터베이스의 테이블로 저장될 때 컬럼 중 team_id가 있기 때문이다. 하지만 m.team.name과 같은 경로 표현식을 사용하면 묵시적 조인이 발생한다.

10.2.15 Named 쿼리: 정적 쿼리

JPQL 쿼리는 크게 동적 쿼리, 정적 쿼리로 나눌 수 있다.

  • 동적 쿼리: em.createQuery("SELECT ...")처럼 JPQL을 문자열로 완성하여 직접 전달하는 것이다. 런타임에 특정 조건에 따라 JPQL을 동적으로 구성할 수 있다.
  • 정적 쿼리: 미리 정의한 쿼리에 이름을 부여해서 필요할 때 사용할 수 있도록 한 것을 Named 쿼리라고 한다. Named 쿼리는 한 번 정의하면 변경할 수 없는 정적인 쿼리이다.

Named 쿼리는 애플리케이션 로딩 시점에 JPQL 문법을 검사하고 미리 파싱해 둔다. 그러므로 컴파일 시점에 오류를 발견할 수 있고 런타임에는 파싱된 결과를 재사용하므로 성능상의 이점도 있다. 그리고 정적 SQL 자체가 데이터베이스 레벨에서의 조회 성능 최적화에도 도움이 된다.

Named 쿼리는 @NamedQuery 애너테이션을 사용해 프로그래밍 방식으로 작성하거나 XML 문서에 작성할 수 있다.

Named 쿼리를 애너테이션에 정의

예제 10.48. @NamedQuery 애너테이션으로 Named 쿼리 정의

@Entity
@NamedQuery(
        name = "Member.findByUsername",
        query = "SELECT m FROM Member m WHERE m.username = :username"
)
public class Member {
	...

@NamedQuery 애너테이션의 name 속성에는 쿼리의 이름을, query 속성에는 문장을 전달한다.

예제 10.49. @NamedQuery 사용

@Test
@Transactional
void namedQueryExample() {
    List<Member> resultList = em.createNamedQuery("Member.findByUsername", Member.class)
            .setParameter("username", "kim")
            .getResultList();

    Assertions.assertEquals(2, resultList.size());
}

EntityManager.createNamedQuery() 메서드에 Named 쿼리 정보를 전달한다.

Named 쿼리는 영속성 유닛 단위로 관리된다. 그러므로 Member.findByUsername과 같이 충돌이 발생하지 않도록 명명 규칙을 정하면 관리가 용이해진다.

하나의 엔터티에 여러 Named 쿼리를 정의하려면 @NamedQueries 애너테이션을 사용한다.

예제 10.50. @NamedQueries 사용

@Entity
@NamedQueries({
        @NamedQuery(
                name = "Member.findByUsername",
                query = "SELECT m FROM Member m WHERE m.username = :username"
        ),
        @NamedQuery(
                name = "Member.count",
                query = "SELECT COUNT(m) FROM Member m"
        )
})
public class Member {
	...

@NamedQuery 애너테이션은 다음과 같이 정의되어 있다.

예제 10.51. @NamedQuery 애너테이션

@Repeatable(NamedQueries.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface NamedQuery {
    String name();	// Named 쿼리 이름 (필수)

    String query();	// Named 쿼리 정의 (필수)

	// 쿼리 실행 시 잠금 모드를 설정할 수 있다.
    LockModeType lockMode() default LockModeType.NONE;

	// JPA 구현체에 쿼리 힌트를 제공할 수 있다.
    QueryHint[] hints() default {};
}
  • lockMode: 쿼리 실행 시 잠금을 획득한다.
  • hints: SQL 힌트가 아니라 JPA 구현체에 제공하는 힌트이다. 2차 캐시 활용 등에 사용한다.

Named 쿼리를 XML에 정의

JPA에서 애너테이션으로 작성할 수 있는 것들은 XML로도 작성할 수 있다. 보통 애너테이션을 활용한 프로그래밍 방식이 직관적이고 편리하지만, Named 쿼리에 한해서는 XML을 사용하는 것이 더 편리하다.

자바에서 멀티 라인 문자를 다루는 것은 상당히 번거롭기 때문에 다음과 같이 XML을 사용하는 것이 그나마 현실적인 대안이 될 수 있다.

예제 10.52. META-INF/ormMember.xml, XML에 정의한 Named 쿼리

<?xml version="1.0" encoding="UTF-8" ?>
<entity-mappings xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm" version="2.1">
    <named-query name="Member.findByUsername">
        <query><![CDATA[
                SELECT m
                FROM Member m
                WHERE m.username = :username
        ]]></query>
    </named-query>
    
    <named-query name="Member.count">
        <query>SELECT COUNT(m) FROM Member m</query>
    </named-query>

</entity-mappings>

스프링 부트에서는 위와 같은 XML 파일을 애플리케이션 프로퍼티 파일에서 지정할 수 있다. 만약 파일명이 META-INF/orm.xml이라면 JPA가 기본 매핑 파일로 인식하기 때문에 다음과 같은 설정이 필요하지는 않다.

application.properties

# 여러 파일을 지정할 경우 쉼표(,)로 구분한다.
spring.jpa.properties.hibernate.jpa.mapping-file=META-INF/ormMember.xml

환경에 따른 설정

XML과 애너테이션 간 설정이 충돌하면 XML이 우선권을 가진다. 따라서 애플리케이션이 운영 환경에 따라 다른 쿼리를 실행해야 한다면 각 환경에 맞춘 XML을 상황에 맞추어 변경하여 배포하면 된다.

10.3 Criteria

Criteria 쿼리는 JPQL을 Java 프로그래밍 방식으로 작성하도록 도와주는 빌더 클래스 API이다. 프로그래밍 방식으로 JPQL을 작성하므로 문법 오류를 컴파일 시점에 감지할 수 있고 문자열 기반의 JPQL보다 동적 쿼리를 안전하게 생성할 수 있다는 장점이 있다. 하지만 코드의 가독성이 떨어진다는 단점도 있다.

10.3.1 Criteria 기초

Criteria API는 jakarta.persistence.criteria 패키지에 있다.

예제 10.53. Criteria 쿼리 시작

@Test
@Transactional
void criteriaQueryExample() {
    // Criteria 쿼리 빌더
    CriteriaBuilder cb = em.getCriteriaBuilder();

    // Criteria 생성, 반환 타입 지정
    CriteriaQuery<Member> cq = cb.createQuery(Member.class);

    // FROM clause
    Root<Member> m = cq.from(Member.class);

    // SELECT clause
    cq.select(m);

    TypedQuery<Member> query = em.createQuery(cq);
    List<Member> members = query.getResultList();

    Assertions.assertEquals(3, members.size());
}

위 예제에서 Criteria 쿼리를 완성하는 과정은 다음과 같다.

  1. Criteria 쿼리를 생성하기 위해 우선 Criteria 빌더(CriteriaBuilder)를 EntityManager 또는 EntityManagerFactory에서 얻을 수 있다.
  2. Criteria 쿼리 빌더로부터 Criteria 쿼리(CriteriaQuery)를 생성한다. 이때 반환 타입을 지정할 수 있다.
  3. FROM 절을 생성한다. 반환된 값 m은 Criteria에서 사용하는 특별한 별칭으로, 조회의 시작점이라는 의미로 쿼리 루트(Root)라고 부른다.
  4. SELECT 절을 생성한다.

이후 완성된 Criteria 쿼리를 EntityManager.createQuery() 메서드에 매개변수로 전달하면 된다.

예제 10.54. 검색 조건 추가

@Test
@Transactional
void criteriaQueryExample2() {
    CriteriaBuilder cb = em.getCriteriaBuilder();

    CriteriaQuery<Member> cq = cb.createQuery(Member.class);

    // FROM clause
    Root<Member> m = cq.from(Member.class);

    // WHERE clause definition
    Predicate usernameEqual = cb.equal(m.get("username"), "kim");

    // ORDER BY clause definition
    jakarta.persistence.criteria.Order ageDesc = cb.desc(m.get("age"));

    // Query build
    cq.select(m)
            .where(usernameEqual)   // WHERE clause
            .orderBy(ageDesc);      // ORDER BY clause

    List<Member> members = em.createQuery(cq).getResultList();

    Assertions.assertEquals(2, members.size());
}

검색 조건과 정렬 조건을 추가하여 다음과 같은 과정이 추가되었다.

  1. 쿼리 빌더의 equal() 메서드를 사용해 검색 조건을 정의한다.
  2. 쿼리 빌더의 desc() 메서드를 사용해 정렬 조건을 정의한다.
  3. SELECT 절 생성 이후 WHERE, ORDER BY 절을 생성한다.

Criteria는 검색 조건부터 정렬 조건까지 Criteria 빌더(CriteriaBuilder)를 사용해서 코드를 완성한다.

쿼리 루트(Query Root)와 별칭의 특징은 다음과 같다.

  • 조회의 시작점이다.
  • Criteria에서 사용되는 특별한 별칭으로 JPQL의 별칭과 같다.
  • 별칭은 엔터티에만 부여할 수 있다.
  • 프로그래밍 방식으로 JPQL을 완성하는 도구이기 때문에 경로 표현식도 존재한다.
    • m.get("username")은 JPQL의 m.username과 같다.
    • m.get("team").get("name")은 JPQL의 m.team.name과 같다.

예제 10.55. 숫자 타입 검색

@Test
@Transactional
void criteriaQueryExample3() {
    CriteriaBuilder cb = em.getCriteriaBuilder();

    CriteriaQuery<Member> cq = cb.createQuery(Member.class);

    Root<Member> m = cq.from(Member.class);

    Predicate ageGt10 = cb.greaterThan(m.get("age"), 10);
    jakarta.persistence.criteria.Order ageDesc = cb.desc(m.get("age"));

    cq.select(m)
            .where(ageGt10)
            .orderBy(ageDesc);

    List<Member> members = em.createQuery(cq).getResultList();

    Assertions.assertEquals(3, members.size());
}

레거시 Java는 타입 추론 기능이 약했기 때문에 위 예제에서 cb.greaterThan(m.get("age"), 10) 부분을 cb.greaterThan(m.<Integer>get("age"), 10)과 같이 작성해야 했다. 하지만 최신 버전에서는 매우 우수한 타입 추론 기능을 제공하기 때문에 제네릭을 사용할 필요가 없다.

10.3.2 Criteria 쿼리 생성

Criteria를 사용하려면 CriteriaBuilder.createQuery() 메서드를 호출하여 Criteria 쿼리(CriteriaQuery)를 생성해야 한다.

예제 10.56. CriteriaBuilder

public interface CriteriaBuilder {
    
    // 객체 반환 타입
    CriteriaQuery<Object> createQuery();

	// 제네릭(엔터티, 임베디드 타입 등) 반환 타입
    <T> CriteriaQuery<T> createQuery(Class<T> var1);
	
    // Tuple 반환 타입
    CriteriaQuery<Tuple> createTupleQuery();
    
  	...
}

CriteriaQuery 객체 생성 시 반환 타입을 엔터티로 지정하면 EntityManager.createQuery() 메서드에서 반환 타입을 지정하지 않아도 된다.

예제 10.57. 반환 타입 지정

CriteriaBuilder cb = em.getCriteriaBuilder();

// Member 엔터티를 반환 타입으로 지정
CriteriaQuery<Member> cq = cb.createQuery(Member.class);

...

List<Member> resultList = em.createQuery(cq).getResultList();

예를 들어 위와 같이 EntityManager.createQuery() 메서드에 반환 타입을 지정하지 않았지만 TypedQuery 객체를 생성하는 것이 가능하다.

예제 10.58. Object로 조회

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Object> cq = cb.createQuery();

...

List<Object> resultList = em.createQuery(cq).getResultList();

반환 타입을 지정할 수 없거나 반환 타입이 둘 이상이면 위와 같이 타입을 지정하지 않고 Object 타입으로 반환받을 수 있다.

예제 10.59. Object[]로 조회

CriteriaBuilder cb = em.getCriteriaBuilder();

CriteriaQuery<Object[]> cq = cb.createQuery(Object[].class);

...

List<Object[]> resultList = em.createQuery(cq).getResultList();

multiselect() 메서드 사용 시 반환 타입이 둘 이상이 될 수도 있는데 이때는 Object[] 타입으로 값을 관리하는 것이 편리하다.

예제 10.60. 튜플로 조회

CriteriaBuilder cb = em.getCriteriaBuilder();

CriteriaQuery<Tuple> cq = cb.createTupleQuery();

...

TypedQuery<Tuple> query = em.createQuery(cq);

Criteria가 제공하는 Tuple은 Java의 Map과 비슷한 자료구조이다. Tuple 타입으로 반환받는 것도 가능하다.

10.3.3 조회

예제 10.61. CriteriaQuery

public interface CriteriaQuery<T> extends AbstractQuery<T> {
    CriteriaQuery<T> select(Selection<? extends T> var1);

    CriteriaQuery<T> multiselect(Selection<?>... var1);

    CriteriaQuery<T> multiselect(List<Selection<?>> var1);
    
    ...

조회 대상을 한 건, 여러 건 지정

  • select(): 조회 대상을 하나만 지정할 때 사용한다. cq.select(m)과 같이 사용한다.
  • multiselect(): 조회 대상을 여러 건 지정할 때 사용한다. cq.multiselect(m.get("username"), m.get("age"))과 같이 사용한다. cq.select(cb.array(m.get("username"), m.get("age")))과 같이 CriteriaBuilder.array() 메서드를 활용할 수도 있다.

DISTINCT

select(), multiselect() 메서드 뒤에 distinct(true) 메서드를 사용하면 된다.

예제 10.62. 완성된 코드

@Test
@Transactional
void criteriaQuerySelectDistinct() {
    CriteriaBuilder cb = em.getCriteriaBuilder();

    CriteriaQuery<Object[]> cq = cb.createQuery(Object[].class);
    Root<Member> m = cq.from(Member.class);
    cq.multiselect(m.get("username"), m.get("age")).distinct(true);

    TypedQuery<Object[]> query = em.createQuery(cq);
    List<Object[]> resultList = query.getResultList();

    Assertions.assertEquals(3, resultList.size());
}

NEW, construct()

쿼리의 결과를 특정 DTO 객체에 매핑할 때 사용하는 JPQL의 select new 생성자() 구문을 Criteria에서는 CriteriaBuilder.construct(클래스_타입, ...)과 같이 사용한다.

CriteriaBuilder.construct() 정의

<Y> CompoundSelection<Y> construct(Class<Y> var1, Selection<?>... var2);

예제 10.63. Criteria construct()

@Test
@Transactional
void criteriaQueryConstruct() {
    CriteriaBuilder cb = em.getCriteriaBuilder();

    CriteriaQuery<MemberDTO> cq = cb.createQuery(MemberDTO.class);
    Root<Member> m = cq.from(Member.class);

    cq.select(cb.construct(MemberDTO.class, m.get("username"), m.get("age")));

    TypedQuery<MemberDTO> query = em.createQuery(cq);
    List<MemberDTO> resultList = query.getResultList();

    Assertions.assertEquals(3, resultList.size());
}

JPQL은 select new jpabook.entity.dto.MemberDTO()처럼 패키지 경로를 모두 명시해야 했지만 Criteria는 MemberDTO.class와 같이 간략하게 사용할 수 있다.

튜플

Criteria는 Map과 비슷한 튜플이라는 특별한 반환 객체를 제공한다.

예제 10.64. 튜플

@Test
@Transactional
void criteriaQueryTuple() {
    CriteriaBuilder cb = em.getCriteriaBuilder();

    // CriteriaQuery<Tuple> cq = cb.createQuery(Tuple.class);
    CriteriaQuery<Tuple> cq = cb.createTupleQuery();

    Root<Member> m = cq.from(Member.class);
    cq.multiselect(
            m.get("username").alias("username"),
            m.get("age").alias("age")
    );

    TypedQuery<Tuple> query = em.createQuery(cq);
    List<Tuple> resultList = query.getResultList();

    Assertions.assertEquals(3, resultList.size());
    for (Tuple tuple : resultList) {
        String username = tuple.get("username", String.class);
        Integer age = tuple.get("age", Integer.class);

        Assertions.assertNotNull(username);
        Assertions.assertNotNull(age);
    }
}

튜플은 검색 키로 사용할 튜플 전용 별칭을 Root.alias() 메서드를 사용해 필수로 할당해야 한다. 선언한 튜플 별칭으로 데이터를 조회할 수 있다. 튜플은 이름 기반이므로 순서 기반의 Object[] 타입 반환 객체보다 안전하다. 또한 Tuple.getElements() 메서드로 현재 튜플의 별칭과 Java 타입도 조회할 수 있다.

예제 10.65. 튜플과 엔터티 조회

@Test
@Transactional
void criteriaQueryTupleSelectEntity() {
    CriteriaBuilder cb = em.getCriteriaBuilder();

    CriteriaQuery<Tuple> cq = cb.createTupleQuery();
    Root<Member> m = cq.from(Member.class);
    cq.select(cb.tuple(
            m.alias("m"),
            m.get("username").alias("username")
    ));

    TypedQuery<Tuple> query = em.createQuery(cq);
    List<Tuple> resultList = query.getResultList();

    Assertions.assertEquals(3, resultList.size());
    for (Tuple tuple : resultList) {
        Member member = tuple.get("m", Member.class);
        String username = tuple.get("username", String.class);

        Assertions.assertNotNull(member);
        Assertions.assertNotNull(username);
    }
}

튜플은 엔터티도 조회할 수 있다. 그리고 cq.multiselect(...)cq.select(cb.tuple(...))는 같은 역할을 한다.

10.3.4 집합

GROUP BY

예제 10.66. 집합 예

@Test
@Transactional
void criteriaQueryGroupBy() {
    /**
     * JPQL:
     * SELECT m.team.name, max(m.age), min(m.age)
     * FROM Member m
     * GROUP BY m.team.name
     */
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<Object[]> cq = cb.createQuery(Object[].class);
    Root<Member> m = cq.from(Member.class);

    Expression<Integer> maxAge = cb.max(m.get("age"));
    Expression<Integer> minAge = cb.min(m.get("age"));

    cq.multiselect(m.get("team").get("name"), maxAge, minAge);
    cq.groupBy(m.get("team").get("name"));  // GROUP BY

    TypedQuery<Object[]> query = em.createQuery(cq);
    List<Object[]> resultList = query.getResultList();

    Assertions.assertEquals(1, resultList.size());
    Assertions.assertEquals(3, resultList.get(0).length);
}

cq.groupBy(m.get("team").get("name"))이 JPQL의 GROUP BY m.team.name과 같다.

HAVING

위 예제에서 팀 구성원 중 가장 어린 구성원의 나이가 10살을 초과한다는 조건을 추가하려면 다음과 같이 코드를 작성할 수 있다.

HAVING 절 추가

cq.multiselect(m.get("team").get("name"), maxAge, minAge)
	.groupBy(m.get("team").get("name"))
    .having(cb.gt(minAge, 10));	// HAVING

having(cb.gt(minAge, 10))이 JPQL의 HAVING MIN(m.age) > 10과 같다.

10.3.5 정렬

정렬 조건도 CriteriaBuilder 클래스의 desc(), asc() 메서드를 통해 생성할 수 있다.

ORDER BY 절 추가

cq.select(m)
	.where(ageGt)
    .orderBy(cb.desc(m.get("age")));	// ORDER BY m.age DESC

ORDER BY API는 다음과 같이 정의되어 있다.

  • CriteriaQuery<T> orderBy(Order... o)
  • CriteriaQuery<T> orderBy(List<Order> o)

10.3.6 조인

조인은 Root.join() 메서드와 JoinType 열거형 클래스를 사용한다.

JoinType 열거형 클래스 정의

public enum JoinType {
    INNER,	// 내부 조인
    LEFT,	// 왼쪽 외부 조인
    RIGHT;	// 오른쪽 외부 조인(JPA 구현체나 데이터베이스에 따라 미지원 가능)
}

예제 10.67. JOIN 예

@Test
@Transactional
void criteriaQueryJoin() {
    /**
     * JPQL:
     * SELECT m, t FROM Member m
     * INNER JOIN m.team t
     * WHERE t.name = 'Team 1'
     */
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<Object[]> cq = cb.createQuery(Object[].class);

    Root<Member> m = cq.from(Member.class);
    Join<Member, Team> t = m.join("team", JoinType.INNER);  // INNER JOIN

    cq.multiselect(m, t)
            .where(cb.equal(t.get("name"), "Team 1"));

    List<Object[]> resultList = em.createQuery(cq).getResultList();

    Assertions.assertEquals(3, resultList.size());
}

참고로 FETCH JOINRoot.fetch() 메서드를 사용한다.

10.3.7 서브쿼리

간단한 서브쿼리

다음은 나이가 평균 이상인 회원을 구하는 서브쿼리이다.

예제 10.68. 간단한 서브쿼리

@Test
@Transactional
void criteriaQuerySimpleSubquery() {
    /**
     * JPQL:
     * SELECT m FROM Member m
     * WHERE m.age >= (SELECT AVG(m2.age) FROM Member m2)
     */
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<Member> mainQuery = cb.createQuery(Member.class);

    Subquery<Double> subquery = mainQuery.subquery(Double.class);
    Root<Member> m2 = subquery.from(Member.class);
    subquery.select(cb.avg(m2.get("age")));

    Root<Member> m = mainQuery.from(Member.class);
    mainQuery.select(m)
            .where(cb.ge(m.get("age"), subquery));

    List<Member> members = em.createQuery(mainQuery).getResultList();

    Assertions.assertEquals(2, members.size());
}

상관 서브쿼리

서브쿼리에서 메인쿼리의 정보를 참조하려면 메인쿼리에서 사용한 별칭을 획득해야 한다. 서브쿼리는 메인쿼리의 Root 또는 Join을 통해 생성된 별칭을 받아 where(cb.equal(subM.get("username"), m.get("username")))과 같이 사용한다.

다음은 1팀에 소속된 회원을 찾는 상관 서브쿼리 사용의 예이다. 실제로는 조인을 사용하는 것이 더 효과적일 수 있다.

예제 10.69. 상관 서브쿼리

@Test
@Transactional
void criteriaQueryCorrelatedSubquery() {
    /**
     * JPQL:
     * SELECT m FROM Member m
     * WHERE EXISTS (SELECT t FROM m.team t WHERE t.name = 'Team 1')
     */
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<Member> mainQuery = cb.createQuery(Member.class);

    Root<Member> m = mainQuery.from(Member.class);

    Subquery<Team> subquery = mainQuery.subquery(Team.class);
    Root<Member> subM = subquery.correlate(m);  // 메인쿼리의 별칭을 참조
    Join<Member, Team> t = subM.join("team");
    subquery.select(t)
            .where(cb.equal(t.get("name"), "Team 1"));

    mainQuery.select(m)
            .where(cb.exists(subquery));

    List<Member> members = em.createQuery(mainQuery).getResultList();

    Assertions.assertEquals(3, members.size());
}

코드에서 핵심은 Subquery.correlate() 메서드이다. 매개변수로 메인쿼리의 쿼리 루트(m)를 전달하여 새로운 쿼리 루트(subM)를 생성하면 메인쿼리의 별칭을 참조하여 JOIN 등을 수행할 수 있게 된다.

10.3.8 IN 식

IN 식은 CriteriaBuilder.in() 메서드를 통해 사용할 수 있다.

예제 10.70. IN 식 사용 예

@Test
@Transactional
void criteriaQueryInExpression() {
    /**
     * JPQL:
     * SELECT m FROM Member m
     * WHERE m.username IN ("kim", "ahn")
     */
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<Member> cq = cb.createQuery(Member.class);
    Root<Member> m = cq.from(Member.class);

    cq.select(m)
            .where(cb.in(m.get("username"))
                    .value("kim")
                    .value("ahn"));

    List<Member> members = em.createQuery(cq).getResultList();

    Assertions.assertEquals(3, members.size());
}

10.3.9 CASE 식

CASE 식은 CriteriaBuilder 클래스의 selectCase(), when(), otherwise() 메서드를 통해 사용할 수 있다.

예제 10.71. CASE 식 사용 예

@Test
@Transactional
void criteriaQueryCaseExpression() {
    /**
     * JPQL:
     * SELECT m.username,
     *      CASE WHEN m.age>=60 THEN 600
     *           WHEN m.age<=15 THEN 500
     *           ELSE 1000
     *      END
     * FROM Member m
     */
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<Object[]> cq = cb.createQuery(Object[].class);

    Root<Member> m = cq.from(Member.class);

    cq.multiselect(
            m.get("username"),
            cb.selectCase()
                    .when(cb.ge(m.get("age"), 60), 600)
                    .when(cb.le(m.get("age"), 15), 500)
                    .otherwise(1000)
    );

    List<Object[]> resultList = em.createQuery(cq).getResultList();
    Assertions.assertEquals(3, resultList.size());
    for (Object[] list : resultList) {
        Assertions.assertEquals(1000, (Integer) list[1]);
    }
}

10.3.10 파라미터 정의

예제 10.72. 파라미터 정의 예

@Test
@Transactional
void criteriaQueryParameterBinding() {
    /**
     * JPQL:
     * SELECT m FROM Member m
     * WHERE m.username = :usernameParam
     */
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<Member> cq = cb.createQuery(Member.class);

    Root<Member> m = cq.from(Member.class);

    cq.select(m)
            .where(cb.equal(m.get("username"), cb.parameter(String.class, "usernameParam")));

    List<Member> resultList = em.createQuery(cq)
            .setParameter("usernameParam", "kim")
            .getResultList();

    Assertions.assertEquals(2, resultList.size());
    for (Member member : resultList) {
        Assertions.assertEquals("kim", member.getUsername());
    }
}

CriteriaBuilder.parameter() 메서드의 매개변수로 타입과 파라미터 이름을 전달하여 JPQL의 :usernameParam과 같이 파라미터를 정의하고 setParameter() 메서드로 파라미터 바인딩을 수행한다.

하이버네이트는 Criteria에서 파라미터를 정의하지 않고 직접 값을 입력해도 실제 SQL을 생성할 때는 PreparedStatement에 파라미터 바인딩을 사용한다.

10.3.11 네이티브 함수 호출

네이티브 함수 호출은 CriteriaBuilder.function() 메서드를 사용하면 된다.

네이티브 함수 호출

@Test
@Transactional
void criteriaQueryNativeFunction() {
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<Long> cq = cb.createQuery(Long.class);

    Root<Member> m = cq.from(Member.class);
    Expression<Long> function = cb.function("SUM", Long.class, m.get("age"));
    cq.select(function);

    Long sum = em.createQuery(cq).getSingleResult();

    Assertions.assertEquals(69, sum);
}

10.3.12 동적 쿼리

동적 쿼리는 문자 기반인 JPQL보다는 프로그래밍 기반의 Criteria로 작성하는 것이 더 편리하다.

예제 10.73. JPQL 동적 쿼리

@Test
@Transactional
void JPQLDynamicQuery() {
    Integer age = 25;
    String username = null;
    String teamName = "Team 1";

    StringBuilder jpql = new StringBuilder("SELECT m FROM Member m JOIN m.team t ");
    List<String> criteria = new ArrayList<>();

    if (age != null)
        criteria.add(" m.age = :age ");
    if (username != null)
        criteria.add(" m.username = :username ");
    if (teamName != null)
        criteria.add(" t.name = :teamName ");

    if (criteria.size() > 0)
        jpql.append(" where ");

    for (int i = 0; i < criteria.size(); ++i) {
        if (i > 0)
            jpql.append(" and ");
        jpql.append(criteria.get(i));
    }

    TypedQuery<Member> query = em.createQuery(jpql.toString(), Member.class);
    if (age != null)
        query.setParameter("age", age);
    if (username != null)
        query.setParameter("username", username);
    if (teamName != null)
        query.setParameter("teamName", teamName);

    List<Member> resultList = query.getResultList();
    Assertions.assertEquals(1, resultList.size());
}

예제 10.74. Criteria 동적 쿼리

@Test
@Transactional
void criteriaDynamicQuery() {
    Integer age = 25;
    String username = null;
    String teamName = "Team 1";

    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<Member> cq = cb.createQuery(Member.class);

    Root<Member> m = cq.from(Member.class);
    Join<Member, Team> t = m.join("team");

    List<Predicate> criteria = new ArrayList<>();

    if (age != null)
        criteria.add(cb.equal(m.get("age"), cb.parameter(Integer.class, "age")));
    if (username != null)
        criteria.add(cb.equal(m.get("username"), cb.parameter(String.class, "username")));
    if (teamName != null)
        criteria.add(cb.equal(t.get("name"), cb.parameter(String.class, "teamName")));

    cq.where(cb.and(criteria.toArray(new Predicate[0])));

    TypedQuery<Member> query = em.createQuery(cq);
    if (age != null)
        query.setParameter("age", age);
    if (username != null)
        query.setParameter("username", username);
    if (teamName != null)
        query.setParameter("teamName", teamName);

    List<Member> resultList = query.getResultList();
    Assertions.assertEquals(1, resultList.size());
}

Criteria 사용으로 동적 쿼리 작성 시 문자열을 직접 조작하지 않으므로 코드는 안전해졌지만 Criteria 특유의 낮은 가독성 때문에 여전히 복잡하다.

10.3.13 함수 정리

Expression 클래스의 메서드는 다음과 같은 것들이 있다. m.get("username")의 반환 타입이 대표적으로 Expression 클래스의 객체이다.

function nameJPQL
isNull()IS NULL
isNotNull()IS NOT NULL
in()IN

JPQL에서 사용하는 함수는 대부분 CriteriaBuilder에 정의되어 있다.

  • 조건 함수
function nameJPQL
and()AND
or()OR
not()NOT
equal(), notEqual()=, <>
lt(), lessThan()<
le(), lessThanOrEqualTo()<=
gt(), greaterThan()>
ge(), greaterThanOrEqualTo()>=
between()BETWEEN
like(), notLike()LIKE, NOT LIKE
isTrue(), isFalse()IS TRUE, IS FALSE
in(), not(in())IN, NOT IN
exists, not(exists())EXISTS, NOT EXISTS
isNull(), isNotNull()IS NULL, IS NOT NULL
isEmpty(), isNotEmpty()IS EMPTY, IS NOT EMPTY
isMember, isNotMember()MEMBER OF, NOT MEMBER OF
  • 스칼라와 기타 함수
function nameJPQL
sum()+
neg(), diff()-
prod()*
quot()/
all()ALL
any()ANY
some()SOME
abs()ABS
sqrt()SQRT
mod()MOD
size()SIZE
length()LENGTH
locate()LOCATE
concat()CONCAT
upper()UPPER
lower()LOWER
substring()SUBSTRING
trim()TRIM
currentDate()CURRENT_DATE
currentTime()CURRENT_TIME
currentTimestampCURRENT_TIMESTAMP
  • 집합 함수
function nameJPQL
avg()AVG
max(), greatest()MAX
min(), least()MIN
sum(), sumAsLong(), sumAsDouble()SUM
count()COUNT
countDistinct()COUNT DISTINCT
  • 분기 함수
function nameJPQL
nullIf()NULLIF
coalesce()COALESCE
selectCase()CASE

10.3.14 Criteria 메타 모델 API

10.4 QueryDSL

QueryDSL도 Criteria처럼 JPQL 빌더 역할을 하여 프로그래밍 방식으로 JPQL을 작성할 수 있다. 그러나 Criteria보다 가독성이 훨씬 좋다. QueryDSL은 오픈소스 프로젝트로 처음에는 HQL(하이버네이트 쿼리 언어)을 코드로 작성할 수 있도록 해 주는 프로젝트로 시작하여 현재는 JPA, JDO, JDBC, MongoDB, 자바 컬렉션 등을 다양하게 지원한다.

10.4.1 QueryDSL 설정

필요 라이브러리

스프링 부트를 기준으로 다음과 같은 의존성들을 설치한다.

예제 10.77. pom.xml 추가

...
        <dependency>
            <groupId>com.querydsl</groupId>
            <artifactId>querydsl-jpa</artifactId>
            <version>5.1.0</version>
            <classifier>jakarta</classifier>
        </dependency>
        <dependency>
            <groupId>com.querydsl</groupId>
            <artifactId>querydsl-apt</artifactId>
            <version>5.1.0</version>
            <scope>provided</scope>
            <classifier>jakarta</classifier>
        </dependency>
...
  • querydsl-jpa: QueryDSL JPA 라이브러리
  • querydsl-apt: 쿼리 타입(Q)을 생성할 때 필요한 라이브러리

환경설정

QueryDSL을 사용하려면 Criteria의 메타 모델처럼 엔터티를 기반으로 쿼리 타입이라는 쿼리용 클래스를 생성해야 한다.

예제 10.78. 쿼리 타입 생성용 pom.xml 추가

...
    <build>
        <plugins>
			...            
            <plugin>
                <groupId>com.mysema.maven</groupId>
                <artifactId>apt-maven-plugin</artifactId>
                <version>1.1.3</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>process</goal>
                        </goals>
                        <configuration>
                            <outputDirectory>target/generated-sources/annotations</outputDirectory>
                            <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
...

콘솔에서 mvn compile 명령을 실행하거나 IntelliJ에서 사진과 같이 Maven 컴파일을 수행하면 outputDirectory 속성에 지정한 경로에 Q 클래스 파일들이 생성된다.

10.4.2 시작

예제 10.79. QueryDSL 시작

@Test
@Transactional
void queryDSLExample() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    QMember qMember = new QMember("m");
    List<Member> members =
            query.selectFrom(qMember)
                    .where(qMember.username.eq("kim"))
                    .orderBy(qMember.age.desc())
                    .fetch();

    Assertions.assertEquals(2, members.size());
}

최신 버전의 QueryDSL은 문법이 많이 바뀌었기 때문에 이 예제부터는 서적과 다른 코드를 작성한다.

QueryDSL을 사용하려면 우선 com.querydsl.jpa.impl.JPAQueryFactory 객체를 생성해야 하고 생성자 매개변수로 EntityManager 빈을 넘겨주어야 한다. 그렇기 때문에 구성 파일을 따로 두어 의존성 주입을 받도록 설정할 수도 있다. 다음으로 사용할 쿼리 타입(Q)을 생성하는데, 생성자 매개변수로 별칭을 전달한다. 그 다음 작성되는 JPAQueryFactory 객체를 사용한 프로그래밍 방식의 코드는 가독성이 매우 우수하기 때문에 어떤 JPQL을 생성할지 단번에 알 수 있다.

기본 Q 생성

쿼리 타입(Q)은 사용의 편의성을 위해 기본 인스턴스를 멤버 변수로 저장하고 있다. 하지만 같은 엔터티를 조인하거나 서브쿼리에 사용하면 같은 별칭이 사용되므로 이때는 별칭을 직접 지정해야 한다.

예제 10.80. Member 쿼리 타입

@Generated("com.querydsl.codegen.DefaultEntitySerializer")
public class QMember extends EntityPathBase<Member> {
	...

    public static final QMember member = new QMember("member1");
    ...

예제 10.81. 쿼리 타입 사용

QMember qMember = new QMember("m");	// 직접 지정
QMember qMember = QMember.member;	// 기본 인스턴스 사용

쿼리 타입의 기본 인스턴스 사용 시 다음과 같이 import static을 활용하여 코드를 더 가독성 있게 작성할 수 있다.

예제 10.82. import static 활용

import static jpabook.entity.QMember.member;
...
@Test
@Transactional
void queryDSLStaticQEntity() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    List<Member> members =
            query.selectFrom(member)
                    .where(member.username.eq("kim"))
                    .orderBy(member.age.desc())
                    .fetch();

    Assertions.assertEquals(2, members.size());
}

10.4.3 검색 조건 쿼리

예제 10.83. QueryDSL 기본 쿼리 기능

@Test
@Transactional
void basic() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    QItem item = QItem.item;
    List<Item> list =
             query.selectFrom(item)
                     .where(item.name.eq("high-end product").and(item.price.goe(20000)))
                     .fetch();

    Assertions.assertEquals(2, list.size());
}

예제 10.84. 실행된 JPQL

Hibernate: 
    /* select
        item 
    from
        Item item 
    where
        item.name = ?1 
        and item.price >= ?2 */ 
        select
            i1_0.id,
            i1_0.dtype,
            i1_0.name,
            i1_0.price,
            i1_0.stock_quantity,
            i1_1.artist,
            i1_1.etc,
            i1_2.author,
            i1_2.isbn,
            i1_3.actor,
            i1_3.director 
        from
            item i1_0 
        left join
            album i1_1 
                on i1_0.id=i1_1.id 
        left join
            book i1_2 
                on i1_0.id=i1_2.id 
        left join
            movie i1_3 
                on i1_0.id=i1_3.id 
        where
            i1_0.name=? 
            and i1_0.price>=?

where() 절에는 and() 또는 or()을 사용할 수 있다. 또한 매개변수로 단순히 검색 조건을 나열해도 되며 이때는 AND 연산이 된다.

10.4.4 결과 조회

쿼리 작성을 마친 후 결과 조회 메서드를 호출하면 실제 데이터베이스를 조회한다. 대표적인 결과 조회 메서드는 다음과 같다.

  • fetch(): 조회 결과가 한 건 이상일 때 사용한다.
  • fetchOne(): 조회 결과가 한 건일 때 사용한다. 결과가 없으면 null, 두 건 이상이면 NonUniqueResultException 예외가 발생한다.
  • fetchFirst(): 조회 결과가 한 건 이상일 때 첫 번째 데이터를 반환한다.

10.4.5 페이징과 정렬

예제 10.85. 페이징과 정렬

@Test
@Transactional
void pagingAndSorting() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    QItem item = QItem.item;
    List<Item> list =
            query.selectFrom(item)
                    .where(item.price.gt(20000))
                    .orderBy(item.price.desc(), item.stockQuantity.asc())
                    .offset(10)
                    .limit(20)
                    .fetch();

    Assertions.assertEquals(0, list.size());
}

정렬은 orderBy() 메서드를 사용하고 쿼리 타입(Q)이 제공하는 asc(), desc() 메서드를 활용한다. 페이징은 offset(), limit() 메서드를 적절히 조합하여 구현한다.

페이징은 restrict() 메서드에 com.querydsl.core.QueryModifiers 객체를 매개변수로 사용하여 구현할 수도 있다.

예제 10.86. 페이징과 정렬 QueryModifiers 사용

@Test
@Transactional
void pagingAndQueryModifiers() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    QItem item = QItem.item;
    QueryModifiers queryModifiers = new QueryModifiers(20L, 10L);   // limit, offset
    List<Item> list =
            query.selectFrom(item)
                    .restrict(queryModifiers)
                    .fetch();

    Assertions.assertEquals(0, list.size());
}

10.4.6 그룹

GROUP BY 절은 groupBy() 메서드, HAVING 절은 having() 메서드로 구현한다.

예제 10.88. groupBy() 사용

@Test
@Transactional
void groupBy() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    QItem item = QItem.item;
    List<Item> list =
            query.selectFrom(item)
                    .groupBy(item.id, item.name)
                    .having(item.price.min().goe(10000))
                    .fetch();
    
    Assertions.assertEquals(3, list.size());
}

10.4.7 조인

조인(Join)은 innerJoin(), leftJoin(), rightJoin(), fullJoin()을 사용할 수 있다. JPQL의 ON과 성능 최적화를 위한 fetchJoin()도 사용할 수 있다.

조인의 기본 문법은 다음과 같이 첫 번째 인자로 조인 대상, 두 번째 인자로 별칭(alias)으로 사용할 쿼리 타입을 전달하는 것이다.

join(조인_대상, 별칭으로_사용할_쿼리_타입)

예제 10.89. 기본 조인

@Test
@Transactional
void basicJoin() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    QOrder order = QOrder.order;
    QMember member = QMember.member;
    QOrderItem orderItem = QOrderItem.orderItem;

    List<Order> orders = query.selectFrom(order)
            .join(order.member, member)
            .leftJoin(order.orderItems, orderItem)
            .fetch();

    Assertions.assertEquals(0, orders.size());
}

다음은 조인에 ON 절을 사용하는 예이다.

예제 10.90. 조인 on 사용

@Test
@Transactional
void joinWithOn() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    QOrder order = QOrder.order;
    QOrderItem orderItem = QOrderItem.orderItem;

    List<Order> orders =
            query.selectFrom(order)
                    .leftJoin(order.orderItems, orderItem)
                    .on(orderItem.count.gt(2))
                    .fetch();

    Assertions.assertEquals(0, orders.size());
}

다음은 페치 조인을 사용하는 예이다.

예제 10.91. 페치 조인 사용

@Test
@Transactional
void fetchJoin() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    QMember member = QMember.member;
    QOrder order = QOrder.order;
    QOrderItem orderItem = QOrderItem.orderItem;

    List<Order> orders =
            query.selectFrom(order)
                    .innerJoin(order.member, member).fetchJoin()
                    .leftJoin(order.orderItems, orderItem).fetchJoin()
                    .fetch();

    Assertions.assertEquals(0, orders.size());
}

다음은 세타 조인을 사용하는 예이다.

예제 10.92. FROM 절에 여러 조건 사용

@Test
@Transactional
void thetaJoin() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    QOrder order = QOrder.order;
    QMember member = QMember.member;

    List<Order> orders =
            query.select(order)
                    .from(order, member)
                    .where(order.member.eq(member))
                    .fetch();

    Assertions.assertEquals(0, orders.size());
}

10.4.8 서브쿼리

QueryDSL 4 버전부터는 JPASubQuery 클래스가 사라지고 com.querydsl.jpa.JPAExpressions 클래스를 통해 서브쿼리를 조작한다.

예제 10.93. 서브쿼리 예제 - 한 건

@Test
@Transactional
void singleRowSubquery() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    QItem item = QItem.item;
    QItem itemSub = new QItem("itemSub");

    List<Item> items =
            query.selectFrom(item)
                    .where(item.price.eq(
                            JPAExpressions.select(itemSub.price.max())
                                    .from(itemSub)
                    ))
                    .fetch();

    Assertions.assertEquals(1, items.size());
    Assertions.assertEquals(30000, items.get(0).getPrice());
}

예제 10.94. 서브쿼리 예제 - 여러 건

@Test
@Transactional
void multiRowSubquery() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    QItem item = QItem.item;
    QItem itemSub = new QItem("itemSub");

    List<Item> items =
            query.selectFrom(item)
                    .where(item.in(
                            JPAExpressions.selectFrom(itemSub)
                                    .where(item.name.eq(itemSub.name))
                    ))
                    .fetch();

    Assertions.assertEquals(3, items.size());
}

10.4.9 프로젝션과 결과 반환

SELECT 절에 조회 대상을 지정하는 것을 프로젝션이라 한다.

프로젝션 대상이 하나

다음과 같이 데이터는 해당 필드의 타입으로 반환된다.

예제 10.95. 프로젝션 대상이 하나

@Test
@Transactional
void singleColumnProjection() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    QItem item = QItem.item;
    List names = query.select(item.name).from(item).fetch();

    Assertions.assertEquals(3, names.size());
    for (Object name : names) {
        Assertions.assertInstanceOf(String.class, name);
    }
}

여러 컬럼 반환과 튜플

프로젝션 대상이 여러 필드일 시 QueryDSL은 기본적으로 Map 컬렉션과 유사한 내부 타입인 com.querydsl.core.Tuple을 사용한다. 이 클래스의 get() 메서드와 쿼리 타입을 통해 데이터를 조회할 수 있다.

예제 10.96. 튜플 사용 예제

@Test
@Transactional
void multiColumnProjection() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    QItem item = QItem.item;
    List<Tuple> tuples =
            query.select(item.name, item.price).from(item).fetch();
    // List<Tuple> tuples = query.select(new QTuple(item.name, item.price)).from(item).fetch();

    Assertions.assertEquals(3, tuples.size());
    for (Tuple tuple : tuples) {
        Assertions.assertInstanceOf(String.class, tuple.get(item.name));
        Assertions.assertInstanceOf(Integer.class, tuple.get(item.price));
    }
}

빈 생성

쿼리 결과를 엔터티가 아닌 특정 객체 형태로 받고 싶으면 빈 생성(Bean population) 기능을 사용한다. QueryDSL은 객체 생성을 위해 다음과 같은 방법들을 제공한다.

  • 프로퍼티 접근
  • 필드 직접 접근
  • 생성자 사용

com.querydsl.core.types.Projections 클래스 객체를 사용해 방법을 지정한다.

예제 10.97. 예제 ItemDTO

public class ItemDTO {

    private String username;
    private Integer price;

    public ItemDTO() {}

    public ItemDTO(String username, Integer price) {
        this.username = username;
        this.price = price;
    }
    ...
}

예제 10.98. 프로퍼티 접근(Setter)

@Test
@Transactional
void beanPopulationProperty() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    QItem item = QItem.item;
    List<ItemDTO> result =
            query.select(Projections.bean(ItemDTO.class, item.name.as("username"), item.price))
                    .from(item)
                    .fetch();

    Assertions.assertEquals(3, result.size());
}

Projections.bean() 메서드는 수정자(Setter)를 사용해서 값을 채운다. 만약 객체 필드와 매핑할 프로퍼티의 이름이 다르면 as() 메서드를 사용해 별칭을 부여해야 한다.

예제 10.99. 필드 직접 접근

@Test
@Transactional
void beanPopulationFieldDirect() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    QItem item = QItem.item;
    List<ItemDTO> result =
            query.select(Projections.fields(ItemDTO.class, item.name.as("username"), item.price))
                    .from(item)
                    .fetch();

    Assertions.assertEquals(3, result.size());
}

Projections.fields() 메서드는 필드에 직접 접근하여 값을 채운다. 필드의 접근 제어자가 private이더라도 동작한다.

예제 10.100. 생성자 사용

@Test
@Transactional
void beanPopulationConstructor() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    QItem item = QItem.item;
    List<ItemDTO> result =
            query.select(Projections.constructor(ItemDTO.class, item.name, item.price))
                    .from(item)
                    .fetch();

    Assertions.assertEquals(3, result.size());
}

Projections.constructor() 메서드는 생성자를 사용해 값을 채운다. 앞선 방법들과 달리 별칭을 부여할 필요가 없는 대신 생성자의 매개변수 순서에 잘 맞추어 인자를 전달해야 한다.

DISTINCT()

JPAQueryFactory 객체의 select() 메서드 이후 distinct() 메서드 사용 시 전체 결과의 유일성을 보장하고, select() 메서드의 인자로 주어지는 필드에 countDistinct() 메서드 사용 시 해당 필드 값의 유일성만을 보장한다.

10.4.10 수정, 삭제 배치 쿼리

QueryDSL의 수정, 삭제 배치 쿼리도 JPQL 배치 쿼리와 같이 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리를 전송한다.

예제 10.101. 수정 배치 쿼리

@Test
@Transactional
void updateBatch() {
    QItem item = QItem.item;
    JPAUpdateClause updateClause = new JPAUpdateClause(em, item);
    Long count = updateClause.where(item.name.eq("high-end product"))
            .set(item.price, item.price.add(10000))
            .execute();

    Assertions.assertEquals(3L, count);
}

com.querydsl.jpa.impl.JPAUpdateClause 클래스 객체를 사용해 수정 배치 쿼리를 작성한다.

예제 10.102. 삭제 배치 쿼리

@Test
@Transactional
void deleteBatch() {
    QItem item = QItem.item;
    JPADeleteClause deleteClause = new JPADeleteClause(em, item);
    Long count = deleteClause.where(item.name.eq("high-end product"))
            .execute();

    Assertions.assertEquals(3L, count);
}

com.querydsl.jpa.impl.JPADeleteClause 클래스 객체를 사용해 삭제 배치 쿼리를 작성한다.

10.4.11 동적 쿼리

com.querydsl.core.BooleanBuilder 클래스 객체를 사용하면 동적 쿼리를 편리하게 생성할 수 있다.

예제 10.103. 동적 쿼리 예제

@Test
@Transactional
void dynamicQuery() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    SearchParams params = new SearchParams("high-end product", 10000);
    QItem item = QItem.item;

    BooleanBuilder builder = new BooleanBuilder();
    if (StringUtils.hasText(params.getName())) {
        builder.and(item.name.contains(params.getName()));
    }
    if (params.getPrice() != null) {
        builder.and(item.price.gt(params.getPrice()));
    }
    List<Item> result =
            query.selectFrom(item)
                    .where(builder)
                    .fetch();

    Assertions.assertEquals(2, result.size());
}

10.4.12 메서드 위임

메서드 위임(Delegate methods) 기능을 사용하면 쿼리 타입에 검색 조건을 직접 정의할 수 있다.

예제 10.104. 검색 조건 정의

public class ItemExpression {

    @QueryDelegate(Item.class)
    public static BooleanExpression isExpensive(QItem item, Integer price) {
        return item.price.gt(price);
    }
}

우선 정적(static) 메서드를 정의하여 com.querydsl.core.annotations.QueryDelegate 애너테이션으로 지정하고 애너테이션의 속성에 기능을 적용할 엔터티를 지정한다. 메서드의 첫 번째 매개변수는 대상 엔터티의 쿼리 타입(Q), 나머지는 필요한 매개변수를 정의한다.

예제 10.105. 쿼리 타입에 생성된 결과

@Generated("com.querydsl.codegen.DefaultEntitySerializer")
public class QItem extends EntityPathBase<Item> {
	...
    public BooleanExpression isExpensive(Integer price) {
        return ItemExpression.isExpensive(this, price);
    }
}

메이븐 컴파일 시 생성된 쿼리 타입에 앞서 정의한 메서드 위임 기능이 포함된 것을 확인할 수 있다.

메서드 위임 기능 적용

@Test
@Transactional
void delegateMethods() {
    JPAQueryFactory query = new JPAQueryFactory(em);
    QItem item = QItem.item;
    List<Item> items =
            query.selectFrom(item)
                    .where(item.isExpensive(10000))
                    .fetch();

    Assertions.assertEquals(2, items.size());
}

10.5 네이티브 SQL

JPQL은 다음과 같이 특정 데이터베이스에 종속적인 기능은 지원하지 않는다.

  • 특정 데이터베이스만 지원하는 함수
    • JPQL에서 네이티브 SQL 함수를 호출할 수 있다(JPA 2.1).
    • 하이버네이트는 데이터베이스 방언별로 종속적인 함수들을 정의해 두었다. 또한 직접 호출할 함수를 정의할 수도 있다.
  • 특정 데이터베이스만 지원하는 문법
    • 오라클의 CONNECT BY와 같이 특정 데이터베이스에 과하게 종속된 SQL 문법의 경우 네이티브 SQL을 사용해야 한다.
  • 특정 데이터베이스만 지원하는 SQL 쿼리 힌트
    • 하이버네이트를 포함한 몇몇 JPA 구현체들이 지원한다.
  • 인라인 뷰, UNION, INTERSECT, EXCEPT
    • 하이버네이트 6부터는 집합 연산은 지원한다. 인라인 뷰는 다른 일부 JPA 구현체들이 지원한다.
  • 스토어드 프로시저
    • JPQL에서 스토어드 프로시저를 호출할 수 있다(JPA 2.1).

하지만 JPA는 위와 같은 기능들을 사용할 수 있는 다양한 방법을 열어두었고 특히 JPA 구현체들은 JPA 표준보다 다양한 방법들을 제공한다. 다양한 이유로 JPQL을 사용할 수 없을 때 JPA를 통해 네이티브 SQL을 사용할 수 있다. 그러므로 네이티브 SQL을 사용하면 엔터티를 조회할 수 있고 JPA가 지원하는 영속성 컨텍스트의 기능을 그대로 사용할 수 있다. 반면 JDBC API는 단순한 데이터의 목록을 조회할 뿐이다.

10.5.1 네이티브 SQL 사용

네이티브 쿼리 API

// 결과 타입 정의
public Query createNativeQuery(String sqlString, Class resultClass);

// 결과 타입 정의 불가 시
public Query createNativeQuery(String sqlString);

public Query createNativeQuery(String sqlString, String resultSetMapping);	// 결과 매핑 사용

엔터티 조회

네이티브 SQL은 EntityManager.createNativeQuery(SQL, 결과_클래스)를 사용한다. JPQL과 사용 방식은 유사하나 데이터베이스로 SQL이 직접 전송되며 위치 기반 파라미터만 지원한다는 차이가 있다(하이버네이트는 이름 기반 파라미터도 지원). 중요한 점은 SQL을 직접 사용하는 것 외 나머지는 JPQL과 동일하다는 것이다. 조회된 엔터티도 영속성 컨텍스트에서 관리된다.

예제 10.106. 엔터티 조회 코드

@Test
@Transactional
void selectEntity() {
    // SQL 정의
    String sql =
            "SELECT id, username, age, team_id " +
                    "FROM member WHERE age > ?";

    Query nativeQuery = em.createNativeQuery(sql, Member.class)
            .setParameter(1, 20);

    List<Member> resultList = nativeQuery.getResultList();

    Assertions.assertEquals(2, resultList.size());
}

값 조회

예제 10.107. 값 조회

@Test
@Transactional
void selectNonEntity() {
    // SQL 정의
    String sql =
            "SELECT id, age, username, team_id " +
                    "FROM member WHERE age > ?";

    Query nativeQuery = em.createNativeQuery(sql)
            .setParameter(1, 20);

    List<Object[]> resultList = nativeQuery.getResultList();
    Assertions.assertEquals(2, resultList.size());

    for (Object[] row : resultList) {
        Assertions.assertInstanceOf(Long.class, row[0]);
        Assertions.assertInstanceOf(Integer.class, row[1]);
        Assertions.assertInstanceOf(String.class, row[2]);
        Assertions.assertInstanceOf(Long.class, row[3]);
    }
}

엔터티로 조회하지 않고 단순히 여러 값으로 조회하려면 기존의 메서드의 두 번째 인자를 전달하지 않으면 된다. 이때 쿼리의 결과 타입은 Object[]의 리스트가 된다. 그리고 이때는 영속성 컨텍스트에서 관리하는 엔터티가 없기 때문에 JDBC API로 데이터를 조회했을 때와 같다.

결과 매핑 사용

매핑이 복잡해지면 @SqlResultSetMapping 애너테이션을 활용한다.

예제 10.108. 결과 매핑 사용

@Test
@Transactional
void resultSetMapping() {
    // SQL 정의
    String sql =
            "SELECT m.id, age, username, team_id, i.order_count " +
                    "FROM member m " +
                    "LEFT JOIN " +
                    "   (SELECT im.id, COUNT(*) AS order_count " +
                    "    FROM orders o, member im " +
                    "    WHERE o.member_id = im.id" +
                    "    GROUP BY im.id) i " +
                    "ON m.id = i.id";

    Query nativeQuery = em.createNativeQuery(sql, "memberWithOrderCount");

    List<Object[]> resultList = nativeQuery.getResultList();
    Assertions.assertEquals(3, resultList.size());

    for (Object[] row : resultList) {
        Assertions.assertInstanceOf(Member.class, row[0]);
        Assertions.assertInstanceOf(Long.class, row[1]);
    }
}

예제 10.109. 결과 매핑을 정의

@Entity
@SqlResultSetMapping(
        name = "memberWithOrderCount",
        entities = {@EntityResult(entityClass = Member.class)},
        columns = {@ColumnResult(name = "order_count")}
)
public class Member {
...

@SqlResultSetMapping 애너테이션으로 JPQL 실행 결과 반환되는 Object[] 타입 객체의 각 원소를 매핑한다. 위의 경우 order_count를 제외한 나머지 속성은 Member 엔터티로 매핑하고, order_count 속성은 값으로 매핑하는 방법을 보여준다.

다음으로는 JPA 표준 명세의 코드를 통해 결과 매핑에 대해 알아보자.

예제 10.110. 표준 명세 예제 - SQL

Query q = em.createNativeQuery(
	"SELECT o.id AS order_id, " +
    	"o.quantity AS order_quantity, " +
        "o.item AS order_item, " +
        "i.name AS item_name, " +
    "FROM orders o, item i " +
    "WHERE (order_quantity > 25) AND (order_item = i.id)",
    "OrderResults"
);

예제 10.111. 표준 명세 예제 - 매핑 정보

@SqlResultSetMapping(
	name = "OrderResults",
    entities = {
    	@EntityResult(
        	entityClass = com.acme.Order.class,
            fields = {
            	@FieldResult(name = "id", column = "order_id"),
                @FieldResult(name = "quantity", column = "order_quantity"),
                @FieldResult(name = "item", column = "order_item")
            }
        )
    },
    columns = {
    	@ColumnResult(name = "item_name")
    }
)

@FieldResult 애너테이션을 한 번이라도 사용하면 전체 필드를 이 애너테이션으로 매핑해야 한다. 이 애너테이션은 두 엔터티 조회 시 컬럼명이 중복될 때 별칭을 부여한 후 매핑해야 할 때 사용할 수 있다.

결과 매핑 애너테이션

  • @SqlResultSetMapping
속성기능
name결과 매핑 이름
entities@EntityResult를 사용해 엔터티를 결과로 매핑한다.
columns@ColumnResult를 사용해 컬럼을 결과로 매핑한다.
  • @EntityResult
속성기능
entityClass결과로 사용할 엔터티의 클래스를 지정한다.
fields@FieldResult를 사용해 결과 컬럼을 필드와 매핑한다.
discriminatorColumn상속 관계에서 엔터티의 인스턴스 타입을 구분한다.
  • @FieldResult
속성기능
name결과를 받을 필드 이름
column결과 컬럼 이름
  • @ColumnResult
속성기능
name결과 컬럼 이름

10.5.2 Named 네이티브 SQL

네이티브 SQL도 JPQL과 같이 명명된 네이티브 SQL(Named Native SQL)을 사용해 정적인 SQL을 작성할 수 있다.

예제 10.112. 엔터티 조회

@NamedNativeQuery(
        name = "Member.memberSQL",
        query = "SELECT id, age, username, team_id FROM member WHERE age > ?",
        resultClass = Member.class
)
public class Member {
...

사용 예

@Test
@Transactional
void namedNativeQuery() {
    TypedQuery<Member> nativeQuery =
            em.createNamedQuery("Member.memberSQL", Member.class)
                    .setParameter(1, 20);
    List<Member> resultList = nativeQuery.getResultList();
    Assertions.assertEquals(2, resultList.size());
}

@NamedNativeQuery 애너테이션으로 명명된 네이티브 SQL을 등록하고 JPQL의 명명된 쿼리와 같은 EntityManager.createNamedQuery() 메서드를 사용한다. 그러므로 쿼리를 TypedQuery 객체로 관리할 수 있다.

예제 10.113. 결과 매핑 사용

@SqlResultSetMapping(
        name = "memberWithOrderCount",
        entities = {@EntityResult(entityClass = Member.class)},
        columns = {@ColumnResult(name = "order_count")}
)
@NamedNativeQuery(
        name = "Member.memberWithOrderCount",
        query = "SELECT m.id, age, username, team_id, i.order_count " +
                "FROM member m " +
                "LEFT JOIN " +
                "   (SELECT im.id, COUNT(*) AS order_count " +
                "    FROM orders o, member im " +
                "    WHERE o.member_id = im.id " +
                "    GROUP BY im.id) i " +
                "ON m.id = i.id",
        resultSetMapping = "memberWithOrderCount"
)
public class Member {
...

사용 예

@Test
@Transactional
void namedNativeQueryWithResultSetMapping() {
    List<Object[]> resultList =
            em.createNamedQuery("Member.memberWithOrderCount")
                    .getResultList();
    Assertions.assertEquals(3, resultList.size());

    for (Object[] row : resultList) {
        Assertions.assertInstanceOf(Member.class, row[0]);
        Assertions.assertInstanceOf(Long.class, row[1]);
    }
}

@NamedNativeQuery

속성기능
name명명된 쿼리 이름(필수)
querySQL 쿼리(필수)
hints벤더 종속적인 힌트
resultClass결과 클래스
resultSetMapping결과 매핑 사용

hints 속성은 SQL 힌트가 아닌 JPA 구현체제 제공하는 힌트를 의미한다.

10.5.3 네이티브 SQL XML에 정의

예제 10.114. ormMember.xml

<?xml version="1.0" encoding="UTF-8" ?>
<entity-mappings xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm" version="2.1">
    <named-native-query name="Member.memberWithOrderCountXml"
    result-set-mapping="memberWithOrderCountResultMap">
        <query><![CDATA[
            SELECT m.id, age, username, team_id, i.order_count
            FROM member m
            LEFT JOIN (
                SELECT im.id, COUNT(*) AS order_count
                FROM orders o, member im
                WHERE o.member_id = im.id
                GROUP BY im.id) i
            ON m.id = i.id
        ]]></query>
    </named-native-query>
    
    <sql-result-set-mapping name="memberWithOrderCountResultMap">
        <entity-result entity-class="jpabook.entity.Member"/>
        <column-result name="order_count"/>
    </sql-result-set-mapping>
</entity-mappings>

XML에 명명된 네이티브 SQL을 정의할 때는 <named-native-query> 태그를 <sql-result-set-mapping> 태그보다 먼저 정의해야 한다. XML 기반의 방식과 애너테이션 기반의 방식 모두 애플리케이션 레벨에서의 사용 방법은 같다.

10.5.4 네이티브 SQL 정리

네이티브 SQL도 Query, TypedQuery(명명된 네이티브 쿼리) 타입 객체를 반환한다. 그러므로 페이징 API와 같은 JPQL API를 적용할 수 있다.

예제 10.115. 네이티브 SQL과 페이징 처리

@Test
@Transactional
void paging() {
    String sql = "SELECT id, age, username, team_id FROM member";
    Query nativeQuery = em.createNativeQuery(sql, Member.class)
            .setFirstResult(1)
            .setMaxResults(2);
    List<Member> resultList = nativeQuery.getResultList();

    Assertions.assertEquals(2, resultList.size());
}

하이버네이트가 생성한 쿼리

Hibernate: 
    /* dynamic native SQL query */ 
    SELECT
        id,
        age,
        username,
        team_id 
    FROM
        member 
    offset
        ? rows 
    fetch
        next ? rows only

네이티브 SQL은 JPQL로 정의 불가능한 특정 데이터베이스에 종속적인 기능을 사용할 때 쓰인다. 하지만 이식성이 떨어진다는 단점이 있다. 만약 네이티브 SQL로도 작성 불가능한 쿼리가 있다면 MyBatis 또는 스프링 프레임워크의 JdbcTemplate(SQL Mapper)과 JPA를 함께 사용하는 방안도 고려할 수 있다.

10.5.5 스토어드 프로시저(JPA 2.1)

10.6 객체지향 쿼리 심화

10.6.1 벌크 연산

영속성 컨텍스트의 변경 감지 기능이나 병합을 사용해 엔터티를 수정하거나, EntityManager.remove() 메서드를 사용해 삭제하는 것은 수백 개 이상의 엔터티 처리에는 매우 부적절한 방법이다. 이럴 때 벌크 연산을 사용한다.

예제 10.123. UPDATE 벌크 연산

@Test
@Transactional
void bulkUpdate() {
    String sqlString =
            "UPDATE Product p SET p.price = p.price * 2 WHERE p.stockAmount < :stockAmount";

    Integer resultCount = em.createQuery(sqlString)
            .setParameter("stockAmount", 20)
            .executeUpdate();

    Assertions.assertEquals(1, resultCount);
}

예제 10.124. DELETE 벌크 연산

@Test
@Transactional
void bulkDelete() {
    String sqlString =
            "DELETE FROM Product p WHERE p.price < :price";

    Integer resultCount = em.createQuery(sqlString)
            .setParameter("price", 20000)
            .executeUpdate();

    Assertions.assertEquals(1, resultCount);
}

UPDATE, DELETE 벌크 연산 모두 executeUpdate() 메서드를 사용한다. 반환 값은 벌크 연산의 영향을 받은 레코드 개수이다.

벌크 연산의 주의점

벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리한다. 그러므로 영속성 컨텍스트와 데이터베이스의 데이터가 불일치하는 문제가 발생할 수 있다.

예제 10.125. 벌크 연산 시 주의점 예제

@Test
@Transactional
void bulkProblem() {
    // 상품 1 조회
    Product product =
            em.createQuery("SELECT p FROM Product p WHERE p.name = :name", Product.class)
                    .setParameter("name", "product1")
                    .getSingleResult();

    Assertions.assertEquals(10000, product.getPrice());

    // 벌크 연산 수행
    em.createQuery("UPDATE Product p SET p.price = p.price * 2")
            .executeUpdate();

    Assertions.assertEquals(10000, product.getPrice());
}

위 테스트의 경우 두 번째 검증 로직에서는 product.price의 값이 20000인 것을 기대하는 것이 맞지만 영속성 컨텍스트는 데이터베이스에서 직접 수행된 벌크 연산을 인지하지 못하기 때문에 테스트가 통과한다. 이러한 문제를 해결하기 위해서는 다음과 같은 방안을 적용할 수 있다.

  • EntityManager.refresh() 사용
    벌크 연산 수행 직후 em.refresh(product)와 같이 메서드를 사용하여 데이터베이스로부터 엔터티를 다시 조회할 수 있다. 하지만 이는 데이터베이스에 불필요하게 쿼리를 두 번 전송하게 된다.
  • 벌크 연산 우선 실행
    엔터티 조회 전 벌크 연산을 우선 실행하는 것이 가장 실용적인 해결 방안이다.
  • 벌크 연산 수행 후 영속성 컨텍스트 초기화
    벌크 연산 수행 직후 즉시 영속성 컨텍스트를 초기화하면 영속성 컨텍스트에 남아 있는 엔터티를 제거할 수 있다. 영속성 컨텍스트 초기화 이후 엔터티를 조회하면 벌크 연산이 적용된 상태의 데이터베이스에서 엔터티를 조회하게 된다.

벌크 연산은 영속성 컨텍스트와 2차 캐시를 무시하고 데이터베이스에서 SQL을 직접 실행하게 하기 때문에 이로 인해 발생할 수 있는 불일치를 주의해야 한다. 가능하면 벌크 연산을 우선 실행하되 상황에 따라 영속성 컨텍스트 초기화도 고려해볼 수 있다.

10.6.2 영속성 컨텍스트와 JPQL

쿼리 후 영속 상태인 것과 아닌 것

JPQL의 조회 대상은 엔터티, 임베디드 타입, 값 타입과 같이 다양한 종류가 있다. JPQL로 조회된 엔터티는 영속성 컨텍스트에서 관리되고 나머지는 관리되지 않는다.

JPQL로 조회한 엔터티와 영속성 컨텍스트

영속성 컨텍스트에 이미 존재하는 엔터티를 JPQL로 조회 시 데이터베이스로부터 조회한 결과는 버리고 영속성 컨텍스트의 엔터티가 반환된다. 영속성 컨텍스트는 영속 상태인 엔터티의 동일성을 보장한다. 그러므로 기존에 영속성 컨텍스트에 있던 엔터티가 대체되지 않고 JPQL로 조회한 결과를 버리는 방식을 선택하는 것이다.

find() vs. JPQL

EntityManager.find() 메서드는 최초 조회 시에만 데이터베이스로부터 엔터티를 조회하고 이후에는 영속성 컨텍스트의 1차 캐시에 기본 키 값을 기준으로 엔터티를 관리하여 추가적인 데이터베이스 조회가 발생하지 않는다.

하지만 JPQL은 항상 데이터베이스에 SQL을 실행하여 결과를 조회한다. 또한, 영속성 컨텍스트의 1차 캐시를 먼저 검사하지 않고 데이터베이스로부터 먼저 데이터를 조회한 후 기본 키 값을 기준으로 1차 캐시에 엔터티가 존재하는지 확인한다. 이때 만약 이미 엔터티가 존재한다면 나중에 조회한 결과는 버리게 되는 것이다.

10.6.3 JPQL과 플러시 모드

영속성 컨텍스트에 플러시를 호출하려면 EntityManager.flush() 메서드를 사용하거나 플러시 모드(FlushMode)를 설정하여 적절한 시점에 플러시가 호출되게 유도할 수 있다.

플러시 모드 설정

em.setFlushMode(FlushModeType.AUTO);	// 커밋 또는 쿼리 실행 시 플러시(기본 값)
em.setFlushMode(FlushModeType.COMMIT);	// 커밋 시에만 플러시

쿼리와 플러시 모드

JPQL은 영속성 컨텍스트의 데이터를 고려하지 않고 데이터베이스에서 데이터를 조회한다. 그렇기 때문에 플러시 모드에 따라 영속성 컨텍스트에서 변경된 내용이 데이터베이스에 플러시되기 전 조회가 발생해 영속성 컨텍스트와 데이터 불일치가 발생할 수 있다.

예제 10.126. 쿼리와 플러시 모드 예제

@Test
@Transactional
void flushMode() {
    Product product = em.find(Product.class, 1L);

    // 가격을 20,000원으로 변경
    product.setPrice(20000);

    // 가격이 20,000원인 상품을 조회
    Product foundProduct =
            em.createQuery("SELECT p FROM Product p WHERE p.price = 20000", Product.class)
                    .getSingleResult();

    Assertions.assertNotNull(foundProduct);
}

위 테스트는 성공한다. 플러시 모드의 기본 값은 커밋 또는 쿼리 실행 시 플러시를 호출하는 것이기 때문에 JPQL 실행 전 플러시가 호출되고 JPQL은 상품의 가격이 20,000원으로 변경된 데이터베이스에서 데이터를 조회하게 된다. 그러므로 데이터 불일치는 발생하지 않는다.

예제 10.127. 플러시 모드 설정

@Test
@Transactional
void flushMode() {
    em.setFlushMode(FlushModeType.COMMIT);  // 커밋 시에만 플러시

    Product product = em.find(Product.class, 1L);

    // 가격을 20,000원으로 변경
    product.setPrice(20000);

    // 가격이 20,000원인 상품을 조회
    Product foundProduct =
            em.createQuery("SELECT p FROM Product p WHERE p.price = 20000", Product.class)
                    .getSingleResult();

    Assertions.assertNotNull(foundProduct);
}

위 테스트는 실패한다. 커밋 시에만 플러시를 호출하도록 설정하면 JPQL 실행 시 영속성 컨텍스트의 변경 사항이 반영되지 않은 상태의 데이터베이스에서 데이터를 조회하게 된다. 그러므로 데이터 불일치가 발생한다. 이를 예방하기 위해 쿼리 실행 전 플러시가 호출되어야 할 때는 JPQL 실행 시 EntityManager.setFlushMode()를 호출하여 플러시 모드를 명시적으로 FlushModeType.AUTO로 변경해 주어야 한다.

플러시 모드와 최적화

FlushModeType.COMMIT 모드는 JPQL로 조회한 데이터와 영속성 컨텍스트의 데이터 간 불일치가 발생하지 않을 것이란 보장이 있을 때는 플러시 호출이 반복되지 않게 하여 성능상 이점이 있을 수 있다.

10.7 정리

  • JPQL은 SQL을 추상화해서 특정 데이터베이스 기술에 의존하지 않는다.
  • Criteria나 QueryDSL은 JPQL을 만들어주는 빌더 역할을 할 뿐이다.
  • Criteria나 QueryDSL은 동적 쿼리를 편리하게 작성할 수 있다.
  • Criteria는 JPA가 공식적으로 지원하는 기능이지만 직관적이지 않고 사용하기 불편하다. QueryDSL은 JPA가 공식적으로 지원하는 기능은 아니지만 직관적이고 편리하다.
  • JPA도 네이티브 SQL을 제공하므로 직접 SQL을 사용할 수 있다. 하지만 특정 데이터베이스에 종속적인 SQL을 사용하면 이식성이 떨어진다. 따라서 최대한 JPQL을 사용하고 최후의 수단으로 네이티브 SQL을 사용해야 한다.
  • JPQL은 대량의 데이터를 수정하거나 삭제하는 벌크 연산을 지원한다.
profile
안녕하세요

0개의 댓글