본문 바로가기
[SpringBoot]

[Kotlin + Spring] 내가 QueryDSL을 도입하게 된 이유

by Hevton 2024. 4. 8.
반응형

 

개인으로 새로 개발하고 있는 프로젝트가 있습니다.

원래는 이전 프로젝트들처럼 FireBase or SupaBase 로 간단하게 MVP 개발 후에 백엔드를 추후 구축하려 했으나

오래 걸릴 것 같지 않고 백엔드가 필요해져셔 Kotlin + Spring 으로 백엔드를 구축하고 있습니다.

단순 JPA로만 구현을 하다가, 확장성을 높여보기 위해서 QueryDSL 도입을 추진하게 되었습니다.

 

N+1 문제

JPA를 사용해보신 분들이라면 모두 한 번쯤은 겪으셨을만한 문제입니다.

다른 라이브러리는 문제 없지만, JPA 는 이렇습니다.

 

N + 1 문제 또는 1 + N 문제는 (같은 말입니다)

OneToMany 관계에서 LAZY 조회를 하던 EAGER 조회를 하던, 프록시 객체를 이용하는 JPA 특성 상 발생하는 문제입니다.

 

예를 들어, Post : Comment = 1 : N 관계에서

1 개의 Post 당 5개의 Comment가 있고, 총 10개의 Post가 있다고 봤을 때,

findAllPost()를 통해 모든 Post를 불러온 뒤에 (1번의 쿼리), Comment를 가져오면 N번(여기선 10)의 쿼리가 더 발생합니다.

우리가 머릿속으로 생각했을 땐 이게 한 번의 쿼리로 끝나야 할 것 같은데, 쿼리가 내부적으로 수많이 발생하게 되죠.

다시 말씀드리지만 이는 EGAER로 조회하던 LAZY로 조회하던 똑같습니다.

( 김영한 선생님의 말씀에 따르면 서버 성능의 80% 문제는 N + 1 문제라고 하죠.. )

 

설계가 잘못되어서 이러한 문제가 너무나 많이 발생한다면,

실질적으로 request 수가 1천번이 발생할 때 10억회의 쿼리가 발생되는 사고가 날 수도 있습니다.

이를 해결할 방법으로 알려진 다양한 방법이 있습니다.

 

 

1. Fetch Join

페치 조인을 통해, Post를 가져올 때 Comment까지 한번에 다 가져오는 방법입니다.

그냥 raw쿼리가 아닌 JPQL 쿼리를 이용해 영속성을 유지하며 사용하게 됩니다.

 

단 Fetch Join은 모든 경우에 대한 카테시안 곱이 발생하므로 중복 데이터가 발생하게 됩니다.

그래서 Post 단에서 List<Comment>가 아닌 Set<Comment>로 관리해주어야 좋습니다.

이렇게 해주지 않으면 중복 값 처리를 못할 뿐더러, Hibernate의 OOM 이슈가 터질 수도 있습니다.

 

물론 Set으로 해줘도 OOM 이슈가 터질 수 있는데요, JPA에서는 ToMany 관계를 대상으로 두 개 이상의 Fetch Join을 시행하지 않길 권장하고 있습니다. 해줘도 따로 해야겠습니다.

 

Fetch Join 시에 단일 컬렉션이 아닌 여러 개를 Fetch Join 하지 말라고 하는 이유는 바로, 성능 이슈 때문인데요,

Hibernate의 jpa multiple bag fetch exception 이슈를 목격한 경험이 있으실 수 있습니다.

이는 fetch join 시에 모두 메모리에 올라오는 과정에서, 카테시안 곱으로 인해 메모리의 OOM이 터져버리는 것이죠

이를 미연에 방지하고자 DISTNICT 나 Set을 사용하긴 하나, 그래도 메모리에 올라와서 이슈가 발생할 여지가 있다는 것은 동일합니다.

 

또한, 아까 말씀드렸다시피 중복 데이터가 기본적으로 발생하기 때문에 페이징이 불가능합니다.

이는 쿼리의 결과가 당연히 1 : N 관계에서 1에 초점이 맞춰지는게 아니라 N에 초점이 맞춰지는 결과이기 때문에

하나의 Post 대상으로 여러개의 Comment들에 대한 쿼리 결과가 나오기 때문입니다.

 

2. EntityGraph

EntityGraph를 지정하여서 필요한 필드들을 한번에 fetch join 하는 방법이 있습니다.

Fetch Join은 @Query를 복잡하게 직접 작성해야 하는 반면에 EntityGraph는 비교적 간단하게 작성할 수 있지만

작동 원리는 결국 Fetch Join입니다. 사용하는 입장에서 더 간단한 방법이라고 생각하시면 됩니다.

 

 

3. Batch Size

가장 보편적인 방법입니다. IN 쿼리를 이용해서 쿼리의 갯수를 많이 줄이는 방법입니다.

하지만 페이징 갯수에 최적화된 배치 사이즈를 지정해 주는 것도 여간 쉬운 일이 아니긴 합니다.

Batch Size 캐싱 방식은, 지정된 숫자의 1/2 씩 되어가기 때문에

Batch Size를 100으로 두면, 100, 50, 25, 12 ... 이런 방식으로만 캐싱이 되기 때문에

페이징을 20으로 둔다면 결국 한 번의 쿼리가 아니라 두 번의 쿼리가 발생합니다.

하지만!! 결과적으로 수없이 많은 N + 1 쿼리의 크기 문제를 줄일 수 있긴 합니다.

 

저는 전체 게시글을 불러오는 작업 (페이징이 필요한 경우) 에는 Batch Size를 적용했고,

