개발자로 후회없는 삶 살기

[문법] JPQL과 페치 조인 성능 최적화 본문

[백엔드]/[JPA | 학습기록]

[문법] JPQL과 페치 조인 성능 최적화

몽이장쥰 2024. 8. 11. 19:34

서론

※ 과거에 기록한 내용에서 중요한 부분만 발췌하여 모두가 이해하기 쉽게 다시 서술한다.

 

본론

- JPQL 소개

JPA는 다양한 방법으로 SQL을 사용할 수 있다.

 

🚨 나이가 18 이상인 회원을 검색하고 싶다면 em.find로 할 수 있나?

따라서 JPQL이 나왔고 엔터티 객체 중심으로 쿼리를 짤 수 있다. m은 엔터티 자체를 의미하며, m 객제 자체를 조회하라는 의미이다.

 

→ 규칙

1) 엔터티 필드는 대소문자 구분함
2) jpql 키워드는 대소문자 구분 없음
3) 별칭(m) 필수

 

→ Type, Query

Type은 반환 타입이 명확할 때(Member)

 

Query는 명확하지 않을 때(이름과 나이)

 

createQuery에 타입 정보를 줄 수 있으면 Type을 쓰고, 문자만 쓰면 String도 class를 할 수 있는데, 없으면 그냥 Query 타입이다.

 

→ 결과 조회

1) getResultList() : 1개 이상, 결과가 없으면 빈 List 
2) getSingleResult() : 1개만 조회, 결과가 1개가 아니면(0개 혹은 2개 이상)이면 예외

getSingleResult()는 결과가 없으면 예외가 터지면, 별로라서 스프링에서는 Optional로 반환한다.

 

→ 파라미터 바인딩

이름 방식으로 바인딩을 지원한다.

 

- 프로젝션

select 절에서 어떠한 컬럼을 가져올 지 지정한다. 3가지가 가능하다.

 

이렇게 가져온 모든 것은 List에 있는 모든 것이 컨텍스트에 의해 관리가 된다.

 

연관 관계를 조회하면 조인이 발생한다. 하지만, 묵시적 조인은 사용하지 않아야 한다.

 

→ 스칼라 타입에서 타입을 어떻게 가져갈까?

DTO을 넣는 방법이 가장 깔끔한데, 패키지가 길어지는 문제가 발생한다.

 

- 페이징

getResultList를 할 때, 시작 위치부터 몇 개 가져올지를 추상화해놨다. 스프링에서도 jpql이 내부적으로 이처럼 돌아간다.

 

- 조인

SQL에서 조인할 때는 fk를 지정해줘야 했는데 jpql은 fk가 연관이 되어있어서 따로 해줄 필요가 없다.

 

→ ON 절

1. 조인 대상 필터링

회원과 팀을 조회할 때, 팀에서 A를 필터링하고 조인하는 것이 좋다.

 

2. 연관관계 없는 엔터티 외부 조인

연관관계가 없는 조인은 on 절에서 조인 기준을 넣으면 된다. SQL과 동일하다.

 

3. 타입 표현

이넘은 파라미터로 넣을 수 있고

 

상속 관계 엔터티 Item과 Book에서 타입 정보가 Book인 것만 where 검색하고 싶으면 이런 식으로 할 수 있다.

 

- 경로 표현식

.으로 객체 그래프를 탐색하는 법을 알아보자.

 

1. 상태 필드

단순 필드 조회이다.

 

2. 연관 필드

연관 관계를 위한 필드로 대상이 엔터티인지 컬랙션인지로 구분한다.

 

-> 특징

상태 필드 : 멤버 객체에서 멤버 필드 조회는 조인 발생 X
연관 필드 : 묵시적 내부 조인 발생

 

연관관계 team의 이름을 사용하면, 조인이 일어난다.

 

지연 로딩을 했을 때 참조하고 있는 객체를 .name으로 사용한 것이라고 볼 수 있고 따라서 조인이 일어난다. 웬만하면, 이렇게 조인이 일어나게 짜면 안된다. 실무에서 [조인을 어떻게 거는지]가 성능에 영향을 많이 줘서 조인을 잘해야 하는데, 맘대로 조인이 되면 튜닝하기 어려워진다. 이렇게 짜면 DBA가 와서 이 조인 쿼리 찾아달라고 하면 못 찾는다. sql과 jpql을 맞춰서 짜야한다.

 

컬랙션 조인도 역시 조인이 발생하고, JPA에서 List의 여러 요소 중 어떤 것을 탐색하는 지 알 수 없어서 객체 그래프 탐색을 막아놨다.

 

🚨 이걸 탐색하려면?

묵시적 조인이란, join 키워드를 사용하지 않았는데 원치 않게 조인이 일어나는 것이다. 묵시적 조인이 발생하면 안되고, 명시적으로 join을 사용하여 jpql을 sql처럼 짜야한다.

 

묵시적이 아닌 명시적으로 join 키워드를 사용하는 것이 정답이다. 연관관계를 사용하여 SQL 호출이 일어날 때는 경로 탐색으로 하면 안 되고 반드시 SQL을 딱 보고 어떤 일이 발생할 지 알 수 있도록 명시적으로 해야한다.

 

