JPQL 중급

dev_314·2023년 3월 27일
0

JPA - Trial and Error

목록 보기
10/16

참고
JPQL 기초

경로 표현식

.을 찍어 객체 그래프를 탐색하는 것

경로 표현식 종류

세 가지 경로 표현식이 존재함

SELECT m.username // 상태 필드
FROM Member AS m
JOIN m.team AS t // 단일 값 연관 필드
JOIN m.orders AS o // 컬렉션 값 연관 필드
WHERE t.nmae = '팀A';

상태 필드

단순히 값을 저장하기 위한 필드

단일 값 연관 필드

  • @ManyToOne, @OneToOne
  • 대상이 엔티티인 경우

컬렉션 값 연관 필드

  • @OneToMany, @ManyToMany
  • 대상이 컬렉션인 경우
필드특징
상태 필드경로 탐색의 끝, 추가 탐색 X
단일 값 연관 필드묵시적 Inner Join 발생, 추가 탐색 O
컬렉션 값 연관 필드묵시적 Inner Join 발생, 추가 탐색 X

묵시적(Implicit) Inner Join

jpql은 그냥 select m.team처럼 특정 컬럼을 불러오듯 사용하는데, 이를
이를 sql로 변환하는 과정에서 필수적으로 join이 발생한다.

즉, 묵시적인 내부 조인이 발생한다.

  1. 묵시적 조인은 INNER JOIN만 가능하다. 그래서 묵시적 내부 조인이라고 부른다
  2. 묵시적 내부 조인은 쿼리 추적이 어렵다. 즉, 쿼리 튜닝이 어렵다.

따라서 묵시적 내부 조인을 사용하지 말고, 최대한 jqpl과 sql이 일치하도록 jqpl을 짜야 한다.

명시적(Explicit) Join

m.team.name // 가능
t.members.username // 컬렉션 값 연관 필드이므로 불가능
t.members.size // 컬렉션 값 연관 필드는 이 정도만 가능

컬렉션 값 연관 필드를 더 잘 사용하기 위해선 명시적 조인 쿼리를 짜야한다.

SELECT m.username
FROM Team AS t
JOIN t.members AS m

명시적 조인을 하면 Alias를 지정할 수 있고, Alias를 통해 컬렉션 탐색이 가능해진다.

결론

SELECT o.member.team
FROM Order AS o
// 묵시적 Join이 2번 일어남
// 비권장

SELECT t.members 
FROM Team AS t
// 묵시적 Join이 1번 일어남
// 컬렉션 값 연관 경로이므로, 추가 탐색 불가능
// 비권장

SELECT t.members.username
FROM Team AS t
// 묵시적 Join이 1번 일어남
// 컬렉션 값 연관 경로이므로, 추가 탐색 불가능
// 실행 불가능한 jqpl

SELECT m.username
FROM Team AS t JOIN t.members AS m
// 명시적 Join을 사용
// 명시적 Join을 사용했으므로 컬렉션에서 추가 탐색 가능
// 권장
  1. Implicit Inner Join은 사용하지 말자. 쿼리를 추적하기 어렵다.
  2. 대신 Explicit Join을 사용하자.

Fetch Join

성능 최적화를 위해 JQPL이 제공하는 기능 (SQL 기능 아님)

연관된 엔티티나 컬렉션을 JPQL한 번으로 조회할 수 있게 한다.

// 멤버를 조회할 때 팀도 같이 조회하고 싶다.
SELECT m
FROM Member AS m JOIN FETCH m.team;

// 위 JPQL에 대해 다음의 SQL이 발생함
SELECT m.*, t.*
FROM Member AS m INNER JOIN Team AS t ON m.tid = t.id;

따지고 보면 Eager Loading이랑 다를 바가 없다.

fetch = FetchType.LAZY으로 연관관계를 설정했어도, JQPL에 FETCH JOIN이 있으면 Eager Loading이 작동한다.

