10장 객체지향 쿼리 언어

sua·2023년 7월 4일
0

JPA는 복잡한 검색 조건을 사용해서 엔티티 객체를 조회할 수 있는 다양한 쿼리 기술을 지원한다. JPQL은 가장 중요한 객체지향 쿼리 언어다. 이 장에서 다루는 Criteria나 QueryDSL은 결국 JPQL을 편리하게 사용하도록 도와주는 기술이므로 JPA를 다루는 개발자라면 JPQL을 필수로 학습해야 한다. 이 장 마지막에는 객체지향 쿼리에 대한 심화 내용을 다룬다.

10.1 객체지향 쿼리 소개

EntityManager.find() 메소드를 사용하면 식별자로 엔티티 하나를 조회할 수 있다. 이렇게 조회한 엔티티에 객체 그래프 탐색을 사용하면 연관된 엔티티들을 찾을 수 있다. 이 둘은 가장 단순한 검색 방법이다.

  • 식별자로 조회 EntityManager.find()
  • 객체 그래프 탐색(예: a.getB().getC())

이 기능만으로 애플리케이션을 개발하기는 어렵다. 예를 들어 나이가 30살 이상인 회원을 모두 검색하고 싶다면 좀 더 현실적이고 복잡한 검색 방법이 필요하다. 그렇다고 모든 회원 엔티티를 메모리에 올려두고 애플리케이션에서 30살 이상인 회원을 검색하는 것은 현실성이 없다. 결국 데이터는 데이터베이스에 있으므로 SQL로 필요한 내용을 최대한 걸러서 조회해야 한다. 하지만 ORM을 사용하면 데이터베이스 테이블이 아닌 엔티티 객체를 대상으로 개발하므로 검색도 테이블이 아닌 엔티티 객체를 대상으로 하는 방법이 필요하다.
JPQL은 이런 문제를 해결하기 위해 만들어졌는데 다음과 같은 특징이 있따.

  • 테이블이 아닌 객체를 대상으로 검색하는 객체지향 쿼리다.
  • SQL을 추상화해서 특정 데이터베이스 SQL에 의존하지 않는다.

SQL이 데이터베이스 테이블을 대상으로 하는 데이터 중심의 쿼리라면 JPQL은 엔티티 객체를 대상으로 하는 객체지향 쿼리다. JPQL을 사용하면 JPA는 이 jPQL을 분석한 다음 적절한 SQL을 만들어 데이터베이스를 조회한다. 그리고 조회한 결과로 엔티티 객체를 생성해서 반환한다.
JPQL을 한마디로 정의하면 객체지향 SQL이다. 따라서 SQL에 익숙한 개발자는 몇가지 차이점만 이해하면 쉽게 적응할 수 있다.
JPA는 JPQL 뿐만 아니라 다양한 검색 방법을 제공하는데 다음은 JPA가 공식 지원하는 기능이다.

  • JPQL
  • Criteria 쿼리: JPQL을 편리하게 작성하도록 도와주는 API, 빌더 클래스 모음
  • 네이티브 SQL : JPA에서 JPQL 대신 직접 SQL을 사용할 수 있다.

다음은 JPA가 공식 지원하는 기능은 아니지만 알아둘 가치가 있다.

  • QueryDSL : Criteria 쿼리처럼 JPQL을 편하게 작성하도록 도와주는 빌더 클래스 모음, 비표준 오픈소스 프레임워크다.
  • JDBC 직접 사용, MyBatis 같은 SQL 매퍼 프레임워크 사용: 필요하면 JDBC를 직접 사용할 수 있다.

JPQL을 이해해야 나머지도 이해할 수 있다. 전체적인 감을 잡기 위해 하나 하나 아주 간단히 살펴보자.

10.1.1 JPQL 소개

JPQL은 엔티티 객체를 조회하는 객체지향 쿼리다. 문법은 SQL과 비슷하고 ANSI 표준 SQL이 제공하는 기능을 유사하게 지원한다.
JQQL은 SQL을 추상화해서 특정 데이터베이스에 의존하지 않는다. 그리고 데이터베이스 방언만 변경하면 JPQL을 수정하지 않아도 자연스럽게 데이터베이스를 변경할 수 있따. 예를 들어 같은 SQL 함수라도 데이터베이스마다 사용 문법이 다른 것이 있는데 JPQL이 제공하는 표준화된 함수를 사용하면 선택한 방언에 따라 해당 데이터베이스에 맞춘 적절한 SQL 함수가 실행된다.
JPQL은 SQL 보다 간결하다. 엔티티 직접 조회, 묵시적 조인, 다형성 지원으로 SQL 보다 코드가 간결한다.

아래의 회원 엔티티를 대상으로 JPQL을 사용하는 간단한 예제를 보자.

@Entity(name="Memberm") // name 속성의 기본값은 클래스명
public class Member {
	@Column(name = "name")
    private String username;
    ..
}
// 쿼리 생성
String jpql = "select m from Member as m where m.username = 'kim'";
List<Member> resultList = em.createQuery(jpql, Member.class).getResultList();

위 코드는 회원 이름이 kim인 엔티티를 조회한다. JPQL에서 Member는 엔티티 이름이다. 그리고 m.username은 테이블 컬럼명이 아니라 엔티티 객체의 필드명이다.
em.createQuery() 메소드에 실행할 JPQL과 반환할 엔티티의 클래스 타입인 Member.class를 넘겨주고 getResultList() 메소드를 실행하면 JPA는 JPQL을 SQL로 변환해서 데이터베이스를 조회한다. 그리고 조회한 결과로 Member 엔티티를 생성해서 반환한다.


10.1.2 Criteria 쿼리 소개

Criteria는 JPQL을 생성하는 빌더 클래스다. Criteria의 장점은 문자가 아닌 query.select(m).where(...)처럼 프로그래밍 코드로 JPQL을 작성할 수 있다는 점이다.
예를 들어 JPQL에서 select m from Membeeee m처럼 오타가 있다고 가정해보자. 그래도 컴파일은 성공하고 애플리케이션을 서버에 배포할 수 있다. 문제는 해당 쿼리가 실행되는 런타임 시점에 오류가 발생한다는 점이다. 이것이 문자기반 쿼리의 단점이다.
반면에 Criteria는 문자가 아닌 코드로 JPQL을 작성한다. 따라서 컴파일 시점에 오류를 발견할 수 있다.
문자로 작성한 JPQL보다 코드로 작성한 Criteria의 장점은 다음과 같다.

  • 컴파일 시점에 오류를 발견할 수 있다.
  • IDE를 사용하면 코드 자동완성을 지원한다.
  • 동적 쿼리를 작성하기 편한다.

JPA는 2.0부터 Criteria를 지원한다. 간단한 Criteria 사용 코드를 보자.

// 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();

위 코드를 보면 쿼리를 문자가 아닌 코드로 작성한 것을 확인할 수 있다. 아쉬운 점은 m.get("username")을 보면 필드명을 문자로 작성했다. 만약 이 부분도 문자가 아닌 코드로 작성하고 싶으면 메타 모델을 사용하면 된다.
자바가 제공하는 어노테이션 프로세서 기능을 사용하면 어노테이션을 분석해서 클래스를 생성할 수 있다. JPA는 기능을 사용해서 Member 엔티티 클래스로부터 Member_라는 Criteria 전용 클래스를 생성하는데 이것을 메타 모델이라 한다.
메타 모델을 사용하면 온전히 코드만 사용해서 쿼리를 작성할 수 있다.

// 메타모델사용전->사용후
m.get("username") => m.get(Member_.username)

Criteria가 가진 장점이 많지만 모든 장점을 상쇄할 정도로 복잡하고 장황하다. 따라서 사용하기 불편한 건 물론이고 Criteria로 작성한 코드도 한눈에 들어오지 않는다는 단점이 있다.

10.1.3 QueryDSL 소개

QueryDSL도 Criteria처럼 JPQL 빌더 역할을 한다. 장점은 코드 기반이면서 단순하고 사용하기 쉽다. 그리고 작성한 코드도 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 쿼리 전용 클래스다.


10.1.4 네이티브 SQL 소개

JPQ는 SQL을 직접 사용할 수 있는 기능을 지원하는데 이것을 네이티브 SQL이라 한다.
JPQL를 사용해도 가끔은 특정 데이터베이스에 의존하는 기능을 사용해야 할 때가 있다. 예를 들어 오라클 데이터베이스만 사용하는 CONNECT BY 기능이나 특정 데이터베이스에서만 동작하는 SQㅣ 힌트는 같은 것이다.
이런 기능들은 전혀 표준화되어있지 않으므로 JPQL에서 사용할 수 없다. 그리고 SQL은 지원하지만 JPQL이 지원하지 않는 기능도 있다. 이때는 네이티브 SQL을 사용하면 된다.
단점은 특정 데이터베이스에 의존하는 SQL을 작성해야 한다는 것이다. 따라서 데이터베이스를 변경하면 네이티브 SQL도 수정해야 한다.

String sql = "SELECT ID, AGE, TEAM_ID, NAME FROM MEMBER WHERE NAME = 	'kim'";
List<Member> members = em.createNativeQuery(sql, Member.class).getResultList();

네이티브 SQL은 em.createNativeQuery()를 사용하면 된다. 나머지는 API가 JPQL과 같다. 실행하면 직접 작성한 SQL을 데이터베이스에 전달한다.


10.1.5 JDBC 직접 사용, 마이바티스 같은 SQL 매퍼 프레임워크 사용

JDBC 커넥션에 직접 접근하고 싶으면 JPA는 JDBC 커넥션을 획득하는 AI를 제공하지 않으므로 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나 마이바티스를 JPA과 함께 사용하면 영속성 컨텍스트를 적절한 시점에 강제로 플러시해야 한다.


10.2 JPQL

이전 절에서 엔티티를 쿼리하는 다양한 방법을 소개했다. 이러한 방법들은 모두 JPQL에서 시작된다. 이제 JPQL의 사용 방법 위주로 설명한다.
그 전에 JPQL의 특징을 다시 한 번 정리해보자.

  • JPQL은 객체지향 쿼리 언어다. -> 테이블을 대상으로 쿼리x, 엔티티 객체를 대상으로 o
  • JPQL은 SQL을 추상화 -> 특정 데이터베이스 SQL에 의존 x
  • JPQL은 결국 SQL로 변환된다.

시작하기 전에 이번 절에서 예제로 사용할 도메인 모델을 보자.