- 페치 조인

연관된 엔터티나 컬랙션을 SQL 한 번에 조회하는 기능이다. 쿼리 2번 나갈 것을 한 방 쿼리로 풀 때 사용한다. (회원과 팀이 기본은 지연로딩인데 같이 쓸 때는 페치 조인으로 한 방 쿼리로 쓴다.)

 

→ 엔터티 페치 조인

회원을 조회할 때 팀도 동적으로 한 번에 가져올 수 있다.

 

원래 조인은 이렇게 사용했는데

 

FETCH를 붙인다. 실제 실행된 SQL은 M과 T의 데이터를 모두 다 SELECT 한다. 회원과 팀은 연관이 되어있어서 같은 id로 이너 조인하고 둘의 데이터를 한 번에 가져온다. 즉시로딩 말고 이것을 써야한다.

 

-> 예제

이런 상태에서 페치 조인을 하면

 

5개의 객체를 가져와서 1차 캐시에 넣고 조인하여 반환한다.

 

→ 실습

페치 조인을 하지 않고 회원의 팀을 사용하면, 현재는 지연 로딩으로 되어있어서

 

회원을 먼저 호출하고 회원의 팀을 사용할 때 팀이 호출된다.

 

페치 조인을 사용하면, SELECT * 이라서 LIST로 가져오고, 페치로 멤버와 팀을 한 번에 가져온다는 얘기다.

 

쿼리가 1번 나간다. 조인으로 1방 쿼리가 나가고 이때 사용하는 팀은 프록시가 아니고 실 객체이다. 지연로딩이라 프록시가 아니라 실 객체이다. 실무에서는 지연 로딩으로 셋팅하고 1방 쿼리는 페치 조인을 사용하면 된다.

 

⇒ 컬랙션 페치 조인

1대다 관계에서 페치 조인을 알아보자

 

실행해보면 sql은 한 번만 나가는 게 맞다.

 

하지만, 가져온 팀을 for문 돌려보면 팀 A가 실제론 1개인데 2번 나간다. 즉, 팀이 2팀인데 저 리스트에 3개가 들어갔다는 얘기다.

 

→. 컬랙션 페치 조인 주의점

1대다에선 데이터 뻥튀기가 일어난다. SQL에서도 1대다 조인은 뻥튀기가 된다.

 

중복 제거은 distinct를 쓰면 된다.

 

→ 일반 조인과 차이

일반 조인은 select에 t가 있어서 팀 컬러만 가져온다. 1차 캐시에 멤버가 없고 사용하면 지연 로딩이 또 일어난다. 따라서 jpa로 여러 개의 객체를 사용하려면, 일반 조인이 아니라, 페치 조인을 사용해야한다.

 

페치 조인은 회원과 팀 정보를 전부 가져온다. 연관된 데이터를 한 번에 가져오고 1차 캐시에 엔터티를 넣어 놓는다.

 

- 페치 조인의 한계

1. 컬랙션 조인 대상에는 별칭 불가

 

여기까지만 가능하고

별칭을 추가해서 필터링하는게 안 된다.

 

페치조인의 근본은 전부 다 가져오는 것이라서 필터링으로 중간에 자르면 안된다. 그리고 이경우에는 1측에서 다측을 자르는게 아니고 다측에서 1측을 필터링 해야했다. 다대1에서는 별칭 사용이 가능하다.

 

2. 둘 이상의 컬렉션은 페치 조인 불가

1대다도 뻥튀기 되는데 이건 정말 distinct로도 불가하다.

 

3. 컬랙션 페치 조인은 페이징 API를 쓸 수 없다.

다대1 페치 조인은 페이징이 가능한데, 일대다는 데이터를 뻥튀기로 가져오고 중간에 자르는 것이 불가능해서 오류가 발생한다.

 

이 경우, 다대일로 하던가

 

그게 아니라면, 배치 사이즈를 줘야한다. 페치조인을 이렇게 사용하면 네이티브 쿼리로 하면 정말 오래 걸릴 것을 조인으로 다 할 수 있고 성능상 큰 이점을 받을 수 있다.

 

- 엔터티 직접 사용

JPA는 엔터티를 직접 넘길 수 있다. 이 둘은 같은 SQL이 발생한다. JPA에서 엔터티는 pk라고 보면 된다.

 

파라미터로 넣어도 pk로 사용되고

 

다측에서 fk로 필터링할 때도, 1측 엔터티를 사용하면 fk로 사용된다.

 

다측에 원하는 fk로 검색하는 것이고 이때도 엔터티(team)이 id로 쓰인다고 보면 된다. 객체가 DB 입장에서는 다 ID라고 보면 된다.

 

- 벌크 연산

한 건 수정이 아닌 여러 건 수정 삭제에 사용된다. 예를들어서 재고가 10개 미만인 상품 가격을 10% 상승하려고 할 때 JPA 변경 감지를 하면 많은 SQL이 발생한다. 이를 JPA는 한 방에 되게 한다.

 

→ 사용법

