이전글
페치 조인(Fetch Join)
- 페치 조인은 SQL에서 이야기하는 조인의 종류는 아니고, JPQL에서 성능 최적화를 위해 제공하는 기능이다.
- 연관된 엔티티나 컬렉션을 한 번에 같이 조회하는 기능인데, JOIN FETCH 명령어로 사용할 수 있다.
엔티티 페치 조인
SELECT m FROM Member m JOIN FETCH m.team
- 회원(m)과 팀(m.team)을 함께 조회한다.
- 일반적인 JPQL 조인과는 다르게 m.team 다음에 별칭이 없는데, 페치 조인은 별칭을 사용할 수 없다.
SELECT M.*, T.*
FROM MEMBER M
INNER JOIN TEAM T
ON M.TEAM_ID = T.ID
- 엔티티 페치 조인 JPQL에서 SELECT m으로 회원 엔티티만 선택했는데, 실행된 SQL을 보면 SELECT M.*, T.*로 회원과 연관된 팀도 함께 조회된 것을 확인할 수 있다.
- 아래 그림을 보면, 회원과 팀 객체가 객체 그래프를 유지하면서 조회된 것을 확인할 수 있다.
String jpql = "SELECT m FROM Member m JOIN FETCH m.team";
List<Member> members = em.createQuery(jpql, Member.class).getResultList();
for (Member member : members) {
// 페치 조인으로 회원과 팀을 함께 조회했기 때문에 지연 로딩 발생 안함
System.out.print("username = " + member.getUsername());
System.out.println(", teamname = " + member.getTeam().name());
}
- 회원과 팀을 지연 로딩으로 설정했다고 가정해 보자.
- 회원을 조회할 때 페치 조인을 사용해서 팀도 함께 조회했으므로 연관된 팀 엔티티는 프록시가 아닌 실제 엔티티다.
- 따라서, 연관된 팀을 사용해도 지연 로딩이 일어나지 않는다.
- 프록시가 아닌 실제 엔티티이므로 회원 엔티티가 영속성 컨텍스트에서 분리되어 준영속 상태가 되어도 연관된 팀을 조회할 수 있다.
컬렉션 페치 조인
이번에는 일대다 관계인 컬렉션을 페치 조인해 보자.
팀(t)을 조회하면서, 페치 조인을 사용해 연관된 회원 컬렉션(t.members)도 함께 조회한다.
// 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'
- 컬렉션을 페치 조인한 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
- SQL의 DISTINCT는 중복된 결과를 제거하는 명령어다.
- JPQL의 DISTINCT 명령어는 SQL에 DISTINCT를 추가하는 것은 물론이고, 애플리케이션에서 한 번 더 중복을 제거한다.
SELECT DISTINCT t FROM Team t JOIN FETCH t.members
WHERE t.name = '팀A'
- 먼저 DISTINCT를 사용하면 SQL에 SELECT DISTINCT가 추가된다.
- 하지만 지금은 각 로우의 데이터가 다르므로, DISTINCT는 효과가 없다.
- 다음으로 애플리케이션에서 DISTINCT 명령어를 보고 중복된 데이터를 걸러낸다.
- SELECT DISTINCT t의 의미는 팀 엔티티의 중복을 제거하라는 것이다.
- 따라서, 중복인 '팀A'는 다음 그림처럼 하나만 조회된다.
컬렉션 페치 조인 사용 예제에 DISTINCT를 사용하면, 출력 결과는 다음과 같다.
teamname = 팀A, team = Team@0x100
-> username = 회원1, member = Member@0x200
-> username = 회원2, member = Member@0x300
참고)
JPA 구현체로 Hibernate를 사용하는데, 스프링 부트 3 버전부터는 Hibernate 6 버전을 사용하며,
Hibernate 6 버전은 페치 조인 사용 시 자동으로 중복 제거를 하도록 변경되었음!
페치 조인과 일반 조인의 차이
페치 조인을 사용하지 않고, 조인만 사용하면 어떻게 될까?
// 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'
- JPQL에서 회원 컬렉션을 조인했으므로, 회원 컬렉션도 함께 조회할 것으로 기대하면 안 된다.
- 실행된 SQL을 살펴보면, 팀만 조회하고 회원은 전혀 조회하지 않는다.
- JPQL은 결과를 반환할 때, 연관관계까지 고려하지 않는다!! 단지 SELECT 절에 지정한 엔티티만 조회할 뿐이다.
- 따라서 팀 엔티티만 조회하고 연관된 회원 컬렉션은 조회하지 않는다.
- 만약 회원 컬렉션을 지연 로딩으로 설정하면, 프록시나 아직 초기화하지 않은 컬렉션 래퍼를 반환한다.
- 즉시 로딩으로 설정하면 회원 컬렉션을 즉시 로딩하기 위해 쿼리를 한 번 더 실행한다.
- 반면에, 페치 조인을 사용하면 연관된 엔티티도 함께 조회한다.
// 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'
다시 비교비교 해보자~!
페치 조인의 특징과 한계
특징
- 페치 조인을 사용하면 SQL 한 번으로 연관된 에티티들을 함께 조회할 수 있어서, SQL 호출 횟수를 줄여 성능을 최적화할 수 있다.
- 다음처럼 엔티티에 직접 적용하는 로딩 전략은 애플리케이션 전체에 영향을 미치므로 글로벌 로딩 전략이라 부른다.
- @OneToMany(fetch = FetchType.LAZY) // 글로벌 로딩 전략
- 페치 조인은 글로벌 로딩 전략보다 우선한다!
- 글로벌 로딩 전략을 지연 로딩으로 설정해도 JPQL에서 페치 조인을 사용하면, 페치 조인을 적용해서 함께 조회한다.
최적화를 위해 글로벌 로딩 전략을 즉시 로딩으로 설정하면, 애플리케이션 전체에서 항상 즉시 로딩이 일어난다. 물론 일부는 빠를 수 있지만 전체로 보면 사용하지 않는 엔티티를 자주 로딩하므로 오히려 성능에 악영향을 미칠 수 있다. 따라서, 글로벌 로딩 전략은 될 수 있으면 지연 로딩을 사용하고 최적화가 필요하면 페치 조인을 적용하는 것이 효과적이다.
페치 조인을 사용하면 연관된 엔티티를 쿼리 시점에 조회하므로, 지연 로딩이 발생하지 않고 준영속 상태에서도 객체 그래프를 탐색할 수 있다.
한계
페치 조인 대상에는 별칭을 줄 수 없다.
- 별칭을 줄 수 없기 때문에 SELECT 절, WHERE 절, 서브 쿼리에 페치 조인 대상을 사용할 수 없다.
- JPA 표준에서는 지원하지 않지만 하이버네이트를 포함한 몇몇 구현체들은 페치 조인에 별칭을 지원한다.
- 하지만, 별칭을 잘못 사용하면 연관된 데이터 수가 달라져서 데이터 무결성이 깨질 수 있다.
- 연관된 데이터 수가 달라진 상태에서 2차 캐시에 저장되면, 다른 곳에서 조회할 때도 연관된 데이터 수가 달라지는 문제가 발생할 수 있다.
둘 이상의 컬렉션을 페치 할 수 없다.
- 구현체에 따라서 되기도 하는데, 컬렉션 * 컬렉션의 카테시안 곱이 만들어지므로 주의해야한다.
- 하이버네이트에서는 예외가 발생한다.
컬렉션을 페치 조인하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다.
- 컬렉션(일대다)이 아닌 단일 값 연관 필드(일대일, 다대일)들은 페치 조인을 사용해도 페이징 API를 사용할 수 있다.
- 하이버네이트에서 컬렉션을 페치 조인하고 페이징 API를 사용하면 경고 로그를 남기면서 메모리에서 페이징 처리를 한다. 데이터가 많으면 성능 이슈와 메모리 초과 예외가 발생할 수 있어서 위험하다.
정리
- 페치 조인은 SQL 한 번으로 연관된 여러 엔티티를 조회할 수 있어서 성능 최적화에 상당히 유용
- 실무에서 자주 사용
- 하지만 모든 것을 페치 조인으로 해결할 수 없음!
- 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적...
- 반면 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 한다면 억지로 페치 조인을 사용하기보다는 여러 테이블에서 필요한 필드들만 조회해서 DTO로 반환하는 것이 더 효과적일 수 있음
경로 표현식(Path Expression)
- 경로 표현식이라는 것은 쉽게 이야기해서 .을 찍어서 객체 그래프를 탐색하는 것이다.
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이 모두 경로 표현식을 사용한 예다.
경로 표현식의 용어정리
- 상태 필드(state field): 단순히 값을 저장하기 위한 필드(필드 or 프로퍼티)
- 연관 필드(association field): 연관관계를 위한 필드, 임베디드 타입 포함(필드 or 프로퍼티)
- 단일 값 연관 필드: @ManyToOne, @OneToOne 대상이 엔티티
- 컬렉션 값 연관 필드: @OneToMany, @ManyToMany 대상이 컬렉션
- 상태 필드는 단순히 값을 저장하는 필드이고, 연관 필드는 객체 사이의 연관관계를 맺기 위해 사용하는 필드다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Column(name = "name")
private String username; // 상태 필드
private Integer age; // 상태 필드
@ManyToOne(..)
private Team team; // 연관 필드(단일 값 연관 필드)
@OneToMany(..)
private List<Order> orders; // 연관 필드(컬렉션 값 연관 필드)
}
경로 표현식과 특징
JPQL에서 경로 표현식을 사용해서 경로 탐색을 하려면 다음 3가지 경로에 따라서 어떤 특징이 있는지 이해해야 한다.
- 상태 필드 경로: 경로 탐색의 끝이다. 더는 탐색할 수 없다.
- 단일 값 연관 경로: 묵시적으로 내부 조인이 일어난다. 단일 값 연관 경로는 계속 탐색할 수 있다.
- 컬렉션 값 연관 경로: 묵시적으로 내부 조인이 일어난다. 더는 탐색할 수 없다. 단, FROM 절에서 조인을 통해 별칭을 얻으면 별칭으로 탐색할 수 있다.
상태 필드 경로 탐색
다음 JPQL의 m.username, m.age는 상태 필드 경로 탐색이다.
SELECT m.username, m.age FROM Member
이 JPQL을 실행한 결과 SQL은 다음과 같다.
SELECT m.NAME, m.AGE FROM MEMBER m
단일 값 연관 경로 탐색
다음 JPQL을 보자.
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.member를 통해 주문에서 회원으로 단일 값 연관 필드로 경로 탐색을 했다.
단일 값 연관 필드로 경로 탐색을 하면 SQL에서 내부 조인이 일어나는데, 이를 묵시적 조인이라고 한다.
참고로 묵시적 조인은 모두 내부 조인이다.
외부 조인은 명시적으로 JOIN 키워드를 사용해야 한다.
- 명시적 조인: JOIN을 직접 적어주는 것
- ex) SELECT m FROM Member m JOIN m.team t
- 묵시적 조인: 경로 표현식에 의해 묵시적으로 조인이 일어나는 것, 내부 조인만 할 수 있다.
- ex) SELECT m.team FROM Member m
이번에는 복잡한 예제를 보자.
// JPQL
SELECT o.member.team
FROM Order o
WHERE o.product.name = 'productA'
AND o.address.city = 'JINJU'
주문 중에서 상품명이 'productA'고, 배송지가 'JINJU'인 회원이 소속된 팀을 조회한다.
실제로 내부 조인이 몇 번 일어날지 생각해 보자.
// 실행된 SQL
SELECT t.*
FROM ORDERS 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.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 t.members.size FROM Team t
경로 탐색을 사용한 묵시적 조인 시 주의사항
경로 탐색을 사용하면 묵시적 조인이 발생해서 SQL에서 내부 조인이 일어날 수 있다. 이때 주의사항은 다음과 같다.
- 항상 내부 조인이다.
- 컬렉션은 경로 탐색의 끝이다. 컬렉션에서 경로 탐색을 하려면 명시적으로 조인해서 별칭을 얻어야 한다.
- 경로 탐색은 주로 SELECT, WHERE 절(다른 곳에서도 사용됨)에서 사용하지만 묵시적 조인으로 인해 SQL의 FROM 절에 영향을 준다.
조인이 성능상 차지하는 부분은 아주 크다. 묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어렵다는 단점이 있다. 따라서, 성능이 중요하면 분석하기 쉽도록 묵시적 조인보다는 명시적 조인을 사용하자.
서브 쿼리
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 기능을 사용해도 같은 결과를 얻을 수 있다.
- 실행되는 SQL도 같다.
SELECT m FROM Member m
WHERE m.orders.size > 0
서브 쿼리 함수
[NOT] EXISTS (subquery)
- 서브 쿼리에 결과가 존재하면 참이다. NOT은 반대
ex) 팀 A 소속인 회원
SELECT m FROM Member m
WHERE EXISTS (SELECT t FROM m.team t WHERE t.name = '팀A')
{ ALL | ANY | SOME } (subquery)
- 비교 연산자와 같이 사용한다.
- ALL: 조건을 모두 만족하면 참이다.
- ANY 혹은 SOME: 둘은 같은 의미다. 조건을 하나라도 만족하면 참이다.
ex) 전체 상품 각각의 재고보다 주문량이 많은 주문들
SELECT o FROM Order o
WHERE o.orderAmount > ALL (SELECT p.stockAmount FROM Product p)
ex) 어떤 팀이든 팀에 소속된 회원
SELECT m FROM Member m
WHERE m.team = ANY (SELECT t FROM Team t)
[NOT] IN (subquery)
- 서브 쿼리의 결과 중 하나라도 같은 것이 있으면 참이다.
- 참고로 IN은 서브 쿼리가 아닌 곳에서도 사용한다.
ex) 20세 이상을 보유한 팀
SELECT t FROM Team t
WHERE t IN (SELECT t2 FROM Team t2 JOIN t2.members m2 WHERE m2.age >= 20)
조건식
타입 표현
JPQL에서 사용하는 타입은 다음과 같으며, 대소문자는 구분하지 않는다.
종류 | 설명 | 예제 |
문자 | 작은 따옴표 사이에 표현 작은 따옴표를 표현하고 싶으면 연속 두개 사용 '' |
'HELLO' 'She''s' |
숫자 | L(Long 타입 지정) D(Double 타입 지정) F(Float 타입 지정) |
10L 10D 10F |
날짜 | DATE {d 'yyyy-mm-dd'} TIME {t 'hh-mm-ss'} DATETIME {ts 'yyyy-mm-dd hh:mm:ss.f'} |
{d '2012-03-24'} {t '10-11-11'} {ts '2012-03-24 10-11-11.123'} m.createDate = {d '2012-03-24'} |
Boolean | TRUE, FALSE | |
Enum | 패키지명을 포함한 전체 이름을 사용해야 한다. | jpabook.MemberType.Admin |
엔티티 타입 | 엔티티의 타입을 표현한다. 주로 상속과 관련해서 사용한다. | TYPE(m) = Member |
연산자 우선순위
- 경로 탐색 연산 (.)
- 수학 연산: +, -(단항 연산자), *, /, +, -
- 비교 연산: =, >, >=, <, <=, <>, [NOT] BETWEEN, [NOT] LIKE, [NOT] IN, IS [NOT] NULL, IS [NOT] EMPTY, [NOT] MEMBER [OF], [NOT] EXISTS
- 논리 연산: NOT, AND, OR
논리 연산과 비교식
- 논리 연산
- AND: 둘 다 만족하면 참
- OR: 둘 중 하나만 만족해도 참
- NOT: 조건식의 결과 반대
- 비교식
- =, >, >=, <, <=, <>
BETWEEN, IN, LIKE, NULL 비교
BETWEEN
- X [NOT] BETWEEN A AND B
ex) 나이가 10 ~ 20인 회원
SELECT m FROM Member m
BETWEEN m.age BETWEEN 10 AND 20
IN
- X [NOT] IN (예제)
ex) 이름이 회원1이나 회원2인 회원
SELECT m FROM Member m
WHERE m.username IN ('회원1', '회원2')
LIKE
- 문자표현식 [NOT] LIKE 패턴값 [ESCAPE 이스케이프문자]
- 문자표현식과 패턴값을 비교한다.
- %: 아무 값들이 입력되어도 된다(값이 없어도 됨).
- _: 한 글자는 아무 값이 입력되어도 되지만 값이 있어야 한다.
// 중간에 원이라는 단어가 들어간 회원(ex. 좋은회원, 회원, 원)
SELECT m FROM Member WHERE m.username LIKE '%원%'
// 처음에 회원이라는 단어가 포함(ex. 회원1, 회원ABC)
SELECT m FROM Member WHERE m.username LIKE '회원%'
// 마지막에 회원이라는 단어가 포함(좋은 회원, A회원)
SELECT m FROM Member WHERE m.username LIKE '%회원'
// 회원A, 회원_
SELECT m FROM Member WHERE m.username LIKE '회원_'
// 회원3
SELECT m FROM Member WHERE m.username LIKE '__3'
// 회원%
SELECT m FROM Member WHERE m.username LIKE '회원\%' ESCAPE '\'
NULL
- {단일값 경로 | 입력 파라미터} IS [NOT] NULL
- NULL은 =으로 비교하면 안 되고, 꼭 IS NULL을 사용해야 한다.
WHERE m.username IS NULL
WHERE NULL = NULL // 거짓
WHERE 1=1 // 참
컬렉션 식
컬렉션에만 사용하는 특별한 기능으로, 컬렉션은 컬렉션 식 이외에 다른 식은 사용할 수 없다.
빈 컬렉션 비교 식
- {컬렉션 값 연관 경로} IS [NOT] EMPTY
- 컬렉션에 값이 비었으면 참
// JPQL: 주문이 하나라도 있는 회원
SELECT m FROM Member m
WHERE m.orders IS NOT EMPTY
// 실행된 SQL
SELECT m.* FROM MEMBER m
WHERE EXISTS(
SELECT o.ID
FROM ORDERS o
WHERE m.ID = o.MEMBER_ID
)
컬렉션은 컬렉션 식만 사용할 수 있다는 점에 주의하자. 다음의 IS NULL처럼 컬렉션 식이 아닌 것은 사용할 수 없다.
SELECT m FROM Member m
WHERE m.orders IS NULL // 오류
컬렉션의 멤버 식
- {엔티티나 값} [NOT] MEMBER [OF] {컬렉션 값 연관 경로}
- 엔티티나 값이 컬렉션에 포함되어 있으면 참
SELECT t FROM Team t
WHERE :memberParam MEMBER OF t.members
스칼라 식
스칼라는 숫자, 문자, 날짜, CASE, 엔티티 타입(엔티티의 타입 정보) 같은 가장 기본적인 타입들을 말한다.
수학 식
- +, -: 단항 연산자
- *, /, +, -: 사칙 연산
문자함수
함수 | 설명 | 예제 |
CONCAT(문자1, 문자2, ...) | 문자를 합한다. | CONCAT('A', 'B') = AB |
SUBSTRING(문자, 위치, [길이]) | 위치부터 시작해 길이만큼 문자를 구한다. 길이 값이 없으면 나머지 전체 길이를 뜻한다. | SUBSTRING('ABCDEF', 2,3) = BCD |
TRIM([ [LEADING | TRAILING | BOTH] [트림 문자] FROM ] 문자) |
LEADING: 왼쪽만 TRAILING: 오른쪽만 BOTH: 양쪽다 트림 문자를 제거한다. 기본값은 BOTH, 트림 문자의 기본값은 공백이다. |
TRIM(' ABC ') = ABC |
LOWER(문자) | 소문자로 변경 | LOWER('ABC') = abc |
UPPER(문자) | 대문자로 변경 | UPPER('abc') = ABC |
LENGTH(문자) | 문자 길이 | LENGTH('ABC') = 3 |
LOCATE(찾을 문자, 원본 문자, [검색 위치 시작]) | 검색 위치부터 문자를 검색한다. 1부터 시작하고 못 찾으면 0 반환 | LOCATE('DE', 'ABCDEFG') = 4 |
수학함수
함수 | 설명 | 예제 |
ABS(수학식) | 절대값을 구한다. | ABS(-10) = 10 |
SQRT(수학식) | 제급근을 구한다. | SQRT(4) = 2.0 |
MOD(수학식, 나눌 수) | 나머지를 구한다. | MOD(4, 3) = 1 |
SIZE(컬렉션 값 연관 경로식) | 컬렉션의 크기를 구한다. | SIZE(t.members) |
INDEX(별칭) | LIST 타입 컬렉션의 위치값을 구함. 단 컬렉션이 @OrderColumn을 사용하는 LIST 타입일 때만 사용할 수 있다. | t.members m WHERE INDEX(m) > 3 |
날짜함수
날짜 함수는 데이터베이스의 현재 시간을 조회한다.
- CURRENT_DATE: 현재 날짜
- CURRENT_TIME: 현재 시간
- CURRENT_TIMESTAMP: 현재 날짜 시간
SELECT CURRENT_DATE, CURRENT_TIME, CURRENT_TIMESTAMP FROM Team t
// 결과: 2013-08-19, 23:38:17, 2013-08-19 23:38:17.736
// 종료 이벤트 조회
SELECT e FROM Event e
WHERE e.endDate > CURRENT_DATE
하이버네이트는 날짜 타입에서 년, 월, 일, 시간, 분, 초 값을 구하는 기능을 지원한다.
YEAR, MONTH, DAY, HOUR, MINUTE, SECOND
SELECT YEAR(CURRENT_TIMESTAMP), MONTH(CURRENT_TIMESTAMP), DAY(CURRENT_TIMESTAMP)
WHERE Member
- 데이터베이스들은 각각의 방식으로 더 많은 날짜 함수를 지원한다. 그리고 각각의 날짜 함수는 하이버네이트가 제공하는 방언에 등록되어 있다. 예를 들어, 오라클 방언을 사용하면 to_date, to_char 함수를 사용할 수 있다.
- 물론 다른 데이터베이스를 사용하면 동작하지 않는다.
CASE 식
특정 조건에 따라 분기할 때 CASE 식을 사용한다.
- 기본 CASE
- 심플 CASE
- COALESCE
- NULLIF
기본 CASE
CASE
{WHEN <조건식> THEN <스칼라식>}+
ELSE <스칼라식>
END
SELECT
CASE WHEN m.age <= 10 THEN '학생요금'
WHEN m.age >= 60 THEN '경로요금'
ELSE '일반요금'
END
FROM Member m
심플 CASE
- 심플 CASE는 조건식을 사용할 수는 없지만, 문법이 단순하다. 자바의 switch case 문과 비슷하다.
CASE <조건대상>
{WHEN <ㅡ칼라식1> THEN <스칼라식2>}+
ELSE <스칼라식>
END
SELECT
CASE t.name
WHEN '팀A' THEN '인센티브110%'
WHEN '팀B' THEN '인센티브120%'
ELSE '인센티브105%'
END
FROM Team t
COALESCE
- COALESCE (<스칼라식> {, <스칼라식>}+)
- 스칼라식을 차례대로 조회해서 null이 아니면 반환한다.
ex) m.username이 null이면 '이름 없는 회원'을 반환
SELECT COALESCE(m.username, '이름 없는 회원') FROM Member m
NULLIF
- NULLIF (<스칼라식>, <스칼라식>)
- 두 값이 같으면 null을 반환하고, 다르면 첫 번째 값을 반환한다.
- 집합 함수는 null을 포함하지 않으므로, 보통 집합 함수오 함께 사용한다.
ex) 사용자 이름이 '관리자'면 null을 반환하고, 나머지는 본인의 이름을 반환
SELECT NULLIF(m.username, '관리자') FROM Member m
다형성 쿼리
JPQL로 부모 엔티티를 조회하면, 그 자식 엔티티도 함께 조회한다.
아래의 예제에서 Item의 자식으로 Book, Album, Movie가 있다.
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
public abstract class Item {...}
@Entity
@DiscriminatorValue("B")
@Getter @Setter
public class Book extends Item {
private String author;
private String isbn;
}
// Album, Movie 생략
다음과 같이 조회하면 Item의 자식도 함께 조회한다.
List resultList = em.createQuery("SELECT i FROM Item i").getResultList();
단일 테이블 전략을 사용할 때 실행되는 SQL은 다음과 같다.
// SQL
SELECT * FROM ITEM
조인 전략을 사용할 때 실행되는 SQL은 다음과 같다.
// 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
FROM 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은 엔티티의 상속 구조에서 조회 대상을 특정 자식 타입으로 한정할 때 주로 사용한다.
ex) 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 2.1에 추가된 기능인데, 자바의 타입 캐스팅과 비슷하다.
- 상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용한다.
- JPA 표준은 FROM, WHERE 절에서 사용할 수 있지만, 하이버네이트는 SELECT 절에서도 TREAT를 사용할 수 있다.
ex) 부모인 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'
- JPQL을 보면 TREAT를 사용해서 부모 타입인 Item을 자식 타입인 Book으로 다룬다.
- 따라서 author 필드에 접근할 수 있다.
사용자 정의 함수 호출(JPA 2.1)
- JPA 2.1부터 사용자 정의 함수를 지원한다.
- function_invocation::= FUNCTION(function_name {, function_arg}*)
- ex) SELECT FUNCTION('group_concat', i.name) FROM Item i
- 하이버네이트 구현체를 사용하면 다음과 같이 방언 클래스를 상속해서 구현하고, 사용할 데이터베이스 함수를 미리 등록해야 한다.
public class MyH2Dialect extends H2Dialect {
public MyH2Dialect() {
registerFunction("group_concat",
new StandardSQLFunction("group_concat", StandardBasicTypes.STRING))
}
}
- 다음과 같이 hibernate.dialect에 해당 방언을 등록해야 한다.
- persistence.xml
<property name="hibernate.dialect" value="com.example.MyH2Dialect"/>
- 하이버네이트 구현체를 사용하면 다음과 같이 축약해서 사용할 수 있다.
- ex) SELECT group_concat(i.name) FROM Item i
기타 정리
- enum은 = 비교 연산만 지원한다.
- 임베디드 타입은 비교를 지원하지 않는다.
EMPTY STRING
JPA 표준은 ''을 길이 0인 EMPTY STRING으로 정했지만, 데이터베이스에 따라 ''를 NULL로 사용하는 경우도 있으므로 확인하고 사용해야 한다.
NULL 정의
- 조건을 만족하는 데이터가 하나도 없으면 NULL이다.
- NULL은 알 수 없는 값(unknown value)이다. NULL과의 모든 수학적 계산 결과는 NULL이 된다.
- NULL == NULL은 알 수 없는 값이다.
- NULL IS NULL은 참이다.
JPA 표준 명세는 NULL 값과 TRUE, FALSE의 논리 계산을 다음과 같이 정의했다.
AND 연산
AND | TRUE | FALSE | NULL |
TRUE | TRUE | FALSE | NULL |
FALSE | FALSE | FALSE | FALSE |
NULL | NULL | FALSE | NULL |
OR 연산
OR | TRUE | FALSE | NULL |
TRUE | TRUE | TRUE | TRUE |
FALSE | TRUE | FALSE | NULL |
NULL | TRUE | NULL | NULL |
NOT 연산
NOT | |
TRUE | FALSE |
FALSE | TRUE |
NULL | NULL |
엔티티 직접 사용
기본 키 값
- 객체 인스턴스는 참조 값으로 식별하고, 테이블 row는 기본 키 값으로 식별한다.
- 따라서, JPQL에서 엔티티 객체를 직접 사용하면 SQL에서는 해당 엔티티의 기본 키 값을 사용한다.
// JPQL
SELECT COUNT(m.id) FROM Member m // 엔티티의 ID를 직접 사용
SELECT COUNT(m) FROM Member m // 엔티티를 직접 사용
- 두 번째의 COUNT(m)을 보면 엔티티의 별칭을 직접 넘겨주었다.
- 이렇게 엔티티를 직접 사용하면 JPQL이 SQL로 변환될 때 해당 엔티티의 기본 키를 사용한다.
- 따라서, 실제 실행된 SQL은 둘 다 같다.
SELECT COUNT(m.ID) AS cnt FROM MEMBER m
- JPQL의 COUNT(m)이 SQL에서 COUNT(m.ID)로 변환된 것을 확인할 수 있다.
- 엔티티를 파라미터로 직접 받는 경우는 어떨까?
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에서 member = :member로 엔티티를 직접 사용하는 부분이 SQL에서 WHERE m.ID = ?로 기본 키 값을 사용하도록 변환된 것을 확인할 수 있다.
- 물론 아래와 같이 식별자 값을 직접 사용해도 결과는 같다.
String qlString = "SELECT m FROM Member m WHERE m.id = :memberId";
List resultList = em.createQuery(qlString)
.setParameter("memberId", 4L)
.getResultList();
외래 키 값
- 이번에는 외래 키를 사용하는 예를 보자!
- 다음 예제는 특정 팀에 소속된 회원을 찾는다.
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 = ? (팀 파라미터의 ID 값)
- 엔티티 대신 식별자 값을 직접 사용할 수도 있다.
Team team = em.find(Team.class, 1L);
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은 같다.
Named 쿼리: 정적 쿼리
JPQL은 크게 동적 쿼리와 정적 쿼리로 나눌 수 있다.
- 동적 쿼리
- 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 {...}
List<Member> resultList = em.createQuery("Member.findByUsername", Member.class)
.setParameter("username", "회원1")
.getResultList();
참고)
Named 쿼리는 영속성 유닛 단위로 관리되므로 충돌을 방지하기 위해서 findByUsername이 아닌 Member.findByUsername과 같이 엔티티 이름을 앞에 주었다. 이렇게 하면 관리도 쉽다!
- 하나의 엔티티에 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 {...}
다음은 @NamedQuery 애노테이션이다. 간단히 알아보자!
@Target({ElementType.TYPE})
public @interface NamedQuery {
String name(); // Named 쿼리 이름 (필수)
String query(); // JPQL 정의 (필수)
LockModeType lockMode() default LockModeType.NONE; // 쿼리 실행 시 락 모드를 설정할 수 있다.
QueryHint[] hints() default {}; // JPA 구현체에 쿼리 힌트를 줄 수 있다.
}
- lockMode: 쿼리 실행 시 락을 건다.
- hints: JPA 구현체에게 제공하는 힌트로, 예를 들면 2차 캐시를 다룰 때 사용한다.
Named 쿼리를 XML에 정의
- JPA에서 애노테이션으로 작성할 수 있는 것은 XML로도 작성할 수 있다.
- 물론 애노테이션을 사용하는 것이 직관적이고 편리하다.
- 하지만 Named 쿼리를 작성할 때는 XML을 사용하는 것이 더 편리하다.
- 자바 언어로 멀티라인 문자를 다루는 것은 상당히 귀찮은 일이다.
- 자바에서 이런 불편함을 해결하려면 XML을 사용하는 것이 그나마 현실적인 대안이다.
META-INF/ormMember.xml에 정의한 Named 쿼리
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence/orm
http://xmlns.jcp.org/xml/ns/persistence/orm_2_2.xsd"
version="2.2">
<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>
<![CDATA[SELECT COUNT(m) FROM Member m]]>
</query>
</named-query>
</entity-mappings>
- <![CDATA[ ]]>를 사용하면 XML 파서가 SQL 쿼리를 잘못 해석하는 것을 방지할 수 있다.
- 정의한 ormMember.xml을 인식하도록 META-INF/persistence.xml에 다음 코드를 추가해야 한다.
<persistence-unit name="jpabook">
<mapping-file>META-INF/ormMember.xml</mapping-file>
...
</persistence-unit>
- META-INF/orm.xml은 JPA가 기본 매핑파일로 인식해서 별도의 설정을 하지 않아도 되지만, 이름이나 위치가 다르면 설정을 추가해야 한다.
환경에 따른 설정
- 만약 XML과 애노테이션에 같은 설정이 있으면 XML이 우선권을 가진다.
- 따라서 애플리케이션이 운영 환경에 따라 다른 쿼리를 실행해야 한다면, 각 환경에 맞춘 XML을 준비해 두고 XML만 변경해서 배포하면 된다.