샘플 모델 UML과 샘플 모델 ERD를 보자. 실무에서는 주문 모델링이 더 복잡하지만 JPQL의 이해가 목적이기 때문에 단순화했다. 여기서 주의할 점은 회원이 상품을 주문하는 다대다 관계라는 것이다. 그리고 Address는 임베디드 타입인데 이것은 값 타입이므로 UML에서 스테레오 타입을 사용해서 <<Value>>로 정의했다. ERD를 보면 이것은 ORDERS 테이블에 포함되어 있다.


10.2.1 기본 문법과 쿼리 API

JPQL도 SQL과 비슷하게 SELECT, UPDATE, DELETE 문을 사용할 수 있다. 참고로 INSERT 문은 엔티티를 저장할 때는 EntityManager.persist() 메소드를 사용하면 되므로 없다.

select_문 :: =
select_절
from_절
[where_절]
[groupby_절][having_절]
[orderby_절]

update_문 :: = update_절 [where_절]
delete_문 :: = delte_절 [where_절]

위의 JPQL 문법을 보면 SQL과 전체 구조가 비슷한 것을 알 수 있다. JPQL에서 UPDATE, DELETE 문을 벌크 연산이라 하는데 이는 10.6절에서 설명한다.

SELECT 문

SELECT 문은 다음과 같이 사용한다.

SELECT m FROM Member AS m where m.username = 'Hello'

대소문자 구분

엔티티와 속성은 대소문자를 구분한다. 예를 들어 Member, username은 대소문자를 구분한다. 반면에 SELECT, FROM, AS 같은 JPQL 키워드는 대소문자를 구분하지 않는다.

엔티티 이름

JPQL에서 사용한 Member는 클래스 이름이 아니라 엔티티 명이다. 엔티티 명은 @Entity(name="XXX")로 지정할 수 있다. 이를 지정하지 않으면 클래스명을 기본값으로 사용한다. 기본값으로 사용하는 것을 추천한다.

별칭은 필수

Member AS m을 보면 Member에 m이라는 별칭을 주었다. JPQL은 별칭을 필수로 사용해야 한다. AS는 생략할 수 있기 때문에 Member m 처럼 사용해도 된다.


TypeQuery, Query

작성한 JPQL을 실행하려면 쿼리 객체를 만들어야 한다. 쿼리 객체는 TypeQuery와 Query가 있는데 TypeQuery는 반환할 타입을 명확하게 지정할 수 있을 때 사용하고, Query는 반환 타입을 명확하게 지정할 수 없을 때 사용한다.

TypedQuery<Member> query = em.createQuery("SELECT m FROM Member m", Member.class);

List<Member> resultList = query.getResultList();
for (Member member : resultList) {
	System.out.println("member = " + member);
}

em.createQuery()의 두 번째 파라미터에 반환할 타입을 지정하면 TypeQuery를 반환하고 지정하지 않으면 Query를 반환한다. 조회 대상이 Member 엔티티이므로 조회 대상 타입이 명확하다. 이때는 위 코드 처럼 TypeQuery를 사용할 수 있다.

Query query = em.createQuery("SELECT m.username, m.age from Member m");
List resultList = query.getResultList();

for(Object o : resultList) {
	Object[] list = (Object[]) o; // 결과가 둘 이상이면 Object[] 반환
    System.out.println("username = " + result[0]);
    System.out.println("age = " + result[1]);
}

위 코드에서 조회 대상이 String 타입인 회원 이름과 Integer 타입인 나이이므로 조회 대상 타입이 명확하지 않다. 이처럼 SELECT 절에서 여러 엔티티나 컬럼을 선택할 때는 반환할 타입이 명확하지 않으므로 Query 객체를 사용해야 한다.
Query 객체는 SELECT 절의 조회 대상이 예제처럼 둘 이상이면 Object[]를 반환하고 SELECT 절의 조회 대상이 하나면 Object를 반환한다.

두 코드들을 비교해보면 타입을 변환할 필요가 없는 TypeQuery를 사용하는 것이 더 편리한 것을 알 수 있다.

결과 조회

다음 메소드들을 호출하면 실제 쿼리를 실행해서 데이터베이스를 조회한다.

  • query.getResultList() : 결과를 예제로 반환한다. 만약 결과가 없으면 빌 컬렉션을 반환한다.
  • query.getSingleResult() : 결과가 정확히 하나일 때 사용한다.
    • 결과가 없으면 javax.persistence.NoResultException 예외가 발생한다.
    • 결과가 1개 보다 많으면 javax.persistence.NonUniqueResultException 예외가 발생한다.

10.2.2 파라미터 바인딩

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();

위 코드 JPQL을 보면 :username이라는 이름 기준 파라미터를 정의하고 query.setParameter()에서 username이라는 이름으로 파라미터를 바인딩한다. 참고로 JPQL API는 대부분 메소드 체인 방식으로 설계되어 있어서 아래 코드와 같이 연속해서 작성할 수도 있다.

List<Member> members = em.createQuery("SELECT m FROM Member m where m.username = :username", Member.class)
	.setParameter("username", usernameParam)
    .getResultList();

위치 기준 파라미터

위치 기준 파라미터를 사용하려면 ? 다음에 위치 값을 주면 된다. 위치 값은 1부터 시작한다.

List<Member> members = em.createQuery("SELECT m FROM Member m where m.username = ?1", Member.class)
	.setParameter(1, usernameParam)
    .getResultList();

위치 기분 파라미터 방식 보다는 이름 기준 파라미터 바인딩 방식을 사용하는 것이 더 명확하다.


10.2.3 프로젝션

SELECT 절에 조회할 대상을 지정하는 것을 프로젝션이라 하고 [SELECT {프로젝션 대상} FROM]으로 대상을 선택한다. 프로젝션 대상은 엔티티, 엠비디드 타입, 스칼라 타입이 있다. 스칼라 타입은 숫자, 문자 등 기본 데이터 타입을 말한다.

엔티티 프로젝션

SELECT m FROM Member m // 회원
SELECT m.team FROM Member m // 팀

처음에는 회원을 조회했고 두 번째는 회원과 연관된 팀을 조회했는데 둘 다 엔티티를 프로젝션 대상으로 사용했다. 쉽게 말해서 원하는 객체를 바로 조회하는 것인데 컬럼을 하나하나 나열 해서 조회해야 하는 SQL과 차이가 있다. 이렇게 조회한 엔티티는 영속성 컨텍스트에서 관리된다.

임베디드 타입 프로젝션

JPQL에서 임베디드 타입은 엔티티와 거의 비슷하게 사용된다. 하지만, 임베디드 타입은 조회의 시작점이 될 수 없다는 제약이 있다.
아래 코드는 임베디드 타입인 Address를 시작점으로 사용해서 잘못된 쿼리다.

String query = "SELECT a FROM Address a";

아래 코드에서 Order 엔티티가 시작점이다. 이렇게 엔티티를 통해서 임베디드 타입을 조회할 수 있다.

String query = "SELECT o.address FROM Order o";
List<Address> addresses = em.createQuery(query, Address.class).getResultList();

임베디드 타입은 엔티티 타입이 아닌 값 타입이다. -> 이렇게 직접 조회한 임베디드 타입은 영속성 컨텍스트에서 관리되지 않는다.

스칼라 타입 프로젝션

스칼라 타입은 숫자, 문자, 날짜와 같은 기본 데이터 타입이다. 예를 들어 전체 회원의 이름을 조회하려면 다음처럼 쿼리하면 된다.

List<String> username = em.createQuery("SELECT username FROM Member m", String.class).getResultList();

중복 데이터를 제거하려면 DISTINCT를 사용한다.

SELECT DISTINCT username FROM Member m

통계 쿼리도 주로 스칼라 타입으로 조회한다.

Double orderAmountAvg = em.createQuery("SELECT AVG(o.orderAmount) FROM Order o", Double.class).getSingleResult();

여러 값 조회

엔티티 대상으로 조회하면 편리하지만 꼭 필요한 데이터들만 선택해서 조회해야 할 때도 있다.
프로젝션에 여러 값을 선택하면 TypeQuery를 사용할 수 없고 Query를 사용해야 한다.

Query query = em.createQuery("SELECT m.username, m.age FROM Member m");
List resultList = query.getResultList();

Iterator iterator = resultList.iterator();
while(iterator.hasNext()) {
	Object[] row = (Object[]) iterator.next();
    String username = (String) row[0];
    Integer age = (Integer) row[1];
}

제네릭에 Object[]를 사용하면 다음 코드처럼 조금 더 간결하게 개발할 수 있다.

List<Object[]> resultList = em.createQuery("SELECT m.username, m.age FROM Member m").getResultList();

for(Object[] row : resultList) {
    String username = (String) row[0];
    Integer age = (Integer) row[1];
}

엔티티 타입도 여러 값을 함께 조회할 수 있다.

List<Object[]> resultList = em.createQuery("SELECT o.member, o.product, o.orderAmount FROM Order o").getResultList();

for(Object[] row : resultList) {
    Member member = (Member) row[0]; // 엔티티
    Product product = (Product) row[1]; // 엔티티
    int orderAMount = (Integer) row[2]; // 스칼라
}

이때도 조회한 엔티티는 영속성 컨테스트에서 관리된다.

NEW 명령어

아래 코드는 username, age 두 필드를 프로젝션해서 타입을 지정할 수 없으므로 TypeQuery를 사용할 수 없다. 따라서 Object[]를 반환받았다. 실제 애플리케이션 개발시에는 Object[]를 직접 사용하지 않고 두번째 코드의 UserDTO처럼 의미 있는 객체로 변환해서 사용할 것이다.

List<Object[]> resultList = em.createQuery("SELECT m.username, m.age FROM Member m").getResultList();

// 객체 변환 작업
List<UserDTO> userDtos = new ArrayList<UserDto>();
for(Object[] row : resultList) {
    UserDto userDto = new UserDTO((String) row[0], (Integer)row[1]);
    userDtos.add(userDto);
}
return userDtos;
public class UserDTO {
	private String username;
    private int age;
    
    public UserDTO(String username, int age) {
    	this.username = username;
        this.age = age;
    }
}

이런 객체 변환 작업은 지루하다. 이번에는 아래 코드처럼 NEW 명령어를 사용해보자.

TypedQuery<UserDTO> query = em.createQuery("SELECT new jpabook.jpql.UserDTO(m.username, m.age) FROM Member m", UserDTO.class);