Lazy Loading을 적용한 상황에서, 프록시 Entity에 접근하면 조회 쿼리가 발생하게 된다. 결국 Lazy Loading을 적용했음에도, 비즈니스 로직에 따라 N+1 Problem이 발생할 수도 있다.

그렇기에, 기본적으로 Lazy Loading을 적용하고, 특별히 다른 Entity에 접근할 필요가 있는 로직은 Fetch Join을 사용하여 N+1 Problem을 피하려는 것이다.

일대다 Collection Fetch Join

다음과 같이 데이터가 있다고 하자.
Team 테이블

idname
1A
2B

Member 테이블

idnametid
1m11
2m21
3m32

그런 상황에서 Collection에 대해 Fetch Join을 수행하보자.

// 팀과, 그 팀에 소속된 멤버들을 조회
SELECT t
FROM Team AS t JOIN FETCH t.members
WHERE name = 'A'

// 다음과 같은 SQL이 발생한다.
SELECT t.*, m.*
FROM Team AS t INNER JOIN Member AS m ON t.id = m.tid
WHERE name = 'A'

SQL 쿼리 결과는 다음과 같을 것이다.

idnameid(member)nametid
1A1m11
1A2m21

개발자는 아마 다음과 같은 데이터를 원할 것이다. (개념적으로)

[
	Team{
    	id:1, 
        name:'A', 
        members: [{...}, {...}]
	}
]

그런데 실제로는 다음과 같은 결과가 나온다.

[
	Team{
    	id:1, 
        name:'A', 
        members: [{...}, {...}]
	},
	Team{
    	id:1, 
        name:'A', 
        members: [{...}, {...}]
	}
]

결과 record 개수만큼 중복된 데이터가 발생했다.
JPA 스펙상, 컬렉션은 레코드 개수만큼 돌려주도록 설계되어 있기 때문이다. (SQL의 결과 레코드 개수 만큼 데이터를 돌려줌)

DISTINCT로 해결하기

JPQL의 DISTINCT로 중복된 결과를 제거할 수 있다.
DISTINCT은 두 가지 작업을 수행한다.

1. SQL에 DISTINCT 삽입
2. JPA 수준에서, 같은 식별자를 가진 중복 엔티티 제거
// JPQL DISTINCT
SELECT DISTINCT t
FROM Team AS t JOIN FETCH t.members
WHERE t.name = 'A'

이를 통해 원래 의도했던 결과를 얻을 수 있다.

[
	Team{
    	id:1, 
        name:'A', 
        members: [{...}, {...}]
	}
]

일반 Join과의 차이점

SELECT t
FROM Team AS t JOIN t.members AS m
WHERE t.name = '팀A'

// SQL
SELECT T.*
FROM Team AS t
INNER JOIN Member AS M ON m.tid = t.id
WHERE t.name = '팀A'

Fetch 없이 컬렉션 조회를 하면, Join을 하긴 하는데 Project이 이뤄지지는 않는다.

즉, JPQL은 결과를 반환할 때 연관관계를 고려하지 않고, 단순히 SELECT절에 지정한 Project대상만 고려한다.

Fetch Join의 한계

1. Fetch Join 대상에는 Alias를 붙일 수 없다. (붙이지 말라)

기본적으로 JPA는 Fetch Join 대상에는 Alias를 붙일 수 없다. (하이버네이트는 가능)

SELECT t
FROM Team AS t JOIN FETCH t.members AS m
WHERE m.age > 10

원래 컬렉션의 결과는 10개인데, Alias를 사용해서 조건문을 사용하니 3개만 조회됨. CASCADE, Orphan등이 걸린 상황에서, 이러한 결과를 다루면 예상치 못한 동작이 발생할 수도 있다. (JPA의 철학이 아님)

그러므로 최대한 사용하지 말자

2. 둘 이상의 컬렉션은 Fetch Join 불가능

3. 컬렉션을 Fetch Join하면 페이징 API를 사용할 수 없다.

페이징 쿼리는 DB 레벨에서 수행하는 작업이고, Fetch Join은 JPA 레벨에서 수행하는 작업이다. 이 과정에서 데이터 불일치 문제가 발생할 수 있다.

