코드는 Github을 통해 확인할 수 있습니다.
이 글은 Fetch Join에 조건문을 사용해보면서 어떠한 상황에서 문제가 생기는 지 알아보는 예제입니다.
Fetch Join은 일대다 조인 시 한계점이 많아서 default_batch_fetch_size를 설정하는 것이 가장 좋은 방법이지만 드물게나마 Fetch Join에 조건문을 걸고 이용해보고 싶다면 어떠한 방법으로 사용해야 올바른 방법인지 알 수 있습니다.
제대로 설명이 될지는 모르겠으나 한번 시작해보겠습니다!
들어가기 전
Member Entity와 Order Entity를 아래와 같이 만들었습니다.
총 3명의 회원을 만들고 회원마다 각각 Apple, Banana, Orange를 주문한 상태입니다.
Fetch Join 이란?
Fetch Join은 N+1 문제가 발생했을 때 JPQL에서 사용할 수 있는 조인 방식입니다.
참고로 Fetch란 '가져오다'라는 뜻입니다. 그래서 Fetch라는 뜻에 걸맞게 해당 조인을 사용하면 연관된 Entity를 모두 즉시 로딩으로 데이터를 가져올 수 있기 때문에 대표적인 N+1 해결 방식으로 알려져있습니다.
N+1의 해결 방법을 Fetch Join이 아니라 Native SQL로도 해결할 수는 있습니다.
다만, Native SQL은 특정 DB에서 사용하는 힌트 등과 같이 JPQL로 해결이 되지 않는 SQL을 사용할 때만 쓰는게 좋습니다. Native SQL로 모든 것을 해결하려고 하는 것은 좋지 않습니다.
N+1을 해결하기 위해 Fetch Join을 사용하는 것이 올바른 사용 방법입니다.
N+1 발생 케이스
위의 Member Entity와 Order Entity의 Global Fetch Type은 지연로딩(LAZY) 방식을 사용 합니다.
아래는 지연 로딩 방식에서의 N+1 발생 예제 입니다.
Order Entity를 조회해와서 Order안에 있는 Member Entity를 조회할 때 지연 로딩이 발생해서 관련된 회원만큼 쿼리가 발생하는 N+1 현상이 나타나고 있습니다.
아래처럼 Order에 연관된 Member를 조회할 때마다 N+1이 발생하고 있습니다.
N+1 해결법
Fetch Join을 사용해서 연관된 Entity를 모두 즉시 로딩할 수 있습니다.
아래와 같이 join뒤에 fetch를 적어서 사용할 수 있으며 getMember()를 통해 Member를 조회하더라도 N+1이 발생하지 않고 데이터 조회가 가능합니다.
얼만큼 N+1이 발생할 때 Fetch Join을 통해 성능을 최적화 해야할까요?
단순히 N의 값이 2~3개여도 최적화를 해야할까요?
일반적으로 N+1에 대한 성능 최적화는 DB 조회가 무수히 많을 시에 고려할만한 전략입니다.
왜냐면 N+1의 양이 적고 고정되어있다면 그것만으로는 전체 애플리케이션에 영향이 갈 정도로는 느리지 않기 때문입니다.
과거에는 Mysql에서 Join의 성능이 너무 느려서 오히려 Join 방식보다 N+1이 더 빨랐던 적도 있다고 합니다.
하지만 지금은 Join의 속도도 빨라졌기 때문에 둘 다 빠른 속도를 보이고 있습니다.
그렇기 때문에 속도 측면에서 N+1에 대한 성능 최적화는 조회가 많은 대상 혹은 많아질 것 같은 대상에 적용하는 것이 좋겠습니다.
Fetch Join에 조건 걸기
조건문 사용하기
Fetch Join도 항상 모든 데이터를 가져올 필요는 없기 때문에 Where 조건문을 걸 수가 있습니다.
다만 Join에 사용되는 On 절은 걸 수가 없습니다.
On 절은 Join이 되기전 필터링을 거는 것인데 Fetch Join은 연관된 Entity나 Collection을 모두 가져온다라는 의미로 쓰이기 때문에 On 절을 쓰는게 불가능 합니다.
Where절은 이미 Join이 끝나서 전부 가져온 테이블을 필터링 하는 조건문이기때문에 사용해도 상관이 없습니다. 하지만 Where 조건을 통해서 연관된 Entity의 상태가 손상되면 안됩니다. 이 문제에 대해 차근차근 살펴보도록 하겠습니다.
우선 아래의 정상적인 결과를 출력하는 JPQL을 통해 살펴보겠습니다.
Banana를 주문한 사람에 대한 예제입니다.
회원명이 Lee인 사람을 출력하는 예제 입니다.
위의 2개의 JPQL은 문제가 없이 출력되었습니다.
하지만 JPA에서는 Fetch Join의 대상에 별칭(Alias)을 주는 것을 금지하고 있습니다.
왜냐면 별칭을 이용해 Where 조건을 사용하면 연관된 Entity가 손상이 될 가능성이 있고 그 결과 추후에 DB 데이터의 일관성에 영향이 생길 수도 있기 때문입니다. 어떻게 영향이 생기는지는 뒤에 다시 설명하겠습니다.
두번째 JPQL을 보면 Fetch Join의 대상인 Member에 Alias를 부여해서 사용하고 있습니다.
join fetch o.member m where m.name = 'Lee'
JPA에서는 금지했지만 JPQL에서 이 문법이 가능한 이유는 JPA의 구현체인 Hibernate에서 Fetch Join의 대상에 Alias를 주는 것을 허용하고 있기 때문입니다.
하지만 JPA에서는 Fetch Join의 대상에 Alias를 주지 말라고 했기 때문에 조치할 수 있는 방법은 조회하는 Entity의 순서를 바꾸면 됩니다.
Fetch Join 대상 Entity에 Alias 제거하기
Fetch Join 대상을 뒤바꿔서 다대일 조건에서 일대다 조건으로 Query 문을 작성했습니다.
Member는 Fetch Join의 대상이 아니기 때문에 조건을 걸어도 상관 없이 출력됩니다.
일대다 조회는 Member Entity안에 있는 Orders 컬렉션 필드를 대상으로 조인을 하고 있습니다.
이런 경우 일대다 조회는 위처럼 같은 데이터가 여러번 뻥튀기가 되기 때문에 아래와 같이 distinct를 통해 데이터 중복 문제를 해결 할 수 있습니다.
이제 JPA가 원하는 대로 Fetch Join에 별칭도 주지 않았습니다.
이외에도 일대다 Fetch Join은 한계점이 많습니다. Alias에 관한 단점과 뻥튀기 조회 뿐만 아니라 일대다 조인 2개 이상 불가, 페이징 처리 불가 등의 단점도 존재합니다.
Fetch Join 대상 Entity에 Alias 주기
근데 개발을 하다보면 어쩔 수 없이 Member에도 조건이 필요하고 Order에도 조건이 필요할 수 있습니다.
그럴 경우에는 JPA가 금지하고 있는 Fetch Join의 대상에 조건을 걸 수도 있는데요. 이럴 때 어떤 문제가 생기는지 확인해보겠습니다.
일단 Fetch Join에 Alias를 줘서 생기는 문제는 보통 일대다 조건에서 발생합니다.
일대다 조건에서 컬렉션 대상은 어떤 조건을 걸든 모든 데이터가 나오는게 정상입니다.
다대일은 애초에 '다'쪽이 컬렉션이 아니기 때문에(Order(다)에 대한 Member(일)은 Orders가 아니기에 컬렉션이 될 수 없다.) 컬렉션에 관한 문제는 발생하지 않습니다.
그러므로 일대다 예제만 확인 해보겠습니다.
우선 아래의 예제에서 Fetch Join이 아닌 일반 Inner Join을 사용해서 Apple을 주문한 케이스만 출력해보겠습니다.
결과는 Apple을 주문한 케이스만 출력되는게 아니라 모두 출력 되는데요.
왜냐면 JPQL은 SQL이 아니라 객체를 대상으로 하기 때문에 객체 관계의 유지를 위해 Collection 데이터는 어떠한 필터링을 걸더라도 전부 나와야하는게 정상입니다.
근데 전부 나와야 하는 Collection 데이터는 Fetch Join 대상에 Where 필터링을 걸면 데이터가 깨집니다.
Collection 데이터가 불완전한 상태로 결과가 나옵니다. Fetch Join의 대상이 되는 Entity는 항상 다 들고온다는 전제하에 개발이 되어야 합니다. 근데 위처럼 데이터가 깨진 상태로 나오면 어떤 문제가 생기는지 한번 아래 코드에서 확인하겠습니다.
작성된 JPQL은 2개이며 그 중에 두번째 JPQL에서는 조건문을 넣지 않았는데 해당 JPQL의 결과가 Apple 필터링이 걸려서 출력되고 있습니다.
이유는 첫번째 JPQL에서 읽혀진 Member의 식별자인 Id를 기준으로 영속성 컨텍스트에 저장되었기 때문인데요.(두 쿼리가 동일한 EntityManager를 사용하는 경우)
JPQL은 항상 DB부터 조회를 해오고 찾은 Entity가 영속성 컨텍스트에 존재한다면 DB에서 찾은 Entity를 버리고 영속성 컨텍스트 내 기존에 조회된 Entity를 가져옵니다.
그래서 두번째 JPQL을 DB에서 조회하더라도 이미 식별자인 Id(1, 2, 3 회원)을 영속성 컨텍스트가 갖고 있으니 우선적으로 영속성 컨텍스트를 조회해서 원하지 않는 데이터가 나온 것 입니다.
보통 하나의 비즈니스 로직에서 위와 같이 같은 Entity를 2번 조회하는 경우는 많지 않으나 대표적으로 발생할 수 있는 케이스는 애플리케이션 전 영역에서 사용되는 캐시인 2차 캐시에 첫번째 JPQL의 내용이 들어갔다라고 생각하면 추후 생길 문제는 작지 않을 수 있습니다.
다른 비즈니스 로직에서 JPQL을 작성했는데 불완전한 2차 캐시를 불러오게 된다면 추후에 삭제나 수정에 있어서 문제가 크게 생길 수가 있겠죠.
"어? 나는 Fetch Join으로 다 들고 온 거 같은데 왜 데이터가 깨져있지?"와 같은 상황이 생길 수 있습니다. 그리고 그 상황을 인지하지 못한다면 잘못 가져온 데이터를 삭제, 수정을 할 수 있고 그 결과는 시스템 장애로 이어질 수가 있습니다.
이런 버그를 왜 안막지?
실제로 Hibernate 진영에서 사용자들이 컬렉션 데이터를 캐시하는 것을 아예 막아달라고 하는 과거의 글도 존재합니다.
이렇게 큰 장애를 초래할 수 있는 경우를 Hibernate쪽에서 막지 않는 이유는 알 수 없지만 개발자가 위의 기능을 충분히 잘 숙지했다는 전제하에 오로지! 조회! 기능!으로만 쓰이면 문제가 없습니다. 물론 조회도 잘못된 데이터가 조회될 수 있지만 대부분의 장애는 데이터가 수정, 삭제가 일어날 때 나타나는 것이니까요.
결과적으로 조회 기능의 확장성 때문에 막지 않는 것 같다는 생각도 듭니다.
Fetch Join 쿼리를 올바르게 받아오는 방법
위의 개념들을 알고 쓴다면 Fetch Join의 대상에도 충분히 Alias를 사용할 수 있습니다.
다만 여러명이 개발하는 환경에서는 모든 개발자가 이런 지식을 알 수 있는 것이 아니기 때문에 Alias를 줘서 DB 데이터의 일관성에 영향을 주는 방향의 개발은 멀리해야 합니다.
나는 단순히 조회용으로 썼지만 누군가는 뒤에 수정, 삭제용으로 쓸 수도 있기 때문입니다.
그렇기 때문에 JPQL을 받아오는 방법을 Entity 타입이 아닌 값 타입인 DTO나 Stateless Session(무상태 세션)을 통해 받아오는 것이 영속성 컨텍스트에 영향을 주지 않고 올바르게 사용 가능한 방법입니다.
Stateless Session(무상태 세션)
데이터 스트리밍에 주로 사용되는 Stateless Session은 1차 캐시, 2차 캐시에 상관없이 조회된다는 특징이 존재합니다.
그렇기 때문에 위와 같은 상황의 영속성 컨텍스트에서 데이터가 깨지는 문제를 신경쓰지 않아도 됩니다.
DTO로 가져오기
DTO를 사용해서 가져오면 Entity를 조회한 것이 아니기 때문에 영속성 컨텍스트와는 관련이 없어서 안전하게 사용할 수 있습니다. Fetch Join을 사용하지 않고 Join을 사용해야 하며 Join을 사용하더라도 즉시 로딩한 것 처럼 모든 데이터를 DTO에 넣어서 사용할 수 있습니다.
참고
https://www.inflearn.com/questions/15876
https://loosie.tistory.com/750
https://stackoverflow.com/questions/5816417/how-to-properly-express-jpql-join-fetch-with-where-clause-as-jpa-2-criteriaq/5819631#5819631
http://java-persistence-performance.blogspot.com/2012/04/objects-vs-data-and-filtering-join.html
https://hibernate.atlassian.net/browse/HHH-7863
https://hibernate.atlassian.net/browse/HHH-2003
https://blog.termian.dev/posts/jpql-join-fetch-with-condition/
'📖 ORM' 카테고리의 다른 글
MSA 환경에서의 JPA 사용법 (0) | 2023.06.21 |
---|---|
Kotlin에서 JPA 사용법 (0) | 2023.06.21 |
@Transactional 사용 시 자기 호출(Self-Invocation) 이슈 - 실습으로 배우는 JPA 3편 (2) | 2022.07.06 |
findAll()에 관한 N+1 테스트 - 실습으로 배우는 JPA 2편 (0) | 2022.06.30 |
@OnDelete와 CascadeType.ALL, orphanRemoval 속성 (0) | 2022.06.24 |