List<UserDTO> resultList = query.getResultList();

SELECT 다음에 NEW 명령어를 사용하면 반환 받을 클래스를 지정할 수 있는데 이 클래스의 생성자에 JPQL 조회 결과를 넘겨줄 수 있다. 그리고 NEW 명령어를 사용한 클래스로 TypeQuery를 사용할 수 있어서 지루한 객체 변환 작업을 줄일 수 있다.

NEW 명령어를 사용할 때는 다음 2가지를 주의해야 한다.

  1. 패키지 명을 포함한 전체 클래스 명을 입력해야 한다.
  2. 순서와 타입이 일치하는 생성자가 필요하다.

10.2.4 페이징 API

페이징 처리용 SQL을 작성하는 일은 지루하고 반복적이다. 더 큰 문제점은 데이터베이스마다 페이징을 처리하는 SQL 문법이 다르다는 점이다.
JPA는 페이징을 다음 두 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.setMaxResults(20);
query.getResultList();

위 코드를 분석하면 FirstResult의 시작은 10이므로 11번째부터 시작해서 20건의 데이터를 조회한다. 따라서 11~30번의 데이터를 조회한다.
데이터베이스마다 다른 페이징 처리를 같은 API로 처리할 수 있는 것은 데이터베이스 방언 덕분이다.
페이징 SQL을 더 최적화하고 싶다면 JPA가 제공하는 페이징 API가 아닌 네이티브 SQL을 직접 사용해야 한다.


10.2.5 집합과 정렬

집합은 집합함수와 함께 통계 정보를 구할 때 사용한다. 집합 함수부터 알아보자.

집합 함수

함수설명
COUNT결과 수를 구한다. 반환 타입: Long
MAX, MIN최대, 최소 값을 구한다. 문자, 숫자, 날짜 등에 사용한다.
AVG평균값을 구한다. 숫자 타입만 사용할 수 있다. 반환 타입: Double
SUM합을 구한다. 숫자 타입만 사용할 수 있다. 반환 타입: 정수합 Long, 소수합 Double, BigInteger합 BigInteger, BigDecimal합 BigDecimal

집합 함수 사용 시 참고 사항

  • NULL 값은 무시하므로 통계에 잡히지 않는다(DISTINCT가 정의되어 있어도 무시된다)
  • 만약 값이 없는데 SUM, AVG, MAX, MIN 함수를 사용하면 NULL 값이 된다. 단 COUNT는 0이 된다.
  • DISTINCT를 집합 함수 안에 사용해서 중복된 값을 제거하고 나서 집합을 구할 수 있다.
select COUNT( DISTINCT m.age ) FROM Member m
  • DISTINCTCOUNT에서 사용할 때 임베디드 타입은 지원하지 않는다.

GROUP BY, HAVING

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

HAVINGGROUP 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

문법은 다음과 같다.

groupby_절 ::= GROUP BY (단일값 경로 | 별칭)+
having_절 ::= HAVING 조건식

이런 쿼리들을 리포팅 쿼리나 통계 쿼리라 한다. 통계 쿼리는 보통 전체 데이터를 기준으로 처리하므로 실시간으로 사용하기엔 부담이 많다. 결과가 아주 많다면 통계 결과만 저장하는 테이블을 별도로 만들어두고 사용자가 적은 새벽에 통계 쿼리를 실행해서 그 결과를 보관하는 것이 좋다.

정렬(ORDER BY)

ORDER BY는 결과를 정렬할 때 사용한다. 다음은 나이를 기준으로 내림차순으로 정렬하고 나이가 같으면 이름을 기준으로 오름차순으로 정렬한다.

select m from Member m order by m.age DESC, m.username ASC

문법은 다음과 같다.

orderby_절 ::= ORDER BY (상태필드 경로 | 결과 변수 (ASC | DESC))+

  • ASC : 오름차순(기본값)
  • DESC : 내림차순

문법에서 이야기하는 상태 필드는 아래 코드의 t.name 같이 객체의 상태를 나타내는 필드를 말한다. 그리고 결과 변수는 SELECT 절에 나타나는 값을 말한다. 아래 예에서 cnt가 결과 변수다.

select t.name, COUNT(m.age) as cnt
from Member m LEFT JOIN m.team t
GROUP BY t.name
ORDER BY cnt

10.2.6 JPQL 조인

JPQL도 조인을 지원하는데 SQL 조인과 기능은 같고 문법만 약간 다르다.

내부 조인

내부 조인은 INNER JOIN을 사용한다. INNER는 생략할 수 있다.

String teamName = "팀A";
String query = "SELCT 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 조인의 가장 큰 특징은 연관 필드를 사용한다는 것이다. 여기ㅓ m.team이 연관 필드인데 연관 필드는 다른 엔티티와 연관관계를 가지기 위해 사용하는 필드를 말한다.

  • FROM Member m : 회원을 선택하고 m이라는 별칭을 주었다.
  • Member m JOIN m.team t : 회원이 가지고 있는 연관 필드로 팀과 조인한다. 조인한 팀에는 t라는 별칭을 주었다.

조인 결과를 활용해보자.

SELECT m.username, t.name
FROM Member m JOIN m.team t
WHERE t.name = '팀A'
ORDER BY m.age DESC

쿼리는 '팀A' 소속인 회원을 나이 내림차순으로 정렬하고 회원명과 팀명을 조회한다.

만약 조인한 두 개의 엔티티를 조회하려면 다음과 같이 JPQL을 작성하면 된다.

SELECT m, t FROM Member m JOIN m.team t

서로 다른 타입의 두 엔티티를 조회했으므로 TypeQuery를 사용할 수 없다. 따라서 다음처럼 조회해야 한다.

List<Object[]> result = em.createQuery(query).getResultList();

for(Object[] row : result) {
	Member member = (Member) row[0];
    Team team = (Team) row[1];
}

외부 조인

JPQL의 외부 조인으 다음과 같이 사용한다.

SELECT m
FROM Member m LEFT [OUTER] JOIN m.team t

외부 조인은 기능상 SQL의 외부 조인과 같다. OUTER은 생략 가능해서 보통 LEFT JOIN으로 사용한다.

컬렉션 조인

일대다 관계나 다대다 관계처럼 컬렉션을 사용하는 곳에 조인하는 것을 컬렉션 조인이라 한다.

  • [회원 -> 팀]으로의 조인은 다대일 조인이면서 단일 값 연관 필드(m.team)를 사용한다.
  • [팀 -> 회원]은 반대로 일대다 조인이면서 컬렉션 값 연관 필드(m.members)를 사용한다.

다음 코드를 보자

SELECT t, m FROM Team t LEFT JOIN t.members m

여기서 t LEFT JOIN t.members는 팀과 팀이 보유한 회원목록을 컬렉션 값 연관 필드로 외부 조인했다.

세타 조인

WHERE 절을 사용해서 세타 조인을 할 수 있다. 세타 조인은 내부 조인만 지원한다. 세타 조인을 사용하면 아래 코드 처럼 전혀 관계없는 엔티티도 조인할 수 있다. 예제를 보면 전혀 관련없는 Member.usernamTeam.name을 조인한다.

// JPQL
select count(m) from Member m, Team t
where m.username = t.name

// SQL
SELECT COUNT(M.ID)
FROM MEMBER M CROSS JOIN TEAM T
WHERE M.USERNAME=T.NAME

JOIN ON 절(JPA 2.1)

JPA 2.1부터 조인할 때 ON 절을 지원한다. ON 절을 사용하면 조인 대상을 필터링하고 조인할 수 있다. 내부 조인의 ON 절은 WHERE 절을 사용할 때와 결과가 같으므로 보통 ON 절은 외부 조인에서만 사용한다.
아래 예제를 보자. 모든 회원을 조회하면서 회원과 연관된 팀도 조회하자. 이때 팀은 이름이 A인 팀만 조회하자.

// JPQL
select m, t from Member m
left join m.team t on t.name = 'A'

10.2.7 페치 조인

페치 조인은 SQL에서의 조인 종류가 아니라 JPQL에서 성능 최적화를 위해 제공하는 기능이다. 연관된 엔티티나 컬렉션을 한 번에 같이 조회하는 기능으로 join fetch 명령어로 사용할 수 있다.
JPA 표준 명세에 정의된 페치 조인 문법은 다음과 같다.

페치 조인 : [LEFT [OUTER] | INNER ] JOIN FETCH 조인경로

엔티티 페치 조인

페치 조인을 사용해서 회원 엔티티를 조회하면서 연관된 팀 엔티티도 함께 조회하는 JPQL을 보자.

select m from Member m join fetch m.team

코드를 보면 join 다음에 fetch라고 적었다. 이렇게 하면 연관된 엔티티나 컬렉션을 함께 조회하는데 여기서는 회원(m)과 팀(m.team)을 함께 조회한다. 일반적인 JPQL 조인과는 다르게 m.team 다음에 별칭이 없는데 페치 조인은 별칭을 사용할 수 없기 때문이다.(하이버네이트는 페치 조인에도 별칭을 허용한다고 한다.)
실행된 SQL은 다음과 같다.

SELECT M.*, T.*
FROM MEMBER M
INNER JOIN TEAM T ON M.TEAM_ID=T.ID

페치 조인을 사용하면 아래 그림처럼 SQL 조인을 시도한다.


아래는 SQL에서 조인의 결과다.

엔티티 페치 조인 JPQL에서 select m으로 회원 엔티티만 선택했는데 실행된 SQL을 보면 SELECT M.*, T.*로 회원과 연관된 팀도 함께 조회된 것을 확인할 수 있다. 그리고 위의 그림을 보면 회원과 팀 객체가 객체 그래프를 유지하면서 조회된 것을 확인할 수 있다. 아래 코드는 이 JPQL을 사용하는 코드다.

String jqpl = "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().name());
}

출력 결과는 다음과 같다.

username = 회원1, teamname = 팀A
username = 회원2, teamname = 팀A
username = 회원3, teamname = 팀B

회원과 팀을 지연 로딩으로 설정했다고 가정해보자. 회원을 조회할 때 페치 조인을 사용해서 팀도 함께 조회했으므로 팀 엔티티는 프록시가 아닌 실제 엔티티다. 따라서 연관된 팀을 사용해도 지연 로딩이 일어나지 않는다. 또한, 프록시가 아닌 실제 엔티티이므로 회원 엔티티가 영속성 컨텍스트에서 분리되어 준영속 상태가 되어도 연관된 팀을 조회할 수 있다.

