Photo by Annie Spratt on Unsplash

JPA를 이용한 Query Service의 성능 개선기

작년 4분기부터 올해 초까지 현재 근무하고 있는 회사에서 제공하고 있는 Saas 솔루션의 대시보드 개발을 맡았다. Spring Boot와 JPA를 이용하였고 현재는 베타버전이 거의 완성되었다. 개발 막바지에 성능과 관련된 이슈를 경험하여 글로 남기려고 한다.

요구 사항과 구현

대시보드에서 구현해야했던 비즈니스 사이드의 요구사항은 다음과 같았다.

  • 우리가 Solution을 통해 고객에게 전달하고 있는 Message를 대시보드에 테이블 형태로 보여주세요.
  • 그런데 테이블에서 Message의 속성별로 필터링도 가능해야합니다.
JPA Query service architecture

여기서 Solution을 이용하고 있는 고객들은 주기적으로 Message라는 것을 받는데 이는 SolutionService를 통해 Postgres에 저장되고 있다. 대시보드는 Postgres에서 Message를 가져와서 Frontend에 전달하기만 하면 되었다. Message의 속성에 대해서는 아래에서 더 설명하겠다.

구조는 간단하다. 솔루션 쪽의 서비스가 관련 데이터를 Postgres에 쌓고 있고 DashboardService는 해당 DB에서 데이터를 가공하여 Frontend에 뿌려주면 된다.

Message Entity는 다른 Entity들과 다음과 같은 관계를 가지고 있다.

Query service entity relation

Message는 Event와 Many-to-One 관계를 가지고 있고 Event가 Message를 참조하지 않는 단방향 관계이다. Event와 Item의 관계도 Message와 Event의 관계와 동일하다.

위에서 언급했던 Message의 속성이란 Message의 status, Message가 가지고 있는 Event의 name, Message가 가지고 있는 Event가 가지고 있는 Item의 id와 같은 것들을 말한다.

DashboardService의 기술스택은 Spring Boot + JPA이기 때문에 쿼리 결과를 매핑할 Entity 가 존재해야한다.

Entity들은 위와 같이 정의되어있고 Application 레이어에서 Message 도메인 객체를 가져오는 수단으로 MessageQueryService를 두었다. 간단하게 로직은 다음과 같다.

findAllWithFilter 메소드가 하는 일은 itemId에 해당하는 Message들 중에서 eventNames, statuses 에 해당하는 속성을 가진 Message들만 필터링해서 가져오는 것이다. 이해를 돕기위해 SQL로 표현하면 다음과 같다.

여기서 Specification을 사용해서 쿼리를 만든 이유는 필터의 대상이 가까운 미래에 추가되거나 없어질 것을 염두에 두었기 때문이다. 쿼리를 프로그래밍적으로 만들어서 필터를 추가할 때 코드의 변화를 최소화하고자 하였다.

MessagePredicateBuilderFactory를 통해 동적으로 MessagePredicateBuilder를 생성하고 MessagePredicateBuilder는 사용자가 넘겨준 필터링 인자들을 이용해 쿼리를 완성시킨다.

테스트 결과

로컬에서 기본적으로 제대로 동작하는지 확인하고 dev 클러스터에 올려서 확인해보니 수십 초 정도 걸린다. 이게 무슨 일이야…?

잠깐 쉬고나서 dev 클러스터의 Message 개수를 살펴보니 백만 단위의 Message가 존재한다. 그리고 어떤 것이 문제인지 살펴보기 위해 코드와 dev 클러스터의 로그를 살펴보았다. 그렇지만 에러는 없었고 모든 것이 정상 동작하는 것처럼 보였다.

문제의 원인이 어디에 있나?

가장 먼저 했던 일은 Application 레이어 쪽에서 실행하고 있는 함수들의 시간을 측정했다. 글의 이해를 돕기 위해 Message들을 가져오기위해 일어나는 일련의 동작들을 설명하면 다음과 같다.

Query service flow

Application 레이어에서는 Domain 로직을 aggregate한 것이기 때문에 Application 레이어에서 실행되는 Domain 로직의 시간을 측정하였다.

이 때 시간을 측정하기 위한 베스트 방법은 로컬에서 dev 클러스터의 환경과 비슷하게 만드는 것이다. 하지만 짧은 시간에 환경을 비슷하게 만드는 것이 어려워 시간 측정 로직만 추가하여 바로 dev 클러스터에 배포하였다.

결과는 findAllWithFilters()findFilterKeys()가 거의 비슷하게 시간이 걸렸다. 이걸 보고 가장 먼저 든 생각은 ‘Message를 조회하는 로직과 필터키를 찾는 로직을 서로 다른 엔드포인트로 만들자’였다. Frontend 쪽에서는 분리한 두개의 엔드포인트에 병렬로 요청을 날려 처리하면 걸리는 시간이 절반 정도로 줄 것이다.

