기본 문법
JPQL의 기본 문법은 SQL 표준과 거의 동일하지만, 엔티티에 대한 부분이 포함된다는 점만 차이가 있다. 엔티티와 엔티티의 속성은 대소문자를 구분해야하고(Member, age), 그 외의 SELECT, FROM, WHERE 등 JPQL 키워드는 대소문자를 구분하지 않는다. 이때 테이블의 이름이 아닌, 엔티티 이름(일반적으로 클래스명)을 사용하고, as는 생략할 수 있지만 별칭(m)은 필수로 추가해야한다.
파라미터 바인딩
// 이름 기반 바인딩
String sql = "SELECT m FROM Member m WHERE m.username = :username"
query.setParameter("username", usernameParam);
// 위치 기반 바인딩
String sql = "SELECT m FROM Member m WHERE m.username = ?1"
query.setParameter(1, usernameParam);
Java
복사
이와 같이 콜론(:)이나 물음표(?) 기호를 통해, 파라미터 바인딩으로 변수를 JPQL 쿼리 안에 넣을 수 있다. 하지만 위치 기반 바인딩의 경우 중간에 다른 바인딩이 끼어드는 경우 숫자를 재정렬하거는 등의 불편함이 있기 때문에, 가급적 이름 기반 바인딩을 사용하기를 권장된다.
프로젝션
•
엔티티 프로젝션
SELECT m FROM Member m
SELECT m.team FROM Member m
SQL
복사
•
임베디드 타입 프로젝션
SELECT m.address FROM Member m
SQL
복사
•
스칼라 타입 프로젝션
SELECT DISTINCT m.username, m.age FROM Member m
SQL
복사
여러 데이터를 조합하여 가져오는 스칼라 타입 프로젝션의 경우 데이터를 받는 Object 타입을 통해 받거나 데이터들에 맞춘 DTO를 통해 new 명령어를 통해 받아오는 방법이 있다.
// DTO
public class MemberDto {
private String username;
private int age;
public MemberDto(String username, int age) {
this.username = username;
this.age = age;
}
}
// Query
List<MemberDto> resultList = em.createQuery("SELECT new jpql.MemberDto(m.username, m.age) FROM Member m")
.getReusultList();
Java
복사
패키지 명을 전부 입력해야하고 순서와 타입이 맞는 생성자가 필요하지만, 제일 깔끔하고 유지보수하기에 좋은 방법이다.
페이징
JPA는 페이징을 두 개의 API로 추상화해버렸다.
•
setFristResult(int startPosition) : 조회 시작 위치
•
setMaxResult(int maxResult) : 조회할 데이터 수
List<Member> result = em.createQuery("select m from Member m order by m.name desc")
.setFirstResult(0)
.setMaxResult(10)
.getResultList();
Java
복사
JPA가 이를 보고 방언에 맞춰 쿼리를 작성해준다.
# 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 ?, ?
SQL
복사
# 오라클 방언
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_ > ?
SQL
복사
조인
JPQL을 통해 내부 조인, 외부 조인, 세타 조인 등 여러 Join 연산을 수행 할 수 있다.
•
내부 조인
SELECT m FROM Member m [INNER] JOIN m.team t
SQL
복사
•
외부 조인
SELECT m FROM Member m LEFT [OUTER] JOIN m.team t
SQL
복사
•
세타 조인
SELECT COUNT(m) FROM Member m, Team t WHERE m.username = t.name
SQL
복사
세타 조인
연관 관계가 없는 두 테이블을 FROM 구문에 같이 불러서 검색을 수행할 때 사용하는 조인 방법으로, CROSS JOIN으로도 불리며 카르테시안 곱이 발생한다.
JPQL을 통해 SQL의 Join + ON 절을 통해 Join 대상의 필터링도 수행할 수 있다.
SELECT m, t FROM Member m LEFT JOIN m.team t ON t.name = 'A'
SQL
복사
추가적으로 JPQL에서 지원하는 ON 절을 통해서 연관관계가 없는 엔티티도 외부 조인할 수 있다.
SELECT m, t FROM Member m LEFT JOIN Team t ON m.username = t.name
SQL
복사
서브 쿼리
JPQL도 SQL의 서브 쿼리를 작성할 수 있다.
# 나이가 평균보다 많은 회원
SELECT m FROM Member m
WHERE m.age > (SELECT AVG(m2.age) FROM Member m2)
SQL
복사
# 한 건이라도 주문한 고객
SELECT m FROM Member m
WHERE (SELECT COUNT(o) FROM Order o WHERE m = o.member) > 0
SQL
복사
JPQL에서도 SQL과 동일하게 메인 쿼리에서 쓰인 엔티티를 서브 쿼리에서 사용하는 경우에는 서브쿼리에서 엔티티를 분리한 경우에 비해 성능이 잘 안나오게 된다.
JPQL의 서브 쿼리에서 지원하는 함수들도 있다.
•
[NOT] EXISTS (subquery) : 서브 쿼리에 결과가 존재하면 참
SELECT m FROM Member m
WHERE EXISTS (SELECT t FROM m.team t WHERE t.name = 'teamA')
SQL
복사
•
ALL (subquery) : ALL을 모두 만족하면 참
SELECT o FROM Order o
WHERE o.orderAmount > ALL (SELECT p.stockAmount FROM Product p)
SQL
복사
•
{SOME | ANY} (subquery) : 조건을 하나라도 만족하면 참
SELECT m FROM Member m
WHERE m.team = ANY (SELECT t FROM Team t)
SQL
복사
•
[NOT] IN (subquery) : 서브 쿼리의 결과 중 하나라도 같은 것이 있다면 참
SELECT m FROM Member m
WHERE m.id IN (1, 2, 3)
SQL
복사
JPA 표준 스펙상으로 WHERE, HAVING 절에서만 서브 쿼리를 사용하는 것이 가능하다. 여기에 더해 Hibernate에서는 SELECT 절에서도 서브 쿼리를 지원한다. 하지만 FROM 절의 서브 쿼리는 JPQL에서는 불가능하며, JOIN으로 풀어서 해결할 수 없다면 쿼리를 분해해서 여러 번 호출하거나 네이티브 쿼리를 사용해야한다.
JPQL 타입 표현
SELECT m.username, 10F, 'HELLO', true FROM Member m
WHERE m.type = jpabook.MemberType.ADMIN
SQL
복사
•
문자 : ‘HELLO’, ‘She”s’
•
숫자 : 10L(Long), 10D(Double), 10F(Float)
•
Boolean : TRUE, FALSE
•
ENUM : jpabook.MemberType.Admin(패키지명 포함)
•
엔티티 타입 : TYPE(m) = Member(상속 관계에서 사용, DTYPE으로 변환되어 SQL 생성)
CASE 조건식
기본 CASE 식 : 조건을 넣어 참이면 수행
SELECT
CASE WHEN m.age <= 10 then '학생요금'
WHEN m.age >= 60 then '경로요금'
ELSE '일반요금'
end
FROM Member m
SQL
복사
단순 CASE 식 : 단순 값을 넣어 값과 일치하면 수행
SELECT
CASE t.name
WHEN 'teamA' then '인센티브 10%'
WHEN 'teamB' then '인센티브 20%'
ELSE '인센티브 0%'
end
FROM Team t
SQL
복사
COALESCE : 하나씩 조회해서 null이 아니면 반환
SELECT COALESCE(m.username, '이름 없는 회원') FROM Member m
SQL
복사
NULLIF : 두 값이 같으면 null 반환, 다르면 첫 번째 값 반환
SELECT NULLIF(m.username, '관리자') FROM Member m
SQL
복사
JPQL 기본 함수
•
JPQL 표준 함수
◦
CONCAT : 문자열 합치기(|| 연산자로 사용 가능 - hibernate 구현체)
◦
SUBSTRING : 부분 문자열 가져오기
◦
TRIM : 공백 제거
◦
LOWER, UPPER : 소문자, 대문자로 변환
◦
LENGTH : 문자열 길이 구하기
◦
LOCATE : 넣은 문자열의 위치(locate(’de’, ‘abcdefg’) → 4)
◦
ABS, SQRT, MOD : 수학 연산 구하기(SQL과 동일)
◦
SIZE : 연관관계 컬렉션의 값 개수 구하기
•
사용자 정의 함수
public class MyH2Dialect extends H2Dialect {
public MyH2Dialect() {
registerFunction("gourp_concat",
new StandardSQLFunction("group_concat", StandardBasicTypes.STRING));
}
}
Java
복사
이와 같이 사용자 정의 함수를 등록하고, 설정 프로퍼티에서 해당 클래스를 등록하고 나면 가져다가 사용할 수 있다.
"SELECT FUNCTION('group_concat', m.username) FROM Member m" // JPA 문법
"SELECT group_concat(m.username) FROM Member m" // HQL 문법
Java
복사
경로 표현식
경로 표현식이란 점을 찍어 객체 그래프를 탐색하는 것을 의미하는데, 상태 필드와 단일 값 연과 필드, 컬렉션 값 연관 필드로 세 가지 경로 표현식이 있다.
•
상태 필드(state field)
"SELECT m.username FROM Member m"
Java
복사
단순히 값을 저장하기 위한 필드를 말한다. 경로 탐색의 끝으로, 더 이상 탐색이 안된다.
•
연관 필드(association field)
단일 값 연관 필드
// 묵시적 조인
"SELECT m.team FROM Member m"
// 명시적 조인
"SELECT t FROM Member m JOIN Team t"
Java
복사
@ManyToOne과 @OneToOne처럼 대상이 엔티티인 연관 필드이다. 묵시적인 inner join이 발생하며, 이후 추가적인 탐색이 가능하다.
컬렉션 값 연관 필드
// 묵시적 조인
"SELECT t.members FROM Team t"
// 불가능
"SELECT t.members.username FROM Team t"
// 명시적 조인
"SELECT m.username FROM Team t JOIN t.members m"
Java
복사
@ManyToMany과와@OneToMany처럼 대상이 컬렉션인 연관 필드이다. 묵시적인 inner join이 발생하며, 이후 추가적인 탐색이 불가능하다. 추가적인 탐색이 불가능하기 때문에, 컬렉션 이후의 데이터가 필요하다면 명시적 조인으로 별칭을 얻은 후 별칭을 통해 탐색이 가능하다.
묵시적 내부 조인은 성능 튜닝에 큰 영향을 미치기 때문에 주의해서 사용해야한다. 또한 묵시적 조인은 내부 조인만 가능하기 때문에, 외부 조인을 사용하고 싶다면 명시적 조인을 사용해야한다.
묵시적 조인으로 인해 DB 내부에서 Join 쿼리가 얼마나 발생할 지 예측하는 것이 번거롭고 복잡하기 때문에, 단일 값 연관 필드나 컬렉션 값 연관 필드 모두 가급적 성능 튜닝을 위해 묵시적 조인을 사용하지 말고 명시적 조인을 사용하는 것이 권장된다.
Fetch Join
Fetch Join은 SQL에는 없고 JPQL에서 성능 최적화를 위해서 지원하는 기능이다. Fetch Join을 통해 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회해오는 것이 가능하다.
// JPQL
SELECT m FROM Member m JOIN FETCH m.team
// SQL
SELECT M.*, T.* FROM MEMBER M
INNER JOIN TEAM T ON M.TEAM_ID = T.ID
SQL
복사
우리는 성능 최적화를 위해 모든 연관관계에 로딩 전략을 LAZY로 설정하길 권장한다는 것을 이해했다. 하지만 LAZY 로딩의 경우 JPQL의 Join으로 가져오더라도 프록시 객체를 생성해두고 실제 SQL은 사용할 때만 발생하기 때문에, 첫 조회 이후 연관관계 데이터가 필요 시마다 SQL이 발생하게 되는 N+1 문제가 발생하게 된다. 이 N+1 문제는 LAZY 로딩 전략 뿐만 아니라 EAGER 로딩 전략에서도 JPQL을 사용하면 발생하게 된다.
이러한 N+1 문제는 Fetch Join을 통해 해결할 수 있다. Fetch Join으로 JPQL을 실행하면, 로딩 전략과 관계없이 Join된 연관관계 엔티티를 전부 조회해 들고 온다. 이를 통해 영속성 컨텍스트에 전부 저장해두어, 이후 사용할 때 추가적인 쿼리가 발생하지 않는 것이다.
일대다 관계에서 컬렉션 Fetch Join의 경우 다음과 같이 사용할 수 있다.
// JPQL
SELECT t FROM Team t JOIN FETCH t.members WHERE t.name = 'teamA'
// SQL
SELECT T.*, M.* FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = 'teamA'
SQL
복사
이와 같이 컬렉션을 Join으로 불러오게 되는 경우에 결국 RDB에서 데이터를 Join으로 불러오는 것이기 때문에 데이터가 컬렉션 수만큼 불어나게 된다.
영속성 컨텍스트에서도 중복된 데이터를 저장하게 되어, 우리가 사용할 때 이러한 중복 항목들을 보게 된다.
이러한 문제는 DISTINCT 문법을 통해 해결할 수 있다.
// JPQL
SELECT DISTINCT t
FROM Team t JOIN FETCH t.members
WHERE t.name = 'teamA'
SQL
복사
이와 같이 JPQL에 distinct를 추가하게 되면, JPQL에서는 두 가지 기능을 수행한다. 먼저 SQL에 DISTINCT를 추가하여 쿼리를 수행하고, 애플리케이션에서 엔티티 중복을 제거한다.
// SQL
SELECT DISTINCT T.*, M.* FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = 'teamA'
SQL
복사
SQL에 DISTINCT를 추가하더라도, 아래와 같이 실제 Join한 데이터가 다르기 때문에 SQL 결과에서 중복 제거를 하지 않는다.
추가적으로 애플리케이션에서 중복 제거를 시도하는데,
위 결과에서는 같은 식별자를 가진 중복된 Team 엔티티를 제거하게 된다. 이러한 애플리케이션 중복 제거 기능은 Hibernate 6 버전부터는 기본으로 반영되어, DISTINCT 문법을 사용하지 않더라도 자동으로 적용된다.
위에서도 언급 했지만, Fetch Join과 일반 Join의 차이점은 실제 조회하는 데이터에서 차이가 난다.
// 일반 Join 시 발생 SQL
select
team0_.id as id1_3_,
team0_.name as name2_3
from
Team team0_
inner join
Member members1_
on team0_.id=members1_.TEAM_ID
SQL
복사
일반 Join의 경우 JPQL은 연관관계를 고려해서 반환하지 않기 때문에, 위처럼 join은 수행하지만 실제 가져오는 데이터는 JPQL의 select 절에 지정한 엔티티 값만 가져오게 된다. 이로 인해 이후의 로직에서 초기화가 안된 프록시 엔티티들을 다시 조회하게되는 N+1 문제가 발생한다.
// Fetch Join 시 발생 SQL
select
team0_.id as id1_3_0_,
members1_.id as id1_0_1,
team0_.name as name2_3_0,
members1_.age as age2_0_1,
members1_.TEAM_ID as TEAM_ID5_0_1_,
members1_.type as type3_0_1_,
members1_.username as username4_0_1_,
members1_.TEAM_ID as TEAM_ID5_0_0__,
members1_.id as id1_0_0__
from
Team team0_
inner join
Member members1_
on team0_.id=member1_.TEAM_ID
SQL
복사
반면 Fetch Join의 경우 연관된 엔티티도 같이 즉시 로딩으로 불러오기 때문에, 첫 조회 시 select에서 team과 member에 대한 정보를 모두 가져온다. 이렇게 가져온 데이터를 영속성 컨텍스트 내에 엔티티에 저장하기 때문에, 이후 연관된 엔티티를 사용하여도 추가적인 쿼리가 발생하지 않는다. 다시말해, Fetch Join은 객체 그래프를 SQL 한 번에 조회하는 방법이라고 생각하면 된다.
이처럼 N+1 상황에서 만능처럼 사용되는 Fetch Join에도 한계가 있다.
•
별칭, WHERE 조건 사용 금지
Fetch Join은 연관된 엔티티 값 전부를 가져오겠다는 의미로, 별칭을 추가하여 where를 통해 조건을 추가하는 행위를 해서는 안된다. JPA에서는 연관관계 엔티티를 모두 조회하는 것을 전제로 두고 있는데, where 조건을 통해 특정 엔티티가 필터링 된다면 cascade나 orphanRemoval 등 조건에 의해 나머지가 삭제되거나 이상하게 동작할 가능성이 있다.
•
둘 이상의 컬렉션 페치 조인 금지
일대다 관계를 조인하는 컬렉션 페치 조인은 데이터를 카르테시안 곱만큼 증가시킨다. 이러한 컬렉션 페치 조인을 두 번이상 반복하게 되면, 데이터가 기하급수적으로 증가하고 가져온 데이터가 잘 맞지 않는 현상이 발생한다.
•
컬렉션 페치 조인 시 페이징 사용 불가
일대일, 다대일 관계 같은 단일 값 연관 필드들은 페치 조인해도 페이징을 사용할 수 있다. 하지만 일대다 관계에서 페이징을 사용하면 데이터 수가 증가하면서, JPA에서는 조회하는 데이터의 수를 예측할 수 없어 경고를 남기고 모든 데이터를 가져온 후 메모리에서 직접 페이징을 수행한다. 이런 상황에 페이징이 필요하다면 반대 관계로 조회하여 페이징을 수행하거나, 연관관계 없이 단일 엔티티를 조회 후 N+1 문제를 BatchSize를 통해서 해결한다.
이러한 Fetch Join은 객체 그래프를 유지할 때 사용하면 효과적이다. 만약 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과값을 반환한다면, Fetch Join보다는 일반 조인을 사용하고 필요한 데이터들만 조회하여 DTO로 반환하는 것이 더 효과적일 수 있다.
엔티티 직접 사용
SELECT COUNT(m.id) FROM Member m # 엔티티 아이디를 사용
SELECT COUNT(m) FROM Member m # 엔티티를 직접 사용
SQL
복사
JPQL에서 엔티티를 직접 사용하면, 자동으로 SQL에서 해당 엔티티의 기본 키 값을 사용한다. 위 두 JPQL은 동일하게 아래의 SQL로 실행이 된다.
SELECT COUNT(m.id) FROM Member m
SQL
복사
이는 파라미터에 엔티티를 사용해도 동일하다.
SELECT m FROM Member WHERE m = :member
SELECT m FROM Member WHERE m.id = :memberId
SQL
복사
위 두 JPQL은 모두 memberId로 검색을 수행하는 SQL로 변환되어 조회한다. 이는 외래 키에도 동일하게 적용되어, 외래 키에 연관된 엔티티 객체를 직접 사용할 수도 있다.
SELECT m FROM Member WHERE m.team = :team
SQL
복사
Named 쿼리
Named 쿼리는 애노테이션이나 XML 등에 미리 정의해두고 이름을 부여해, 이후 이름을 통해 호출해서 사용하는 JPQL이다. 동적 쿼리는 불가능하고 정적 쿼리만 가능하다.
@Entity
@NamedQuery(
name = "Member.findByUsername",
query = "select m from Member m where m.username = :username"
)
public class Member {
...
}
Java
복사
em.createdNamedQuery("Member.findByUsername", Member.class)
.setParameter("username", "회원1")
.getResultList();
Java
복사
Named 쿼리의 장점은 애플리케이션 로딩 시점에 초기화해두어 SQL을 캐싱해두고 있다가, 이후 사용 시에는 캐싱된 SQL을 호출해서 사용하기 때문에 코스트가 적다는 것이다. 또한 애플리케이션 로딩 시점에 쿼리를 검증할 수 있다. Spring Data JPA에서는 @Query 애노테이션을 통해 JPQL을 적용하면, NamedQuery처럼 애플리케이션 로딩 시점에 쿼리가 검증된다.
애노테이션과 XML에 동시에 정의되어있다면, XML에 우선하여 적용된다. 여러 XML 파일을 두고 배포 시 환경에 따라 다르게 적용할 수도 있다.
벌크 연산
만약 DB에 저장된 모든 상품에 일괄적으로 가격을 10% 상승해야하는 상황에서, 이를 JPA 변경 감지 기능을 통해 수행하려면
1.
모든 상품을 리스트로 조회
2.
상품 엔티티 가격 10% 증가
3.
트랜잭션 커밋 시점에 변경감지 동작
순으로 수행해야한다.
조회를 통해 메모리에 많은 데이터가 올라오는 것은 둘째치더라도, 변경된 데이터가 100개라면 UPDATE SQL이 100번 실행되게 된다.
이를 해결하기 위해 JPA에서 지원하는 기능이, 쿼리 한 번으로 여러 테이블 레코드를 변경하는 벌크 연산이다.
String jpql = "update Procduct p set p.price = p.price * 1.1"
em.createQuery(jpql).executeUpdate();
Java
복사
이와 같이 벌크 연산에 대한 JPQL을 작성하고 executeUpdate를 호출하여 일괄적으로 수행하도록 처리할 수 있다. 이를 통해 UPDATE, DELETE 모두 수행할 수 있다.
이러한 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리하기 때문에, 애초에 벌크 연산을 트랜잭션 초기에 먼저 실행하거나 벌크 연산 후에 영속성 컨텍스트를 초기화 시켜야만 한다. Spring Data JPA에서는 Modifying 애노테이션을 통해 벌크 연산을 수행할 수 있는데, 해당 애노테이션의 속성의 clearAutomatically를 true로 바꾸어두면 자동으로 수행된다.