컬렉션 페치 조인

이번에는 일대다 관계인 컬렉션을 페치 조인해보자.

select t from Team t join fetch t.members where t.name = '팀A'

위 코드는 팀(t)을 조회하면서 페치 조인을 사용해서 연관된 회원 컬렉션(t.members)도 함께 조회한다.
실행된 SQL은 다음과 같다.

SELECT T.*, M.*
FROM TEAM T INNER JOIN MEMBER M ON T.ID=M.TEAM_ID
WHERE T.NAME = '팀A'




컬렉션을 페치 조인한 JPQL에서 select t로 팀만 선택했는데 실행된 SQL을 보면 T.*, M.*로 팀과 연관된 회원도 함께 조회한 것을 확인할 수 있다. 그리고 위 첫번째 그림의 TEAM 테이블에서 '팀A;는 하나지만 MEMBER 테이블과 조인하면서 결과가 증가해서 두번째 그림의 조인 결과 테이블을 보면 같은 '팀A'가 2건 조회되었다. 따라서 세번째 그림의 컬렉션 페치 조인 결과 객체에서 teams 결과 예제를 보면 주소가 0x100으로 같은 '팀A'를 2건 가지게 된다.
컬렉션 페치 조인을 사용하는 예제를 보자.

String jpql = "select t from Team t join fetch t.members where t.name = '팀A'"
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.getUsername() + ", member = " + member);
    }
}

출력 결과는 다음과 같다.

teamname = 팀A, team = Team@0x100
->username = 회원1, member = Member@0x200
->username = 회원2, member = Member@0x300
teamname = 팀A, team = Team@0x100
->username = 회원1, member = Member@0x200
->username = 회원2, member = Member@0x300

출력 결과를 보면 같은 '팀A'가 2건 조회된 것을 확인할 수 있다.


페치 조인과 DISTINCT

JPQL의 DISTINCT 명령어는 SQL에 DISTINCT를 추가하는 것은 물론이고 애플리케이션에서 한 번 더 중복을 제거한다.
바로 직전에 컬렉션 페치 조인은 팀A가 중복으로 조회되었다. 이번에는 DISTINCT를 추가해보자.

select distinct t from Team t join fetch t.members where t.name = '팀A'

먼저 DISTINCT를 사용하면 SQL에 SELECT DISTINCT가 추가된다. 하지만 지금은 각 로우의 데이터가 다르므로 SQL의 DISTINCT는 효과가 없다.
다음으로 애플리케이션에서 distinct 명령어를 보고 중복된 데이터를 걸러낸다. select distinct t의 의미는 팀 엔티티의 중복을 제거하라는 것이다. 따라서 중복인 팀A는 아래 그림처럼 하나만 조회된다.

컬렉션 페치 조인 사용 예제에 distinct를 추가하면 출력 결과는 다음과 같다.

teamname = 팀A, team = Team@0x100
->username = 회원1, member = Member@0x200
->username = 회원2, member = Member@0x300


페치 조인과 일반 조인의 차이

JPQL은 결과를 반환할 때 연관관계까지 고려하지 않는다. 단지 SELECT 절에 지정한 엔티티만 조회할 뿐이다. 따라서 팀 엔티티만 조회하고 연관된 회원 컬렉션은 조회하지 않는다. 만약 회원 컬렉션을 지연 로딩으로 설정하면 아래 그림처럼 프록시나 아직 초기화하지 않은 컬렉션 래퍼를 반환한다. 즉시 로딩으로 설정하면 회원 컬렉션을 즉시 로딩하기 위해 쿼리를 한 번 더 실행한다.

반면에 페치 조인을 사용하면 연관된 엔티티도 함께 조회한다.


페치 조인의 특징과 한계

페치조인을 사용하면 SQL 한 번으로 연관된 엔티티들을 함께 조회할 수 있어서 SQL 호출 횟수를 줄여 성능을 최적화할 수 있다.
엔티티에 직접 적용하는 로딩 전략은 애플리케이션 전체에 영향을 미치므로 글로벌 로딩 전략이라고 부른다. 페치 조인은 글로벌 로딩 전략보다 우선한다.

@OneToMany(fetch = FetchType.LAZY) // 글로벌 로딩 전략

글로벌 로딩 전략은 될 수 있으면 지연 로딩을 사용하고 최적화가 필요하면 페치 조인을 적용하는 것이 효과적이다. 또한 페치조인을 사용하면 준영속 상태에서도 객체 그래프를 탐색할 수 있다.

페치조인은 다음과 같은 한계가 있따.

  • 페치 조인 대상에는 별칭을 줄 수 없다.
    • SELECT, WHERE 절, 서브 쿼리에 페치 조인 대상을 사용할 수 없다.
    • 몇몇 구현체들은 별칭을 지원하기도 하지만 잘못 사용하면 연관된 데이터 수가 달라져서 데이터 무결성이 깨질 수 있으므로 조심해야 한다. 특히 2차 캐시와 함께 사용할 때 조심해야 하는데 연관된 데이터 수가 달라진 상태에서 2차 캐시에 저장되면 다른 곳에서 조회할 때도 연관된 데이터 수가 달라지는 문제가 발생할 수 있다.
  • 둘 이상의 컬렉션을 페치할 수 없다.
  • 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다.
    • 컬렉션이 아닌 단일 값 연관 필드(일대일, 다대일)들은 페치 조인을 사용해도 페이징 API를 사용할 수 있다.
    • 하이버네이트에서 컬렉션을 페치 조인하고 페이징 API를 사용하면 경고 로그를 남기면서 메모리에서 페이징 처리를 한다. 데이터가 많으면 성능 이슈와 메모리 초가 예외가 발생할 수 있어서 위험하다.

페치 조인은 SQL 한 번으로 연관된 여러 엔티티를 조회할 수 있어서 성능 최적화에 상당히 유용하다. 하지만 모든 것을 페치 조인으로 해결할 수는 없다. 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다. 반면에 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 한다면 억지로 페치 조인을 사용하기보다는 여러 테이블에서 필요한 필드들만 조회해서 DTO로 반환하는 것이 더 효과적일 수 있다.


10.2.8 경로 표현식

JPQL에서 사용하는 경로 표현식을 알아보고 경로 표현식을 통한 묵시적 조인도 알아보자.
경로 표현식은 쉽게 이야기해서 .을 찍어 객체 그래프를 탐색하는 것이다.
다음 JPQL을 보자.

select m.username from Member m join m.team t join m.orders o
where t.name = '팀A'

여기서 m.username, m.team, m.orders, t.name이 모두 경로 표현식을 사용한 예다.

경로 표현식의 용어 정리

  • 상태 필드 : 단순히 값을 저장하기 위한 필드(필드 or 프로퍼티)
  • 연관 필드 : 연관관계를 위한 필드, 임베디드 타입 포함(필드 or 프로퍼티)
    • 단일 값 연관 필드 : @ManyToOne, @OneToOne, 대상이 엔티티
    • 컬레션 값 연관 필드 : @OneToMany, @ManyToMany, 대상이 컬렉션
      아래 코드로 상태 필드와 연관 필드를 알아보자.
@Entity
public class Member {
    @Id @GeneratedValue
    private String id; 

    @Column(name = "NAME") 
    private String username; // 상태 필드
    private Integer age; // 상태 필드

    @ManyToOne(..)
    private Team team; // 연관 필드(단일 값 연관 필드)
    
    @OneToMany(..)
    private List<Order> orders; // 연관 필드(컬렉션 값 연관 필드)
}

정리하면 다음 3가지 경로 표현식이 있다.

  • 상태 필드: 예 t.username, t.age
  • 단일 값 연관 필드 : 예 m.team
  • 컬렉션 값 연관 필드: 예 m.orders

경로 표현식과 특징

JPQL에서 경로 표현식을 사용해서 경로 탐색을 하려면 다음 3가지 경로에 따라 어떤 특징이 있는지 이해해야 한다.

  • 상태 필드 경로 : 경로 탐색의 끝이다. 더는 탐색할 수 없다.
  • 단일 값 연관 경로 : 묵시적으로 내부 조인이 일어난다. 단일 값 연관 경로는 계속 탐색할 수 있다.
  • 컬렉션 값 연관 경로 : 묵시적으로 내부 조인이 일어난다. 더는 탐색할 수 없다. 단 FROM 절에서 조인을 통해 별칭을 얻으면 별칭으로 탐색할 수 있다.

예제를 통해 경로 탐색을 하나씩 알아보자.

상태 필드 경로 탐색

다음 JPQL의 m.username, m.age는 상태 필드 경로 탐색이다.

select m.username, m.age from Member m

이 JPQL을 실행한 결과 SQL은 다음과 같다.

select m.name, m.age from Member m

단일 값 연관 경로 탐색

select o.member from Order o

이 JPQL을 실행한 결과 SQL은 다음과 같다.

select m.*
from Orders o inner join Member m on o.member_id=m.id

JPQL을 보면 o.members를 통해 주문에서 회원으로 단일 값 연관 필드로 경로 탐색을 했다. 단일 값 연관 필드로 경로 탐색을 하면 SQL에서 내부 조인이 일어나느데 이것을 묵시적 조인이라 한다. 참고로 묵시적 조인을 모두 내부 조인이다. 외부 조인은 명시적으로 JOIN 키워드를 사용해야 한다.

  • 명시적 조인 : JOIN을 직접 적어주는 것
SELECT m FROM MEMBER m jOIN m.team t
  • 묵시적 조인: 경로 표현식에 의해 묵시적으로 조인이 일어나는 것. 내부 조인 INNER JOIN만 할 수 있다.
SELECT m.team FROM Member m

이번에는 복잡한 예제를 보자.

select o.member.team from Order o
where o.product.name = 'productA' and o.address.city = 'JINJU'

주문 중에서 상품명이 'productA'고 배송지가 'JINJU'인 회원이 소속된 팀을 조회한다. 실제 내부 조인이 몇번 일어날지 생각해보자.

selec t.*
from Order o inner join Member m on o.member_id = m.id
inner join Team t on m.team_id = t.id
inner join Product p on o.product_id = p.id
where p.name = 'productA' and o.city = 'JINJU'

실행된 SQL을 보면 총 3번의 조인이 발생했다. 참고로 o.address 처럼 임베디드 타입에 접근하는 것도 단일 값 연관 경로 탐색이지만 주문 테이블에 이미 포함되어 있으므로 조인이 발생하지 않는다.