모든 회원의 나이를 20살로 바꿔보자.

 

→ 주의 사항

벌크는 컨텍스트가 아닌 DB에 바로 적용하는 거라서 주의가 필요하다.

 

1) 컨텍스트가 원래 비어있었으면 문제 X
2) 컨텍스트와 DB의 차이가 발생하면 컨텍스트 초기화 필수

 

→ 실행

벌크 연산을 수행하면 그 직전에 jpql처럼 flush가 발생한다. 이 경우 1번 사항으로 문제가 없지만, 데이터 정합성을 위해 벌크 연산 이후에는 em.clear로 초기화를 무조건 해줘야한다. 그리고 DB에서 다시 가져와야 한다. 근데 1 건 수정할 때는 일반 update가 당연히 좋다.

 

- API 성능 최적화

→ DTO로 바로 조회

레포지토리에서 DTO로 바로 조회할 수 있다. 그러면 select 절에 데이터가 줄어들어서 전송 용량이 줄어든다. 하지마, 요즘은 네트워크 성능이 좋아져서 select 절에 필드 몇 개 있다고 차이가 별로 없고, 레포지토리는 엔터티를 조회해야 하고, DTO를 쓰면 코드 변경이 많아져서 권장하지 않는다. 차라리 레디스를 써서 성능 최적화를 해야한다. 레디스에 데이터를 저장할 때는 엔터티를 반드시 DTO로 변환해야 한다.(키 = sql 쿼리, value = dto)

 

→ 컬랙션 페치 조인의 차이

1대다 컬랙션의 치명적인 단점은 페이징을 할 수 없다.

 

1대다는 뻥튀기가 돼서 4개를 가져오는데 여기서 페이징을 하면 1개 끊고 3개를 가져온다. 따라서 페이징 자체가 불가능해진다. 다대1에서는 페이징 가능한다.

 

→ 해결 방법

다대1은 페치 조인을 사용하고 1대다는 제거하고 옵션 쿼리를 쓴다. 다측에 관련있는 oi.item도 제거해야한다.

 

yml에 배치 사이즈 옵션을 주면 된다. toOne 관계만 페치 조인하고 페이징한다. 컬랙션은 어떻게 되는 걸까?

 

get으로 OrderItem을 사용해보자

 

원래는 지연 로딩으로 OrderItem을 사용할 때 주문 1 2개, 주문 2 2개 쿼리가 각각 나왔는데 지금은 In 쿼리가 생겼다. 근데 지금은 OrderItem에 대한 쿼리가 사라진다.

 

설명

“주문에 컬랙션이 있다면” 주문과 관련된 컬랙션이라고 보면, OrderItem.Order_id in (Order_id)로 OrderItem의 Order_id가 앞에서 페치 조인으로 가져온 주문과 같은 주문 아이템 컬렉션을 미리 가져와서 가지고 있다.

 

조인을 하지 않기 때문에 데이터 중복 상관 없이 Order와 관련된 데이터를 미리 가져오는 것이다. 배치 사이즈가 2면 in 쿼리를 1개만 가져와서 가지고 있고 관련 컬랙션이 100인데 사이즈가 10이면 10개씩 10번 가져온다. 가져왔기 때문에 컨텍스트에 들어있고, 초기화할 때 쿼리가 안 날라간다. 원래는 OrderItem이 지연 로딩이라서 get으로 사용할 때 초기화가 돼서 OrderItem을 조회하는 쿼리가 get 할 때 나가야 하는데 이미 OrderItem을 가져와서 더 이상 쿼리가 나가지 않는다.

 

주문 내역이 4개 있었는데 2개를 정확히 가져온다. in 절은 pk 기준으로 가져와서 정말 빠른 쿼리이다. 페치 조인을 쓰면 1(주문) + m(OrderItem) + m(상품) 이었을 쿼리가 1 + 1 + 1이 된다. 가져온 1측과 관련된 컬랙션을 미리 가져와서 그렇다.

 

상품도 쿼리가 1인 이유는 사실 옵션을 쓰면 toOne도 In 쿼리가 나가서

 

따라서, Order 외에 다른 조인 쿼리를 전부 제거해도 된다. Order와 관련된 모든 것들을 미리 가져온다. 따라서 to1에 대한 페치 조인도 사라진다. 주문만 jpql로 하고 멤버, 배송, OrderItem, 상품은 옵션 쿼리로 5번의 쿼리로 해결된다.

 

🚨 네트워크 이슈

(주문 + 회원 + 배송 ) 한 방쿼리 1번
OrderItem 옵션 쿼리 1번
Item 옵션 쿼리 1번

 

하지만, 옵션 쿼리는 Order와 연관된 테이블 개수만큼 미리 쿼리를 날리는 것이라서 네트워크 통신이 더 많이 발생한다. 전부 다 fetch join 했을 때는 한 방 쿼리인데 위처럼 하면 옵션 쿼리 2번과 1방 쿼리 1번으로 3번의 쿼리이다. 따라서 to1은 페치조인을 하고 toM에 관련된 것만 옵션 쿼리를 하는게 좋다.

Comments