저번 글에 이어서 객체지향 쿼리 언어인 JPQL의 문법을 학습한다.
1. JPQL(Java Persistence Query Language)
경로 표현식
a. 개념
경로 표현식은 .(점)을 찍어서 객체 그래프를 탐색하는 것을 말한다. 아래처럼 사용해서 엔티티의 필드를 읽어오거나 엔티티와 연관된 엔티티 필드를 읽어올 수 있다.
SELECT m.username # -> 상태 필드
FROM Member m
JOIN m.team t # -> 단일 값 연관 필드
JOIN m.orders o # -> 컬렉션 값 연관 필드
WHERE t.name='팀A'
b. 용어 정리
경로 표현식은 크게 세 가지로 나눌 수 있다. 용어 정리를 하면서 내부적으로 동작하는 방식을 구분해 보자.
- 상태 필드(state field)
- 단순히 값을 저장하기 위한 필드
- ex. m.username
- 경로 탐색의 끝이기 때문에 추가 탐색이 불가능하다.
# JPQL과 SQL 모두 동일
SELECT m.username, m.age
FROM Member m
- 연관 필드(association field): 연관관계를 위한 필드
- 단일 값 연관 필드
- @ManyToOne, @OneToOne 애노테이션이 사용되며, 대상이 엔티티인 경우
- ex. m.team
- 묵시적 내부 조인(INNER JOIN)이 발생한다.
- 경로가 연관된 엔티티에서 끝나기 때문에 추가 탐색이 가능하다.
- 컬렉션 값 연관 필드
- @OneToMany, @ManyToMany 애노테이션이 사용되며, 대상이 컬렉션인 경우
- ex. m.orders
- 묵시적 내부 조인(INNER JOIN)이 발생한다.
- 경로가 컬렉션으로 끝나기 때문에 특정 대상을 선택할 수 없어 추가 탐색이 불가능하다.
- 컬렉션의 사이즈 정도는 탐색할 수 있다.
- 이때, FROM 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색할 수 있다.
- 단일 값 연관 필드
# 단일 값 연관 경로 탐색
# JPQL
SELECT o.member
FROM Order o
# SQL
SELECT m.*
FROM Orders o
INNER JOIN Member m ON o.member_id=m.id
# 컬렉션 값 연관 경로 탐색
# 탐색 실패
SELECT t.members.username
FROM Team t
#FROM 절에 명시적 조인으로 별칭 설정 후 탐색 성공
SELECT m.username
FROM Team t
JOIN t.members m;
c. 명시적 조인과 묵시적 조인
명시적 조인은 JOIN 키워드를 직접 사용한다.
- 가능한 명시적 조인을 사용하자.
SELECT m
FROM Member m
JOIN m.team t
묵시적 조인은 경로 표현식에 의해 묵시적으로 SQL 조인이 발생한다.
- 내부 조인(INNER JOIN)만 가능하다. 외부 조인(OUTER JOIN)을 사용하려면 명시적 조인을 사용해야 한다.
- 컬렉션은 경로 탐색의 끝이기 때문에, 추가적으로 탐색하려면 명시적 조인을 통해 별칭을 얻어야 한다.
- 경로 탐색은 주로 SELECT, WHERE 절에서 사용하지만, 묵시적 조인으로 인해 SQL의 FROM 절(JOIN)에 영향을 주게 된다.
SELECT m.team
FROM Member m
실무 팁
조인은 SQL 튜닝에 중요한 포인트가 된다. 묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어렵기 때문에
가급적이면 명시적 조인을 사용하는 게 낫다.
JPQL을 작성할 때, SQL을 비슷하게 맞춰서 작성해 보자.
💫페치 조인
참고
실무에서 정말 중요한 부분이다.
페치 조인(FETCH JOIN)은 SQL 조인 종류가 아니라, JPQL에서 성능 최적화를 위해 제공하는 기능이다. 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능으로 실무에서 정말 많이 사용한다. JOIN FETCH 명령어를 아래처럼 사용하면 된다.
[ LEFT [OUTER] | INNER ] JOIN FETCH {조인_경로}
a. 다대일 관계 페치 조인
다대일 관계(회원-팀)에서의 예시를 통해 더 자세하게 알아보자. 페치 조인을 사용하면 SQL 한 번에 회원을 조회하면서 연관된 팀도 함께 조회한다. JPQL로 실행된 SQL을 살펴보면, 프로젝션이 m뿐인데도 회원과 함께 팀(T.*)도 조회한 걸 확인할 수 있다.
- 즉시 로딩(Eager Loading)과 똑같은데, 조회할 연관 엔티티를 지정(ex. 팀)할 수 있다는 점에서 다르다.
# JPQL
SELECT m
FROM Member m
JOIN FETCH m.team
# SQL
SELECT M.*, T.*
FROM MEMBER M
INNER JOIN TEAM T ON M.TEAM_ID=T.ID
코드로도 살펴보자. 페치 조인을 사용해 회원과 팀을 한 번에 조회했기 때문에 지연 로딩이 발생하지 않는다.
- 여기서 페치 조인을 사용하지 않은 경우를 살펴보자. 회원에서 팀이 지연 로딩(Lazy Loading)으로 설정돼 있기 때문에, 회원만 조회하더라도 팀은 프록시 객체로 함께 조회된다. 이후 for문을 돌며 회원이 속한 팀의 이름을 조회할 때가 팀이 진짜 사용되는 시점이므로, 이때 SELECT SQL을 날려 팀을 조회한다.
- SELECT SQL 실행 → DB 접근 및 조회 → 영속성 컨텍스트 1차 캐시에 저장 → 결과 반환
- 페치 조인을 사용한 경우엔 팀이 지연 로딩으로 설정돼 있더라도 회원을 조회할 때 팀도 한 번에 조인해 가져온다. 페치 조인이 우선순위를 갖는다는 것이다.
String jpql = "SELECT m FROM Member m JOIN FETCH m.team";
// SQL 한 번에 회원과 팀 조회
List<Member> members = em.
.createQuery(jpql, Member.class)
.getResultList();
for (Member m : members) {
System.out.println("username = " + m.getUsername() +
", " + "teamname = " + m.getTeam().name());
}
// 출력 (페치 조인 미사용 시 내부 동작 과정; 팀은 지연 로딩)
// username = 회원1, teamname = 팀A (SQL 실행 - DB -> 영속성 컨텍스트 1차 캐시 저장)
// username = 회원2, teamname = 팀A (영속성 컨텍스트 - 1차 캐시)
// username = 회원3, teamname = 팀B (SQL 실행 - DB)
b. 컬렉션 페치 조인
일대다 관계(팀-회원)에서의 예시도 살펴보자. DB 입장에선 일대다 관계에서 조인을 하게 되면 데이터가 늘어나게 된다.
- 팀A에 회원1과 회원2가 속해있다. 이 상태에서 조인을 하면 [컬렉션 페치 조인 - 2] 그림처럼 DB에서 2개의 행을 가져오게 된다.
- 이때 팀A(ID=1)을 영속성 컨텍스트에 올려 놓고, 다음 팀A도 지금 영속성 컨텍스트에 있는 것과 똑같이 쓰게 된다. 따라서 [컬렉션 페치 조인 - 3] 그림에서 teams 결과 리스트에 같은 주솟값이 2개가 올라가게 된다.
# JPQL
SELECT t
FROM Team t
JOIN FETCH t.members
WHERE t.name='팀A'
# SQL
SELECT T.* M.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID=M.TEAM_ID
WHERE T.NAME='팀A'
코드로도 살펴보자. 위와 동일하게 페치 조인을 사용해 회원과 팀을 한 번에 조회했기 때문에 지연 로딩이 발생하지 않는다.
- teams 결과 리스트엔 팀A(회원1, 회원2)의 주솟값이 2개 들어가 있기 때문에 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 t : teams) {
System.out.println("teamname = " + team.getName() + ", team = " + team);
for (Member m : t.getMembers()) {
System.out.println("-> username = " + m.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
c. 페치 조인과 DISTINCT
참고
하이버네이트6부터는 DISTINCT 명령어를 사용하지 않아도 애플리케이션에서 중복 제거가 자동으로 적용된다. 따라서 위에서 설명한 것처럼 팀과 팀에 속한 회원 결과가 2번 출력되지 않는다. 아래 그림을 참고하자.
Starting with Hibernate ORM 6 it is no longer necessary to use distinct in JPQL and HQL to filter out the same parent entity references when join fetching a child collection. The returning duplicates of entities are now always filtered by Hibernate.
SQL의 DISTINCT는 중복된 결과를 제거하는 명령어다. JPQL의 DISTINCT는 아래 2가지 기능을 제공한다.
- SQL에 DISTINCT를 추가
- 애플리케이션에서 엔티티 중복 제거
- 따라서 같은 식별자(ID=1)를 가진 Team 엔티티가 제거된다. → 하이버네이트6부터 자동 적용된다.
d. 페치 조인과 일반 조인
페치 조인과 일반 조인의 차이를 알아보자. 일반 조인 실행 시 연관된 엔티티를 함께 조회하지 않는다.
- JPQL은 결과를 반환할 때 연관관계를 고려하지 않는다. 단지 SELECT 절에 지정한 엔티티를 조회할 뿐이다. 여기서는 팀 엔티티만 조회하고 회원 엔티티는 조회하지 않는다.
# JPQL
SELECT t
FROM Team T
JOIN t.members m
WHERE t.name='팀A'
# SQL
SELECT T.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID=M.TEAM_ID
WHERE T.NAME='팀A'
페치 조인을 사용할 때만 연관된 엔티티도 함께 조회한다. 페치 조인을 사용하면 사실상 즉시 로딩이 일어난다고 보면 된다.
- 엔티티가 A, B, C, D가 존재하고 A가 B, C, D와 연관된 상태에서 A를 조회하는 경우를 살펴보자. 즉시 로딩을 사용하면 A를 조회할 때 A와 함께 연관된 B, C, D까지 모두 로딩한다. 그러나 페치 조인을 사용하면 A를 조회할 때 로딩할 엔티티를 따로 지정할 수 있다.
# JPQL
SELECT t
FROM Team T
JOIN FETCH t.members
WHERE t.name='팀A'
# SQL
SELECT T.*, M.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID=M.TEAM_ID
WHERE T.NAME='팀A'
e. 특징과 한계
페치 조인을 사용할 때 꼭 알고 있어야 하는 특징과 한계가 존재한다.
- 페치 조인 대상에는 별칭(ex. m)을 줄 수 없다.
- 하이버네이트는 가능하다.
- 페치 조인을 사용한다는 건 기본적으로 연관된 모든 것(객체 그래프)을 가져오겠다는 뜻이다. 연관된 것 중 일부만 가져오려고 JPQL을 아래처럼 짜게 되면 어떤 SQL이 실행될지 알 수 없고, 객체 그래프를 완전히 탐색할 수 없을 수도 있다. 따라서 가급적으론 사용하지 않는 게 좋다. → 데이터 정합성 문제
String jpql = "SELECT t FROM Team t JOIN FETCH t.members m WHERE m.age>10";
// 불가능
- 둘 이상의 컬렉션은 페치 조인을 사용할 수 없다.
- 이것도 데이터 정합성 문제가 발생할 수 있다.
- 컬렉션을 페치 조인하면 데이터가 늘어나기 때문에 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다.
- 페이징은 철저하게 DB 중심으로 진행된다. 따라서 [컬렉션 페치 조인 - 2] 그림 같은 경우에서 페이징으로 1개를 가져온다고 하면, 회원1의 정보만 페이징으로 가져오고 회원2는 무시하는 등의 문제가 생길 수도 있다. 따라서 일대다 관계에서 (컬렉션) 페치 조인을 하게 되면 1을 기준으로는 페이징을 할 수 없다. 부족한 설명은 해당 링크를 참고해 보자.
- 일대일, 다대일 같은 단일 값 연관 필드는 페치 조인해도 페이징 API를 사용할 수 있다.
- 하이버네이트는 아래와 같은 경고 로그를 남기고 메모리에서 페이징한다. 따라서 실행된 SQL을 확인해 보면 페이징 쿼리가 동작하지 않고, limit과 offset 없이 팀의 전체 데이터를 불러온 것을 확인할 수 있다. 이런 방식은 매우 위험하기 때문에 절대 사용하면 안 된다.
String jpql = "SELECT t FROM Team t JOIN FETCH t.members";
List<Team> result = em
.createQuery(jpql, Team.class)
.setFirstResult(0)
.setMaxResults(1)
.getResultList();
// 하이버네이트 페이징 에러
// WARN: HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
참고
웬만하면 컬렉션 페치 조인은 사용하지 말고 아래와 같은 제한적인 상황에서만 사용하자.
- 대상 컬렉션의 데이터 수가 적은 경우
- 페이징이 필요 없는 경우
- 대상 컬렉션 이외에 컬렉션 페치 조인을 할 컬럼이 없는 경우
마지막으로 페치 조인의 내용을 정리해 보자.
- 페치 조인을 사용하면 연관된 엔티티들을 SQL 한 번으로 조회할 수 있기 때문에 성능 최적화에 사용된다.
- 일대다 관계에서 페이징 API와 페치 조인을 함께 사용해야 하는 경우 아래 2가지 방법을 사용해야 한다.
- 배치 사이즈 지정 → N+1 문제도 해결 가능
- 팀에서 회원 필드에 @BatchSize(size = 100) 애노테이션을 사용해 지연 로딩 시 가져 올 개수 지정
- 글로벌 페치 전략 사용(hibernate.default_batch_fetch_size=100)
- 페치 조인과 페이징 API 작업 분리 (JPQL 2번 작성)
- 배치 사이즈 지정 → N+1 문제도 해결 가능
// 애노테이션 사용
@Entity
public class Team {
...
@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
///
// 글로벌 페치 전략 사용 - application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
///
String query = "SELECT t FROM Team t";
List<Team> result = em
.createQuery(query, Team.class)
.setFirstResult(0)
.setMaxResults(2)
.getResultList();
- 엔티티에 직접 사용하는 글로벌 로딩 전략보다 우선한다.
- 실무에서 글로벌 로딩 전략은 모두 지연 로딩이다.
- ex. @OneToMany(fetch = FetchType.LAZY)
- 최적화가 필요한 곳(ex. N+1 문제 발생)엔 페치 조인을 적용해 보자.
정리
물론 모든 것을 페치 조인으로 해결할 수는 없다. 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다.
여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 하는 경우엔, 일반 조인을 사용하고 필요한 데이터만 조회해서 DTO로 반환하는 것이 더 효과적이다.
다형성 쿼리
아래와 같이 다형적으로 엔티티를 설계했다고 가정해 보자. 이런 경우 JPA에서 특수한 기능을 제공한다.
a. TYPE
조회 대상을 특정 자식으로 한정할 수 있다.
- ex. Item 중 Book, Movie만 조회 (strategy = SINGLE_TABLE)
# JPQL
SELECT i
FROM Item i
WHERE TYPE(i) IN (Book, Movie)
# SQL
SELECT I.*
FROM ITEM I
WHERE I.DTYPE IN ('B', 'M')
b. TREAT (JPA 2.1)
자바의 타입 캐스팅과 유사한 기능으로, 상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용한다.
- ex. 부모 Item과 자식 Book (strategy = SINGLE_TABLE)
# JQPL
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'
엔티티 직접 사용
a. 기본 키 값
JPQL에서 엔티티를 직접 사용하면, SQL에서 해당 엔티티의 기본 키 값을 사용한다.
# JPQL - 엔티티 아이디 사용
SELECT COUNT(m.id)
FROM Member m
# JPQL - 엔티티 직접 사용
SELECT COUNT(m)
FROM Member M
# SQL -> 엔티티 직접 사용 = 엔티티 아이디 사용
SELECT COUNT(M.ID) AS CNT
FROM MEMBER M
파라미터로 넘겨도 실행되는 SQL은 동일하다.
// 엔티티를 파라미터로 전달
String jpql = "SELECT m FROM Member m WHERE m=:member";
List result List = em
.createQuery(jpql)
.setParameter("member", member)
.getResultList();
// 식별자를 직접 전달
String jpql = "SELECT m FROM Member m WHERE m.id=:memberId";
List result List = em
.createQuery(jpql)
.setParameter("memberId", memberId)
.getResultList();
# SQL
SELECT M.*
FROM MEMBER M
WHERE M.ID=?
b. 외래 키 값
외래 키 값을 사용하는 경우도 비슷하다.
Team team = em.find(Team.class, 1L);
// 엔티티 직접 사용
String q1String = "SELECT m FROM Member m WHERE m.team=:team";
List result List = em
.createQuery(q1String)
.setParameter("team", team)
.getResultList();
// 엔티티 아이디 사용
String q2String = "SELECT m FROM Member m WHERE m.teamId=:teamId";
List result List = em
.createQuery(q2String)
.setParameter("teamId", teamId)
.getResultList();
# SQL
SELECT M.*
FROM MEMBER M
WHERE M.TEAM_ID=?
Named 쿼리
Named 쿼리는 미리 정의해서 이름을 부여해 두고 사용하는 JPQL을 말한다. 정적 쿼리라고도 부르며 애노테이션에 적용하거나 XML에 정의할 수 있다. 애플리케이션 로딩 시점에 초기화한 뒤 재사용(Caching)하며, 이때 쿼리를 검증한다.
- @NamedQuery 애노테이션 사용
- 애플리케이션 로딩 시점에 SQL로 파싱해서 캐싱해 둔다. 따라서 에러를 컴파일 시점에 잡을 수 있다.
@Entity
@NamedQuery(
name = "Member.findByUsername",
query = "select m from Member m where m.username=:username:")
public class Member {
...
}
///
List<Member> resultList = em
.createNamedQuery("Member.findByUsername", Member.class)
.setParameter("username", "회원1")
.getResultList();
- XML에 정의
참고
XML이 항상 우선권을 가지며, 애플리케이션 운영 환경에 따라 다른 XML을 배포할 수 있다.
참고
딱히 위에처럼 작성할 일은 없다. 실무에서는 주로 Spring Data JPA를 사용해 아래와 같이 작성하게 된다. 아래 @Query 애노테이션을 사용해 작성한 쿼리가 NamedQuery로 등록된다.
public interface UserRepository extends JpaRepository<User, Long> {
@Query("select u from User u where u.emailAddress = ?1")
User findByEmailAddress(String emailAddress);
}
벌크 연산
재고가 10개 미만인 모든 상품의 가격을 10% 상승하려면 어떻게 해야 할까? JPA 변경 감지 기능(Dirty Checking)으로 실행하려면 너무 많은 SQL을 실행해야 한다. 만약 변경된 데이터가 100건이라면, 100번의 UPDATE SQL이 실행된다.
- 재고가 10개 미만인 상품을 리스트로 조회한다.
- 상품 엔티티의 가격을 10% 증가한다.
- 트랜잭션 커밋 시점에 변경 감지가 동작한다.
이때, 벌크 연산을 사용하면 쿼리 한 번으로 여러 테이블의 로우(엔티티)를 변경할 수 있다.
- executeUpdate()의 결과는 영향받은 엔티티의 수를 반환한다.
- JPA는 UPDATE와 DELETE를 지원하고, 하이버네이트로 INSERT(insert into ... select)도 사용할 수 있다.
String q1String = "update Product P " +
"set p.price = p.price * 1.1 " +
"where p.stockAmount < :stockAmount";
int resultCount = em
.createQuery(q1String)
.setParameter("stockAmount", 10)
.executeUpdate();
벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리(SQL 실행 → 영속성 컨텍스트 flush)를 날린다. 벌크 연산을 수행하더라도 영속성 컨텍스트의 1차 캐시엔 반영되지 않는다. 따라서 벌크 연산을 사용할 땐 아래 2가지 방법 중 하나를 사용하는 게 좋다.
- 영속성 컨텍스트에 영속화하기 전(1차 캐시가 비어있을 때), 벌크 연산을 먼저 실행한다.
- 벌크 연산을 수행한 후에 바로 영속성 컨텍스트(1차 캐시)를 초기화(clear)한다.