컬렉션 값 연관 경로 탐색

JPQL을 다루면서 많이 하는 실수 중 하나는 컬렉션 값에서 경로 탐색을 시도하는 것이다.

select t.members from Team t // 성공
select t.membrs.username from Team t // 실패

t.members처럼 컬렉션까지는 경로 탐색이 가능하다. 하지만 t.members.username처럼 컬렉션에서 경로 탐색을 시작하는 것은 허락하지 않는다. 만약 컬렉션에서 경로 탐색을 하고 싶으면 아래 처럼 조인을 사용해서 새로운 별칭을 획득해야 한다.

select m.username from Team t join t.members m

join t.member m으로 컬렉션에 새로운 별칭을 얻었다. 이제 별칭 m부터 다시 경로 탐색을 할 수 있다.

컬렉션은 컬렉션의 크기를 구할 수 있는 size라는 특별한 기능을 사용할 수 잇다. size를 사용하면 COUNT 함수를 사용하는 SQL로 적절히 변환된다.

select t.members.size from Team t

경로 탐색을 사용한 묵시적 조인 시 주의 사항

  • 항상 내부 조인이다.
  • 컬렉션은 경로 탐색의 끝이다. 컬렉션에서 경로 탐색을 하려면 명시적으로 조인해서 별칭을 얻어야 한다.
  • 경로 탐색은 주로 SELECT, WHERE 절(다른 곳에서도 사용됨)에서 사용하지만 묵시적 조인으로 인해 SQL의 FROM 절에 영향을 준다.

조인이 성능상 차지하는 부분은 아주 크다. 묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어렵다는 단점이 있다. 따라서 단순하고 성능에 이슈가 없으면 크게 문제가 안 되지만 성능이 중요하면 분석이 쉽도록 묵시적 조인보다는 명시적 조인을 사용하자.


10.2.9 서브 쿼리

JPQL도 SQL처럼 서브 쿼리를 지원한다. 여기에는 몇 가지 제약이 있는데 서브 쿼리를 WHERE, HAVING 절에서만 사용할 수 있고 SELECT, FROM 절에서는 사용할 수 없다.

사용 예를 보자.
다음은 나이가 평균보다 많은 회원을 찾는다.

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

참고로 이 쿼리는 다음처럼 컬렉션 값 연관 필드의 size 기능을 사용해도 같은 결과를 얻을 수 잇다.

select m from Member m
where m.orders.size > 0

서브 쿼리 함수

  • [NOT] EXISTS (subquery)
  • {ALL | ANY | SOME} (subquery)
  • {NOT} IN (suquery)

EXISTS

문법: [NOT] EXISTS (subquery)
설명: 서브쿼리에 결과가 존재하면 참이다. NOT은 반대
예 팀 A 소속인 회원

select m from Member m
where exists (select t from m.team t where tm.name = '팀A')

{ALL | ANY | SOME}

문법: {ALL | ANY| SOME} (subquery)
설명: 비교 연산자와 같이 사용한다.

  • ALL : 조건을 모두 만족하면 참이다.
  • ANY 혹은 SOME : 둘은 같은 의미다. 조건을 하나라도 만족하면 참이다.

예 전체 상품 각각의 제고보다 주문량이 많은 주문들

select o from Order o
where o.orderAmount > ALL (select p.stockAmount from Product p)

예 어떤 팀이든 팀에 소속된 회원

select m from Member m
where m.team = ANY (select t from Team t)

IN

문법: [NOT] IN (subquery)
설명: 서브쿼리의 결과 중 하나라도 같은 것이 있으면 참이다. 참고로 IN은 서브쿼리가 아닌 곳에서도 사용한다.
예 20세 이상을 보유한 팀

select t from Team t
where t IN (select t2 from Team t2 JOIN t2.members m2 where m2.age >= 20)

10.2.11 다형성 쿼리

JPQL로 부모 엔티티를 조회하면 그 자식 엔티티도 함께 조회한다.
아래 코드를 보면 Item의 자식으로 Book, Album, Movie가 있다.

@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {...}

@Entity
@DiscriminatorValue("B")
public class Book extends Item {
	...
    private String author;
}

// Album, Movie 생략

다음과 같이 조회하면 Item의 자식도 함께 조회한다.

List resultList = em.createQuery("select i from Item i").getResultList();

단일 테이블 전략(InheritanceType.SINGLE_TABLE)을 사용할 때 실행되는 SQL은 다음과 같다.

SELECT * FROM ITEM

조인 전략(InheritanceType.JOINED)을 사용할 때 실행되는 SQL은 다음과 같다.

SELECT i.ITEM_ID, i.DTYPE, i.name, i.price, i.stockQuantity, b.author. b.isbn, a.artist, a.etc, m.actor, m.director
FORM Item i left outer join Book b on i.ITEM_ID=b.ITEM_ID
left outer join Album a on i.ITEM_ID=a.ITEM_ID
left outer join Movie m on i.ITEM_ID=m.ITEM_ID

TYPE

TYPE은 엔티티의 상속 구조에서 조회 대상을 특정 자식 타입으로 한정할 때 주로 사용한다.
예 Item 중에 Book, Movie를 조회하라.

// JPQL
select i from Item i
where type(i) IN (Book, Movie)

// SQL
SELECT i FROM Item i
WHERE i.DTYPE in ('B', 'M')

TREAT(JPA 2.1)

TREAT은 자바의 타입 캐스팅과 비슷하다. 상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용한다. JPA 표준은 FROM, WHERE 절에서 사용할 수 있지만 하이버네이트는 SELECT 절에서도 TREAT를 사용할 수 있다.
예 부모인 Item과 자식 Book이 있다.

// JPQL
select i from Item i where treat(i as Book).author = 'kim'

// SQL
select i.* from Item i
where i.DTYPE='B' and i.author='kim'

10.2.12 사용자 정의 함수 호출(JPA 2.1)

JPA 2.1부터 사용자 정의 함수를 지원한다.

문법

function_invocation::= FUNCTION(function_name {, function_arg}*)

하이버네이트 구현체를 사용하면 아래와 같이 방언 클래스를 상속해서 구현하고 사용할 데이터베이스 함수를 미리 등록해야 한다.

public class MyH2Dialect extends H2Dialect {
	public MyH2Dialect() {
    	registerFunction("group_concat", new StandardSQLFunction("group_concat", StandardBasicTypes.STRING));
    }
}

그리고 아래와 같이 hibernate.dialect에 해당 방언을 등록해야 한다.

<property name="hiberante.dialect" value="hello.MyH2Dialect" />

하이버네이트 구현체를 사용하면 다음과 같이 축약해서 사용할 수 있다.

select group_concat(i.name) from Item i

10.2.13 기타 정리

  • enum은 = 비교 연산만 정리한다.
  • 임베디드 타입은 비교를 지원하지 않는다.

EMPTY STRING

JPA 표준은 ''을 길이 0인 Empty String으로 정했지만 데이터베이스에 따라 ''를 NULL로 사용하는 데이터베이스도 있으므로 확인하고 사용해야 한다.

NULL 정의

  • 조건을 만족하는 데이터가 하나도 없으면 NULL이다.
  • NULL은 알 수 없는 값이다. NULL과의 모든 수학적 계산 결과는 NULL이 된다.
  • Null == Null은 알 수 없는 값이다.
  • Null is Null은 참이다.

10.2.14 엔티티 직접 사용

기본 키 값

JPQL에서 엔티티 객체를 직접 사용하면 SQL에서는 해당 엔티티의 기본 키 값으 사용한다.
다음 JPQL 코드를 보자.

select count(m.id) from Member m //엔티티의 아이디를 사용
select count(m) from Member m // 엔티티를 직접 사용

두 번째의 count(m)을 보면 엔티티의 별칭을 직접 넘겨주었다. 이렇게 엔티티를 직접 사용하면 JPQL이 SQL로 변환될 때 해당 엔티티의 기본키를 사용한다. 따라서 실제 실행된 SQL은 둘 다 아래 코드로 같다.

select count(m.id) as cnt
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=?

JPQL과 SQL을 비교해보면 JPQL에서 where m = :member로 엔티티를 직접 사용하는 부분이 SQL에서 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();

기본키 값이 1L인 팀 엔티티를 파라미터로 사용하고 있다. m.team은 현재 team_id라는 외래키와 매핑되어 있다. 따라서 아래와 같은 SQL이 실행된다.

select m.*
from Member m
where m.team_id=?

엔티티 대신 아래와 같이 식별자 값을 직접 사용할 수 있다.

String qlString = "select m from Member m where m.team.id = :teamId";
List resultList = em.createQuery(qlString).setParameter("teamId", 1L).getResultList();

m.team.id를 보면 Member와 Team 간에 묵시적 조인이 일어날 것 같지만 MEMBER 테이블이 team_id 외래키를 가지고 있으므로 묵시적 조인은 일어나지 않는다. 물론 m.team.name을 호출하면 묵시적 조인이 일어난다. 따라서 m.team을 사용하든 m.team.id를 사용하든 생성되는 SQL은 같다.


10.2.15 Named 쿼리: 정적 쿼리

  • 동적 쿼리 : em.createQuery("select ..") 처럼 JPQL을 문자로 완성해서 직접 넘기는 것을 동적 쿼리라 한다. 런타임에 특정 조건에 따라 JPQL을 동적으로 구성할 수 있다.
  • 정적 쿼리 : 미리 정의한 쿼리에 이름을 부여해서 필요할 때 사용할 수 있는데 이를 Named 쿼리라 한다. Named 쿼리는 한번 정의하면 변경할 수 없는 정적 쿼리다.

Named 쿼리는 애플리케이션 로딩 시점에 JPQL 문법을 체크하고 미리 파싱해둔다. 따라서 오류를 빨리 확인할 수 있고 사용하는 시점에는 파싱된 결과를 재사용하므로 성능상 이점도 있다. 그리고 Named 쿼리는 변하지 않는 정적 SQL이 생성되므로 데이터베이스의 조회 성능 최적화에도 도움이 된다.
Named 쿼리는 @NamedQuery 어노테이션을 사용해서 자바 코드에 작성하거나 XML 문서에 작성할 수 있다.

Named 쿼리를 어노테이션에 정의

쿼리에 이름을 부여해서 사용하는 방법이다.

