1. 객체지향 쿼리 언어 소개
JPA는 다양한 쿼리 방법을 지원한다. DB에서 특정 조건으로 데이터를 뽑으려면 결국 SQL이 실행돼야 한다. 이전 섹션들에서 JPA가 제공한 em.find() 같은 것 말고도, 검색 조건 같은 걸 적용하는 복잡한 쿼리를 어떻게든 짤 수 있어야 한다.
- JPQL
- JPA Criteria
- QueryDSL
- 네이티브 SQL
- JDBC API 직접 사용, MyBatis, SpringJdbcTemplate 함께 사용
실무에선 대부분 JPQL로 해결할 수 있지만 가끔가다 표준 문법으로 처리할 수 없는 경우가 생기면 다른 쿼리 방법을 사용해야 한다. 아래에서 하나씩 알아보자.
JPQL
가장 단순한 조회 방법은 지금까지 해온 EntityManager를 통해 find()로 조회하거나, 객체 그래프 탐색(a.getB().getC())을 통해 조회하는 것들이 있다. 그러나 나이가 18살 이상인 회원을 모두 검색하고 싶다면 앞에 말한 방법으로는 해결할 수 없다.
- JPA를 사용하면 엔티티 객체를 중심으로 개발하게 된다. 검색을 할 때에도 테이블이 아닌 엔티티 객체를 대상으로 검색하는데, 모든 DB 데이터를 객체로 변환해서 검색할 수는 없다. 따라서 애플리케이션이 필요한 데이터만 DB에서 불러오려면, 결국 검색 조건이 포함된 SQL을 실행해야 한다. 이때 사용하는 게 JPQL이다.
JPA는 SQL을 추상화한 JPQL이라는 객체지향 쿼리 언어를 제공한다.
- SQL을 추상화했기 때문에 특정 DB SQL에 의존하지 않는다.
- SQL과 문법이 유사하고, 기본적으로 ANSI 표준 문법(SELECT, FROM, WHERE, GROUP BY, HAVING, JOIN)을 지원한다.
- DB 테이블을 대상으로 쿼리를 작성하는 SQL과는 다르게, JPQL은 엔티티 객체를 대상으로 쿼리를 작성한다.
// 조회(검색)
String jpql = "select m from Member m where m.name like '%hello%'";
List<Member> result = em.createQuery(jpql, Member.class)
.getResultList();
JPQL
테이블이 아닌 객체를 대상으로 검색하는 객체지향 쿼리 언어로, 객체지향 SQL이라고도 부른다.
위의 JPQL을 작성하면 결국 SQL로 번역되어 실행된다. 실행된 SQL을 살펴보면 아래와 같다.
select
m.id as id,
m.age as age,
m.USERNAME as USERNAME,
m.TEAM_ID as TEAM_ID
from
Member m
where
m.age>18
JPA Criteria
위에서 JPQL은 코드로 작성할 때 단순한 string으로 보이기 때문에 동적 쿼리를 만들기 어렵다. 동적으로 쓰려면 조건에 맞춰 String을 이어 붙이는 방식으로 구현해야 한다. 당연히 에러가 발생하기 쉽고, 찾기도 어렵다는 문제가 생긴다. 이때 사용하는 게 JPA Criteria다.
Criteria를 사용하면 문자가 아닌 자바 코드로 JPQL을 작성할 수 있다. JPQL의 빌더 역할을 한다고 보면 되고, JPA의 공식 기능으로 자리 잡았다.
- 자바 코드로 JPQL을 작성하기 때문에 컴파일 시점에 문법 오류를 찾을 수 있다.
- 그런데 너무 복잡하고 실용성이 없다는 단점이 있어서 실무에서는 QueryDSL을 사용하는 걸 권장한다.
- 아래 코드를 보고 한참을 생각해야 어떤 SQL이 실행될지 파악할 수 있다.
// Criteria 사용 준비
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Member> query = cb.createQuery(Member.class);
// 루트 클래스 (조회를 시작할 클래스)
Root<Member> m = query.from(Member.class);
// 쿼리 생성
CriteriaQuery<Member> cq = query.select(m)
.where(cb.equal(m.get("username"), "kim"));
List<Member> resultList = em.createQuery(cq).getResultList();
QueryDSL
JPA Criteria와 동일하게 문자가 아닌 자바 코드로 JPQL을 작성할 수 있고, JPQL의 빌더 역할을 한다.
- 컴파일 시점에 문법 오류를 찾을 수 있고, 동적 쿼리를 작성하기에 단순하고 편리하다는 장점이 있다.
- 아래 코드만 봐도 어떤 SQL이 실행될지 쉽게 파악할 수 있다.
- 실무에서는 QueryDSL을 사용하는 것을 권장한다.
// JPQL
// select m from Member m where m.age > 18
JPAFactoryQuery query = new JPAQueryFactory(em);
QMember m = QMember.member;
List<Member> list = query.selectFrom(m).where(m.age.at(18))
.orderBy(m.name.desc())
.fetch();
네이티브 SQL
JPA가 제공하는 SQL을 직접 사용하는 기능이다. JPQL로 해결할 수 없는 특정 DB에 의존적인 기능을 사용할 때 쓴다.
- ex. 오라클 CONNECT BY, 특정 DB만 사용하는 SQL 힌트
String sql = "SELECT ID, AGE, TEAM_ID, NAME FROM MEMBER WHERE NAME = 'KIM'";
List<Member> resultList = em.createNativeQuery(sql, Member.class)
.getResultList();
JDBC API 직접 사용, MyBatis, SpringJdbcTemplate 함께 사용
JPA를 사용하면서 JDBC 커넥션을 직접 사용하거나, 스프링 JdbcTemplate 또는 MyBatis를 함께 사용할 수 있다.
- 단, 영속성 컨텍스트를 적절한 시점에 강제로 flush() 해야 한다.
- ex. JPA를 우회해서 SQL을 실행하기 직전에 영속성 컨텍스트 수동 플러시
Member member = new Member();
member.setUsername("member1");
em.persist(member); // 쓰기 지연 SQL 저장
// flush 호출
// = SQL 실행
// = commit 되기 직전 또는 em을 통해 query가 날아갈 때
// jdbcCon.executeQuery(...);
// 실행해도 flush가 자동으로 호출되지 않음
// = DB에 회원이 저장되지 않음
for (Member member1 : resultList) {
System.out.println("member1 = " + member1); // 값이 없으므로 출력 X
}
2. JPQL(Java Persistence Query Language)
JPQL은 객체지향 쿼리 언어다. 따라서 테이블을 대상으로 쿼리하지 않고 엔티티 객체를 대상으로 쿼리한다.
- SQL을 추상화하기 때문에 특정 DB SQL에 의존하지 않는다.
- 작성하고 나면 결국 SQL로 변환된다.
기본 문법과 쿼리 API
아래 객체 모델과 DB 모델을 보면서 JPQL의 기본 문법과 쿼리 API에 대해 알아보자.
a. JPQL 문법
JPQL 문법은 SQL과 동일하다. update와 delete는 벌크 연산을 통해 한 번에 여러 개를 변경할 수 있다.
나이가 18살 이상인 회원을 찾는 쿼리를 JPQL로 작성해 보면 아래와 같다.
- 엔티티(Member)와 속성(age)은 대소문자를 구분한다.
- 테이블 이름이 아닌 엔티티 이름을 사용해야 한다.
- JPQL 키워드(SELECT, FROM, where)는 대소문자를 구분하지 않는다.
- 별칭(m)은 필수지만 as는 생략해도 된다.
String jpql = "select m from Member as m WHERE m.age > 18";
b. 집합과 정렬
기본적으로 ANSI 표준 문법에서 제공하는 건 다 사용할 수 있다.
- GROUP BY와 HAVING, ORDER BY도 비슷하게 사용하면 된다.
c. TypeQuery, Query
TypeQuery
- 반환 타입이 명확할 때 사용한다.
TypedQuery<Member> q1 = em
.createQuery("select m from Member m", Member.class);
TypedQuery<String> q2 = em
.createQuery("select m.username from Member m", String.class);
Query
- 반환 타입이 명확하지 않을 때 사용한다.
Query q1 = em
.createQuery("select m.username, m.age from Member m");
d. 결과 조회 API
query.getResultList()
- 결과가 하나 이상일 때, 리스트로 반환하도록 한다.
- 결과가 없다면, 빈 리스트를 반환한다.
TypedQuery<Member> query = em
.createQuery("select m from Member m", Member.class);
List<Member> resultList = query.getResultList();
query.getSingleResult()
- 결과가 정확히 하나일 때, 단일 객체를 반환하도록 한다. 다른 경우엔 에러가 발생한다.
- 결과가 없으면, jakarta.persistence.NoResultException
- 둘 이상이면, jakarta.persistence.NonUniqueResultException
TypedQuery<Member> query = em
.createQuery("select m from Member m", Member.class);
Member result = query.getSingleResult();
Spring Data JPA를 사용하면, getSingleResult()를 호출했는데 결과가 없는 경우 null이나 Optional을 반환해 준다.
e. 파라미터 바인딩
이름 기준(속성=:파라미터)
TypedQuery<Member> query = em
.createQuery("select m from Member m where m.username=:username", Member.class);
query.setParameter("username", usernameParam);
위치 기준(속성=?파라미터)
TypedQuery<Member> query = em
.createQuery("select m from Member m where m.username=?1", Member.class);
query.setParameter(1, usernameParam);
참고
[위치 기준 파라미터 바인딩]은 사용하지 말자.
이유는 EnumType을 ORDINAL로 설정하면 안 되는 이유와 동일하다.
프로젝션(SELECT)
SELECT 절에 조회할 대상을 지정하는 것을 말한다. 프로젝션의 대상은 엔티티 타입과 임베디드 타입, 스칼라 타입(숫자, 문자 등 기본 데이터 타입)이 올 수 있다. SELECT 뒤에 DISTINCT를 적으면 중복된 행을 제거할 수 있다.
- 엔티티 프로젝션
- SELECT m FROM Member m
- SELECT m.team FROM Member m
- 임베디드 타입 프로젝션
- SELECT m.address FROM Member m
- 스칼라 타입 프로젝션
- SELECT m.username, m.age FROM Member m
참고
엔티티 프로젝션의 결과로 나온 모든 엔티티는 영속성 컨텍스트에 의해 관리된다.
여러 값을 조회하는 경우에 대해 더 자세히 알아보자. SELECT 절에 여러 필드가 나열돼 있을 땐 아래 3가지 방법으로 조회하게 된다.
- Query 타입으로 조회
List resultList = em
.createQuery("select m.username, m.age from Member m")
.getResultList();
Object o = resultList.get(0);
Object[] result = (Object[]) o;
- Object[] 타입으로 조회
List<Object[]> resultList = em
.createQuery("select m.username, m.age from Member m")
.getResultList();
Object[] result = resultList.get(0);
- new 명령어로 조회
- 단순 값을 DTO로 바로 조회할 수 있다.
- 패키지 이름을 포함한 전체 class 이름을 입력해야 한다.
- 순서와 타입이 일치하는 생성자가 필요하다.
public class MemberDTO {
private String username;
private int age;
public MemberDTO(String username, int age) {
this.username = username;
this.age = age;
}
// Getter, Setter
}
///
List<MemberDTO> resultList = em
.createQuery("select new jpql.MemberDTO(m.username, m.age) from Member m", MemberDTO.class)
.getResultList();
MemberDTO result = resultList.get(0);
페이징 API
JPA는 페이징을 다음 두 API로 추상화한다.
- setFirstResult(int startPosition)
- 조회 시작 위치 (0부터 시작)
- setMaxResults(int maxResult)
- 조회할 데이터 수
// 페이징 쿼리
String jpql = "select m from Member m order by m.name desc";
List<Member> resultList = em
.createQuery(jpql, Member.class)
.setFirstResult(10)
.setMaxResults(20)
.getResultList();
MySQL과 Oracle은 페이징 문법이 다르다.
- MySQL 방언
SELECT
M.ID AS ID,
M.AGE AS AGE,
M.TEAM_ID AS TEAM_ID
M.NAME AS NAME
FROM
MEMBER M
ORDER BY
M.NAME DESC LIMIT ?, ?
- Oracle 방언
SELECT * FROM
( SELECT ROW_.*, ROWNUM ROWNUM_
FROM
( SELECT
M.ID AS ID,
M.AGE AS AGE,
M.TEAM_ID AS TEAM_ID,
M.NAME AS NAME
FROM MEMBER M
ORDER BY M.NAME
) ROW
WHERE ROWNUM <= ?
WHERE ROWNUM_ > ?
조인
조인은 크게 내부 조인과 외부 조인, 세타 조인으로 나눈다. 엔티티 객체를 중심으로 조인을 진행하기 때문에 Member m 안에 있는 Team t와 조인하도록 할 수 있다.
- 내부 조인(inner join)
- SELECT m FROM Member m [INNER] JOIN m.team t
- 외부 조인(outer join)
- SELECT m FROM Member m LEFT [OUTER] JOIN m.team t
- 세타 조인(cross join)
- SELECT count(m) FROM Member m, Team t where m.username = t.name
JPA 2.1부터 지원하는 ON절을 활용하면 조인 대상을 필터링하거나 연관관계가 없는 엔티티를 외부 조인할 수 있다.
- 조인 대상 필터링
# 회원과 팀을 조인하면서, 팀 이름이 A인 팀만 조인
# 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'
- 연관관계가 없는 엔티티를 외부 조인 (하이버네이트 5.1부터 사용 가능)
# 회원의 이름과 팀의 이름이 같은 대상을 외부 조인
# JPQL
SELECT m, t FROM Member m LEFT JOIN Team t ON m.username=t.name
# SQL
SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.username=t.name
서브 쿼리
SQL에서 말하는 서브 쿼리와 동일하다. 쿼리 안에 서브로 쿼리를 넣을 수 있다고 보면 된다.
- 보통 메인 쿼리와 서브 쿼리가 관계가 없도록 짜야 성능이 잘 나온다.
# 나이가 평균보다 많은 회원
SELECT m FROM Member m WHERE m.age >
( SELECT avg(m2.age) FROM Member m2 )
# 한 건이라도 주문한 고객
SELECT m FROM Member m WHERE
( SELECT count(o) FROM Order o WHERE m = o.member ) > 0
서브 쿼리에서 지원하는 다양한 함수가 존재한다.
- [NOT] EXISTS (subquery): 서브 쿼리에 결과가 존재하면 참을 반환한다.
# 팀A 소속인 회원
SELECT m FROM Member m
WHERE EXISTS ( SELECT t FROM m.team t WHERE t.name='팀A' )
- {ALL | ANY | SOME} (subquery)
- ALL: 서브 쿼리가 모두 만족하면 참을 반환한다.
- ANY, SOME: 서브 쿼리의 결과 중 하나라도 만족하면 참을 반환한다.
# 전체 상품 각각의 재고보다 주문량이 많은 주문들
SELECT o FROM Order o
WHERE o.orderAmount > ALL ( SELECT p.stockAmount
FROM Product p )
- [NOT] IN (subquery): 서브 쿼리의 결과 중 하나라도 같은 것이 있으면 참을 반환한다.
# 어떤 팀이든 팀에 소속된 회원
SELECT m FROM Member m
WHERE m.team = ANY ( SELECT t from Team t )
JPA는 WHERE, HAVING 절에서만 서브 쿼리를 사용할 수 있다.
- 하이버네이트의 지원을 받아 SELECT 절에서도 사용할 수 있다.
참고
하이버네이트6부터는 FROM 절의 서브 쿼리(인라인 뷰)도 지원한다.
원래는 SELECT 절(단일; 스칼라)과 WHERE 절(단일, 다중), HAVING 절에만 사용할 수 있었다.
JPQL 타입 표현과 기타식
a. 타입 표현
- 문자
- ex. 'HELLO', 'SHE''s'
- 숫자
- ex. 10L(Long), 10D(Double), 10F(Float)
- Boolean
- ex. TRUE, FALSE
- ENUM (패키지 이름 포함)
- ex. jpabook.MemberType.ADMIN, jpabook.MemberType.USER
- 엔티티 타입 (상속 관계에서 사용)
- ex. TYPE(i) = Book
b. 기타
SQL과 문법이 같은 식
- EXISTS, IN
- AND, OR, NOT
- =, >, >=, <, <=, <>
- BETWEEN, LIKE, IS NULL
조건식(CASE 등)과 함수
a. 조건식(CASE 등)
CASE 식을 사용해 쿼리 안에서 조건식을 만들 수 있다. switch-case 문과 비슷하게 생겼다.
- 기본 CASE 식
SELECT
CASE WHEN m.age <= 10 THEN '학생요금'
WHEN m.age >= 60 THEN '경로요금'
ELSE '일반요금'
END
FROM Member m
- 단순 CASE 식
SELECT
CASE t.name
WHEN '팀A' THEN '인센티브110%'
WHEN '팀B' THEN '인센티브120%'
ELSE '인센티브105%'
END
FROM Team t
- COALESCE: 하나씩 조회해서 NULL이 아니면 반환한다.
SELECT COALESCE(m.username, '이름 없는 회원') FROM Member m
- NULLIF: 두 값이 같으면 NULL을 반환하고, 다르면 첫 번째 값을 반환한다.
SELECT NULLIF(m.username, '관리자') FROM Member m
b. JPQL 함수
JPQL에서 기본으로 다양한 함수를 제공한다.
- CONCAT: 문자열 더하기 (||을 사용해도 됨)
- SUBSTRING: 문자열 추출하기
- TRIM: 특정 문자 제거하기
- LOWER, UPPER: 대소문자 변경
- LENGTH: 문자열 길이
- LOCATE: 특정 문자의 위치 찾기
- ABS, SQRT, MOD: 절댓값, 제곱근, 나머지 구하기
- SIZE, INDEX(JPA 용도): 아래 코드로 살펴보기
// SIZE: 컬렉션의 크기 반환
String query = "select size(t.members) from Team t";
// INDEX: 값 타입 컬렉션 리스트에서 위치 값을 구할 때 사용 가능
@OrderColumn
String query = "select index(t.members) from Team t";
위에 있는 기본 함수로 해결할 수 없는 경우, 사용자 정의 함수를 만들어 호출할 수 있다.
- 하이버네이트는 사용 전 방언에 추가해야 한다.
...
<properties>
...
<property name="hibernate.dialect" value="dialect.MyH2Dialect"/>
...
</properties>
...
- 사용하는 DB 방언을 상속받고, 사용자 정의 함수를 등록한다.
public class MyH2Dialect extends H2Dialect {
public MyH2Dialect() {
registerFunction("group_concat"
new StandardSQLFunction("group_concat"),
StandardBasicTypes.STRING);
}
}
///
String query = "select function('group_concat', m.username) from Member m";
String query = "select group_concat(m.username) from Member m";
참고
하이버네이트 구현체를 살펴보면 이미 다양한 사용자 정의 함수가 등록돼 있다.