하지만 이게 근본적인 해결책은 아니었다. 엔드포인트를 분리하여도 아직 상당한 시간이 걸렸고 왜 그렇게나 시간이 걸리는지를 찾아야했다.

JPA 쿼리의 상태가?

조금 고민을 하다가 다음으로 하기로 한 것은 실제로 DashboardService에서 어떤 쿼리가 실행되는지 살펴보는 것이었다. 로컬에서 요청을 날려 쿼리를 확인해보았다.

결과를 확인해보니 실행된 쿼리의 형태는 다음과 같았다.

먼저 Message를 page 사이즈에 맞게 가져온 뒤 가져온 Message들을 돌면서 자신과 연결된 Event들과 Item을 가져온다. 이러한 형태의 쿼리는 page 사이즈에 비례해서 시간이 느려진다. O(n)이기 때문에 이를 O(1)로 줄이면 훨씬 조회 속도가 빨라질 것이다.

O(1)로 줄일 수 있는 쿼리가 머리 속에 떠올랐지만 이를 이용하기 위해서는 JPA NativeQuery 기능을 사용해야했고 그렇게 된다면 메소드에 JPA가 제공해주는 추상화된 로직을 담는 것이 아니라 직접 작성한 상당히 구체적인 로직을 담을 것이다.

코드가 어떻게 될지 잠깐 상상해보았을 때 끔찍하긴 했다. 필터들이 동적으로 바뀌기 때문에 쿼리문도 동적으로 생성해주어야하고 쿼리 결과를 다시 도메인 객체로 매핑해주는 로직도 상당히 번잡할 것이다. 그래서 NativeQuery를 사용하는 방법은 최후의 보루로 두고 다시 다른 방법으로 이를 해결할 방법에 대해서 리서치를 하였다.

JPA 1+N issue

리서치를 해보니 현재 겪고 있는 문제가 JPA의 1+N 이슈인 것을 알게되었다. 조회하고자하는 Entity를 한 번 조회하고 안에 nested 된 Entity들을 다시 하나하나 조회한다.

그런데 보통 1+N 문제가 자신과 관련된 Entity를 LazyFetch하기 때문에 발생하는 문제라고 기술하고 있다. 하지만 지금 겪고 있는 문제는 Many-To-One의 관계를 가지고 있기 때문에 default 동작이 EagerFetch에서의 상황이다. 그래서 구글링에서 발견한 해결책 대부분이 현재 나의 문제의 해결책에 해당되지 않는다고 생각했다.

글을 쓰면서 또 잠시 리서치를 하였는데 second-level Hibernate Query Cache를 이용하는 방법이 있었다. 왜 이 방법을 찾지 못했을까…

https://vladmihalcea.com/hibernate-query-cache-n-plus-1-issue/

2시간 더 넘게 리서치를 하고 실험을 했지만 원하는대로 동작하지 않아 결국에 NativeQuery를 사용하기로 마음먹게 되었다.

결국엔 NativeQuery로…

잠깐 쉬고와서 구현을 하기 전에 어떻게하면 native query를 잘 숨길 수 있을까에 대해서 고민하였다. 현재 만들어진 코드들을 최대한 변경하지 않으면서 native query만 저- 구석에 두고 싶었다. 추가되었는지도 모르고 혹시나 native query가 삭제되더라도 전-혀 문제가 되지 않도록.

필터링과 관련된 요구사항이 바뀌면 저 구석탱이에 있는 native query를 사용하는 모듈만 바꾸면 모든 것이 깔끔하게 해결될 수 있도록 코드를 작성하고 싶었다.

그래서 JPA 레포지토리를 커스터마이징하는 방법을 찾다가 다음과 같은 패턴을 사용하기로 결정했다.

기존의 코드는 모두 MessageRepository를 사용하고 있다. 그래서 MessageRepository 를 직접적으로 바꿔버리면 이를 사용하고 있는 코드들이 깨질 수 있다. 또한 MessageRepository 를 바꾸면 JPA에서 제공해주는 편리한 기능들을 더이상 사용할 수 없게 된다.

그래서 MessageRepository 인터페이스는 그대로 둔 채 필요한 기능에 대해서만 따로 인터페이스로 빼서 (CustomRepository 에 해당) 그것의 구현체를 만들기로 하였다.

위와 같이 코드를 분리한 결과 MessageQueryService는 더러운 native sql query문이 들어있는 MessageRepositoryImpl에 대해서 몰라도 된다. (하나 아쉬운 점은 MessageRepositoryImpl 이름인데 JPA에서 저 이름을 강제해서 원하는 다른 이름으로 바꾸지 못했다.)

MessageRepositoryImpl 구현

MessageRepositoryImpl을 구현할 때 고민했던 포인트에 대해서 적어보려고 한다.