@Entity
@NamedQuery(name = "Member.findByUsername", query = "select m from Member m where m.username = :username")
public class MEmber {
	...
}

@NamedQuery.name에 쿼리 이름을 부여하고 @NamedQuery.query에 사용할 쿼리를 입력한다.

List<Memberm> resultList = em.createNamedQuery("Member.findByUsername", Member.class).setParameter("username", "회원1").getResultList();

Named 쿼리를 사용할 때는 위와 같이 em.createNamedQuery() 메소드에 Named 쿼리 이름을 입력하면 된다.

하나의 엔티티에 2개 이상의 Named 쿼리를 정의하려면 아래와 같이 @NamedQueries 어노테이션을 사용하면 된다.

@Entity
@NamedQueries({
        @NamedQuery(name = "Member.findByUsername", query = "select m from Member m where m.username = :username"),
        @NamedQuery(name = "Member.count", query = "select count(m) from Member m")
})
public class Member { ... }

Named 쿼리를 XML에 정의

JPA에서 어노테이션으로 작성할 수 있는 것은 XML로도 작성할 수 있다. 물론 어노테이션을 사용하는 것이 직관적이고 편리하지만 NAmed 쿼리를 작성할 때는 XML을 사용하는 것이 더 편리하다.
자바 언어로 멀티라인 문자를 다루는 것은 상당히 귀찮은 일이지만 이런 불편함을 해결하려면 XML을 사용하는 것이 그나마 현실적인 대안이다.

<?xml version="1.0" encoding="UTF-8"?>

<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>

그리고 정의한 ormMeber.xml을 인식하도록 META-INF/persistence.xml에 다음 코드를 추가해야 한다.

    <persistence-unit name="jpabook">
        <mapping-file>META-INF/ormMember.xml</mapping-file>

환경에 따른 설정

만약 XML과 어노테이션에 같은 설정이 있으면 XML이 우선권을 가진다. 같은 이름의 Named 쿼리가 있으면 XML에 정의한 것이 사용된다. 따라서 애플리케이션이 운영 환경에 따라 다른 쿼리를 실행해야 한다면 각 환경에 맞춘 XML을 준비해두고 XMLm만 변경해서 배포하면 된다.


10.3 Criteria

Criteria 쿼리는 JPQL을 자바 코드로 작성하도록 도와주는 빌더 클래스 API다. 이를 사용하면 문자가 아닌 코드로 작성하므로 문법 오류를 컴파일 단계에서 잡을 수 있고 문자 기반의 JPQL 보다 동적 쿼리를 안전하게 생성할 수 있다는 장점이 있다. 하지만 실제 사용해서 개발해보면 코드가 복잡하고 장황해서 직관적인 이해가 힘들다는 단점도 있다.
Criteria는 결국 JPQL의 생성을 돕는 클래스 모음이므로 내용 대부분이 JPQL과 중복된다. 따라서, 사용법 위주로 알아보자.

10.3.1 Criteria 기초

Criteria API는 javax.persistence.criteria 패키지에 있다.

가장 단순한 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();

모든 회원 엔티티를 조회하는 단순한 JPQL을 Criteria로 작성해보았다.

  • Criteria 쿼리를 생성하려면 먼저 Criteria 빌더를 얻어야 한다. Criteria 빌더는 EntityManager나 EntityManagerFactory에서 얻을 수 있다.
  • Criteria 쿼리 빌더에서 Criteria 쿼리를 생성한다. 이때 반환 타입을 지정할 수 있다.
  • FROM 절을 생성한다. 반환된 값 m은 Criteria에서 사용하는 특별한 별칭이다. m을 조회의 시작점이라는 의미로 쿼리 루트라 한다.
  • SELECT 절을 생성한다.

이렇게 Criteria 쿼리를 완성하고 나면 다음 순서는 JPQL과 같다. em.createQuery(cq)에 완성된 Criteria 쿼리를 넣어주기만 하면 된다.

이번에는 검색 조건과 정렬을 추가해보자.

// JPQL: select m from Member m where m.username='회원1' order by m.age desc

CriteriaBuilder cb = em.getCriteriaBuilder(); // Criteria 쿼리 빌더

// Criteria 생성, 반환 타입 지정
CriteriaQuery<Member> cq = cb.createQuery(Member.class);

Root<Member> m = cq.from(Member.class); // FROM 절

// 검색 조건 정의
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); // ORDER BY 절 생성
    
List<Member> resultList = em.createQuery(cq).getResultList();

아까 보았던 기본 쿼리에 검색 조건과 정렬 조건을 추가했다.

  • 검색 조건을 정의한 부분을 보면 m.get("username")으로 되어있는데 m은 회원 엔팉의 별칭이다. 이것은 JPQL에서 m.username과 같은 표현이다. 그리고 cb.equal(A, B)는 이름 그대로 A = B라는 뜻이다. 따라서 cb.equal(m.get("username"), "회원1")는 JPQL에서 m.username = '회원1'과 같은 표현이다.
  • 정렬 조건을 정의하는 코드인 cb.desc(m.get("age))는 JPQL의 m.age desc와 같은 표현이다.
  • 만들어둔 조건을 where, orderBy에 넣어서 원하는 쿼리를 생성한다.

Criteria는 검색 조건부터 정렬까지 Criteria 빌더를 사용해서 코드를 완성한다.


10.4 QueryDSL

쿼리를 문자가 아닌 코드로 작성해도, 쉽고 간결하며 그 모양도 쿼리와 비슷하게 개발할 수 있는 프로젝트가 QueryDSL이다. QueryDSL도 Criteria처럼 JPQL 빌더 역할을 하는데 JPA Criteria를 대체할 수 있다.
QueryDSL은 오픈소스 프로젝트다. 처음에는 HQL(하이버네이트 쿼리언어)을 코드로 작성할 수 있도록 해주는 프로젝트로 시작해서 지금은 JPA, JDO, JDBC, Lucene, Hibernate Search, 몽고DB, 자바 컬렉션 등을 다양하게 지원한다. QueryDSL은 이름 그대로 쿼리 즉 데이터를 조회하는 데 기능이 특화되어 있다.

10.4.1 QueryDSL 설정

필요 라이브러리

아래 예제와 같이 QueryDSL 라이브러리를 추가하자.

dependencies {
	implementation 'com.mysema.querydsl:querydsl-jpa'
	implementation 'com.mysema.querydsl:querydsl-apt:3.6.3'
}
  • querydsl-jpa : QueryDSL JPA 라이브러리
  • querydsl-apt : 쿼리 타입(Q)을 생성할 때 필요한 라이브러리

환경설정

QueryDSL을 사용하려면 엔티티를 기반으로 쿼리 타입이라는 쿼리용 클래스를 생성해야 한다.
아래와 같이 쿼리 타입 생성용 플러그인을 추가하자.

plugins {
	id 'java'
	id 'org.springframework.boot' version '2.7.4'
	id 'io.spring.dependency-management' version '1.1.0'
	id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}

/*
 * queryDSL 설정 추가
 */
// querydsl에서 사용할 경로 설정
def querydslDir = "$buildDir/generated/querydsl"
// JPA 사용 여부와 사용할 경로를 설정
querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}
// build 시 사용할 sourceSet 추가
sourceSets {
    main.java.srcDir querydslDir
}
// querydsl 컴파일시 사용할 옵션 설정
compileQuerydsl{
    options.annotationProcessorPath = configurations.querydsl
}
// querydsl 이 compileClassPath 를 상속하도록 설정
configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
    querydsl.extendsFrom compileClasspath
}

10.4.2 시작

QueryDSL을 어떻게 사용하는지 아래 예제로 알아보자.

public void queryDSL() {
	EntityManager em = emf.createEntityManager();
    
    JPAQuery query = new JPAQuery(em);
    QMember qMember = new QMember("m"); // 생성되는 JPQL의 별칭이 m
    List<Member> members = query.from(qMember).where(qMember.name.eq("회원1")).orderBy(qMember.name.desc()).list(qMember);
}

QueryDSL을 사용하려면 우선 com.mysema.query.jpa.impl.JPAQuery 객체를 생성해야 하는데 이떄 엔티티 매니저를 생성자에 넘겨준다. 다음으로 사용할 쿼리 타입(Q)을 생성하는데 생성자에는 별칭을 넘겨주면 된다. 이 별칭을 JPQL에서 별칭으로 사용한다.

기본 Q 생성

쿼리 타입(Q)은 사용하기 편리하도록 아래와 같이 기본 인스턴스를 보관하고 있다. 하지만 같은 엔티티를 조인하거나 같은 엔티티를 서브쿼리에 사용하면 같은 별칭이 사용되므로 이때는 별칭을 직접 지정해서 사용해야 한다.

public class QMember extends EntityPathBase<Member> {
	public static final QMember member = new QMember("member1");
}

쿼리 타입은 아래와 같이 사용한다.

QMember qMember = new QMember("m"); // 직접 지정
QMember qMember = QMember.member; // 기본 인스턴스 사용

쿼리 타입의 기본 인스턴스를 사용하면 아래와 같애 import static을 화용해서 코드를 더 간결하게 작성할 수 있다.

import static jpabook.japshop.domain.QMember.member; // 기본 인스턴스

public void basic() {
	EntityManager em = emf.createEntityManager();
    
    JPAQuery query = new JPAQuery(em);
    List<Member> members = query.from(member).where(member.name.eq("회원1")).orderBy(member.name.desc()).list(member);
}

10.4.3 검색 조건 쿼리

QueryDSL의 기본 쿼리 기능을 아래를 통해 알아보자.

JPAQuery query = new JPAQuery(em);
QItem item = QItem.item;
List<Item> list = query.from(item).where(item.name.eq("좋은상품").and(item.price.gt(20000))).list(item); // 조회할 프로젝션 지정

위 코드를 실행하면 아래의 JPQL이 생성되고 실행된다.

select item from Item item where item.name = ?1 and item.price > ?2

QueryDSL의 where 절에는 and나 or을 사용할 수 있다.
쿼리 타입의 필드는 필요한 대부분의 메소드를 명시적으로 제공한다. 몇 가지만 예를 들어 보자. 다음은 where()에서 사용되는 메소드다.

item.price.between(10000,20000); // 가격이 만원~2만원 상품
item.name.contains("상품1"); // 상품1이라는 이름을 포함한 상품
item.name.startsWith("고급"); // 이름이 고급으로 시작하는 상품