하이버네이트는 지원하기는 하는데, 매우 비효율적이다. (모든 데이터를 메모리로 불러와서 처리)

그러므로 최대한 사용하지 말자.

참고

여러 테이블을 Join해서, 결과가 Entity와 달라진다면, 결과를 위한 DTO를 만들어 사용하는 것이 좋다.

다형성 쿼리

조회 대상을 특정 자식으로 한정할 수 있다.

type

SELECT i
FROM Item AS i
WHERE type(i) IN (Book, Movie);

// SQL

SELECT i
FROM i
WHERE i.DTYPE IN ('B', 'M');

treat

일종의 DownCasting

// Book Entity에만 있는 author필드를 사용할 수 있게 됨
SELECT i
FROM Item AS i
WHERE treat(i as Book).author = 'kim'

// SQL
SELECT i.*
FROM Item AS i
WHERE i.DTYPE= 'B' AND i.author = 'kim'
// 구현 전략에 따라 쿼리에 차이가 있음

Entity 직접 사용

JPQL은 다음과 같이 Entity를 직접 사용할 수 있다.

// Entity 직접 사용
SELECT count(m) FROM Member AS m
// id 사용
SELECT count(m.id) FROM Member AS m
// 동일한 SQL이 발생
SELECT count(m.id) AS cnt FROM Meber as m

// PK
SELECT m FROM Member AS m WHERE m = :member
SELECT m FROM Member AS m WHERE m.id = :memberId

// FK
SELECT m FROM Member AS m WHERE m.team = :team;
SELECT m FROM Member AS m WHERE m.team.id = :teamId;

JPQL은 엔티티를 직접 사용하면 해당 엔티티의 PK를 사용한다.

Named Query

Entity에 미리 쿼리를 만들어 놓을 수 있다.

@Entity
@NamedQuery(
	name = "Member.findByUsername"
    query = "SELECT M From Member AS m WHERE m.username = :username"
)
public class Member {
	...
}

다음과 같이 사용한다.

em.createNamedQuery("Member.findByUsername", Member.class)
	.setParameter("username", ...)

다음과 같은 특징이 있다.

  1. 컴파일 타임에 쿼리 문법을 검증해준다.
  2. SQL로 파싱된 결과를 캐싱해 놓는다.
  3. 재사용 가능
  4. XML로 따로 정의해 놓을 수 있다. 운영 환경마다 다른 쿼리를 사용할 수 있다.
  5. Spring Data JPA의 @Query로 개선할 수 있다.

Bulk 연산

JPA dirty checking만으로 대량 데이터 연산을 감당하기 힘들다.

JPA도 bulk 연산을 지원한다.

String query = "UPDATE Product AS p " +
				"SET p.price = p.price * 1.1 " +
                " WHERE p.stockAmount < :stockAmount"
int resultCount = em.createQuery(query)
					.setParameter("stockAmount", 10)
                    .executeUpdate();

하이버네이트는 insert into select를 지원함 (표준 X)

Bulk 연산은 Persistence Context를 무시하고, DB에 직접 쿼리를 날린다.

Member member = new Member();
member.setAge(20);

em.createQuery(age를 21로 변경하는 쿼리).executeUpdate();

// bulk 연산은 영속성 컨텍스트에 반영되지 않는다.
sout(member.getAge()); // 20

DB에 저장된 실제 값과, 영속성 컨텍스트에 저장된 값이 불일치 할 수도 있다.

따라서, Bulk Insert를 수행하면, Persistence Context를 clear해주는게 좋다.

Member member = new Member();
member.setAge(20);

em.createQuery(age를 21로 변경하는 쿼리).executeUpdate();
em.clear()

// bulk 연산은 영속성 컨텍스트에 반영되지 않는다.
sout(member.getAge()); // 준영속 상태이므로 새롭게 조회 쿼리 발생
profile
블로그 이전했습니다 https://dev314.tistory.com/

0개의 댓글