JPQL Java Persistence Query Language는 엔티티 객체를 대상으로 하는 객체 지향 쿼리 언어입니다. 데이터베이스 테이블을 다루는 SQL로는 엔티티를 직접 다루기가 어려웠기 때문에 JPQL이 등장하게 되었습니다.
JPQL은 SQL을 추상화하여 만든 쿼리 언어이기 때문에 표준 SQL 문법과 유사한 부분이 많아서 SQL에 익숙하다면 금방 적응 할 수 있습니다. 또한 특정 데이터베이스에 의존하지 않아서 스프링 설정에서 데이터베이스 방언만 변경해준다면 어떤 DBMS를 사용하더라도 문제없이 호환이 됩니다.
Criteria API, QueryDSL라는 JPQL을 SQL 쿼리 문자열이 아닌 프로그래밍 코드 스타일로 작성할 수 있게 해주는 쿼리 빌더 도구들도 존재합니다.
작성된 JPQL은 실행하게 되면 JPA가 적절한 SQL 쿼리로 변환해주어 쿼리를 실행하게 됩니다.
모든 문법을 소개하면 내용이 많아져서 주요한 문법들만 살펴보려고 합니다.
JPQL도 SQL의 SELECT, UPDATE, DELETE를 사용할 수 있습니다.
INSERT의 경우에는 EntityManager.persist()를 사용해서 삽입하게 되기 때문에 INSERT는 따로 존재하지 않습니다.
다음과 같은 엔티티를 JPQL을 사용해서 CRUD를 수행해보겠습니다. (INSERT 제외)
@Entity
@Table(name = "members_table")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "member_name", nullable = false)
private String name;
@Column(name = "age", nullable = false)
private Integer age;
}
JPQL로 조회를 수행하는 기본적인 쿼리는 다음과 같습니다.
SELECT m FROM Member AS m WHERE m.name = "Kim"
//SELECT 별칭 FROM 엔티티 AS 별칭 WHERE 별칭.컬럼 = 조건
기존 SQL을 생각했을 때 몇 가지 다른 점이 보이시나요? JPQL의 기본적인 문법 사항은 다음과 같습니다.
엔티티와 속성의 대소문자 구분
SQL는 테이블, 컬럼 명에 대해 DBMS마다 대소문자를 구분하는 방식이 달랐습니다. JPQL에서는 무조건 대소문자를 구분합니다.
엔티티 이름 사용
FROM 구문을 보시면 엔티티의 이름(@Entity(name = ""))을 사용하는 것을 확인하실 수 있습니다. 클래스의 이름이 아님에 유의해주세요. @Entity에 name 속성을 지정하지 않으면 엔티티명을 클래스 이름으로 사용하기 때문에 위 예제에서는 엔티티 이름이 클래스 이름과 동일했습니다.
별칭
Member AS m를 통해 별칭을 지정하고 모든 조작을 별칭을 통해 수행합니다. 이처럼 JPQL에서는 별칭을 필수로 사용해야합니다.
추가적으로 AS는 생략하능하기 때문에 Member m으로 사용해도 됩니다. 앞으로는 AS를 생략하도록 하겠습니다.
SELECT m FROM Member m WHERE m.name = "Kim"
UPDATE, DELETE는 벌크 연산이라는 기능을 사용합니다. 벌크 연산은 영속성 컨텍스트를 이용하지 않고 데이터베이스에 직접 쿼리를 적용시키는 것을 의미합니다.
벌크 연산으로 인해 영속성 컨텍스트와 실제 DB에 저장된 데이터가 다를 수 있기 때문에 벌크 연산 수행 후에 영속성 컨텍스트를 초기화하거나 먼저 벌크 연산을 수행한 후에 재조회를 통해 값을 동기화 시키는 것이 중요합니다.
JPQL의 UPDATE, DELETE 구문은 다음과 같습니다.
UPDATE Member m SET m.name = "Lee"
DELETE FROM Member m WHERE m.name = "Lee"
작성된 JPQL 쿼리는 EntityManager.createQuery(query)를 통해 쿼리 객체를 생성해야합니다.
쿼리 객체는 TypedQuery<T>, Query 두 가지 종류가 있습니다. 반환 타입이 명확하면 TypedQuery<T>, 불명확하면 Query를 사용합니다.
TypedQuery<Member> query = em.createQuery(
"SELECT m FROM Member m WHERE m.name = 'Kim'",
Member.class
);
Query query = em.createQuery("SELECT m.name, m.age FROM Member m");
이렇게 만들어진 쿼리 객체는 getResultList(), getSingleResult()를 호출하여 쿼리를 실행하고 결과를 받아옵니다.
getSingleResult()는 결과가 단 하나만 있을 때만 사용해야합니다. 여러 결과를 반환하는 경우 예외를 발생시키는 것에 주의해주세요.
JPQL의 메소드들은 메소드 체인 형식으로 설계되어서 소개했던 또는 앞으로 소개할 대부분의 메소드는 메소드 체인으로 호출할 수 있습니다.
List<T> resultList = em.createQuery().getResultList();
JPQL은 이름, 위치 두 가지 기준을 가지고 파라미터 바인딩을 지원하고 있습니다.
이름으로 파라미터를 구분하는 방식을 이름 기준 파라미터라고 합니다. 이 방식은 파라미터 이름 앞에 :을 붙여서 표현합니다.
String nameParam = "Kim";
TypedQuery<Member> query = em.createQuery(
"SELECT m FROM Member m WHERE m.name = :name",
Member.class
).setParameter("name", nameParam);
위치 기준 파라미터는 위치 값을 사용해서 파라미터를 바인딩하는 방식입니다. 이 방식에서는 파라미터 위치에 ?를 사용하고, ? 뒤에 위치 값을 명시하면 됩니다. 위치 값은 1부터 시작합니다.
String nameParam = "Kim";
TypedQuery<Member> query = em.createQuery(
"SELECT m FROM Member m WHERE m.name = ?1",
Member.class
).setParameter(1, nameParam);
예전에 작성한 JDBC의 위치 기준 파라미터 바인딩 방식을 보면 알겠지만 코드가 늘어지기도 하고, 이름 기준 파라미터 바인딩에 비해 가독성이 떨어지는 모습을 볼 수 있습니다. 따라서 위치 기준 파라미터 바인딩보다는 이름 기준 파라미터 바인딩 방식을 사용하면 더 명확하게 파라미터 바인딩을 수행할 수 있습니다.
프로젝션 Projection은 SELECT에서 조회 대상을 지정하는 것을 의미합니다. 즉 SELECT m FROM에서 조회 대상인 m을 지정한 것을 프로젝션이라고 부릅니다.
프로젝션 대상으로 지정할 수 있는 타입에는 기본형(숫자, 문자, 날짜 등), 임베디드 타입, 엔티티가 있습니다.
SELECT m FROM Member m WHERE m.name = 'Kim'
SELECT m.name, m.age FROM Member m"
예제에서 봐왔던 것 처럼 엔티티 별칭을 통해 엔티티 자체를 지정하거나 특정 필드들을 골라서 지정하면 됩니다.
한 가지 주의점은 임베디드 타입을 지정할 때도 별칭.임베디드 타입과 같은 방식으로 지정해야합니다. 임베디드 타입은 프로젝션의 시작점이 될 수 없습니다.
SELECT m.name, m.age FROM Member m
위 처럼 복합 데이터를 프로젝션 할 때 그냥 데이터를 받고자할 때 일반적으로 DTO 객체를 생성해서 받습니다.
public class MemberResponse {
private String name;
private Integer age;
//생성자 및 getter
}
이 방식은 쿼리 수행 후 결과를 DTO 객체로 매핑하는 별도의 작업이 필요하게 됩니다. 이러한 작업을 줄이기 위해 NEW라는 키워드를 제공하고 있습니다.
SELECT new mypackage.dto.MemberResponse(m.name, m.age) FROM Member m
NEW를 사용하여 반환받을 클래스를 지정할 수 있습니다. 이때 반드시 패키지 경로 까지 포함되고, 필드 순서와 타입이 일치하는 생성자가 존재해야 성공적으로 반환받을 수 있습니다.
페이지네이션은 자주 사용되는 기능이지만 SQL로 작성하는 과정이 상당히 반복 작업을 요구하며 데이터베이스마다 문법이 크게 다르기 때문에 두 개의 별도 API를 통해 제공하고 있습니다.
0)페이지네이션은 정렬 조건도 포함되기 때문에 다음절에서 설명하는 정렬도 함께 읽어주세요.
집합은 집계 함수를 통해 데이터를 집계할 때 사용합니다. 집계 함수는 이전 포스트를 통해 어떤 종류가 있는지 확인해주세요.
어떤 기준을 주고 기준에 맞춰 집합으로 묶는데 GROUP BY를 사용합니다. 다음은 나이별로 회원 엔티티 수를 세어서 반환하는 JPQL 쿼리입니다.
SELECT m.age, COUNT(m) FROM Member m
GROUP BY m.age
추가적으로 HAVING이란 키워드도 있습니다. HAVING은 GROUP BY로 만들어진 집합에 필터링 조건을 걸어줍니다. 다음은 나이별로 회원 엔티티 수를 세는데 65세 미만인 그룹만을 조회하는 쿼리입니다.
SELECT m.age, COUNT(m) FROM Member m
GROUP BY m.age
HAVING m.age <= 65
결과를 정렬할 때는 ORDER BY를 사용합니다. 다음 결과 변수에 따라 오름차순/내림 차순을 결정합니다.
| 결과 변수 | 설명 |
|---|---|
| ASC | 오름차순 (기본값) |
| DESC | 내림차순 |
다음 쿼리는 Member를 나이순 오름차순으로 정렬한 결과를 조회하는 쿼리입니다.
SELECT m FROM Member m
ORDER BY m.age ASC
JOIN 문법 설명을 위해 다음과 같은 엔티티 두 개를 정의했습니다.
@Entity
@Table(name = "members_table")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id", nullable = false)
private Long memberId;
@Column(name = "member_name", nullable = false)
private String name;
@Column(name = "age", nullable = false)
private Integer age;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "department_id")
private Department department;
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
@Entity
@Table(name = "departments_table")
public class Department {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "department_id", nullable = false)
private Long departmentId;
@Column(name = "department_name", nullable = false)
private String departmentName;
@OneToMany(mappedBy = "department")
private List<Member> members = new ArrayList<>();
}
@Entity
@Table(name = "orders_table")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "order_id", nullable = false)
private Long orderId;
@Column(name = "ordered_at", nullable = false)
private LocalDateTime orderedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", referencedColumnName = "member_id", nullable = false)
private Member member;
}
JOIN에 대한 기본적인 개념은 이 포스트를 참조해주세요.
내부 조인은 다음과 같습니다. orderId가 1인 Member 엔티티를 반환하는 조인입니다.
//Long departmentId = 1;
SELECT m FROM Member m
INNER JOIN m.department d
WHERE d.departmentId = :departmentId
외부 조인은 다음과 같습니다.
//String departmentName = "Service"
SELECT m FROM Member m
LEFT OUTER JOIN m.department d
WHERE d.departmentName = :departmentName
조인 대상을 필터링하는 ON 구문도 사용할 수 있습니다. (JPA 2.1 부터)
SELECT m FROM Member m
LEFT OUTER JOIN m.department d
ON d.departmentName = 'Service'
페치 조인은 JPQL에서 제공하는 성능 최적화 조인 기능입니다. 기존 JOIN 구문에 FETCH 키워드를 붙여서 사용합니다.
페치 조인은 연관된 엔티티를 한 번에 조회할 수 있습니다. 다음 페치 조인은 회원 엔티티를 조회할 때 연관된 부서 엔티티도 함께 조회하는 구문입니다.
SELECT m FROM Member m
INNER JOIN FETCH m.department
FETCH구문 뒤에 오는 연관 엔티티에서는 별칭을 사용하지 않습니다. (단, Hibernate에서는 별칭 사용 가능)
JPQL은 프로젝션된 엔티티 등만 조회하고 연관관계를 조회하지 않습니다. 따라서 연관된 엔티티도 함께 조인해야하는 경우에는 페치 조인을 사용해야합니다.
페치 조인은 다음과 같은 문제가 있습니다.
JPQL에서도 부속질의를 사용할 수 있습니다. 부속질의에 대한 기본적인 내용은 해당 포스트를 참조해주세요.
JPQL의 부속질의는 WHERE, HAVING에서만 사용가능하고 SELECT, FROM에서는 사용할 수 없다라는 제약 조건이 걸려있습니다.
부속질의에서 사용할 수 있는 함수는 다음과 같습니다.
| 함수 | 설명 |
|---|---|
| [NOT] EXISTS(q) | 부속질의에 결과가 존재하면 true NOT EXISTS는 반대로 동작 |
| ALL(q), ANY(q), SOME(q) | 비교 연산자와 함께 사용하여 결과 반환ALL은 조건을 모두 만족해야 true, ANY, SOME은 조건을 하나라도 만족하면 true |
| [NOT] IN(q) | 부속질의 결과 중 하나라도 같은 것이 존재하면 true |