코드로 작성되어 있으므로 IDE가 제공하는 코드 자동 완성 기능의 도움을 받으면 필요한 메소드를 손쉽게 찾을 수 있다.


10.4.4 결과 조회

쿼리 작성이 끝나고 결과 조회 메소드를 호출하면 실제 데이터베이스를 조회한다. 보통 uniqueResult()list()를 사용하고 파라미터로 프로젝션 대상을 넘겨준다. 결과 조회 API는 com.mysema.query.Projectable에 정의되어 있다.
대표적인 결과 조회 메소드는 다음과 같다.

  • uniqueResult() : 조회 결과가 한 건일 때 사용한다. 조회 결과가 없으면 null을 반환하고 결과가 하나 이상이면 예외가 발생한다.
  • singleResult() : uniqueResult()와 같지만 결과가 하나 이상이면 처음 데이터를 반환한다.
  • list() : 결과가 하나 이상일 때 사용한다. 결과가 없으면 빈 컬렉션을 반환한다.

10.5 네이티브 SQL

JPQL은 표준 SQL이 지원하는 대부분의 문법과 SQL 함수들을 지원하지만 특정 데이터베이스에 종속적인 기능은 지원하지 않는다. 예를 들어 다음과 같은 것들이다.

  • 특정 데이터베이스만 지원하는 함수, 문법, SQL 쿼리 힌트
  • 인라인 뷰(From 절에서 사용하는 서브쿼리), UNION, INTERSECT
  • 스토어드 프로시저

때로는 특정 데이터베이스에 종속적인 기능이 필요하다. JPA가 특정 데이터베이스에 종속적인 기능을 지원하는 방법은 다음과 같다.

  • 특정 데이터베이스만 사용하는 함수
    • JPQL에서 네이티브 SQL 함수를 호출할 수 있다(JPA 2.1)
    • 하이버네이트는 데이터베이스 방언에 각 데이터베이스에 종속적인 함수들을 정의해두었다. 또한 직접 호출할 함수를 정의할 수도 있다.
  • 특정 데이터베이스만 지원하는 SQL 쿼리 힌트
    • 하이버네이트를 포함한 몇몇 JPA 구현체들이 지원한다.
  • 인라인 뷰, UNION, INTERSECT
    • 하이버네이트는 지원하지 않지만 일부 JPA 구현체들이 지원한다.
  • 스토어 프로시저
    • JPQL에서 스토어드 프로시저를 호출할 수 있다.
  • 특정 데이터베이스만 지원하는 문법
    • 오라클의 CONNECT BY처럼 특정 데이터베이스에 너무 종속적인 SQL 문법은 지원하지 않는다. 이때는 네이티브 SQL을 사용해야 한다.

다양한 이유로 JPQL을 사용할 수 없을 때 JPA는 SQL을 직접 사용할 수 있는 기능인 네이티브 SQL을 제공한다. JPQL을 사용하면 JPA가 SQL을 생성한다. 네이티브 SQL은 이 SQL을 개발자가 직접 정의하는 것이다. 즉, JPQL이 자동 모드라면 네이티브 SQL은 수동 모드다. 네이티브 SQL과 JDBC API를 직접 사용하는 것에는 차이가 있을까? 네이티브 SQL을 사용하면 엔티티를 조회할 수 있고 JPA가 지원하는 영속성 컨텍스트의 기능을 그대로 사용할 수 있다. 반면 JDBC API를 직접 사용하면 단순히 데이터의 나열을 조회할 뿐이다.


10.5.1 네이티브 SQL 사용

엔티티 조회

네이티브 SQL은 em.createNativeQuery(SQL, 결과 클래스를 사용한다. 첫 번째 파라미터는 네이티브 SQL을 입력하고 두 번째 파라미터는 조회할 엔티티 클래스의 타입을 입력한다.

// SQL 정의
String sql = "SELECT ID, AGE, NAME, TEAM_ID " + "FROM MEMBER WHERE AGE > ?";

Query natieQuery = em.createNativeQuery(sql, Member.class).setParameter(1, 20);

List<Member> resultList = nativeQuery.getResultList();

여기서 중요한 점은 네이티브 SQL로 SQL만 직접 사용할 뿐이지 나머지는 JPQL을 사용할 때와 같다. 조회한 엔티티도 영속성 컨텍스트에서 관리된다.

값 조회

이번에는 단순히 값으로만 조회하는 방법을 알아보자.

// SQL 정의
String sql = "SELECT ID, AGE, NAME, TEAM_ID " + "FROM MEMBER WHERE AGE > ?";

Query natieQuery = em.createNativeQuery(sql).setParamter(1, 10);

List<Object[]> resultList = nativeQuery.getResultList();
for(Object[] row : resultList) {
	System.out.println("id = " + row[0]);
    System.out.println("age = " + row[1]);
    System.out.println("name = " + row[2]);
    System.out.println("team_id = " + row[3]);
}

위 코드는 엔티티로 조회하지 않고 단순히 값으로 조회했다. 이렇게 여러 값으로 조회하려면 두번째 파라미터를 사용하지 않으면 된다. 여기서는 스칼라 값들을 조회했을 뿐이므로 결과를 영속성 컨텍스트가 관리하지 않아서 마치 JDBC로 데이터를 조회한 것과 비슷하다.

결과 매핑 사용

엔티티와 스칼라 값을 함께 조회하는 것처럼 매핑이 복잡해지면 @SqlResultSetMapping을 정의해서 결과 매핑을 사용해야 한다.
이번에는 회원 엔티티와 회원이 주문한 상품 수를 조회해보자.

// SQL 정의
String sql = "SELECT M.ID, AGE, NAME, TEAM_ID, I.ORDER_COUNT " + 
	"FROM MEMBER M LEFT JOIN (SELECT IM.ID, COUNT(*) AS ORDER_COUNT " +
    "FROM ORDERS O, MEMBER IM WHERE O.MEMBER_ID = IM.ID) I " +
    "ON M.ID = I.ID";

Query nativeQuery = em.createNativeQuery(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);
}

두번째 파라미터에 결과 매핑 정보의 이름이 사용되었다.
아래 코드를 통해 결과 매핑을 정의하는 코드를 보자.

@Entity
@SqlResultSetMapping(name = "memberWithOrderCount", entities = {@EntityResult(entityClass = Member.class)},
columns = {@ColumnResult(name = "ORDER_COUNT")})
public class Member { ... }

결과 매핑을 잘 보면 회원 엔티티와 ORDER_COUNT 컬럼을 매핑했다. 위 코드에서 사용한 쿼리 결과에서 ID, AGE, NAME, TEAM_ID는 Member 엔티티와 매핑하고 ORDER_COUNT는 단순히 값으로 매핑한다. 그리고 entities, columns라는 이름에서 알 수 있듯이 여러 엔티티와 여러 컬럼을 매핑할 수 있다.


10.5.2 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)
public class Member { ... }

@NamedNativeQuery로 Named 테이티브 SQL을 등록했다. 사용하는 예제를 보자.

TypedQuery<Member> nativeQuery = em.createNAmedQuery("Member.memberSQL", Member.class).setParameter(1, 20);

흥미로운 점은 JPQL Named 쿼리와 같은 createNAmedQuery 메소드를 사용해서 TypeQuery를 사용할 수 있다는 것이다.


10.5.4 네이티브 SQL 정리

네이티브 SQL은 관리하기 쉽지 않고 자주 사용하면 특정 데이터베이스에 종속적인 쿼리가 증가해서 이식성이 떨어진다. 그렇다고 현실적으로 사용하지 않을 수는 없다.
될 수 있으면 표준 JPQL을 사용하고 기능이 부족하면 차선책으로 하이버네이트 같은 JPA 구현체가 제공하는 기능을 사용하자. 그래도 안되면 마지막 방법으로 네이티브 SQL을 사용하자. 그리고 네이티브 SQL로도 부족함을 느꼈다면 MyBatis나 스프링 프레임워크가 제공하는 JdbcTemplate 같은 SQL 매퍼와 JPA를 함께 사용하는 것도 고려할만하다.


10.5.5 스토어드 프로시저(JPA 2.1)

스토어드 프로시저 사용

아래와 같이 단순히 입력 값을 두 배로 증가시켜 주는 proc_multiply라는 스토어드 프로시저가 있다. 이 프로시저는 첫 번째 파라미터로 값을 입력받고 두번째 파라미터로 결과를 반환한다.

DELIMITER //
CREATE PROCEDURE proc_multiply (INOUT inParam INT, INOUT outParam INT)
BEGIN SET outParam = inParam * 2;
END //

JPA로 이 스토어드 프로시저를 호출해보자.
먼저 순서 기반 파라미터 호출 코드를 보자.

StoredProcedureQuery spq = em.createStoredProcedureQuery("proc_multiply");
spq.registerStoredProcedureParamter(1, Integer.class, ParameterMode.IN);
spq.registerStoredProcedureParamter(2, Integer.class, ParameterMode.OUT);

spq.setParamter(1, 100);
spq.execute();

Integer out = (Integer) spq.getOutputParamterValue(2);
System.out.println("out = " + out); // 결과 = 200

스토어드 프로시저를 사용하려면 em.createStoredProcedureQuery() 메소드에 사용할 스토어드 프로시저 이름을 사용하면 된다. 그리고 registerStoredProcedureParamter() 메소드를 사용해서 프로시저에서 사용할 파라미터를 순서, 타입, 파라미터 모드 순으로 정의하면 된다.
또한, 파라미터에 순서 대신에 이름을 사용할 수 있다.


10.6 객체지향 쿼리 심화

10.6.1 벌크 연산

여러 건을 한 번에 수정하거나 삭제하는 벌크 연산을 사용하면 시간을 절약할 수 있다.
예를 들어 재고가 10개 미만인 모든 상품의 가격을 10% 상승시키려면 아래와 같이 벌크 연산을 사용하면 된다.

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();

벌크 연산은 executeUpdate() 메소드를 사용한다. 이 메소드는 벌크 연산으로 영향을 받은 엔티티 건수를 반환한다.
삭제도 같은 메소드를 사용한다. 아래 코드는 가격이 100원 미만인 상품을 삭제하는 코드다.

String qlString = "delete from Product p where p.price < :price";

int resultCount = em.createQuery(qlString).setParameter("price", 100).executeUpdate();

벌크 연산의 주의점

벌크 연산을 사용할 때는 벌크 연산이 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리한다는 점에 주의해야 한다. 벌크 연산 시 어떤 문제가 발생할 수 있는지 아래 예제를 통해 알아보자. 데이터베이스에는 가격이 1000원인 상품A가 있다.