일단 첫 번째는 도메인 객체는 위에서 봤다시피 Message.Event.Item과 같이 nested된 구조이다. 하지만 native query를 통해 가져온 데이터는 nested 하지않고 flat하다. 그래서 flat한 데이터를 담을 DTO와 이를 다시 도메인 객체로 변환할 Mapper가 필요하다. 두 번째도 위에서 잠깐 언급했는데 필터에 따라서 동적으로 native sql문이 바뀌어야한다. 그래서 QueryFactory도 둘까 생각 중이다.

그래서 결국엔 MessageRepositoryImpl이 온전하게 동작하기 위해서는 MessageRepositoryImpl 뿐만 아니라 FlatMessage (DTO), FlatMessageMapper, QueryFactory와 같은 것들이 추가적으로 필요하다.

하지만 여기서 중요한 점이 있는데 FlatMessage, FlatMessageMapper, QueryFactory 같은 것들은 MessageRepositoryImpl에게만 필요한 것이지 다른 모듈에겐 전혀 필요하지 않다. 그래서 굳이 이것들을 외부에 노출시키기보단 MessageRepositoryImpl 내부에 숨기기로 결정하였다.

MessageRepositoryImpl을 사용하는 클라이언트 객체는 사용하기가 훨씬 수월해진다. 또 중요한 것은 native query문을 바꿨을 때 동시에 FlatMessage, FlatMessageMapper, QueryFactory를 함께 바꿔줘야한다. 하지만 이를 모두 MessageRepositoryImpl의 inner 클래스로 두었기 때문에 코드 변경이 일어나는 범위가 MessageRepositoryImpl 클래스 하나로 한정된다.

그래서 사용성과 유지 보수 측면에서 이들을 모두 inner 클래스로 두는게 좋다고 생각하여 다음과 같이 MessageRepositoryImpl을 구현하였다.

다시 테스트! 하지만…

리뷰를 받고 코드가 합쳐진 후 다시 테스트 해보니 여전히 10초 정도 걸린다. 많이 줄었지만 내부에서 요구하는 시간은 최대 1~2초였다. 확실히 프론트에서 눈에 보기에도 넘어가는 속도가 답답하다.

이전에는 여러 쿼리문을 수행하여 속도가 느렸던 것을 쿼리 하나로 줄여서 속도를 개선시켰다. 다시 쉬면서 이제는 어디를 봐야할까에 대해서 생각했다. 생각이 다다른 곳은 ‘내가 만든 쿼리에 문제가 있는 것은 아닐까’ 였고 내가 만든 쿼리를 테스트할 방법에 대해서 리서치를 시작하였다.

Postgres에서 EXPLAIN ANALYZE <SQL> 을 사용하여 <SQL> 에 내가 테스트할 sql을 작성해서 실행시키면 해당 sql이 어느정도 시간이 걸릴지와 각 sql 구문에 대해서 어느정도 cost가 드는지를 보여준다.

참고했던 블로그의 샘플 결과를 가져왔다.

위와 같은 쿼리에 대해서 EXPLAIN ANALYZE 를 사용하면 다음과 같이 결과가 나온다.

보다시피 각각 구문이 얼마나 걸릴지 cost와 시간에 대해서 분석해준다.

현재 사용하고 있는 쿼리 분석 결과를 살펴보니 JOIN 문에 엄청난 cost와 시간이 들었다. Message에 해당 Event와 Item을 가져오기 위해서 JOIN문을 사용했기 때문이다. (그리고 분석 결과 덕분에 빼먹은 인덱싱도 발견하였다.)

그래서 JOIN을 모두 없앤 쿼리로 바꾸고 다시 검사를 해보니 확연히 낮은 cost의 결과를 보여주었다.

결과

결론부터 말하면 JOIN을 모두 없애도 1~2초대에 조회할 수 없었다 (5초 정도까지 나왔다). 혹시나 하는 마음에 stage 클러스터에 올려서 확인해보니 1초대에 결과가 나온다. 잉?

조금 허망하지만 꽤 큰 비중의 문제의 원인은 하드웨어 문제였다. dev 클러스터의 스펙이 1개의 cpu에 4기가 정도의 메모리 stage 클러스터는 그것의 5배 정도 되었다.

문제가 해결되었을 때 허망함 반, 안도 반이었다. 이번 문제를 해결해나가면서 했던 생각의 흐름들이 재미있었던 것 같다. 뿐만아니라 여러 상황들에 대해서 코드들을 어떻게 배치해나갈까 고민했던 시간들도 참 좋았다.

그렇지만 글을 쓰면서 아직 부족하다는 생각이 들 때가 있었는데 아직 JPA에 대해서 많이 모른다는 것과 좀 더 리서치를 잘했으면 문제를 더 빨리, 더 쉽게 해결할 수 있지 않았을까라는 것이었다.