10장_객체지향 쿼리 언어
이 장 중심 내용은 아래와 같다.
- 객체지향 쿼리 소개
- JPQL
- Criteria
- QueryDSL
- Native SQL
- 객체지향 쿼리 심화
JPQL은 가장 중요한 객체지향 쿼리 언어이다. Criteria, QueryDSL 등은 JPQL을 좀 더 쉽게 사용할 수 있게 해주는 것에 불과하다. 따라서, 개발자는 JPQL에 능숙해야 한다.
객체지향 쿼리 소개
EntityManager.find()
메소드를 이용하면 하나의 엔티티를 조회할 수 있고, 여기서 연관된 정보를 조회하려면 객체 그래프 탐색을 하면된다. 하지만, 이 기능만으로는 부족하다. 그렇다고 해서 처음 조회할 때 모든 엔티티를 조회하는 것도 현실적이지 않다. 결국, 데이터는 데이터베이스에 있으므로 SQL을 사용해서 필요한 정보를 적절히 걸러서 불러와야 한다. 하지만, ORM을 사용하면 테이블이 아닌 객체를 대상으로 검색을 해야하는데 이 때 사용하는 것이 JPQL이다.
JPQL은 다음과 같은 특징이 있다.
- 데이터베이스의 테이블의 아닌 객체를 대상으로 탐색한다.
- SQL을 추상화해서 특정 데이터베이스에 종속되지 않는다.
JPQL은 객체를 대상을 검색하는 쿼리이다. JPA는 JPQL을 적절한 SQL 쿼리로 바꿔 데이터베이스에 전송하고 얻은 결과를 엔티티로 생성해 반환한다. JPQL에서 공식적으로 지원하는 기능은 JPQL,Criteria, 네이티브 SQL이 있다. 여기에 QueryDSL, JDBC 직접 사용, mybatis와 같은 SQL 매퍼 사용하는 방법도 알아둘 가치가 있다.
JPQL 소개
JPQL (Java Persistence Query Language) 은 객체를 조회하는 객체지향 쿼리이다. SQL을 추상화해서 특정 데이터베이스에 의존하지 않는다. 문법은 기본 SQL과 비슷하다. JPQL은 엔티티 직접 조회, 묵시적 조인, 다형성 지원이 가능해 SQL보다 간결하다.
@Entity
public class Member{
@Id
public long id;
@Column(name = "name")
public String username;
//...
}
String jpql = "select m from Member as m where m.username = 'kim'";
List<Member> resultList = em.createQuery(jpql, Member.class).getResultList();
위 jpql은 회원이름이 kim
인 회원 객체를 조회하는 쿼리이다. em.createQuery
안에 쿼리문과 함께 반환받을 클래스를 넣어주고, getResultList()
메서드를 실행하면 JPA는 JPQL을 적절한 SQL로 바꿔서 데이터베이스에 조회한다. 그 후, 반환한 데이터를 엔티티로 생성해 반환한다.
Criteria 쿼리 소개
Criteria는 JPQL을 생성하는 빌더 클래스이다. Criteria의 장점은 문자가 query.select(m).where(…)와 같이 프로그래밍 코드로 JPQL을 작성할 수 있다는 점이다.
문자로 작성한 쿼리문의 단점은 오타가 났을 때 오류를 발견할 수 있는 것은 런타임 시점이라는 것이다. Criteria는 문자가 아닌 코드로 JPQL을 작성하기 때문에 컴파일 시점에 오류를 발견할 수 있다. 코드로 작성한 Criteria의 장점은 다음과 같다.
- 컴파일 시점에 문제를 발견할 수 있다.
- 동적으로 쿼리를 작성할 수 있다.
- IDE를 사용하면 코드 자동완성을 지원한다.
하이버네이트를 포함한 몇몇 ORM 프레임워크들은 오래전부터 자신만의 Criteria를 지원했고, JPA는 2.0 이후로 Criteria를 지원하고 있다.
// select m from Member as m where m.username = 'kim' -> Criteria
// 사용준비
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Member> query = cb.createQuery(Member.class);
// 루트 클래스(조회를 시작할 클래스)
Root<Member> query.from(Member.class);
// 쿼리 생성
CriteriaQuery<Member> cq =
query.select(m).where(cb.equal(m.get("username"), "kim"));
List<Member> resultList = em.createQuery(cq).getResultList();
여기서 아쉬운점은 m.get("username")
와 같이 필드명을 문자로 작성했다는점이다. 만약 이 부분도 문자가 아닌 코드로 작성하고 싶으면 메타 모델 MetaModel 을 사용하면 된다.
자바가 제공하는 어노테이션 프로세서 (Annotation Processor)을 사용하면 어노테이션을 분석해서 클래스로 만들 수 있다. JPA는 이것을 이용하면 Member 엔티티 클래스를 분석해 Member_라는 Criteria 전용 클래스를 생성하는데 이것을 메타 모델이라고 한다. 메타 모델을 사용하면 온전히 코드만으로 쿼리를 작성할 수 있다.
m.get("username")
→ m.get(Member_.username)
Criteria는 코드로 쿼리를 작성할 수 있어서 동적으로 쿼리를 작성할 때 유용하다.
Criteria가 가진 장점이 많지만 모든 장점을 상쇄할 정도로 장황하고 복잡하다. 따라서 사용하기 불편한 것은 물론이고 Criteria로 작성한 코드도 한눈에 들어오지 않는다는 단점이 있다.
QueryDSL
QueryDSL도 Criteria와 같은 빌더 클래스이다. QueryDSL의 장점은 코드 기반이면서 단순하고 사용하기 쉽다. 코드도 JPQL과 비슷해서 한 눈에 들어온다.
// 준비
JPAQuery query = new JPAQuery(em);
QMember member = QMember.member;
// 쿼리, 조회 결과
List<Member> members =
query.from(member)
.where(member.username.eq("kim"))
.list(member);
QueryDSL 역시 어노테이션 프로세서를 사용해 쿼리 전용 클래스를 만들어야 한다. QMember는 Member 엔티티 클래스를 기반으로 생성한 QueryDSL 쿼리 전용 클래스이다.
네이티브SQL
JPA에서도 직접 SQL을 사용할 수 있는 기능을 지원하는데 이것을 네이티브SQL이라고 한다.
특정 데이터베이스에서에 의존한 기능이나 SQL에서는 가능한데 JPQL에서 지원하지 않는 기능들은 JPQL을 사용할 수 없다. 이럴 때, 네이티브SQL을 사용하면 된다.
네이티브SQL의 단점은 특정 데이터베이스에 의존하기 때문에 데이터베이스를 바꾸면 네이티브SQL을 수정해야한다는 점이다.
String sql = "'SELECT ID, AGE, TEAM_ID, NAME FROM MEMBER WHERE NAME = 'kim'";
List<Member> resultList =
em.createNativeQuery(sql, Member.class).getResultList();
위와 같이 createNativeQuery
를 사용하면된다.
JDBC 직접 사용, 마이바티스 같은 SQL 매퍼 프레임워크 사용
이럴 일은 많이 있진 않겠지만, JDBC 커넥션에 직접 접근하고 싶으면 JPA는 JDBC 커넥션을 얻는 API를 제공하지 않으므로 JPA 구현체가 제공하는 방법을 사용해야한다. 하이버네이트에서 직접 JDBC Connection을 획득하는 방법은 다음과 같다.
Session session = EntityManager.unwrap(Session.class);
session.doWork(new Work(){
@Override
public void execute(Connection connection) throws SQLException{
// work...
}
});
먼저 JPA EntityManager에서 하이버네이트 Session을 얻어온다. 그 뒤 Session의 doWork메소드를 실행하면 된다. 다만, JDBC나 Mybatis를 JPA와 함께 사용하면 영속성 컨텍스트를 적절한 시점에 강제로 플러쉬해야한다.
JPA를 우회해서 데이터베이스 접근해야 하는데 이렇게 우회한 SQL에 대해서는 JPA가 인식을 하지 못한다. 최악의 시나리오는 영속성 컨텍스트와 데이터베이스를 불일치 상태로 만들어 데이터 무결성을 훼손할 수 있다.
이런 이슈를 해결하는 방법은 JPA를 우회해서 SQL을 실행하기 전에 수동으로 영속성 컨텍스트를 플러시해서 데이터베이스와 영속성 컨텍스트를 동기화하면 된다.
참고로 스프링 프레임워크를 사용하면 JPA와 마이바티스를 손쉽게 통합할 수 있다. 또한, AOP를 적절히 활용해서 JPA를 우회하여 데이터베이스에 접근하는 메소드를 호출할 때마다 영속성 컨텍스트를 플러시하면 위에서 언급한 문제도 깔끔하게 해결할 수 있다.
JPQL
위의 어떤 방법을 사용하든 모두 JPQL (Java Persistence Query Language)에서 시작한다. 특징을 다시 한 번 정리해보면 다음과 같다.
- JPQL은 객체지향 쿼리언어다. 따라서, 테이블이 아닌 엔티티 객체를 대상으로 쿼리한다.
- JPQL은 SQL을 추상화해서 특정 데이터베이스에 의존하지 않는다.
- JPQL은 다시 SQL로 변경된다.
기본 문법과 쿼리 API
JPQL도 SQL과 비슷하게 SELECT, UPDATE, DELETE 문을 사용할 수 있다. 참고로 엔티티를 저장할 때는 EntityManager.persist()
메소드를 사용하면 되기 때문에 INSERT문은 없다.
select_문 :: =
select_절
from_절
[where_절]
[groupby_절]
[having_절]
[orderby_절]
update_문 :: = update_절 [where_절]
delete_문 :: = delete_절 [where_절]
JPQL 문법을 보면 전체 구조는 SQL과 비슷한 것을 알 수 있다. JPQL에서 UPDATE, DELETE 문을 벌크 연산이라 한다.
SELECT 문
SELECT m FROM Member AS m where m.username = 'Hello'
와 같이 사용한다.
2024년 2월 29일
- 대소문자 구분
- SELECT, UPDATE, DELETE, FROM, AS와 같은 JPQL 예약어를 제외한 엔티티명, 속성 등은 대소문자를 구분한다.
- 엔티티 이름
- JPQL에서 사용하는 Member는 클래스명이 아닌 엔티티명이다. 엔티티명을 직접 지정하고 싶다면
@Entity(name = "[이름]")
과 같이 직접 지정할 수 있다. 이름을 지정하지 않으면 클래스명을 따라가게 되는데 직접 지정하지 않는 것을 추천한다. - 별칭은 필수
SELECT username FROM Member m // 오류 발생 SELECT m.username FROM Member m // 올바른 쿼리 SELECT m.username FROM Member AS m // 올바른 쿼리
하이버네이트는 JPQL 표준도 지원하지만, 더 많은 기능을 제공하는 HQL 역시 제공한다. HQL (Hibernate Query Language)을 사용할 때는 SELECT username FROM Member m과 같이 별칭을 사용하지 않고도 조회를 할 수 있다.
- JPQL에서 별칭은 필수이다. 조회할 컬럼에 별칭을 붙여서 작성하지 않으면 문법 오류가 발생하게 된다.
TypedQuery, Query
작성한 JPQL을 실행하기 위해서는 쿼리 객체인 TypedQuery나 Query를 사용해야 한다. 이 때, 반환할 객체의 타입을 명확하게 지정할 수 있다면 TypedQuery를 그렇지 않다면 Query를 사용하면 된다.
TypedQuery<Member> query =
em.createQuery("SELECT m FROM Member m", Member.class);
List<Member> resultList = query.getResultList();
for(Member m : resultList){
System.out.println("member = " + m);
}
em.creatQuery()
의 두 번째 파라미터에 반환할 타입을 지정하면 TypedQuery를 반환하고 지정하지 않으면 Query 객체를 반환한다. 이 경우 조회대상의 타입이 Member로 명확하므로 createQuery()
메소드의 두 번째 파라미터로 Member를 넣어 TypedQuery를 반환받았다.
Query query = em.createQuery("SELECT m.username, m.age FROM Member m");
List resultList = query.getResultList();
for(Object o : resultList){
Object[] result = (Object[]) o; // 결과가 둘 이상이기 때문에 Object[] 반환
System.out.println("username : " + result[0]);
System.out.println("age : " + result[1]);
}
위와 같이 String인 username과 Integer인 age를 조회할 경우 조회 대상 타입이 명확하지 않아 Query 객체를 사용해야 한다. Query객체의 getResultList()
를 실행할 경우 둘 이상의 속성은 Object[]
을 , 하나인 경우 Object
를 반환한다. 코드를 살펴보았을 때 타입 변환을 해야하는 Query보다 TypedQuery가 더 간편한 것을 알 수 있다.
결과 조회
다음 메서드들을 실행하면 실제 쿼리를 실행해서 데이터베이스를 조회한다.
query.getResultList()
: 결과를 리스트로 반환한다. 만약 결과가 없으면 빈 컬렉션을 반환한다.query.getSingleResult()
:결과가 정확히 하나일 경우에 사용한다.- 결과가 없으면 NoResultException예외가 발생한다.
- 결과가 1개보다 많으면 NonUniqueResultException예외가 발생한다.
파라미터 바인딩
이름 기준 파라미터 바인딩과 위치 기준 파라미터 바인딩이 있다. JPQL은 이름 기준 파라미터 바인딩을 사용하고, JDBC는 위치 기준 파라미터 바인딩을 사용한다.
이름 기준 파라미터 바인딩
String usernameParam = "User1";
TypedQuery<Member> query =
em.creatQuery("select m from Member m where m.username = :username", Member.class);
query.setParameter("username", usernameParam);
List<Member> resultList = query.getResultList();
위와 같이 이름으로 구분하는 방법이다. 이름 앞에 :
를 붙여 구분한다.
참고로 JPQL API는 대부분 메서드 체인 방식으로 설계되어 있어 다음과 같이 작성할 수 있다.
String usernameParam = "User1";
List<Member> resultList =
em.createQuery("select m from Member m where m.username = :username", Member.class)
.setParameter("username", usernameParam)
.getResultList();
위치 기준 파라미터 바인딩
List<Member> members =
em.createQuery("select m from Member m where m.usename = ?1", Member.class)
.setParameter(1, usernameParam)
.getResultList();
위치 기준 파라미터 바인딩을 사용하려면 ?
다음에 위치를 지정해주면 된다. 위치값은 1부터 시작한다.
위치 기준 파라미터 바인딩 보다는 이름 기준 파라미터 바인딩을 쓰는 것이 더 명확하다.
프로젝션
SELECT 절에서 조회할 대상을 지정하는 것을 프로젝션이라고 한다. 프로젝션 대상으로 엔티티 타입, 임베디드 타입, 스칼라 타입이 올 수 있다. 스칼라 타입은 문자, 숫자 등의 기본 데이터 타입을 나타낸다.
엔티티 프로젝션
쉽게 말해서 엔티티 프로젝션은 원하는 객체를 바로 조회하는 것이다. 이렇게 조회된 객체는 영속성 컨텍스트에서 관리된다.
임베디드 타입 프로젝션
엔티티 타입 거의 비슷하지만 조회의 시작점이 될 수 없다는 제약이 있다.
// String jpql = "select a from Address a"; // 오류 : 조회의 시작이 될 수 없음.
String jpql = "select O.address from Order o";
List<Address> addresses =
em.creatQuery(jpql, Address.class)
.getResultList();
임베디디 타입은 엔티티 타입과 다르게 값 타입이다. 따라서, 이렇게 조회한 임베디드 타입은 영속성 컨텍스트에서 관리되지 않는다.
스칼라 타입 프로젝션
숫자, 문자, 날짜와 같은 기본 데이터 타입을 스칼라 타입이라고 한다.
List<String> usernames =
em.creatQuery("SELECT DISTINCT m.username FROM Member m", String.class)
.getResultList();
Double orderAmountAvg =
em.createQuery("SELECT AVG(o.orderAmount) FROM Order o", Double.class)
.getSingleResult();
위와 같이 통계쿼리 역시 주로 스칼라 타입으로 조회한다.
여러 값 조회
조회를 할 때 엔티티 타입으로 조회하면 좋겠지만, 꼭 필요한 데이터들만 선택해서 조회해야 할 때도 있다. 프로젝션에 여러 값을 선택하면 TypedQuery를 사용할 수 없고 대신에 Query를 사용해야 한다.
List resultList =
em.createQuery("SELECT oi.orderPrice, oi.count FROM OrderItem oi")
.getResultList();
ListIterator it = resultList.iterator();
while(it.hasNext()){
Object[] row = (Object[])it.next();
Integer price = (Integer)row[0];
Integer count = (Integer)row[1];
}
제네릭에 Object[]
를 사용하면 더 간결하게 개발할 수 있다.
List<Object[]> results =
em.createQuery("SELECT oi.orderPrice, oi.count FROM OrderItem oi")
.getResultList();
for(Object[] row : results){
Integer price = (Integer)row[0];
Integer count = (Integer)row[1];
}
스칼라 타입이 아닌 엔티티도 여러 타입을 조회할 수 있다. 이 경우에도 엔티티는 영속성 컨텍스트에서 관리된다.
NEW 명령어
실제 애플리케이션 개발에서는 위와 같이 Object[]
를 반환받고 이를 변환해서 사용하는 것이 아닌 DTO 클래스를 작성해서 이를 직접 사용하게될 것이다. 아래와 같이 NEW 명령어를 사용하면 된다.
TypedQuery<UserDTO> query =
em.createQuery("SELECT new jpa.book.jpql.UserDTO(m.username, m.age) FROM Member m",
UserDTO.class);
List<UserDTO> users = query.getResultList();
SELECT 다음에 NEW 명령어를 이용해 반환받을 클래스를 지정할 수 있는데 이 클래스에 생성자에 JPQL 결과를 넘겨줄 수 있다.
NEW 명령어를 사용할 때 주의사항은 다음과 같다.
- 패키지명을 포함한 전체 클래스명을 입력해야 한다.
- 반환받을 타입과 순서까지 일치한 생성자 메서드가 있어야 한다.
페이징 API
페이징 처리 SQL은 데이터베이스마다 문법이 다르고 작성이 지루하다. 이에 JPA는 SQL의 페이징 처리를 두 API로 추상화했다.
setFirstResult(int startPosition)
: 조회를 시작할 위치를 지정한다. (미지정 시, 0부터 시작한다)setMaxResults(int maxResult)
: 조회할 컬럼 개수를 지정한다.
TypedQuery<Member> query =
em.createQuery("SELECT m FROM Member m ORDER BY m.username DESC",
Member.class);
query.setFirstResult(10);
query.setMaxResult(20);
query.getResultList();
위 쿼리를 실행하면 10부터 시작이므로 11 ~ 30번 까지 총 20개의 데이터를 조회한다.
데이터베이스마다 다른 페이징 처리를 같은 API로 수행할 수 있는 것은 JPA가 지원하는 데이터베이스 방언 덕분이다. 페이징 쿼리는 정렬조건이 중요하기 때문에 예제에 포함했다.
데이터베이스마다 SQL이 다른 것은 물론이고 오라클과 SQLServer는 따로 페이징 쿼리를 공부해야할 정도로 복잡하다. 페이징 쿼리를 최적화하고 싶다면 JPA가 제공하는 네이티브 SQL을 직접 사용하면 된다.
집합과 정렬
집합은 집합함수와 함께 통계 정보를 구할 때 사용한다.
함수 | 반환 타입 |
---|---|
COUNT | Long |
MAX, MIN | |
AVG | Double |
SUM | 정수 합 Long, 소수 합 : Double, BigInteger 합 : BigInteger, BigDecimal 합 : BigDecimal |
집합 함수 사용 시 참고사항
- NULL 값은 무시하므로 통계에 잡히지 않는다.
- 만약 값이 없는데 SUM, MAX, MIN, AVG 함수를 사용하면 NULL 값이 반환된다. 단 COUNT는 0이된다.
- DISTINCT를 집합 함수 안에 사용해 중복된 값을 제거하고 나서 집합을 구할 수 있다.
- DISTINCT를 COUNT 안에서 사용할 때 임베디드 타입은 지원하지 않는다.
Group By, Having
Group By는 통계 데이터를 구할 때 특정 그룹끼리 묶어준다. Having은 Group By와 함께 사용하는데 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 AVG(m.age) >= 10
이런 쿼리들을 보통 리포팅 쿼리나 통계 쿼리라고 한다. 이러한 통계 쿼리는 애플리케이션에서 수십 줄에 해당하는 기능을 단 몇 줄의 쿼리문으로 실행할 수 있다. 하지만 이런 쿼리들은 보통 전체 테이블을 대상으로 수행하기 때문에 사람이 많이 없는 새벽 시간에 수행하고 이를 별도의 테이블에 저장해두는 것이 좋다.
Order By
결과를 정렬할 때 사용한다.
JPQL 조인
JPQL도 조인을 지원하는데 문법이 약간 다르다.
내부 조인
내부 조인은 INNER JOIN을 사용하고 INNER는 생략이 가능하다.
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();
JPQL의 내부 조인 구문을 보면 SQL과 달리 연관 필드를 이용하고 있는 것을 알 수 있다. 여기에는 연관 필드가m.team인데 연관 필드는 다른 엔티티와 연관관계를 가지기 위해 사용하는 필드를 말한다. 혹시라도 JPQL 조인을 SQL 조인처럼 사용하면 문법 오류가 발생한다.
INNER JOIN Team t
→ 문법 오류 발생
만약 조인한 두 엔티티를 조회하고 싶다면 다음과 같은 쿼리문을 작성하면 된다.
String query = "SELECT m, t FROM Member m JOIN m.team t "
+"WHERE t.name = :teamname";
List<Object[]> results =
em.createQuery(query)
.setParameter("teamname", "팀A")
.getResultList();
for(Object[] res : results){
Member member = (Member) res[0];
Team team = (Team) res[1];
}
두 엔티티 모두 타입이 다르기 때문에 TypedQuery를 사용할 수 없다. 따라서, 위와 같이 조회해야 한다.
외부조인
외부조인은 기능상 SQL의 외부조인과 같다. OUTER는 생략 가능해서 보통 LEFT JOIN으로 사용한다.
//JPQL
SELECT m
FROM Member m LEFT JOIN m.team t
컬렉션 조인
일대다 관계나 다대일 관계에서 컬렉션을 사용하는 곳에 조인하는 것을 컬렉션 조인이라고 한다.
- 회원 → 팀으로의 조인은 다대일 조인이면서 단일 값 연관 필드(m.team)를 사용한다.
- 팀 → 회원으로의 조인은 일대다 조인이면서 컬렉션 값 연관필드(t.members)를 사용한다.
SELECT t, m FROM Team t LEFT JOIN t.members m
조회 시작 위치(FROM Team t)에 유의하여야 하고 여기서는 컬렉션 값(t.members)을 연관필드로 외부 조인했다.
세타조인
WHERE 절을 사용해 세타조인을 할 수 있다. 세타조인은 내부조인만 지원한다.
세타조인을 사용해 아무 관련없는 엔티티도 조인할 수 있다.
// JPQL
select count(m) from Member m, Team t
where m.username = t.name
JOIN ON 절(JPA 2.1)
JPA 2.1부터 ON절을 지원한다. 내부조인에서는 ON과 같은 기능을 WHERE절에서 수행할 수 있으므로 보통 외부조인에서 ON을 사용한다.
// JPQL
select m, t from Member m
left join m.team t on t.name = 'A'
페치 조인
페치 조인은 SQL에 있는 기능이 아닌 JPA에서 성능 최적화를 위해 지원하는 기능이다. 이것은 연관된 엔티티나 컬렉션은 한 번에 같이 조회하는 기능인데, join fetch
명령어로 사용할 수 있다.
페치 조인 문법은 다음과 같다.
페치 조인 ::= [LEFT [OUTER] | INNER] JOIN FETCH 조인 경로
엔티티 페치 조인
select m
from Member m join fetch m.team // 별칭 사용 불가
join 뒤에 fetch를 사용해 멤버 엔티티와 함께 연관된 팀도 조회한다. 페치 조인은 일반적인 JPQL과 다르게 별칭을 사용할 수 없다.
엔티티 페치 조인 SQL에서는 회원만 선택했는데 팀 엔티티도 같이 조회됐다. 또한, 회원과 팀이 객체 그래프를 유지하면서 조회된 것을 확인할 수 있다.
전체 코드는 다음과 같다.
String jpql = "select m from Member m join fetch m.team";
List<Member> members =
em.createQuery(jpql, Member.class)
.getResultList();
for(Member m : members){
// 페치 조인으로 회원과 팀을 함께 조회해서 지연 로딩 발생하지 않음.
System.out.println("username = " + m.getUserName() + ", teamname = " +
m.getTeam().name());
}
회원과 팀을 지연 로딩으로 설정했다고 가정하자. 회원을 조회할 때 페치 조인을 했기 때문에 팀 엔티티는 프록시가 아닌 실제 엔티티이다. 그렇기 때문에 회원 엔티티를 준영속 상태로 만들어도 연관된 팀을 조회가 가능하다.
컬렉션 페치 조인
컬렉션 페치 조인을 할 경우 하나의 팀에 여러 회원이 연관되어 있어 팀이 회원 수만큼 증가하여 조회된다.
String jpql = "select t from Team t join fetch t.members";
List<team> teams = em.createQuery(jpql, Team.class).getResultList();
for(Team team : teams){
System.out.println("teamname = " + team.getName() + ", team = " + team);
for(Member member : team.getMembers()){
// 페치 조인으로 팀과 회원을 함께 조회해서 지연 로딩 발생하지 않음.
System.out.println("-> username = " + member.getName() + ", member =" + member);
}
}
페치 조인과 DISTINCT
DISTINCT 명령을 적용하면 SQL에서 DISTINCT가 적용되는 것은 물론이고 애플리케이션에서도 중복을 제거한다. 위에서 컬렉션 페치 조인을 적용했을 때는 팀A가 두 번 조회되었다.
select **distinct** t
from team t join fetch t.members
where t.name = '팀A'
위 쿼리를 실행했을 때 SQL에서는 달라지는 점이 없다. 중복되는 회원이 없기 때문이다. 하지만 애플리케이션에서는 다르다. 여기서는 distinct
를 통해 중복된 팀 엔티티를 저거한다.
위와 같이 중복된 팀A가 사라져 팀A가 하나만 나오게된다.
페치 조인과 일반 조인의 차이
페치 조인은 연관된 필드를 함께 조회한다고 했다. 일반 조인은 이와 다르게 엔티티 간의 연관관계에 관계없이 SELECT 절에 지정한 엔티티만을 가져온다. 이 때, 연관된 엔티티가 지연 로딩이라면 조회한 엔티티와 연관된 엔티티는 프록시 객체거나 아직 초기화되지 않은 컬렉션 래퍼가 된다. 즉시 로딩으로 설정하면 회원 컬렉션을 즉시 로딩하기 위해 쿼리를 한 번 더 실행한다.
페치 조인의 특징과 한계
페치 조인을 사용하면 SQL을 한 번만 사용해서 연관된 엔티티까지 조회할 수 있어 성능상 이점이 있다. 다음처럼 로딩전략을 직접 지정하는 방식을 글로벌 로딩 전략이라고 한다.
@OneToMany(fetch = FetchType.LAZY) // 글로벌 로딩 전략
페치 조인은 글로벌 로딩 전략보다 우선순위가 높다. 글로벌 로딩 전략을 지연 로딩으로 설정했더라도 페치 조인을 하면 페치 조인을 적용해서 함께 조회한다. 글로벌 로딩 전략을 즉시 로딩으로 해두면 애플리케이션 전체에서 항상 즉시 로딩이 일어난다. 일부는 빠를 수 있지만 전체로 보면 사용하지 않는 엔티티를 자주 로딩하므로 성능에 악영향을 미칠 수 있다. 따라서, 글로벌 로딩 전략은 될 수 있으면 지연 로딩을 사용하고 최적화가 필요하면 페치 조인을 적용하는 것이 효과적이다.
또한, 페치 조인을 사용하면 연관된 엔티티를 쿼리 시점에 조회하므로 지연로딩이 발생하지 않는다. 따라서, 준영속 상태에서도 객체 그래프를 탐색할 수 있다.
페치 조인은 다음과 같은 한계가 있다.
- 페치 조인 대상에는 별칭을 줄 수 없다.
- 따라서, SELECT, WHERE, 서브 쿼리에 페치 조인 대상을 사용할 수 없다.
- 둘 이상의 컬렉션을 페치할 수 없다.
- 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다.
페치 조인은 SQL 한 번으로 연관된 여러 엔티티를 조회할 수 있어 성능 최적화에 상당히 유용하다. 그리고 실무에서 자주 사용하게 된다. 그러나 모든 것을 페치 조인으로 해결할 수는 없다. 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 한다면 여러 테이블에서 필요한 필드들만 조회해서 DTO로 반환하는 것이 더 효과적일 수 있다.
2024년 3월 1일
경로 표현식
경로 표현식(Path Expression)은 쉽게 말해서 .
을 찍어 객체 그래프를 탐색하는 것이다. 경로 표현식을 통해 묵시적 조인이 가능하다.
select m.username
from member m
join m.team t
join m.orders o
where t.name = '팀A'
위 JPQL 중 m.username, m.team, m.orders, t.name 모두 경로 표현식이 사용되었다.
경로 표현식 용어 정리
- 상태 필드 : 단순히 값을 저장하기 위한 필드 (필드 or 프로퍼티)
- 연관 필드 : 연관관계를 위한 필드, 임베디드 타입 포함 (필드 or 프로퍼티)
- 단일 값 연관 필드 :
@ManyToOne
,@OneToOne
, 대상이 엔티티 - 컬렉션 값 연관 필드 :
@OneToMany
,@ManyToMany
, 대상이 컬렉션
- 단일 값 연관 필드 :
경로 표현식과 특징
- 상태 필드 경로 : 경로 탐색의 끝이다. 더이상 탐색할 수 없다.
- 연관 필드 경로 : 묵시적 내부 조인이 일어난다.
- 단일 값 연관 경로 : 단일 값 연관 경로는 계속 탐색이 가능하다.
- 컬렉션 값 연관 경로 : 더이상 탐색이 불가능하다. 단 FROM 절에서 조인을 통해 별칭을 얻으면 별칭으로 탐색할 수 있다.
단일 값 연관 경로 탐색
// jpql
select o.member from Orders o
// 변환된 sql
select m.*
from Orders o
inner join Member m on m.member_id = m.id
JPQL을 보면 o.member를 통해 주문에서 회원으로 단일 값 연관 경로 탐색을 했다. 단일 값 연관 경로 탐색을 하면 SQL에서 내부 조인이 일어나는데 이를 묵시적 조인이라고 한다.
컬렉션 값 연관 경로 탐색
JPQL을 다루면서 많이 하는 실수 중 하나는 컬렉션 값에서 경로 탐색을 시도하는 것이다.
select t.members from Team t // 성공
select t.members.username from Team t // 실패
위와 같이 t.members까지는 가능하지만 t.members.username은 불가능하다. 만약 컬렉션에서 경로 탐색을 하고 싶다면 아래와 같이 조인을 사용해서 새로운 별칭을 획득해야 한다.
select m.username from Team t join t.members m
join t.members m
으로 별칭을 부여받았고 이제 별칭 m부터 다시 경로 탐색을 할 수 있다.
참고로 컬렉션은 컬렉션의 크기를 구할 수 있는 size라는 특별한 기능을 사용할 수 있다. size를 사용하면 count 함수를 사용하는 SQL로 적절히 변환된다.
경로 탐색을 사용한 묵시적 조인 시 주의사항
- 항상 내부조인이다.
- 컬렉션은 경로 탐색의 끝이다. 만약 컬렉션에서 경로 탐색을 하고 싶다면 명시적 조인을 통해 별칭을 부여 받고 해당 별칭부터 경로 탐색을 시작해야 한다.
- 경로 탐색은 주로 SELECT, WHERE 절에서 사용하지만 묵시적 조인으로 인해 SQL의 FROM절에 영향을 준다.
조인은 성능에 영향을 준다. 따라서, 성능이 중요하다면 분석을 위해 명시적으로 조인을 하는 것이 좋다.
서브쿼리
JPQL도 서브쿼리를 지원하지만 WHERE, HAVING절에서만 사용이 가능하다. SELECT, FROM절에서는 사용이 불가능하다.
서브쿼리 함수
- [NOT] EXIST (subquery) : 서브 쿼리에 존재하면 참이다. NOT은 반대
- {ALL | ANY | SOME } (subquery) : 비교 연산자와 같이 사용한다.
- ALL : 모두 만족해야 참이다.
- ANY, SOME : 같은 의미이며 하나라도 만족하면 참이다.
select o from Orders o
where o.orderAmount > ALL(select p.stockAmount from Product p)
- [NOT] IN (subquery) : 서브 쿼리의 결과 중 하나라도 같은 것이 있으면 참이다.
위 함수들을 사용할 수 있다.
조건식
타입 표현
종류 |
설명 |
---|---|
문자 | ''사이에 표현, '을 사용하고 싶으면 ''와 같이 연속 두개 사용 |
숫자 | L(Long) |
D(Double) | |
F(Float) | |
날짜 | DATE {d 'yyyy-mm-dd'} |
TIME {t 'hh:mm:ss'} | |
DATETIME {ts 'yyyy-mm-dd hh:mm:ss.f} | |
Boolean | True, False |
Enum | 패키지명을 포함한 전체 이름을 사용 |
엔티티 타입 | 엔티티의 타입을 표현한다. 주로 상속과 관련해서 사용한다. |
Type(m) = Member |
연산자 우선순위
다음과 같다.
- 경로 탐색 연산
- 수학 연산
- 비교 연산
- 논리 연산
컬렉션 식
컬렉션에서만 사용할 수 있는 특별한 기능이다. 컬렉션은 컬렉션 식 말고 다른 식은 사용할 수 없다.
- 빈 컬렉션 비교 식 : [컬렉션 값 연관 경로] IS [NOT] EMPTY
- 컬렉션의 값이 비었으면 참
- 컬렉션의 멤버 식 : {엔티티나 값} [NOT] MEMBER [OF] {컬렉션 값 연관 경로}
- 엔티티나 값이 컬렉션에 포함되어 있으면 참
select m from Member m
where m.orders is not empty
// select m from Member m where m.orders is null 오류
select t from Team t
where :memberParam member of t.members
컬렉션은 컬렉션 식만 가능하다. 위와 같이 null 비교를 할 수 없다.
스칼라 식
날짜 함수
- CURRENT_DATE : 현재 날짜
- CURRENT_TIME : 현재 시간
- CURRENT_TIMESTAMP : 현재 날짜 시간
CASE 식
특정 조건에 따라 분기할 때 CASE 식을 사용하며, 4가지 종류가 있다.
기본 CASE
CASE
{WHEN <조건식> THEN <스칼라식>}+
ELSE <스칼라식>
END
심플 CASE
CASE <조건 대상>
{WHEN <스칼라식> THEN <스칼라식>}+
ELSE <스칼라식>
END
COALESCE
COALESCE (<스칼라식> {, <스칼라식>}+)
스칼라식을 차례대로 조회해서 null
이 아니면 반환한다.
🔽 이름이 null이면 이름 없는 회원을 반환하라.
select coalesce(m.username, '이름 없는 회원') from Member m
NULLIF
NULLIF(<스칼라식>, <스칼라식>)
두 값이 같으면 null을 반환하고 다르면 첫 번째 값을 반환한다. 집합 함수는 null을 포함하지 않으므로 보통 집합 함수와 함께 사용한다.
🔽 이름이 관리자면 null을 반환하고 나머지는 본인 이름을 반환하라.
select NULLIF(m.username, '관리자') from Member m
다형성 쿼리
JPQL에서 부모타입으로 조회하게 되면 자식 타입까지 모두 조회된다.
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item{...}
@Entity
@DiscriminatorValue("B")
public class Book extends Item{
...
private String author;
}
// Album, Movie 생략
List resultList =
em.createQuery("select i from Item i").getResultList();
단일 테이블 전략을 사용할 때와 조인 전략을 사용할 때 SQL이 다르다.
TYPE
엔티티의 상속 구조에서 조회 대상을 특정 자식 엔티티에 한정할 때 사용한다.
// jpql
select i from Item i
where type(i) IN (Book, Movie)
TREAT
JPA 2.1부터 도입된 기능으로 부모 타입을 특정 자식 타입으로 다룰 때 사용된다. JPA 표준에서는 WHERE 절과 FROM 절에서만 사용이 가능한데 하이버네이트에서는 SELECT 절에서도 사용이 가능하다.
// jpql
select i from Item i
where treat(i as Book).author = 'kim'
위와 같이 Item과 Book이 상속관계에 있을 때 부모 타입인 Item을 Book 취급해 author 필드에 접근할 수 있다.
사용자 정의 함수
JPA 2.1부터 지원한다.
function_invocation::= FUNCTION(function_name, {, function_arg}*)
하이버네이트 구현체를 사용하면 방언 클래스를 상속해서 구현하고 사용할 데이터베이스 함수를 미리 등록해야한다.
기타 정리
enum
은=
비교 연산만 지원한다.- 임베디드 타입은 비교를 지원하지 않는다.
EMPTY STRING
JPA 표준에서 ''
은 길이가 0인 빈 문자열이다. 하지만, 데이터베이스에 따라 NULL로 처리하는 경우도 있어 주의해야 한다.
NULL
- 조건을 만족하는 데이터가 하나도 없으면 NULL이다.
- NULL은 알 수 없는 값이다.
- NULL == NULL은 알 수 없는 값이다.
- NULL IS NULL 은 참이다.
엔티티 직접 사용
기본 키 값 사용
객체 인스턴스는 참조 값으로 식별하고 테이블은 기본 키 값으로 식별한다. 따라서, JPQL에서 엔티티 객체를 직접 사용하면 SQL에서는 해당 엔티티의 기본 키 값을 사용한다.
select count(m.id) from Member m
select count(m) from Member m
엔티티를 파라미터로 직접 받으면 어떻게 될까?
String qlString = "select m from Member m where m = :member";
List resultList = em.createQuery(qlString)
.setParameter("member", member)
.getResultList();
/*
실행된 SQL
select m.*
from Member m
where m.id = ?
*/
위와 같이 엔티티를 직접 사용하는 부분이 기본 키 값을 사용하도록 변환된 것을 알 수 있다.
외래 키 값
외래 키 역시 엔티티를 직접 사용하면 테이블의 기본 키 값으로 변환된다.
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();
String qlString = "select m from Member m where m.team.id = :teamId";
List resultList = em.creatQuery(qlString)
.setParameter("teamId", 1L)
.getResultList();
m.team.id 부분을 봤을 때 묵시적 조인이 일어날 것 같지만 기본적으로 Member 테이블이 TEAM_ID를 가지고 있기 때문에 묵시적 조인은 일어나지 않는다.
Named 쿼리 : 정적 쿼리
JPQL에는 동적 쿼리와 정적 쿼리 크게 두 가지의 쿼리가 있다.
동적 쿼리 : em.createQuery()
처럼 JPQL을 문자로 완성해서 직접 넘기는 것을 동적쿼리라고 한다. 런타임에 특정한 조건에 따라 JPQL을 동적으로 구성할 수 있다.
정적 쿼리 : 미리 정의한 쿼리에 이름을 부여해서 필요 시 사용할 수 있는데 이 것을 Named Query라고 부른다. 한 번 정의하면 수정할 수 없어 정적인 쿼리이다.
애플리케이션 로딩 시점에 문법을 체크하고 파싱한다. 오류를 빨리 확인할 수 있고, 재사용될 수 있어 데이터베이스 성능상 이점이 있다. @NamedQuery
를 붙여 자바 코드에 작성하거나 XML 문서에 작성할 수 있다.
2024년 3월 4일
Named 쿼리를 어노테이션에 정의
@Entity
@NamedQuery(
name= "Member.findByUserName",
query = "select m from Member m where m.username = :username"
)
public class Member{
...
}
List<Member> resultList =
em.createQuery("Member.findByUserName", Member.class)
.setParameter("username", "회원1")
.getResultList();
위와 같이 @NamedQuery.name
에 쿼리 이름을, @NamedQuery.query
에 사용할 쿼리를 부여해 사용한다. Named 쿼리를 사용해 조회 시에는 쿼리 이름을 사용해 조회하면 된다.
Named 쿼리는 영속성 유닛 단위로 관리되기 때문에 충돌을 방지하고 관리를 용이하게 하기 위해 쿼리 이름앞에 엔티티명을 붙였다.
하나의 엔티티에 2개 이상의 Named 쿼리를 등록하려면 @NamedQueries
를 붙여 사용하면 된다.
@NamedQuery
의 속성에는 위 두가지 외에도 lockMode와 hints가 있다. lockMode의 경우에는 쿼리 실행 시 락을 걸고, hints는 JPA 구현체에게 힌트를 제공한다.
Named 쿼리를 XML에 작성
JPA에서 어노테이션으로 작성할 수 있는 것은 XML로도 작성이 가능하다.
<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>
위를 살펴보면
<![CDATA[]]>
라는 것이 있는데 이를 사용하면 그 사이에 있는 문자을 그대로 출력한다. 이를 통해 예약문자도 사용할 수 있다.
그리고 정의한 ormMember.xml
을 인식하도록 META-INF/persistence.xml에 다음 코드를 추가해야 한다.
<persistence-unit name="jpabook">
<mapping-file>META-INF/ormMember.xml</mapping-file>
환경에 따른 설정
XML과 어노테이션에 같은 설정이 있다면 XML이 우선된다. 같은 이름의 Named 쿼리가 두 곳 모두에 있다면, XML에 정의한 것이 사용된다.
Criteria
Criteria는 JPQL의 작성을 돕는 빌더 클래스이다. JPQL을 동적으로 안전하게 작성할 수 있게 해주며, 문법 오류를 컴파일 시점에 잡을 수 있다는 장점이 있다. 하지만, 코드가 복잡하고 직관적으로 이해하기 힘들다는 단점이 있다.
Criteria 기초
Criteria API는 javax.persitence.criteria
패키지에 있다.
// JPQL : select m from Member m
CriteriaBuilder cb = em.getCriteriaBuilder(); // Criteria 쿼리 빌더
// Criteria 생성, 반환 타입 지정
CriteriaQuery<Member> cq = cb.createQuery(Member.class);
Root<Member> m = cq.from(Member.class); // FROM 절
cq.select(m); // SELECT 절
TypedQuery<Member> query =
em.createQuery(cq);
List<Member> members = query.getResultList();
- Criteria 쿼리를 생성하려면 먼저 Criteria 빌더를 얻어야 한다. Criteria 빌더는 EntityManager나 EntityManagerFactory에서 얻을 수 있다.
- Criteria 빌더에서 CriteriaQuery를 생성한다. 이 때 반환 타입을 지정할 수 있다.
- FROM 절을 생성한다. m은 Criteria에서 사용하는 별칭이다. m을 조회에 시작점이라는 의미로 쿼리 Root라고 한다.
- SELECT 절을 생성한다.
여기에 조건과 정렬을 추가하면 다음과 같다.
// JPQL
// select m from Member m
// where m.username = '회원1'
// order by m.age desc
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Member> cq = cb.createQuery(Member.class);
Root<Member> m = cq.from(Member.class);
// 검색 조건 정의
Predicate usernameEqual = cb.equal(m.get("username"), "회원1");
javax.persistence.criteria.Order ageDesc = cb.desc(m.get("age"));
// 쿼리 생성
cq.select(m)
.where(usernameEqual) // WHERE 절
.orderBy(ageDesc);
List<Member> members = em.createQuery(cq).getResultList();
Criteria는 검색 조건부터 정렬까지 Criteria 빌더를 사용해 코드를 완성한다.
Criteria는 JPQL을 코드로 작성하는 빌더 클래스다.
Root<Member> m = cq.from(Member.class);
에서 JPQL의 조회의 시작점이 Criteria의 쿼리 Root이며, Root는 엔티티에만 부여가 가능하다. JPQL의 별칭은 Root의 인스턴스명이며 JPQL의 경로 표현식 m.username은m.get("username")
과 같다.
// select m from Member m
// where m.age > 10 order by m.age desc
Root<Member> m = cq.from(Member.class);
Predicate ageGt = cb.greaterThan(m.<Integer>get("age"), 10);
cq.select(m)
.where(ageGt)
.orderBy(cb.desc(m.get("age")));
m.<Integer>get("age")
를 보면 <>
을 통해 제네릭 타입을 제공했는데 m.get
을 통해서는 age의 타입 정보를 모르기 때문에 제네릭으로 반환타입을 제공해야 한다. 또한, greaterThan 대신 gt()
메소드도 사용가능하다.
Criteria 쿼리를 생성할 때, CriteriaQuery<Member> cq = cb.getCriteriaQuery(Member.class)
처럼 반환 타입을 작성하면 em.createQuery(cq)
와 같이 반환 타입을 작성하지 않아도 된다.
또는 반환 타입을 명시할 수 없거나 두 개 이상일 때는 아래와 같이 사용하게 된다.
// 반환 타입 미지정
CriteriaQuery<Object> cq = cb.createQuery();
List<Object> resultList = em.createQuery(cq).getResultList();
// 반환 타입 둘 이상
CriteriaQuery<Object[]> cq2 = cb.createQuery(Object[].class);
List<Object[]> resultList1 = em.createQuery(cq2).getResultList();
둘 이상일 때 역시 Object로 받아올 수 있지만, Object[]로 반환받는 것이 더 편리하다.
반환 타입을 튜플로 받고 싶으면 튜플을 사용하면 된다.
// 조회 값 반환 타입을 Tuple로 지정
CriteriaQuery<Tuple> cq = cb.creatQuery();
TypedQuery<Tuple> query = em.createQuery(cq);
조회
SELECT 절을 만드는 메서드에는 select()와 multiselect()가 있다.
조회 대상을 한 건, 여러 건 지정
조회 대상을 여러 건 지정하려면 cq.multiselect(m.get("username"), m.get("age"));
와 같이 multiselect를 사용하면 된다. cb.array
를 사용해 조회할 필드들을 담아서 전달할 수도 있다.
DISTINCT
distinct는 select, multiselect 다음에 distinct(true)를 사용하면 된다. 완성된 쿼리는 다음과 같다.
// JPQL : select distinct m.username, m.age 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"), m.get("age")).distinct(true);
// cq.select(cb.array(m.get("username"), m.get("age"))).distint(true);
TypedQuery<Object[]> query = em.createQuery(cq, Object[].class);
List<Object[]> resultList = query.getResultList();
NEW, construct()
JPQL에서 select new 생성자()
구문을 Criteria에서는 cb.construct(클래스 타입, ...)
로 사용한다.
// JPQL : select new jpabook.domain.MemberDTO(m.username, m.age)
// from Member m
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();
JPQL에서는 패키지명까지 다 적어줬지만, Criteria는 코드를 직접 다루므로 MemberDTO.class 처럼 간략하게 사용할 수 있다.
튜플
Criteria는 Map과 비슷한 튜플이라는 특별한 반환 객체를 제공한다.
// JPQL : select m.username, m.age from Member m
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Tuple> cq = cb.createTupleQuery();
// CriteriaQuery<Tuple> cq = cb.createQuery(Tuple.class);
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();
for (Tuple tuple : resultList) {
String username = tuple.get("username", String.class);
Integer age = tuple.get("age", Integer.class);
}
위와 같이 튜플을 사용하기 위해서는 createTupleQuery()
메소드를 사용하거나 Tuple.class를 전달해야 한다.
튜플은 튜플의 검색 키로 사용할 튜플 전용 별칭을 필수로 지정해야 하는데 이를 alias()
메소드로 지정해야 한다. 이후 선언해둔 별칭으로 데이터를 조회할 수 있다.
튜플은 이름 기반이므로 순서 기반인 Object[] 보다 안전하다. 그리고 tuple.getElements() 같은 메서드를 사용해 현재 튜플의 별칭과 자바 타입도 조회할 수 있다. 튜플로 엔티티 역시 조회할 수 있다.
집합
cq.groupBy(m.get("team").get("name"));
은 JPQL에서 group by m.team.name와 동일한 기능을 수행한다.
HAVING
having(cb.gt(minAge, 10))
과 같이 사용할 수 있다.
정렬
정렬 조건도 Criteria 빌더를 통해 생성하는데 cb.desc()
또는 cb.asc()
로 생성할 수 있다.
조인
조인은 join()
메서드와 JoinType 클래스를 사용한다.
/* JPQL
select m, t from Member m
inner join m.team t
where t.name = '팀A'
*/
Root<Member> m = cq.from(Member.class);
Join<Member, Team> t = m.join("team", JoinType.INNER); // 내부 조인
cq.multiselect(m, t)
.where(cb.equal(t.get("name"), '팀A'));
쿼리 루트 m에서 바로 join()
메서드를 사용해 회원과 팀을 조인했다. 조인을 생략하면 내부 조인을 사용한다.
FETCH JOIN은 m.fetch(조인대상, JoinType)을 사용한다.
서브 쿼리
간단한 서브 쿼리
/*
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.<Integer>get("age")));
// 메인 쿼리 생성
Root<Member> m = mainQuery.from(Member.class);
mainQuery.select(m)
.where(cb.ge(m.<Integer>get("age"), subquery));
서브 쿼리는 mainQuery.subquery()로 생성한 뒤 메인 쿼리의 where절에서 사용한다.
상호 관련 서브 쿼리
서브 쿼리에서 메인 쿼리의 정보를 사용하려면 메인 쿼리에서 사용한 별칭을 얻어야 한다. 서브 쿼리는 메인 쿼리의 Root나 Join을 통해 생성된 별칭을 받아서 사용한다.
subQuery.correlate()
메소드로 메인 쿼리의 별칭을 서브 쿼리에서 사용할 수 있다.
IN 식
Criteria 빌더에서 in(…)
메서드를 사용한다
cq.select(m)
.where(cb.in(m.get("username"))
.value("회원1")
.value("회원2"));
CASE 식
selectCase()
, when()
, otherwise()
메서드를 사용한다.
/*
JPQL
select m.username,
case when m.age >= 60 then 600
when m.age <= 15 then 500
else 1000
end
from Member m
*/
Root<Member> m = cq.from(Member.class);
cq.multiselect(
m.get("username"),
cb.selectCase()
.when(cb.ge(m.<Integer>get("age"), 60), 600)
.when(cb.le(m.<Integer>get("age"), 15), 500)
.otherwise(1000)
);
파라미터 정의
cb.select(m)
.where(cb.equal(m.get("username"), cb.parameter(String.class, "usernameParam")));
List<Member> resultList = em.createQuery(cq)
.setParameter("usernameParam", "회원1")
.getResultList();
위와 같이 cb.parameter() 메소드로 타입과 파라미터 이름을 정의하고, setParameter 메소드로 바인딩해준다.
네이티브 함수 호출
네이티브 SQL 함수를 호출하려면 cb.function()
메소드를 사용하면 된다.
동적 쿼리
다양한 검색 조건에 따라 실행 시점에 쿼리를 생성하는 것을 동적 쿼리라호 한다. 동적 쿼리는 문자열 기반인 JPQL보다 코드 기반인 Criteria로 작성하는 것이 더 편한다.
함수 정리
Criteria는 JPQL의 빌더 클래스이므로 JPQL이 지원하는 함수를 코드로 지워난다.
Criteria 메타 모델 API
Criteria는 코드 기반이므로 컴파일 시점에 오류를 발견할 수 있다. 하지만, 여전히 필드명을 작성할 때는 문자로 작성한다. 따라서, 문자를 잘못 적을 경우 오류를 발견할 수 없다. 이 같은 문제를 해결하려면 메타 모델 API를 사용하면 된다.
메타 모델 API를 사용하려면 먼저 메타 모델 클래스를 만들어야 한다.
// 메타 모델 API 적용 전
cq.select(m)
.where(cb.gt(m.<Integer>get("age"), 20))
.orderBy(cb.desc(m.get("age")));
// 적용 후
cq.select(m)
.where(cb.gt(m.get(Member_.age), 20))
.orderBy(cb.desc(m.get(Member_.age)));
위와 같이 문자열 기반에서 정적인 코드 기반으로 변경됐다. 이렇게 하려면 Member_클래스가 필요한데 이런 크래스를 표준 메타 모델 클래스 줄여서 메타 모델 클래스라고 한다. 이런 코드는 개발자가 직접 생성하지 않고 대신에 코드 자동 생성기가 엔티티 클래스를 기반으로 메타 모델 클래스를 만들어준다.
코드 생성기 설정
코드 생성기는 메이븐, 그래들, 엔트같은 빌드 도구를 사용해 실행한다.
이클립스나 인텔리J같은 IDE를 사용하면 더 편리하게 메타 모델이 생성되도록 할 수 있다.
2024년 3월 5일
QueryDSL
Criteria의 장점은 코드 기반으로 쿼리를 작성하기 때문에 오류를 빠르게 잡을 수 있고, IDE 자동 완성등의 도움을 받을 수 있다는 점 등이 있었다. 이 장점만을 가져오고 Criteria가 갖던 복잡함을 해결한 것이 QueryDSL이다.
QueryDSL 설정
필요 라이브러리
<!-- 쿼리DSL -->
<dependency>
<groupId>com.mysema.querydsl</groupId>
<artifactId>querydsl-jpa</artifactId>
<version>3.6.3</version>
</dependency>
<dependency>
<groupId>com.mysema.querydsl</groupId>
<artifactId>querydsl-apt</artifactId>
<version>3.6.3</version>
<scope>provided</scope>
</dependency>
<!-- 미설치시 메이븐 컴파일이 안됨 -->
<dependency>
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
<version>1.3.5</version>
</dependency>
- querydsl-jpa : QueryDSL JPA 라이브러리
- querydsl-apt : 쿼리 타입(Q)를 생성할 때 필요한 라이브러리
How can be solved java.lang.NoClassDefFoundError: javax/annotation/Generated?
환경설정
QueryDSL을 사용하려면 Criteria의 메타 모델처럼 QueryDSL용 쿼리 클래스인 쿼리 타입이 필요하다.
<build>
<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.mysema.query.apt.jpa.JPAAnnotationProcessor
</processor>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
쿼리 타입 생성용 플러그인을 pom.xml에 추가했다. 이제 콘솔에서 mvn compile
을 입력하면 outputDirectory에 지정한 target/generated-sources 위치에 Qmember.java처럼 Q로 시작하는 쿼리 타입들이 생성된다.
시작
JPAQuery query = new JPAQuery(em);
QMember qMember = new QMember("m");// 생성되는 JPQL의 별칭이 m
List<Member> members = query.from(qMember)
.where(qMember.name.eq("회원"))
.orderBy(qMember.name.desc())
.list(qMember);
/*
JPQL:
select m from Member m
where m.name = ?1
order by m.name desc
*/
QuerDSL을 사용하려면 우선 com.mysema.querydsl.jpa.impl.JPAQuery
객체를 생성해야 하는데 이 때 생성자에 엔티티 매니저를 넘겨준다.
다음으로 사용할 쿼리 타입(Q)를 생성하는데 생성자에는 별칭을 넣어주면 된다. 여기서 넣어준 별칭은 JPQL에서 사용할 별칭이 된다.
기본 Q 생성
쿼리 타입(Q)는 사용하기 편하도록 기본 인스턴스를 보관하고 있다. 하지만, 같은 엔티티를 조인하거나 같은 엔티티를 서브쿼리에 사용하면 같은 별칭이 사용되므로 이 때는 별칭을 직접 지정해서 사용해야 한다.
검색 조건 쿼리
JPAQuery query = new JPAQuery(em);
QItem item = QItem.item;
List<Item> list = query.from(item)
.where(item.name.eq("좋은상품").and(item.price.eq(20_000)))
.list(item);
QueryDSL은 where 절에서 and나 or을 사용할 수 있다. 또한, 쿼리 타입의 필드는 필요한 대부분의 메서드를 명시적으로 제공한다.
item.price.between(10000, 20000);
item.name.contains("상품1"); // like '%상품1%'
item.name.startsWith("고급"); // like '고급%'
코드로 작성되어 있어 IDE의 도움을 받을 수도 있다.
결과 조회
쿼리 작성이 끝나고 결과 조회 메서드를 호출하면 실제 데이터베이스를 조회한다. 보통 uniqueResult()
나 list()
메서드를 사용하고 파라미터에 프로젝션 대상을 넘겨준다.
uniqueResult()
: 조회 결과가 정확히 한 건일 때 사용한다. 조회 결과가 없으면 null을 반환하고, 두 개 이상이면com.mysema.query.NonUniqueResultException
이 발생한다.singleResult()
: 위와 비슷하지만 두 개 이상이면 첫 번째 데이터를 반환한다.list()
: 결과가 하나 이상일 때 사용한다. 결과가 없으면 빈 컬렉션을 반환한다.
페이징과 정렬
QItem item = QItem.item;
query.from(item)
.where(item.price.gt(20000))
.orderBy(item.price.desc(), item.stockQuantity.accept())
.offset(10).limit(20)
.list(item);
QueryModifiers queryModifiers = new QueryModifiers(20L, 10L);
List<Item> list =
query.from(item)
.restrict(queryModifiers)
.list(item);
정렬은 orderBy()
메서드를 사용하고 쿼리 타입이 제공하는 desc()
, asc()
를 사용한다. 페이징은 offset()
과 limit()
을 적절히 조합해서 사용한다. 추가로 restrict()
에 com.mysema.query.QueryModifiers
를 파라미터로 사용할 수도 있다.
실제 페이징 처리를 하려면 조회된 전체 데이터 수를 알아야 한다. 이 때는 list()
대신 아래와 같이 listResults()
를 사용한다.
SearchResults<Item> result = query.from(item)
.where(item.price.gt(20000))
.orderBy(item.price.desc(), item.stockQuantity.accept())
.offset(10).limit(20)
.listResults(item);
long total = result.getTotal();
long limit = result.getLimit();
long offset = result.getOffset();
List<Item> results = result.getResults();
listResults()
를 실행하면 전체 데이터 조회를 위한 count 쿼리를 한 번 더 실행한다. 그리고 SearchResults를 반환하는데 이 객체에서 전체 데이터 수를 조회할 수 있다.
그룹
groupBy()
와 having()
메소드를 사용하면 된다.
조인
각각의 메소드들이 존재하며 fullJoin
도 존재한다. 추가로 JPQL의 on과 fetch join도 사용할 수 있다. 기본 문법은 join(조인 대상, 별칭으로 사용할 쿼리 타입)
이다.
QOrder order = QOrder.order;
QMember member = QMember.member;
QOrderItem orderItem = QOrderItem.orderItem;
query.from(order)
.join(order.member, member)
.leftJoin(order.orderItems, orderItem)
.list(order);
// join on
query.from(order)
.leftJoin(order.orderItems, orderItem)
.on(orderItem.count.gt(2))
.list(order);
// fetch 조인
query.from(order)
.innerJoin(order.member, member).fetch()
.leftJoin(order.orderItems, orderItem).fetch()
.list(order);
// 세타 조인
query.from(order, member)
.where(order.member.eq(member))
.list(order);
서브 쿼리
com.mysema.query.jpa.JPASubQuery
를 생성해서 사용한다.서브 쿼리 결과가 하나면 unique()
, 여러 건이면 list()
를 사용하면 된다.
프로젝션과 결과 반환
프로젝션 대상이 하나
프로젝션 대상이 하나면 해당 타입으로 반환한다.
여러 컬럼 반환과 튜플
프로젝션 대상으로 여러 필드를 선택하면 QueryDSL은 기본적으로 내부 타입 Tuple을 사용한다. 조회 결과는 tuple.get() 메소드에 조회한 쿼리 타입을 지정하면 된다.
JPAQuery query = new JPAQuery(em);
QItem item = QItem.item;
List<Tuple> list = query.from(item).list(item.name, item.price);
for (Tuple tuple : list) {
System.out.println("name = " + tuple.get(item.name));
System.out.println("name = " + tuple.get(item.price));
}
빈 생성
쿼리 결과를 엔티티가 아닌 특정 객체로 받고 싶으면 빈 생성 기능을 사용한다. QueryDSL은 객체를 생성하는 다양한 방법을 제공한다.
- 프로퍼티 접근 :
Projections.bean()
를 사용해 수정자로 값을 채운다. - 필드 직접 접근 :
Projections.fields()
를 사용해 직접 필드에 접근해 값을 채운다. 필드를 private으로 설정해도 동작한다. - 생성자 접근 :
Projections.constructor()
는 생성자를 사용한다. 지정한 프로젝션과 파라미터 순서가 동일해야 한다.
원하는 방법을 지정하기 위해 com.mysema.query.types.Projections
를 사용하면 된다.
JPAQuery query = new JPAQuery(em);
QItem item = QItem.item;
query.from(item).list(
Projections.bean(ItemDTO.class, item.name.as("username"), item.price)
);
query.from(item).list(
Projections.fields(ItemDTO.class, item.name.as("username"), item.price)
);
query.from(item).list(
Projections.constructor(ItemDTO.class, item.name, item.price)
);
쿼리 결과와 매핑할 프로퍼티 이름이 다르면 as()
를 사용해서 별칭을 주면 된다.
DISTINCT
query.distinct().from(...)...
와 같이 사용한다.
수정, 삭제 배치 쿼리
QItem item = QItem.item;
JPAUpdateClause updateClause = new JPAUpdateClause(em, item);
long count = updateClause.where(item.name.eq("시골개발자의 JPA 책"))
.set(item.price, item.price.add(100))
.execute();
JPADeleteClause deleteClause = new JPADeleteClause(em, item);
long count2 = deleteClause.where(item.name.eq("시골개발자의 JPA 책"))
.execute();
QueryDSL도 수정, 삭제와 같은 배치 쿼리를 지원한다. 다만, 영속성 관리를 무시하고 데이터베이스에 직접 쿼리한다.
동적 쿼리
com.mysema.query.BooleanBuilder
를 사용해 특정 조건에 따른 동적 쿼리를 편리하게 생성할 수 있다.
메소드 위임
메소드 위임(Delegate methods)을 이용하면 쿼리 타입에 검색 조건을 직접 정의할 수 있다.
public class ItemExpression{
@QueryDelegate(Item.class)
public static BooleanExpression isExpensive(QItem item,
Integer price){
return item.price.gt(price);
}
메소드 위임 기능을 사용하려면 위와 같이 정적 메소드를 만들고 @com.mysema.query.annotation.QueryDelegate
어노테이션에 속성으로 이 기능을 적용할 엔티티를 지정한다. 정적 메소드의 첫 번째 파라미터는 대상 엔티티의 쿼리 타입을 짖어하고 나머지는 필요한 파라미터를 정의한다.
2024년 3월 6일
네이티브 SQL
JPQL은 표준 SQL이 지원하는 대부분의 기능을 지원하지만, 특정 데이터베이스에 종속적인 기능은 지원하지 않는다. 그리고, 그러한 기능들을 꼭 사용해야할 때가 있다. JPA는 해당 기능들을 사용할 수 있는 다양한 방법을 열어두었다.
- 특정 데이터베이스만 사용하는 기능
- JPQL에서 네이티브 SQL을 호출 가능 (JPA 2.1)
- 하이버네이트에 데이터베이스별 방언에 특정 데이터베이스 종속 함수 정의
- 특정 데이터베이스만 지원하는 SQL 쿼리 힌트
- 하이버네이트를 포함한 몇몇 JPA 구현체들이 지원한다.
- 인라인 뷰, UNION, INTERSECT
- 하이버네이트를 제외한 일부 JPA 구현체들이 지원한다.
- 스토어드 프로시저
- JPQL에서 스토어드 프로시저를 호출할 수 있다.(JPA 2.1)
- 특정 데이터베이스만 지원하는 문법
- 너무 특정 데이터베이스에 종속적인 기능은 SQL 문법을 지원하지 않아 네이티브 SQL을 사용해야 한다.
다양한 이유에서 JPQL을 사용할 수 없을 때 SQL을 직접 사용할 수 있는 기능을 네이티브 SQL이라고 한다. 네이티브 SQL은 JPQL이 SQL을 대신 작성해주는 것이 아닌 개발자가 직접 SQL을 정의하는 것이다.
JPA에서 지원하는 네이티브 SQL과 JDBC API를 직접 사용하는 것에는 어떤 차이가 있을까? 네이티브 SQL을 사용하면 엔티티를 조회할 수 있고 영속성 컨텍스트의 기능을 그대로 사용할 수 있다. 반면 JDBC API를 사용하면 단순한 데이터의 나열을 조회하는 것 뿐이다.
네이티브 SQL 사용
네이티브 쿼리 API는 다음 3가지가 있다.
//결과 타입 정의
public Query createNativeQuery(String sqlString, Class resultClass);
// 결과 타입을 정의할 수 없을 때
public Query createNativeQuery(String sqlString);
public Query createNativeQuery(String sqlString,
String resultSetMapping); // 결과 매핑 사용
엔티티 조회
// SQL 정의
String sql = "SELECT ID, AGE, NAME, TEAM_ID " +
"FROM MEMBER WHERE AGE > ?";
TypedQuery<Member> nativeQuery = em.createQuery(sql, Member.class)
.setParameter(1, 20);
List<Member> resultList = nativeQuery.getResultList();
엔티티 조회는 위와 같이 em.createQuery()
에 네이티브 SQL과 조회할 엔티티 클래스 타입을 전달한다. JPQL과 거의 유사하지만 이름 기반이 아닌 위치 기반 파리미터만 지원한다. 여기서 가장 중요한 것은 SQL만 직접 작성한다 뿐이지 JPQL을 사용할 때와 같다. 엔티티도 영속성 컨텍스트에서 관리된다는 점이다.
값 조회
값 조회의 경우 두 번째 파라미터를 사용하지 않는다. JPA는 조회한 값들을 Object[]에 담아서 반환한다. 이 때, 스칼라 값들을 조회했을 뿐이므로 결과를 영속성 컨텍스트에 보관하지 않는다.
결과 매핑 사용
엔티티와 스칼라 값을 함께 조회하는 것처럼 매핑이 복잡해지면 @SqlResultSetMapping
을 정의해서 결과 매핑을 사용해야 한다.
String sql = "SELECT M.ID, AGE, NAME, TEAM_ID, I.ORDER_COUNT " +
"FROM MEMBER M " +
"LEFT JOIN " +
" (SELECT IM.ID, COUNT(*) AS ORDER_AMOUNT " +
" FROM ORDERS O, MEMBER IM " +
" WHERE O.MEMBER_ID = IM.ID) I " +
"ON M.ID = I.ID";
Query nativeQuery = em.createQuery(sql, "memberWithOrderCount");
List<Object[]> resultList = nativeQuery.getResultList();
for (Object[] row : resultList) {
Member member = (Member) row[0];
BigInteger orderCount = (BigInteger) row[1];
System.out.println("member = " + member);
System.out.println("orderCount = " + orderCount);
}
위의 em.createQuery
의 두 번쨰 파라미터에 결과 매핑 정보의 이름이 사용되었다. 결과 매핑은 아래와 같이 정의한다.
위 결과 매핑을 잘 보면 회원 엔티티와 ORDER_COUNT 컬럼을 매핑했다. SQL의 ID, AGE, NAME, TEAM_ID는 회원 엔티티와 ORDER_COUNT는 단순히 값으로 매핑한다.
String sql = "SELECT o.id AS order_id, " +
"o.quantity AS order_quantity, " +
"o.item as order_item, " +
"i.name as item_name, " +
"FROM Order o, Item i " +
"WHERE (order_quantity > 25) AND " +
"(order_item = i.id)";
Query nativeQuery = em.createQuery(sql, "OrderResults");
@SqlResultSetMapping(name = "OrderResults",
entities = {
@EntityResult(entityClass = 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
를 사용해서 컬럼명과 필드명을 직접 매핑하는데 이 설정은 엔티티의 필드에 정의한 @Column
보다 앞선다. 다만, 불편한 점은 하나라도 @FieldResult
를 사용하면 전체 필드를 @FieldResult
로 정의해야 한다는 점이다.
SELECT A.ID, B.ID FROM A, B
위처럼 컬럼명이 중복될 때도 @FieldResult
를 사용해야 한다.
Named 네이티브 SQL
JPQL처럼 네이티브 SQL도 Named 네이티브 SQL을 사용해서 정적 SQL을 작성할 수 있다.
@Entity
@NamedNativeQuery(
name = "Member.memberSQL",
query = "SELECT ID, AGE, NAME, TEAM_ID "
+ "FROM MEMBER WHERE AGE > ?",
resultClass = Member.class
)
TypedQuery<Member> query =
em.createNamedQuery("Member.memberSQL", Member.class)
.setParameter(1, 20);
위와 같이 @NamedNativeQuery
를 사용해 네이티브 SQL을 등록했다. 이후, em.createNativeQuery
를 사용한다.
매핑이 복잡한 Named 네이티브 쿼리를 작성할 경우 Named 네이티브 쿼리의 속성 중 resultSetMapping으로 매핑할 대상을 지정할 수 있다.
@NamedNativeQuery
속성 | 기능 |
---|---|
name | 네임드 쿼리 이름(필수) |
query | SQL 쿼리(필수) |
hints | 벤더 종속적인 힌트 |
resultClass | 결과 클래스 |
resultSetMapping | 결과 매핑 사용 |
네이티브 SQL XML에 정의
XML에 정의할 때는 순서를 지켜야 한다. <named-native-query>
를 먼저 정의하고 <sql-result-set-mapping>
을 정의해야 한다.
XML과 어노테이션 모두 사용하는 코드는 다음과 같다.
List<Object[]> resultList =
em.createNativeQuery("Member.memberWithOrderCount")
.getResultList();
네이티브 SQL 정리
네이티브 SQL도 JPQL 처럼 Query, TypedQuery를 반환한다. 따라서, JPQL API를 그대로 사용할 수 있다.
네이티브 SQL은 관리도 어렵고 이식성도 낮다. 그러므로 될 수 있으면 표준 JPQL을 사용하고 그래도 부족하다면 하이버네이트와 같은 JPA 구현체가 제공하는 기능을 사용하는 것을 추천한다. 그래도 안되면 네이티브 SQL을 사용하자.
스토어드 프로시저
JPA 2.1부터 스토어드 프로시저를 지원한다.
지금은 내가 잘 사용할 것 같지 않아서 페이지만 남겨놓는다(453 ~ 455p)
2024년 3월 7일
객체지향 쿼리 심화
벌크 연산
엔티티를 수정하려면 영속성 컨텍스트의 변경 감지 기능이나 병합을 사용하고 EntityManager.remove()
메서드를 사용하면 된다. 하지만 이 방법으로 수백개 이상의 엔티티를 하나씩 처리하기에는 너무 많은 시간이 소요된다. 이럴 때 여러 건을 한 번에 수정하거나 삭제하는 벌크 연산을 사용하면 된다.
String qlString =
"update Product p "
+ "set p.price = p.price * 1.1 "
+ "where p.stockAmount < :stockAmount";
int resultCount = em.createQuery(qlString)
.setParameter("stockAmount", 10)
.executeUpdate();
벌크 연산은 excuteUpdate()
메서드를 사용하며 삭제도 같은 메서드를 사용한다.
하이버네이트는 INSERT 벌크 연산도 지원한다. 이 때도, 같은 메서드를 사용한다.
벌크 연산의 주의점
벌크 연산은 영속성 컨텍스트르 무시하고 데이터베이스에 직접 쿼리하기 때문에 주의해야 한다.
Item itemA = em.createQuery("select i from Item i where i.name = :name", Item.class)
.setParameter("name", "ItemA")
.getSingleResult();
// 출력결과 : 1000
System.out.println("itemA 수정 전 = " + itemA.getPrice());
// 벌크 연산으로 모든 상품 가격 10% 상승
int updaetResult = em.createQuery("update Item i set i.price = i.price * 1.1")
.executeUpdate();
// 출력결과 : 1000
System.out.println("itemA 수정 후 = " + itemA.getPrice());
위와 같이 벌크 연산은 데이터베이스에 직접 쿼리하기 때문에 영속성 컨텍스트에 있는 상품 A와 데이터베이스에 있는 상품 A의 가격이 다를 수 있다.
이런 문제는 다음과 같은 방법으로 해결할 수 있다.
em.refresh() 사용
벌크 연산을 수행한 직후에 정확한 엔티티를 사용해야 하면 em.refresh()
를 사용해 엔티티를 다시 조회하면 된다.
벌크 연산 먼저 실행
가장 실용적인 방법이다. 벌크 연산을 수행한 후 조회하면 이미 변경된 상품을 조회하게 된다. 이 방법은 JPA와 JDBC를 함께 이용할 때도 유용하다.
벌크 연산 수행 후 영속성 컨텍스트 초기화
벌크 연산 수행한 직후에 영속성 컨텍스트를 초기화해서 혹시나 남아있을 업데이트 되지 않은 값을 제거할 수 있다. 이 후 다시 엔티티를 조회하면 벌크 연산이 적용된 데이터베이스에서 엔티티를 조회한다.
벌크 연산은 2차 캐시와 영속성 컨텍스트를 무시하고 데이터베이스에 직접 데이터베이스에 실행한다. 따라서, 영속성 컨텍스트와 데이터베이스 간에 데이터 차이가 발생할 수 있으므로 조심해야 한다. 가능하면 벌크연산을 먼저 수행하고 상황에 따라 벌크연산 수행 후 영속성 컨텍스트를 초기화하는 것도 중요하다.
영속성 컨텍스트와 JPQL
쿼리 후 영속 상태인 것과 아닌 것
JPQL로 엔티티를 조회하면 영속성 컨텍스트에서 관리되지만 그렇지 않은 경우에는 관리되지 않는다.
JPQL로 조회한 엔티티와 영속성 컨텍스트
JPQL로 데이터베이스에서 조회한 엔티티가 영속성 컨텍스트에 이미 있으면 JPQL로 조회한 결과를 버리고 영속성 컨텍스트에 있는 엔티티를 반환한다. 이 때 식별자 값을 비교한다.
그럼 왜 데이터베이스에서 새로 조회한 결과를 버리고 영속성 컨텍스트에 있는 값을 반환하는 것일까?
이는 영속성 상태의 엔티티의 동일성을 보장하기 위함이다. em.find()
로 조회하든 JPQL로 조회하든 영속성 컨텍스트가 같으면 동일한 엔티티를 반환한다.
find() vs JPQL
em.find()
메소드는 먼저 영속성 컨텍스트에서 엔티티를 찾고 없으면 실제 데이터베이스에서 엔티티를 조회한다. 이 때문에 1차 캐시라고 부르며 성능상 이점이 있다.
em.find()
와 다르게 JPQL은 같은 쿼리를 두 번 사용해도 항상 데이터베이스에 SQL을 실행해서 결과를 조회한다.
JPA 구현체 개발자 입장에서
em.find()
메소드는 식별자 값을 넘기기 때문에 쉽게 영속성 컨텍스트를 조회하지만, JPQL을 분석해서 영속성 컨텍스트를 조회하는 것은 쉬운 일이 아니었을 것이다. 따라서, JPQL로 쿼리한 결과 값을 사용한다.
JPQL의 특징은 다음과 같다.
- JPQL은 항상 데이터베이스를 조회한다.
- JPQL로 조회한 엔티티는 영속 상태다.
- 영속성 컨텍스트에 이미 존재하는 엔티티가 있으면 기존 엔티티를 반환한다.
JPQL과 플러시 모드
플러시는 앞서 영속성 컨텍스트의 변경 내용을 실제 데이터베이스와 동기화하는 작업이라고 했다. JPA는 플러시가 일어날 때 영속성 컨텍스트에 등록, 수정, 삭제한 엔티티를 찾아서, INSERT, UPDATE, DELETE SQL을 생성해 데이터베이스에 반영한다. 플러시는 직접 em.flush()
를 호출할 수도 있지만 보통 플러시 모드에 따라 커밋하기 직전이나 쿼리 실행 직전에 자동으로 플러시가 호출된다.
플러시 모드의 기본 값은 FlushModeType.AUTO
이다. 다른 옵션으로 FlushModeType.COMMIT
이 있는데 이 모드는 커밋 시에만 플러시를 호출한다. 이 옵션은 성능 최적화를 위해 꼭 필요할 때만 사용해야 한다.
쿼리와 플러스 모드
JPQL은 영속성 컨텍스트에 있는 데이터를 고려하지 않고 데이터베이스에서 데이터베이스를 조회한다. 따라서, JPQL을 실행하기 전에 영속성 컨텍스트의 내용을 데이터베이스에 반영해야 한다. 그렇지 않으면 의도치 않은 결과가 발생할 수 있다.
Item item = em.find(Item.class, 1L);
item.setPrice(2000);
Item item2 =
em.createQuery("select i from Item i where i.price = 2000", Item.class)
.getSingleResult();
위의 코드를 실행하면 영속성 컨텍스트에는 가격이 2000원으로 변경되지만 아직 데이터베이스에는 반영되지 않아 1000원으로 남아있다. 이 상태에서 JPQL을 호출해서 가격이 2000원인 상품을 조회하면 아까 바꾼 item이 포함되어 조회된다. 이는 기본 플러시 모드가 FlushTypeMode.AUTO
이기 때문이다. AUTO 상태에는 커밋을 할 때와 쿼리를 실행하기 직전에 영속성 컨텍스트가 플러시된다. 따라서, 방금 2000원으로 수정한 아이템을 조회할 수 있다.
만약, 이 상황에서 플러시모드를 COMMIT으로 수정하면 쿼리시에는 플러시를 하지 않으므로 위에서 수정한 아이템을 조회할 수 없다. 이 때는 직접 em.flush()
를 호출하거나 아래와 같이 Query
객체에 플러시 모드를 설정해주면 된다.
Item item2 = em.createQuery("select i from Item i where i.price = 2000", Item.class)
.setFlushMode(FlushModeType.AUTO)
.getSingleResult();
**Query
객체에서만** 플러시 모드를 AUTO로 지정해줬는데 이 경우 엔티티 매니저에서 지정한 플러시 모드 보다 우선권을 가진다. 일반적인 상황에서는 AUTO를 사용하기 때문에 크게 고려하지 않아도 된다.
플러시 모드와 최적화
플러시가 너무 자주 일어나는 상황에서 COMMIT 모드를 사용하면 플러시 횟수를 줄여서 성능을 최적화할 수 있다. JDBC를 직접 사용해서 SQL을 실행할 때도 플러시 모드를 고민해야 한다. JDBC를 통해 쿼리를 실행하면 JPA는 해당 쿼리를 인식할 방법이 없다. 따라서, 플러시 모드를 AUTO로 지정해도 플러시가 일어나지 않는다. 이때는, JDBC로 쿼리를 실행하기 직전에 em.flush()
로 영속성 컨텍스트의 내용을 데이터베이스와 동기화하는 것이 안전하다.
정리
- JPQL은 SQL을 추상화해 데이터베이스 기술에 의존하지 않는다.
- Criteria나 QueryDSL은 JPQL의 빌더 클래스이다. 중요한 것은 JPQL이다.
- Criteria나 QueryDSL을 사용해 동적으로 변화하는 쿼리를 편리하게 작성할 수 있다.
- Criteria는 JPA 공식이지만 직관적이지 않고 사용하기 불편하지만 QueryDSL은 공식은 아니지만 직관적이고 편리하다.
- JPA도 네이티브SQL을 지원하지만 특정 데이터베이스에 종속적인 SQL을 사용하면 다른 데이터베이스로 변경하기 어렵다. 따라서, 가급적 JPQL을 사용하고 불가피하다면 네이티브 SQL을 사용하는것이 좋다.
- JPQL은 대량의 데이터를 수정하거나 삭제하는 벌크 연산을 지원한다.
'JPA' 카테고리의 다른 글
12장_스프링 데이터 JPA (0) | 2024.04.02 |
---|---|
11장_웹 애플리케이션 제작 (0) | 2024.04.01 |
Java ORM 표준 - JPA 프로그래밍 9장 (0) | 2024.01.19 |
Java ORM 표준 - JPA 프로그래밍 8장 (0) | 2023.11.30 |