페이징이 필요 없는 경우 (하나의 게시글에서 Comment, 작성자, 좋아요 등) 에는 Fetch Join 방식을 선택했습니다.

물론 이 또한 한번에 Fetch가 아니라 Comment 따로, 좋아요 따로, 구독 따로 해야겠지요.

기본적으로 LAZY를 사용하고, 페이징이 필요할 때 (getAllPost) 같은건 BatchSize로 지정하고
그게 아니면 fetchJoin으로 (getOnePost) 해서 페이징 없이 한번의 쿼리로

 

 

 

 

페이징이 필요한 곳에서 아직까지 BatchSize가 보편적인 대안이라고는 합니다.

(1. OOM 이슈 없음, 2. Paging 가능)

Fetch Join은 많아질수록 OOM 이슈가 발생할 수 있고, 연관관계가 많아질수록 더 꼬여집니다.

 

BatchSize를 활용하는 상황 이외에,

페이징을 활용하면서 연관관계 없이도 쿼리를 사용할 수 있는 대안으로 QueryDSL이 떠오르고 있습니다.

 

QueryDSL을 사용하면 Entity 조회뿐 아니라 DTO 조회를 할 수 있고, 

이로 인해 불필요한 컬럼들을 다룰 필요가 없어집니다. 영속성이 필요한 (실시간 데이터 변경이 필요한) 경우에는 Entity로 조회해주고, 성능 개선 및 대량의 데이터 조회가 필요할 때엔 QueryDSL을 사용한다고 합니다.

 

저 또한 일반적인 fetch 조인 대신에 신기술을 도입해보고자 QueryDSL을 프로젝트에 도입해보게 되었습니다.

아직까지 완벽하게 알지 못하더라도, 배우고 나면 장점이 더 느껴질 것 같아서 공부를 택하게 되었습니다.

 

QueryDSL 

QueryDSL은 놀랍게도 오픈소스 라이브러리입니다. "불편하면 우리가 만들게" 의 적절한 예시죠.

JPA 라이브러리 단의 한계를 극복하기 위한 오픈소스 라이브러리의 등장의 의미로써 많은 사람들에게 칭찬과 관심을 받고 있습니다.

 

QueryDSL을 제 프로젝트에 도입한 가장 큰 이유 중 하나는, DTO Projection입니다.

Entity를 통해 쿼리하는 것이 아니라 DTO로 받아냄으로써 필요한 정보들을 Fetch Join할 수 있고, 페이징 또한 가능하다는 강점까지 있습니다. 또한 연관관계를 정해주지 않고도 조인할 수 있기 때문에 QueryDSL을 더 이상 사용하지 않을 이유가 없다고까지 합니다.

(실무에서는 애초부터 테이블 간 꼬임을 방지하기 위해 연관관계를 일부러 설정하지 않는다고도 합니다)

 

단 DTO로 받아내기 때문에 영속성 컨텍스트 내에서 관리되지 않으므로 (읽기전용이지 DB에 다시 쓰기 불가)

영속성 관리가 필요하는 등 Entity 조회가 필요할 때는 일반 repository interface의 메서드를 사용하고

성능 최적화가 필요할 경우 QueryDSL을 사용할 예정입니다.

 

이 세팅에 대해서는 다음 글에서 알아보겠습니다.

 

 

+ QueryDSL을 사용하더라도, 여러 개의 Fetch join에 대한 Hibernate의 OOM 이슈는 발생할 여지는 여전히 있습니다. 결국엔 JPQL을 사용하기 때문입니다. 그래서 Batch Size와 적절히 섞어서 사용하던, 아니면 단일 Fetch 조인만 사용하는게 좋겠습니다.

 

1,2번 방법의 대안이 QueryDSL이며, 3번인 BatchSize는 여전히 유의미합니다.

 

QueryDSL의 DTO Projection을 이용하면, Entity의 모든 정보를 조회할 필요가 없습니다.

여기서 일반적인 Join과 Fetch Join의 차이는, 일반적인 Join은 FK 값만 가져와질 뿐이지 Entity가 영속화되지 않기에 여전히 N + 1 문제가 발생하며 Fetch Join은 관련된 정보 Entity 자체를 조회하기 때문에 영속화가 됩니다.

그리고 일반적인 Join + DTO를 이용한게 DTO Proejction인 것입니다 (https://velog.io/@heoseungyeon/Fetch-Join-vs-%EC%9D%BC%EB%B0%98-Joinfeat.DTO)

두 개 이상의 Fetch Join은 Exception을 발생시키며 OOM이 발생하니까 하지 말라는건 확실한데, 두 개 이상의 일반 Join + DTO는 다른건지는 아직 확실히 모르겠다..! 전자 후자 아직까지 모두 나에게 OOM이 발생하진 않았다.

 

하지만 이런 글을 보면 그냥 Batch Size가 최고인 것 같기도.. 어렵다.

 

 

 

틀린 내용 지적 부탁드립니다! 더 알게되면 수정하겠습니다.

 

 

참고

https://www.youtube.com/watch?v=zMAX7g6rO_Y

https://velog.io/@bagt/QueryDsl-DTO-Projection

https://jojoldu.tistory.com/457

https://velog.io/@hwsa1004/JPA-N-1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-%EC%99%84

https://velog.io/@goseungwon/%EC%BB%A4%EC%84%9C%EA%B8%B0%EB%B0%98-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98queryDsl

https://velog.io/@beinte0419/Spring-Boot-JPA-with-Kotlin-Querydsl-%EC%84%A4%EC%A0%95-%EB%B0%8F-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0

반응형