elevne's Study Note
JPA - JPQL 본문
JPQL 은 객체지향 쿼리 언어이다. 테이블을 대상으로 쿼리하는 것이 아니라, 엔티티 객체를 대상으로 쿼리한다. JPQL 은 SQL 을 추상화하여 특정 데이터베이스 SQL 에 의존하지 않으며, JPQL 은 결국 SQL 로 변환된다. 실습을 위해 4 개의 엔티티를 생성하여 아래와 같은 구조를 갖게하였다.
JPQL 도 SQL 과 비슷하게 SELECT, DELETE, UPDATE 를 수행할 수 있다. (엔티티를 저장할 때에는 EntityManager.persist() 메소드를 사용하면 돼서 INSERT 는 따로 없다)
SELECT 문은 다음과 같이 사용한다.
SELECT m FROM Member AS m where m.username = ‘Hello’
엔티티와 속성은 대소문자를 구분한다. (Member, username 은 대소문자를 구분하여 작성해야한다) 반면 SELECT, FROM, AS 와 같은 JPQL 키워드는 대소문자를 구분하지 않는다. JPQL 에서 사용한 Member 은 클래스가 아니라 엔티티 명이다. 엔티티 명은 @Entity(name="XX") 로 지정할 수 있다. (지정하지 않으면 클래스 명을 기본값으로 사용) 또, Member as m 부분을 보면 Member 에 m 이라는 별칭을 주었다. JPQL 은 별칭을 필수로 사용해야 한다. 별칭 없이 작성하면 잘못된 문법이다.
JPQL 을 실행하기 위해서는 쿼리 객체를 생성해야 한다. 쿼리 객체는 TypedQuery, Query 가 있다. 반환할 타입을 명확하게 지정할 수 있으면 TypedQuery, 그럴 수 없다면 Query 객체를 사용한다.
TypedQuery<Member> query = em.createQuery("SELECT m FROM Member m", Member.class);
List<Member> resultList = query.getResultList();
em.createQuery() 의 두 번째 파라미터에 타입을 지정하면 TypedQuery 를, 그렇지 않으면 Query 를 반환한다. 위에서는 조회 대상이 Member 엔티티로 명확하다. 아래와 같은 경우는 또 다르다.
Query query = em.createQuery("SELECT m.username, m.age from Member m");
List resultList = query.getResultList();
for (Object o : resultList) {
Object[] result = (Object[]) o;
System.out.println("username ="+result[0]);
System.out.println("age ="+result[1]);
}
위 예시는 조회 대상이 String 타입인 회원이름과, Integer 타입인 나이이므로 조회대상 타입이 명확하지 않다. 위처럼 SELECT 절에서 여러 엔티티나 컬럼을 선택할 때에는 반환할 타입이 명확하지 않으므로 Query 객체를 사용해야 한다. Query 객체는 SELECT 절의 조회 대상이 둘 이상이면 Object[], 하나면 Object 를 반환한다.
또, 결과를 받아볼 때에는 query.getResultList() 메소드를 실행한다. 만약 결과가 없으면 빈 컬렉션을 반환한다. 만약 결과가 정확히 하나라면 query.getSingleResult() 를 사용한다. 결과가 없거나 1 개보다 많다면 예외를 발생시킨다.
JDBC 는 위치기준 파라미터 바인딩만 지원하지만 JPQL 은 이름 기준 파라미터 바인딩도 지원한다. 이름 기준 파라미터는 파라미터를 이름으로 구분하는 방법이다. 이름 기준 파라미터는 앞에 ":" 를 붙인다.
String usernameParam = "User1";
TypedQuery<Member> query = em.createQuery("SELECT m FROM Member m where m.username = :username", Member.class);
query.setParameter("username", usernameParam);
List<Member> resultList = query.getResultList();
:username 이라는 이름기준 파라미터를 정의, query.setParameter() 에서 username 이라는 이름으로 파라미터를 바인딩한다. (JPQL API 는 대부분 메소드 체인 방식으로 설계되어 있어서 연속으로 작성할 수 있다) 만약 위치 기준 파라미터를 사용하고 싶다면 "?" 다음에 위치 값을 주면 된다. (위치 값은 1 부터 시작) 위치 기준 파라미터 방식보다는 이름 기준 파라미터 방식을 사용하는 것이 더 명확하다.
List<Member> members = em.createQuery("SELECT m FROM Member m where m.username = ?1", Member.class)
.setParameter(1, "User1")
.getResultList();
JPQL 에서 임베디드 타입은 엔티티와 거의 비슷하게 사용된다. 임베디드 타입은 조회의 시작점이 될 수 없다는 제약이 있긴 하지만, 아래와 같이 조회해볼 수 있긴 하다.
List<Address> addresses = em.createQuery("SELECT o.address FROM Orders o", Address.class)
.getResultList();
임베디드 타입은 엔티티 타입이 아닌 값 타입으로, 직접 조회한 임베디드 타입은 영속성 컨텍스트에서 관리되지 않는다는 것에 유의해야 한다.
여러 값을 조회해야 하는 경우에는 다음과 같이 사용하면 편리할 수 있다.
@SuppressWarnings("unchecked") 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];
int orderAmount = (Integer) row[2];
}
지금은 위처럼 Object[] 를 반환받아 사용하기에 Query 객체를 사용하지만, 실제 애플리케이션 개발 시에는 Object[] 가 아니라 UserDTO 처럼 의미있는 객체로 변환해서 사용한다. for 루프를 돌면서 UserDTO 의 setter 을 호출할 수도 있고, NEW 명령어를 JPQL 내에서 활용해볼 수 있다.
@Data
public class UserDTO {
private String username;
private int age;
public UserDTO(String username, int age) {
this.username = username;
this.age = age;
}
}
TypedQuery<UserDTO> query = em.createQuery("SELECT NEW com.example.jpastudy.chapter10.UserDTO(m.username, m.age)" +
"FROM Member m", UserDTO.class);
List<UserDTO> resultList = query.getResultList();
SELECT 다음에 NEW 명령어를 사용하면 반환받을 클래스를 지정할 수 있는데, 이 클래스의 생성자에 JPQL 조회 결과를 넘겨줄 수 있는 것이다. 그리고 NEW 명령어를 사용한 클래스로 TypedQuery 를 사용할 수 있어 번거로운 객체 변환 작업을 줄일 수 있다. NEW 명령어를 사용할 때에는 항상 패키지 명을 포함한 전체 클래스 명을 입력해야하며, 순서와 타입이 일치하는 생성자가 필요하다.
JPQL 은 페이징 API 도 제공한다. JPA 는 페이징을 setFirstResult, setMaxResult 두 개의 메소드로 추상화하였다. setFirstResult(int startPosition) 은 조회 시작 위치(0부터 시작)를 지정, setMaxResult(int maxResult) 는 조회할 데이터의 수를 지정한다.
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> result = query.getResultList();
집합함수와 함께 통계정보를 구할 때 집합이 사용된다. 예로, 다음 코드는 순서대로 회원 수, 나이 합, 평균 나이, 최대 나이, 최소 나이를 조회한다.
SELECT COUNT(m), SUM(m.age), AVG(m.age), MAX(m.age), MIN(m.age) FROM Member m
GROUP BY 는 통계 데이터를 구할 때 특정 그룹끼리 묶어준다. 다음은 팀 이름을 기준으로 그룹별로 묶어서 통계 데이터를 구한다.
SELECT t.name, COUNT(m.age), SUM(m.age), AVG(m.age), MAX(m.age), MIN(m.age) from Member m LEFT JOIN m.team t GROUP BY t.name
HAVING 은 GROUP BY 와 함께 사용되는데, GROUP BY 로 그룹화한 통계데이터를 기준으로 필터링한다. 아래 코드는 위에서 구한 통계 데이터 중 평균 나이가 10 살 이상인 그룹을 조회한다.
SELECT t.name, COUNT(m.age), SUM(m.age), AVG(m.age), MAX(m.age), MIN(m.age) from Member m LEFT JOIN m.team t GROUP BY t.name HAVING AVG(m.age) >= 10
JPQL 도 조인을 지원한다. SQL 조인과 기능은 같고 문법만 약간 다르다. 먼저 INNER JOIN 예시이다.
String teamName = "팀A";
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();
만약 조인한 두 개의 엔티티를 조회하고자 한다면 아래와 같이 Query 객체를 사용하여 진행해야 한다.
@SuppressWarnings("unchecked") List<Object[]> result = em.createQuery("SELECT m, t FROM Member m INNER JOIN m.team t").getResultList();
for (Object[] row : result) {
Member member = (Member) row[0];
Team team = (Team) row[1];
}
OUTER JOIN 도 사용방법은 동일하다. INNER 이 들어가는 자리에 OUTER 을 넣어주기만 하면 된다.
일대다 관계나 다대다 관계처럼 컬렉션을 사용하는 곳에 조인하는 것을 컬렉션 조인이라고 한다. 회원 -> 팀 으로의 조인은 다대일 조인이면서 단일 값 연관 필드 m.team 을 사용하고, 팀 -> 회원은 반대로 일대다 조인이면서 컬렉션 값 연관 필드 (m.members) 를 사용한다.
SELECT t, m FROM Team t LEFT OUTER JOIN t.members m
WHERE 절을 사용해서 세타조인을 할 수 있다. 세타조인은 내부조인만 지원한다. 이를 사용하면 전혀 관계없는 엔티티도 조인할 수 있다.
SELECT COUNT(m) FROM Member m, Team t WEHRE m.username = t.name
JOIN 시 ON 절을 활용할 수도 있다. ON 절을 사용하면 조인 대상을 필터링하고 조인할 수 있다. 내부조인의 ON 절은 WHERE 과 같은 결과를 반환하므로 ON 절은 OUTER JOIN 에서만 사용한다.
SELECT m, t FROM Member m LEFT OUTER JOIN m.team t on t.name = ‘A’
그 다음으로는 Fetch Join 이라는 것에 대해 알아본다. Fetch Join 은 SQL 에서 이야기하는 조인의 종류는 아니고 JPQL 에서 성능 최적화를 위해 제공하는 기능이다. 이는 연관된 엔티티나 컬렉션을 한 번에 같이 조회하는 기능인데, JOIN FETCH 명령으로 사용할 수 있다. 아래와 같이 사용된다.
SELECT m FROM Member m join fetch m.team
JOIN 다음에 FETCH 라고 적혀있다. 위와 같이 작성하면 연관된 엔티티나 컬렉션을 함께 조회하는데 위에서는 회원과 팀을 함께 조회하는 것이다. 또, 일반적인 JPQL 조인과는 다르게 m.team 다음에 별칭이 없다. Fetch Join 은 별칭을 사용할 수 없다고 한다.
String jpql = "SELECT m FROM Member m JOIN FETCH m.team";
List<Member> members = em.createQuery(jpql, Member.class).getResultList();
for (Member member : members) {
System.out.println("username = "+member.getUsername()+" , teamname ="+member.getTeam().getName());
}
일대다 관계인 컬렉션을 Fetch Join 할 수도 있다.
SELECT t FROM Team t join fetch t.members WHERE t.name = ‘팀A’
String jpql = "SELECT t FROM Team t JOIN FETCH t.members WHERE t.name = '팀A'";
List<Team> teams = em.createQuery(jpql, Team.class).getResultList();
위와 같은 코드를 실행하면 동일한 Member 목록을 가진 Team 엔티티가 복수개 생길 수 있다. 이 때는 DISTINCT 를 사용하여 중복된 결과를 제거한다.
String jpql = "SELECT DISTINCT t FROM Team t JOIN FETCH t.members WHERE t.name = '팀A'";
List<Team> teams = em.createQuery(jpql, Team.class).getResultList();
만약 위 코드에서 Fetch Join 을 사용하지 않고 JOIN 만 사용하면, 회원 컬렉션은 조회되지 않는다. JPQL 은 결과를 반환할 때 연관관계까지 고려하지 않는다. 단지 SELECT 절에 지정한 엔티티만 조회할 뿐이다. 만약 회원 컬렉션을 지연로딩으로 설정한다면 프록시나 아직 초기화되지 않은 컬렉션 래퍼를 반환한다. 즉시 로딩으로 설정하면 회원컬렉션을 즉시 로드하기 위해 쿼리를 한 번 더 실행하게 될 것이다.
이러한 Fetch Join 을 사용하면 SQL 한 번으로 연관된 엔티티를 함께 조인할 수 있기에 SQL 호출 횟수를 줄여 성능을 최적화할 수 있다. @OneToMany(fetch=FetchType.LAZY) 처럼 엔티티에 직접 적용하는 로딩 전략은 애플리케이션 전체에 영향을 미치므로 글로벌 로딩 전략이라고 부른다. Fetch Join 은 글로벌 로딩 전략보다 우선한다. (글로벌 로딩 전략을 지연로딩으로 설정해도 JPQL 에서 Fetch Join 을 사용하면 이를 적용해 함께 조회함)
최적화를 위해 글로벌 로딩 전략을 즉시 로딩으로 설정하면 애플리케이션 전체에서 항상 즉시 로딩이 일어난다. 이는 사용하지 않는 엔티티도 항상 로드하여 성능에 안좋은 영향을 줄 수 있다. 따라서 글로벌 로딩 전략은 가능하면 지연로딩을 사용하고, 최적화가 필요하면 Fetch Join 을 사용하는 것이 효과적이라고 한다. (단 Fetch Join 에는 별칭을 줄 수 없고, 둘 이상의 컬렉션을 Fetch 할 수 없다. 또, 컬렉션을 Fetch Join 하면 Paging API 를 사용할 수 없다)
JPQL 은 서브쿼리도 지원한다.
SELECT m from Member m WHERE m.age > (SELECT AVG(m2.age) FROM Member m2)
위와 같이 작성할 수 있는 것이다.
이 외에도 다양한 조건식들을 지원한다. (논리연산자 비교식, BETWEEN, IN, LIKE, 문자함수, 수학함수, 날짜함수, CASE 식 등)
또, JPQL 에서는 부모엔티티 (상속 슈퍼클래스) 를 조회하면 그 자식 엔티티도 함께 조회된다.
JPQL 쿼리는 크게 동적쿼리와 정적쿼리로 나눌 수 있다. 동적쿼리는 지금까지 해본 것처럼 em.createQuery() 메소드로 직접 문자열을 넘기는 방식을 말한다. 런타임에 특정 조건에 따라 JPQL 을 동적으로 구성할 수 있다. 정적 쿼리는 미리 정의한 쿼리에 이름을 부여해서 필요할 때 사용할 수 있는데, 이를 Named Query 라고 한다. Named Query 는 한 번 저의하면 변경할 수 없는 정적인 쿼리이다. Named Query 는 애플리케이션 로드 시점에 JPQL 문법을 체크하고 미리 파싱해둔다. 오류를 미리 확인할 수 있고, 사용하는 시점에는 파싱된 결과를 재사용하므로 성능상 이점이 있다. 또, 이는 변하지 않는 정적 SQL 을 생성하므로 데이터베이스 조회 성능 최적화에도 도움이 된다. Named Query 는 @NamedQuery 애노테이션을 사용해서 자바 코드에 작성하거나 XML 문서에 작성할 수 있다.
@Entity
@Data
@NamedQuery(name = "Member.findByUsername", query = "SELECT m FROM Member m where m.username = :username")
public class Member {
...
List<Member> resultList = em.createNamedQuery("Member.findByUsername", Member.class)
.setParameter("username", "user1").getResultList();
하나의 엔티티에 여러 개의 Named Query 를 정의하려면 @NamedQueries 애노테이션을 사용하면 된다. @NamedQuery 애노테이션 내에는 name, query 외에도 lockMode, hints 옵션을 넣을 수 있다. lockMode 는 쿼리 실행 시 락 옵션을 지정할 수 있으며 hints 는 SQL 힌트가 아니라 JPA 구현체에게 제공하는 힌트다. (2차캐시를 다룰 때 사용)
XML 로 Named Query 를 사용하는 것이 멀티라인 쿼리를 작성하기 더 편리하다. 아래와 같이 작성한다.
ormMember.xml
<?xml version="1.0" encoding="UTF-8" ?>
<entity-mappings xmlns="http://java.sun.com/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>
이를 /META-INF/persistence.xml 에 추가해준다.
<persistence-unit name="jpastudy">
<mapping-file>META-INF/ormMember.xml</mapping-file>
<properties>
...
Long a = em.createNamedQuery("Member.count", Long.class).getSingleResult();
System.out.println(a);
Reference:
자바 ORM 표준 JPA 프로그래밍
'Backend > JPA 프로그래밍' 카테고리의 다른 글
JPA - QueryDSL (0) | 2023.07.15 |
---|---|
JPA - Criteria (0) | 2023.07.14 |
JPA - Embedded / Collection Value Type (0) | 2023.07.12 |
JPA - Proxy, Cascade, Orphan (0) | 2023.07.11 |
JPA 고급매핑 (0) | 2023.07.09 |