EntityManager.find() 메서드를 사용하면 식별자로 단일 엔터티를 조회할 수 있고, 조회한 엔터티에 객체 그래프 탐색을 사용하여 연관된 엔터티들을 조회할 수 있다. 이것은 가장 단순한 조회 방법이다.
EntityManager.find())Member.getTeam())하지만 조건에 따라 엔터티를 필터링해야 하는 경우 데이터베이스에서는 단순 조회만 수행하고 애플리케이션에서 필터링을 수행하는 것은 매우 비효율적이다. 데이터를 조회할 때부터 SQL로 필터링을 수행하는 것이 훨씬 효율적이며 그것이 데이터베이스의 존재 이유이기도 하다. 하지만 ORM은 데이터베이스 테이블이 아닌 엔터티 객체를 활용한 기술이므로 검색 또한 엔터티 객체를 대상으로 이루어져야 한다. 이러한 불일치를 해결하기 위해 JPQL이 고안되었다.
JPQL의 특징은 다음과 같다.
SQL은 데이터베이스 테이블을 대상으로 하는 데이터 중심 쿼리, JPQL은 엔터티 객체를 대상으로 하는 객체지향 쿼리이다. JPQL 사용 시 JPA는 이를 분석하여 적절한 SQL을 생성해 데이터베이스를 조회한다. 그리고 조회한 결과로 엔터티 객체를 생성해 반환한다.
JPA는 다음과 같은 다양한 검색 방법을 제공한다.
JPA가 공식 지원하는 기능은 아니지만 다음과 같은 유용한 기능도 있다.
Criteria, QueryDSL도 결국 JPQL을 편하게 작성하도록 도와주는 빌더 클래스일 뿐이다. 그러므로 이 기술들을 이해하려면 JPQL에 대한 이해가 선행되어야 한다.
JPQL(Java Persistence Query Language)의 특징은 다음과 같다.
예제 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이다.
Criteria Query의 특징은 다음과 같다.
Criteria는 JPQL을 생성하는 빌더 클래스이다.예제 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 쿼리를 작성할 수 있다.
QueryDSL의 특징은 다음과 같다.
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-클래스를 생성해야 한다. 때문에 초기 설정이 복잡하다.
네이티브 SQL의 특징은 다음과 같다.
예제 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());
}
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를 우회하는 영속성 계층 접근 메서드를 호출할 때마다 영속성 컨텍스트를 플러시하게 할 수 있다.
JPQL의 특징은 다음과 같다.

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 m FROM Member AS m WHERE m.username = 'Hello'
Member는 엔터티 이름으로, @Entity(name = "Member")과 같이 애너테이션으로 지정한다. 기본 값은 클래스 이름이다.m과 같은 별칭을 생략하면 문법 오류가 발생한다. AS 키워드는 생략할 수 있다.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 예외가 발생한다.jakarta.persistence.NonUniqueResultException 예외가 발생한다.JDBC는 위치 기준 파라미터 바인딩만 지원하고, JPQL은 이름 기준 파라미터 바인딩까지 지원한다.
파라미터 바인딩 방식의 특징은 다음과 같다.
이름 기준 파라미터(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());
}
SELECT 절에 조회할 대상을 지정하는 것을 프로젝션(projection, )이라 하고, [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];
}
}
실제 애플리케이션 개발 시에는 데이터 전송 객체(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가지를 주의해야 한다.
페이징 처리용 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을 직접 사용해야 한다.
| 함수 | 설명 |
|---|---|
| COUNT | 결과 수를 구한다. 반환 타입: Long |
| MAX, MIN | 최대, 최소 값을 구한다. 문자, 숫자, 날짜 등에 사용한다. |
| AVG | 평균 값을 구한다. 숫자 타입만 사용할 수 있다. 반환 타입: Double |
| SUM | 합을 구한다. 숫자 타입만 사용할 수 있다. 반환 타입 - 정수합: Long, 소수합: Double, BigInteger합: BigInteger, BigDecimal합: BigDecimal |
NULL 값은 무시된다.NULL 값이 반환된다. COUNT 함수는 예외적으로 0을 반환한다.DISTINCT 키워드를 집합 함수 안에 사용할 수 있다.DISTINCT 키워드를 COUNT 함수 안에 사용 시 임베디드 타입은 지원하지 않는다.groupby_clause ::= GROUP BY {단일_값_경로 | 별칭}+
having_clause ::= HAVING {조건_식}
GROUP BY, HAVING 절도 사용 가능하지만 실시간 환경에서는 통계 쿼리가 효율적으로 부담되기 때문에 보통 통계 결과 테이블을 별도로 유지하는 방법을 많이 사용한다.
orderby_clause ::= ORDER BY {상태_필드_경로 | 결과_변수 [ASC | DESC]}+
(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
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'
페치(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());
}
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)와 같이 엔터티에 직접 적용하는 로딩 전략은 애플리케이션 전체에 영향을 미치므로 글로벌 로딩 전략이라 부른다. 페치 조인은 글로벌 로딩 전략보다 우선한다. 그러므로 글로벌 로딩 전략이 지연 로딩이더라도 페치 조인을 통해 즉시 로딩처럼 동작하게 할 수 있다.
최적화를 위해 글로벌 로딩 전략을 즉시 로딩으로 설정하면 애플리케이션 전체에서 항상 즉시 로딩이 일어난다. 일부 상황에선 빠를 수 있지만 전체적으로 보았을 때 사용하지 않는 엔터티를 자주 로딩하는 것은 오히려 성능에 악영향을 미칠 수 있다. 따라서 글로벌 로딩 전략은 가능한 지연 로딩을 사용하면서 최적화가 필요할 때 페치 조인을 적용하는 것이 효과적이다.
또한 페치 조인 사용 시 연관된 엔터티를 쿼리 시점에 조회하므로 지연 로딩이 발생하지 않고, 준영속 상태에서도 객체 그래프를 탐색할 수 있다.
페치 조인에는 다음과 같은 한계가 있다.
SELECT 절, WHERE 절, 서브쿼리에 페치 조인 대상을 사용할 수 없다.setFirstResult, setMaxResults)를 사용할 수 없다.페치 조인은 주로 객체 그래프를 유지할 때 사용하면 효과적이고, 여러 테이블의 조인 결과를 활용해야 한다면 페치 조인보다는 요구사항에 맞는 SQL의 호출 결과를 DTO로 변환하는 것이 더 효과적일 수 있다.
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이 모두 경로 표현식을 사용한 예이다.
상태 필드는 단순히 값을 저장하는 필드, 연관 필드는 객체 간 연관 관계를 맺기 위해 사용하는 필드이다.
예제 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.agem.teamm.ordersJPQL의 경로 표현식은 종류마다 다음과 같은 특징이 있다.
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 tINNER 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 절에 영향을 준다.JPA 표준에 따르면 JPQL의 서브쿼리는 WHERE, HAVING 절에만 사용할 수 있고 SELECT, FROM 절에는 사용할 수 없다. 하지만 구현체에 따라서는 SELECT, FROM 절에도 사용할 수 있고 하이버네이트는 SELECT 절의 서브쿼리를 허용한다.
[NOT] EXISTS (subquery){ALL | ANY | SOME} (subquery)[NOT] IN (subquery)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'} |
| Boolean | TRUE, FALSE | |
| Enum | 패키지 이름을 포함한 전체 이름을 사용해야 한다. | jpabook.MemberType.Admin |
| 엔터티 타입 | 엔터티의 타입을 표현한다. 주로 상속과 관련해 사용한다. | TYPE(m) = Member |
.+, -, *, /=, >, >=, <, <=, <>, [NOT] BETWEEN, [NOT] LIKE, [NOT] IN, IS [NOT] NULL, IS [NOT] EMPTY, [NOT] MEMBER [OF], [NOT] EXISTSNOT, AND, ORAND, OR, NOT
=, >, >=, <, <=, <>
X [NOT] BETWEEN A AND BX [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 EMPTY를 IS 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 식은 4가지 종류가 있다.
CASECASECOALESCENULLIFCASE
{WHEN <조건_식> THEN <스칼라_식>}+
ELSE <스칼라_식>
END
조건 식을 사용할 수 없지만 문법이 단순하다. 자바의 switch case 문과 유사하다.
CASE <조건_대상>
{WHEN <스칼라_식_1> THEN <스칼라_식_2>}+
ELSE <스칼라_식>
END
NULL이 아닌 첫 번째 값을 반환한다.
COALESCE(<스칼라_식> {,<스칼라_식>}+)
두 값이 같으면 NULL, 다르면 첫 번째 값을 반환한다.
NULLIF(<스칼라_식>, <스칼라_식>)
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은 엔터티 상속 구조에서 조회 대상을 특정 자식 타입으로 한정할 때 주로 사용한다.
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 표준은 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'
enum은 = 비교 연산만 지원한다.JPA 표준은 ''을 길이 0인 Empty String으로 지정했지만 데이터베이스에 따라 이를 NULL로 취급하는 경우도 있으므로 확인해야 한다.
NULL이다.NULL은 알 수 없는 값(unknown value)이다. NULL과의 모든 수학적 연산 결과는 NULL이 된다.NULL == NULL은 알 수 없는 값이다.NULL IS NULL은 참이다.| AND | T | F | NULL |
|---|---|---|---|
| T | T | F | NULL |
| F | F | F | F |
| NULL | NULL | F | NULL |
| OR | T | F | NULL |
|---|---|---|---|
| T | T | T | T |
| F | T | F | NULL |
| NULL | T | NULL | NULL |
| NOT | |
|---|---|
| T | F |
| F | T |
| NULL | NULL |
객체 인스턴스는 참조 값으로 식별하고 테이블의 행은 기본 키 값으로 식별한다. 그러므로 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과 같은 경로 표현식을 사용하면 묵시적 조인이 발생한다.
JPQL 쿼리는 크게 동적 쿼리, 정적 쿼리로 나눌 수 있다.
em.createQuery("SELECT ...")처럼 JPQL을 문자열로 완성하여 직접 전달하는 것이다. 런타임에 특정 조건에 따라 JPQL을 동적으로 구성할 수 있다.Named 쿼리는 애플리케이션 로딩 시점에 JPQL 문법을 검사하고 미리 파싱해 둔다. 그러므로 컴파일 시점에 오류를 발견할 수 있고 런타임에는 파싱된 결과를 재사용하므로 성능상의 이점도 있다. 그리고 정적 SQL 자체가 데이터베이스 레벨에서의 조회 성능 최적화에도 도움이 된다.
Named 쿼리는 @NamedQuery 애너테이션을 사용해 프로그래밍 방식으로 작성하거나 XML 문서에 작성할 수 있다.
예제 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차 캐시 활용 등에 사용한다.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을 상황에 맞추어 변경하여 배포하면 된다.
Criteria 쿼리는 JPQL을 Java 프로그래밍 방식으로 작성하도록 도와주는 빌더 클래스 API이다. 프로그래밍 방식으로 JPQL을 작성하므로 문법 오류를 컴파일 시점에 감지할 수 있고 문자열 기반의 JPQL보다 동적 쿼리를 안전하게 생성할 수 있다는 장점이 있다. 하지만 코드의 가독성이 떨어진다는 단점도 있다.
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 쿼리를 완성하는 과정은 다음과 같다.
CriteriaBuilder)를 EntityManager 또는 EntityManagerFactory에서 얻을 수 있다.CriteriaQuery)를 생성한다. 이때 반환 타입을 지정할 수 있다.FROM 절을 생성한다. 반환된 값 m은 Criteria에서 사용하는 특별한 별칭으로, 조회의 시작점이라는 의미로 쿼리 루트(Root)라고 부른다.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());
}
검색 조건과 정렬 조건을 추가하여 다음과 같은 과정이 추가되었다.
equal() 메서드를 사용해 검색 조건을 정의한다.desc() 메서드를 사용해 정렬 조건을 정의한다.SELECT 절 생성 이후 WHERE, ORDER BY 절을 생성한다.Criteria는 검색 조건부터 정렬 조건까지 Criteria 빌더(CriteriaBuilder)를 사용해서 코드를 완성한다.
쿼리 루트(Query Root)와 별칭의 특징은 다음과 같다.
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)과 같이 작성해야 했다. 하지만 최신 버전에서는 매우 우수한 타입 추론 기능을 제공하기 때문에 제네릭을 사용할 필요가 없다.
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.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() 메서드를 활용할 수도 있다.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());
}
쿼리의 결과를 특정 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.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과 같다.
위 예제에서 팀 구성원 중 가장 어린 구성원의 나이가 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과 같다.
정렬 조건도 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)조인은 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 JOIN은 Root.fetch() 메서드를 사용한다.
다음은 나이가 평균 이상인 회원을 구하는 서브쿼리이다.
예제 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 등을 수행할 수 있게 된다.
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());
}
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.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에 파라미터 바인딩을 사용한다.
네이티브 함수 호출은 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);
}
동적 쿼리는 문자 기반인 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 특유의 낮은 가독성 때문에 여전히 복잡하다.
Expression 클래스의 메서드는 다음과 같은 것들이 있다. m.get("username")의 반환 타입이 대표적으로 Expression 클래스의 객체이다.
| function name | JPQL |
|---|---|
| isNull() | IS NULL |
| isNotNull() | IS NOT NULL |
| in() | IN |
JPQL에서 사용하는 함수는 대부분 CriteriaBuilder에 정의되어 있다.
| function name | JPQL |
|---|---|
| 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 name | JPQL |
|---|---|
| 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 |
| currentTimestamp | CURRENT_TIMESTAMP |
| function name | JPQL |
|---|---|
| avg() | AVG |
| max(), greatest() | MAX |
| min(), least() | MIN |
| sum(), sumAsLong(), sumAsDouble() | SUM |
| count() | COUNT |
| countDistinct() | COUNT DISTINCT |
| function name | JPQL |
|---|---|
| nullIf() | NULLIF |
| coalesce() | COALESCE |
| selectCase() | CASE |
QueryDSL도 Criteria처럼 JPQL 빌더 역할을 하여 프로그래밍 방식으로 JPQL을 작성할 수 있다. 그러나 Criteria보다 가독성이 훨씬 좋다. QueryDSL은 오픈소스 프로젝트로 처음에는 HQL(하이버네이트 쿼리 언어)을 코드로 작성할 수 있도록 해 주는 프로젝트로 시작하여 현재는 JPA, JDO, JDBC, MongoDB, 자바 컬렉션 등을 다양하게 지원한다.
스프링 부트를 기준으로 다음과 같은 의존성들을 설치한다.
예제 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.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)은 사용의 편의성을 위해 기본 인스턴스를 멤버 변수로 저장하고 있다. 하지만 같은 엔터티를 조인하거나 서브쿼리에 사용하면 같은 별칭이 사용되므로 이때는 별칭을 직접 지정해야 한다.
예제 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.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 연산이 된다.
쿼리 작성을 마친 후 결과 조회 메서드를 호출하면 실제 데이터베이스를 조회한다. 대표적인 결과 조회 메서드는 다음과 같다.
fetch(): 조회 결과가 한 건 이상일 때 사용한다.fetchOne(): 조회 결과가 한 건일 때 사용한다. 결과가 없으면 null, 두 건 이상이면 NonUniqueResultException 예외가 발생한다.fetchFirst(): 조회 결과가 한 건 이상일 때 첫 번째 데이터를 반환한다.예제 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());
}
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());
}
조인(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());
}
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());
}
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() 메서드는 생성자를 사용해 값을 채운다. 앞선 방법들과 달리 별칭을 부여할 필요가 없는 대신 생성자의 매개변수 순서에 잘 맞추어 인자를 전달해야 한다.
JPAQueryFactory 객체의 select() 메서드 이후 distinct() 메서드 사용 시 전체 결과의 유일성을 보장하고, select() 메서드의 인자로 주어지는 필드에 countDistinct() 메서드 사용 시 해당 필드 값의 유일성만을 보장한다.
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 클래스 객체를 사용해 삭제 배치 쿼리를 작성한다.
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());
}
메서드 위임(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());
}
JPQL은 다음과 같이 특정 데이터베이스에 종속적인 기능은 지원하지 않는다.
CONNECT BY와 같이 특정 데이터베이스에 과하게 종속된 SQL 문법의 경우 네이티브 SQL을 사용해야 한다.UNION, INTERSECT, EXCEPT하지만 JPA는 위와 같은 기능들을 사용할 수 있는 다양한 방법을 열어두었고 특히 JPA 구현체들은 JPA 표준보다 다양한 방법들을 제공한다. 다양한 이유로 JPQL을 사용할 수 없을 때 JPA를 통해 네이티브 SQL을 사용할 수 있다. 그러므로 네이티브 SQL을 사용하면 엔터티를 조회할 수 있고 JPA가 지원하는 영속성 컨텍스트의 기능을 그대로 사용할 수 있다. 반면 JDBC API는 단순한 데이터의 목록을 조회할 뿐이다.
네이티브 쿼리 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 애너테이션을 한 번이라도 사용하면 전체 필드를 이 애너테이션으로 매핑해야 한다. 이 애너테이션은 두 엔터티 조회 시 컬럼명이 중복될 때 별칭을 부여한 후 매핑해야 할 때 사용할 수 있다.
| 속성 | 기능 |
|---|---|
| name | 결과 매핑 이름 |
| entities | @EntityResult를 사용해 엔터티를 결과로 매핑한다. |
| columns | @ColumnResult를 사용해 컬럼을 결과로 매핑한다. |
| 속성 | 기능 |
|---|---|
| entityClass | 결과로 사용할 엔터티의 클래스를 지정한다. |
| fields | @FieldResult를 사용해 결과 컬럼을 필드와 매핑한다. |
| discriminatorColumn | 상속 관계에서 엔터티의 인스턴스 타입을 구분한다. |
| 속성 | 기능 |
|---|---|
| name | 결과를 받을 필드 이름 |
| column | 결과 컬럼 이름 |
| 속성 | 기능 |
|---|---|
| name | 결과 컬럼 이름 |
네이티브 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]);
}
}
| 속성 | 기능 |
|---|---|
| name | 명명된 쿼리 이름(필수) |
| query | SQL 쿼리(필수) |
| hints | 벤더 종속적인 힌트 |
| resultClass | 결과 클래스 |
| resultSetMapping | 결과 매핑 사용 |
hints 속성은 SQL 힌트가 아닌 JPA 구현체제 제공하는 힌트를 의미한다.
예제 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 기반의 방식과 애너테이션 기반의 방식 모두 애플리케이션 레벨에서의 사용 방법은 같다.
네이티브 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를 함께 사용하는 방안도 고려할 수 있다.
영속성 컨텍스트의 변경 감지 기능이나 병합을 사용해 엔터티를 수정하거나, 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을 직접 실행하게 하기 때문에 이로 인해 발생할 수 있는 불일치를 주의해야 한다. 가능하면 벌크 연산을 우선 실행하되 상황에 따라 영속성 컨텍스트 초기화도 고려해볼 수 있다.
JPQL의 조회 대상은 엔터티, 임베디드 타입, 값 타입과 같이 다양한 종류가 있다. JPQL로 조회된 엔터티는 영속성 컨텍스트에서 관리되고 나머지는 관리되지 않는다.
영속성 컨텍스트에 이미 존재하는 엔터티를 JPQL로 조회 시 데이터베이스로부터 조회한 결과는 버리고 영속성 컨텍스트의 엔터티가 반환된다. 영속성 컨텍스트는 영속 상태인 엔터티의 동일성을 보장한다. 그러므로 기존에 영속성 컨텍스트에 있던 엔터티가 대체되지 않고 JPQL로 조회한 결과를 버리는 방식을 선택하는 것이다.
EntityManager.find() 메서드는 최초 조회 시에만 데이터베이스로부터 엔터티를 조회하고 이후에는 영속성 컨텍스트의 1차 캐시에 기본 키 값을 기준으로 엔터티를 관리하여 추가적인 데이터베이스 조회가 발생하지 않는다.
하지만 JPQL은 항상 데이터베이스에 SQL을 실행하여 결과를 조회한다. 또한, 영속성 컨텍스트의 1차 캐시를 먼저 검사하지 않고 데이터베이스로부터 먼저 데이터를 조회한 후 기본 키 값을 기준으로 1차 캐시에 엔터티가 존재하는지 확인한다. 이때 만약 이미 엔터티가 존재한다면 나중에 조회한 결과는 버리게 되는 것이다.
영속성 컨텍스트에 플러시를 호출하려면 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로 조회한 데이터와 영속성 컨텍스트의 데이터 간 불일치가 발생하지 않을 것이란 보장이 있을 때는 플러시 호출이 반복되지 않게 하여 성능상 이점이 있을 수 있다.