// 상품A 조회(상품A의 가격은 1000원이다.)
Product productA = em.createQuery("select p from Product p where p.name = :name", Product.class).setParameter("name", "productA").getSingleResult();

// 출력 결과: 1000
System.out.println("productA 수정 전 = " + productA.getPrice());

// 벌크 연산 수행으로 모든 상품 가격 10% 상승
em.createQuery("update Product p set p.price = p.price * 1.1").executeUpdate();

// 출력 결과: 1000
System.out.println("productA 수정 후 = " + productA.getPrice());
  • 가격이 1000원인 상품A를 조회했다. 조회된 상품A는 영속성 컨텍스트에서 관리된다.
  • 벌크 연산으로 모든 상품의 가격을 10% 상승시켰다. 따라서 상품A의 가격은 1100원이 되어야 한다.
  • 벌크 연산을 수행한 후에 상품A의 가격을 출력하면 기대했던 1100원이 아니라 1000원이 출력된다.

벌크 연산은 영속성 컨텍스트를 통하지 않고 데이터베이스에 직접 쿼리한다. 따라서 영속성 컨텍스트에 있는 상품A와 데이터베이스에 있는 상품A의 가격이 다를 수 있다. 따라서 벌크 연산은 주의해서 사용해야 한다.
이런 문제를 해결하는 다양한 방법을 알아보자.

em.refresh() 사용

벌크 연산을 수행한 직후에 정확한 상품A 엔티티를 사용해야 한다면 em.refresh()를 사용해서 데이터베이스에서 상품A를 다시 조회하면 된다.

em.refresh(productA); // 데이터베이스에서 상품A를 다시 조회한다.

벌크 연산 먼저 실행

가장 실용적인 해결책은 벌크 연산을 가장 먼저 실행하는 것이다. 예를 들어 위에서 벌크 연산을 먼저 실행하고 나서 상품A를 조회하면 벌크 연산으로 이미 변경된 상품A를 조회하게 된다. 이 방법은 JPA와 JDBC를 함께 사용할 때도 유용하다.

벌크 연산 수행 후 영속성 컨텍스트 초기화

벌크 연산을 수행한 직후에 바로 영속성 컨텍스트를 초기화해서 영속성 컨텍스트에 남아있는 엔티티를 제거하는 것도 좋은 방법이다. 초기화하면 이후 엔티티를 조회할 때 벌크 연산이 적용된 데이터베이스에서 엔티티를 조회한다.

벌크 연산은 영속성 컨텍스트와 2차 캐시를 무시하고 데이터베이스에 직접 실행한다. 따라서 영속성 컨텍스트와 데이터베이스 간에 데이터 차이가 발생할 수 있으므로 주의해서 사용해야 한다. 가능하면 벌크 연산을 먼저 수행하는 것이 좋고 상황에 따라 영속성 컨텍스트를 초기화하는 것도 필요하다.


10.6.2 영속성 컨텍스트와 JPQL

쿼리 후 영속 상태인 것과 아닌 것

JPQL의 조회 대상은 엔티티, 임베디드 타입, 값 타입 같이 다양한 종류가 있다. JPQL로 엔티티를 조회하면 영속성 컨텍스트에선 관리되지만 엔티티가 아니면 영속성 컨텍스트에서 관리되지 않는다.

select m from Member m // 엔티티 조회 (관리o)
select o.address from Order o // 임베디드 타입 조회 (관리x)
select m.id, m.username from Membmer m // 단순 필드 조회(관리x)

예를 들어 임베디드 타입은 조회해서 값을 변경해도 영속성 컨텍스트가 관리하지 않으므로 변경 감지에 의한 수정이 발생하지 않는다. 물론 엔티티를 조회하면 해당 엔티티를 가지고 있는 임베디드 타입은 함께 수정된다.
정리하자면 조회한 엔티티만 영속성 컨텍스트가 관리한다.

JPQL로 조회한 엔티티와 영속성 컨텍스트

만약 아래 예제처럼 영속성 컨텍스트에 회원1이 이미 있는데, JPQL로 회원1을 다시 조회하면 어떻게 될까?

em.find(Member.class, "member1"); // 회원1 조회

// 엔티티 쿼리 조회 결과가 회원1, 회원2
List<Member> resultList = em.createQuery("select m from Member m", Member.class).getResultList();

JPQL로 데이터베이스에서 조회한 엔티티가 영속성 컨텍스트에 이미 있으면 JPQL로 데이터베이스에서 조회한 결과를 버리고 대신에 영속성 컨텍스트에 있던 엔티티를 반환한다. 이때 식별자 값을 사용해서 비교한다.

  1. JPQL을 사용해서 조회를 요청한다.
  2. JPQL은 SQL로 변환되어 데이터베이스를 조회한다.
  3. 조회한 결과와 영속성 컨텍스트를 비교한다.
  4. 식별자 값을 기준으로 member1은 이미 영속성 컨텍스트에 있으므로 버리고 기존에 있던 member1이 반환대상이 된다.
  5. 쿼리 결과인 member1를 반환한다. 여기서 member1은 쿼리 결과가 아닌 영속성 컨텍스트에 있던 엔티티다.
  • JPQL로 조회한 엔티티는 영속 상태다.
  • 영속성 컨텍스트에 이미 존재하는 엔티티가 있으면 기존 엔티티를 반환한다.

영속성 컨텍스트는 영속 상태인 엔티티의 동일성을 보장한다. em.find()로 조회하든 JPQL을 사용하든 영속성 컨텍스트가 같으면 동일한 엔티티를 반환한다.


find() vs JPQL

em.find() 메소드는 엔티티를 영속성 컨텍스트에서 먼저 찾고 없으면 데이터베이스에서 찾는다. 따라서 해당 엔티티가 영속성 컨텍스트에 있으면 메모리에서 바로 찾으므로 성능상 이점이 있다(그래서 1차 캐시라 부른다.)

JPQL도 마찬가지로 주소 값이 같은 인스턴스를 반환한다. 하지만 내부 동작방식은 조금 다르다.
JPQL은 항상 데이터베이스에 SQL을 실행해서 결과를 조회한다.
em.find() 메소드는 영속성 컨텍스트에서 엔티티를 먼저 찾고 없으면 데이터베이스를 조회하지만 JPQL을 사용하면 데이터베이스를 먼저 조회한다.
첫번째 JPQL을 호출하면 데이터베이스에서 회원 엔티티를 조회하고 영속성 컨텍스트에 등록한다. 두번째 JPQL을 호출하면 데이터베이스에서 같은 회원 엔티티를 조회한다. 이때 영속성 컨텍스트에 이미 조회한 같은 엔티티가 있다. 앞서 이야기 한대로 새로 검색한 엔티티는 버리고 영속성 컨텍스트에 있는 기존 엔티티를 반환한다.

JPQL의 특징을 정리해보자.

  • JPQL은 항상 데이터베이스를 조회한다.
  • JPQL로 조회한 엔티티는 영속 상태다.
  • 영속성 컨텍스트에 이미 존재하는 엔티티가 있으면 기존 엔티티를 반환한다.

10.6.3 JPQL과 플러시 모드

플러시는 영속성 컨텍스트의 변경 내역을 데이터베이스에 동기화하는 것이다. JPA는 플러시가 일어날 때 영속성 컨텍스트에 등록, 수정, 삭제한 엔티티를 찾아서 INSERT, UPDATE, DELETE SQL을 만들어 데이터베이스에 반영한다. 플러시를 호출하려면 em.flush() 메소드를 직접 사용해도 되지만 보통 플러시 모드에 따라 커밋하기 직전이나 쿼리 실행 직전에 자동으로 플러시가 호출된다.
플러시 모드는 FlushModeType.AUTO가 기본값이다. 따라서 JPA는 트랜잭션 커밋 직전이나 쿼리 실행 직전에 자동으로 플러시를 호출한다. 다른 옵션으로는 FlushModeType.COMMIT이 있는데 이 모드는 커밋시에만 플러시를 호출하고 쿼리 실행 시에는 플러시를 호출하지 않는다. 이 옵션은 성능 최적화를 위해 꼭 필요할 때만 사용해야 한다.

쿼리와 플러시 모드

JPQL은 영속성 컨텍스트에 있는 데이터를 고려하지 않고 데이터베이스에서 데이터를 조회한다. 따라서 JPQL을 실행하기 전에 영속성 컨텍스트의 내용을 데이터베이스에 반영해야 한다. 그렇지 않으면 의도하지 않은 결과가 발생할 수 있다.
플러시 모드의 기본값은 AUTO로 설정되어 있으므로 일반적인 상황에서는 그러한 결과를 걱정하지 않아도 된다. 그럼 왜 COMMIT 모드를 사용하는 것일까?

플러시 모드와 최적화

COMMIT 모드는 트랜잭션을 커밋할 때만 플러시하고 쿼리를 실행할 때는 플러시하지 않는다. 따라서 JPA 쿼리를 사용할 때 영속성 컨텍스트에는 있지만 아직 데이터베이스에 반영하지 않은 데이터를 조회할 수 없다. 이러한 상황은 잘못하면 데이터 무결성에 심각한 피해를 줄 수 있다. 그럼에도 다음과 같이 플러시가 너무 자주 일어나는 상황에 이 모드를 사용하면 쿼리시 발생하는 플러시 횟수를 줄여서 성능을 최적화할 수 있다.

// 비즈니스 로직
등록()
쿼리() // 플러시
등록()
쿼리() // 플러시
등록()
쿼리() // 플러시
커밋() // 플러시
  • AUTO: 쿼리와 커밋할 때 총 4번 플러시
  • COMMIT : 커밋 시에만 1번 플러시

JPA를 사용하지 않고 JDBC를 직접 사용해서 SQL을 실행할 때도 플러시 모드를 고민해야 한다. JPA를 통하지 않고 JDBC로 쿼리를 직접 실행하면 JPA는 JDBC가 실행한 쿼리를 인식할 방법이 없다. 따라서 별도의 JDBC 호출은 플러시 모드를 AUTO로 설정해도 플러시가 일어나지 않는다. 이때는 JDBC로 쿼리를 실행하기 직전에 em.flush()를 호출해서 영속성 컨텍스트의 내용을 데이터베이스에 동기화하는 것이 안전하다.

profile
가보자고

0개의 댓글