본문 바로가기

Language & Framework/Spring

Completable Future를 활용한 쿼리 병렬 호출하기

그냥 의식의 흐름대로 내가 공부한 내용을 써놓은 거고 남들이 보기에 도움되는 글은 아님..

심지어 결론도 없음..

읽지 마세요..

 

 

최근 우리 회사 어플리케이션에서 홈 API의 성능 이슈 문제가 제기되었다.

홈 API의 성능을 개선하기 위한 방법으로 생각한 방법은 4가지다.

 

1. Index tuning : 기본적인 인덱스 설정은 이미 되어 있고, 홈 API를 위해 covering index를 구성하기는 DB 용량과 사장님들이 사용하는 SAAS API(CUD)의 성능 저하가 우려되어 부담스럽다. 우선 다른 방법들부터 시도해보자.

2. Caching : 당연히 적용해야 하지만, 어차피 캐시가 evict 되었거나 expired 된 시점을 위해 추가적인 성능 개선은 필요함.

3. Async - Blocking : 얼마나 효과적일지 모르겠지만 일단 부담없이 적용해보기 좋음.

4. Async - Nonblocking : Kotlin + SpringBoot를 사용하고 있기 때문에 Coroutines과 결합하여 좋은 시너지를 낼 수 있을 것 같기는 한데, 기존의 servlet thread 기반으로 동작하는 JDBC가 아닌 reactive programming을 위해 만들어진 R2DBC라는 장벽이 기다리고 있다. 나 혼자 어찌어찌 공부해서 쓸 수야 있겠지만, 모두가 함께 관리하는 프로젝트에서 바쁜 시기에 기술 스택을 계속해서 늘리는 건 현명하지 못한 선택이라고 판단해서 패스.

 

결국 제일 만만한 비동기 병렬 처리를 먼저 시도해보기로 결정했다.

 

기존 우리 홈 API는 아래와 같이 완전히 동기적으로 동작하고 있었다.

 

 

내가 원하는 그림은 아래와 같이 각 토픽에 대한 쿼리를 모두 보내고, DB에서 모든 응답이 오는 시점에 데이터를 조합해 클라이언트에게 보내주는 것이였다.

 

 

근데 나 생각해보니 스프링 이벤트를 활용해 비동기 요청 처리를 해본 경험은 있는데, 그에 대한 응답을 받아서 무언가 해본 적이 없다..

그래서 간만에 소스코드 주석부터 한 번 읽어봤삼

 

 

Future는 명시적으로 완료될 수 있습니다. (값과 상태를 설정)
그리고 CompletionStage로 사용될 수 있으며, 완료 시에 트리거로 사용될 수 있는 독립적인 함수와 액션들을 지원합니다.

둘 혹은 그 이상의 쓰레드가 complete, completeExceptionally, cancel을 시도할 때, 오직 그것들 중 하나만 성공합니다.
추가적으로, 이런 상태나 결과에 대한 방법들과 함께, CompeltableFuture는 CompletionStage를 구현합니다. 다음 정책들에 따라.

1. 동기적인 메서드들의 수행 완료를 위해 제공된 액션들은 현재 CompletableFuture를 완료 시키는 쓰레드 혹은 완료된 메서드의 호출에 의해 실행될 수 있습니다.
2. 명시적인 Executor 인자가 없는 모든 비동기 메서드는 ForkJoinPool.commonPool()에 의해 수행됩니다. (이것이 최소 2개의 병렬 레벨을 지원하지 않는 경우 각 태스크별로 새로운 스레드가 만들어집니다.) -> 이게 무슨 말이여..
이것은 서브 클래스에서 defaultExecutor를 정의하는 것으로 non-static method로 override 될 수 있습니다.
보다 간편한 모니터링, 디버깅, 트래킹을 위해서, 모든 생성된 비동기 태스크는 CompletableFuture, AsynchronousCompletionTask의 인스턴스입니다.
time-delays가 있는 작업은 이 클래스에 정의된 adapter method를 사용할 수 있습니다.
예시 : supplyAsync(supplier, delayedExecutor(timeout, timeUnit)).
딜레이와 타임아웃 메서드에 대한 지원을 위해, 이 클래스는 최대 한 개의 데몬 쓰레드를 유지합니다. 그들이 동작하지 않을 때triggering과 canceling을 하기 위해서.
3. 모든 CompletionStage Method들은 다른 public 메서드들과 독립적으로 구현되어 있습니다. 그래서 하나의 메서드의 행동은 충격을 받지 않습니다. 서브 클래스에서 다른 메서드가 override되는 것으로 인해.
4. 모든 CompletionStage 메서드들은 CompletableFuture를 반환합니다. CompletableStage에 정의된 메서드만 사용하도록 제한하고 싶다면 오직 minimalCompletionStage만 사용하세요. 혹은 clinet가 스스로 Future를 수정하지 않게 하길 원한다면 copy 메서드를 사용하세요. -> 뭔소리인데요 ㅜ

CompletableFutures는 또한 Future를 다음 정책들에 따라 구현합니다.

1. 이 클래스는 연산에 완료에 대한 직접적인 권한이 없다. (FutureTask와 달리) 따라서 이것이 취소될 때는 오직 예외적인 완료의 형태로 처리됩니다. cancel 메서드는 completeExceptionnaly와 같은 효과를 가집니다. isCompletedExceptionally 메서드는 만약 CompletableFuture가 예외적인 상황에서 완료되었는지 판단하는 것에 사용될 수 있습니다.
2. CompletionException과 함께하는 예외적으로 완료될 경우, get(), get(long, TimeUnit)은 ExecutionException을 발생시킵니다. (CopletionException과 동일한 이유로)
많은 상황에서 간단하게 사용하기 위해, 이 클래스는 또한 join(), getNow를 정의하고 있습니다. 해당 메서드들은 CompletionException을 발생시킵니다.

완료 결과를 전달하기 위해 사용된 인자들은 (T 타입 파라미터) Null일 수 있다. 하지만 통과한 Null Value는 다른 파라미터에서 NullPointerException을 발생시킬 것이다.
이 클래스의 서브클래스들은 일반적으로 가상 생성자 메서드인 newIncompleteFuture를 오버라이드해야 한다. 이것들은 CompletetionStage로부터 반환되는 값의 구체적인 타입을 설정합니다.

 

뭐라는 건지 알겠음?

난 모르겠음. 그냥 요즘 영어 읽어본지 너무 오래돼서 간만에 읽어봄 ㅎ

 

일단 무시하고 한 번 써보자.

 

 

 

 

매 호출 시 무려 2초라는 작업 시간이 걸리는 굉장히 헤비한 doHardWork()라는 함수를 만들고, CompletableFuture 클래스의 supplyAsync 메서드로 5회 호출하여 각 값을 더해서 결과를 구하는 시간과, 단순히 동기적으로 결과를 구하는 시간을 비교해보았다.

 

 

CompletableFuture를 이용한 작업은 당연하게도 모두 별개의 스레드에서 함수를 호출하기 때문에 2초만에 모든 함수 호출이 종료되어 값을 반환했고, 동기적으로 작성한 로직은 5배의 시간이 소요되었다.

 

와! 정말 대단해!

supplyAsync는 그러면 뭐하는 친구일까?

 

 

Supplier를 호출해 얻은 값으로 ForkJoinPool.commonPool()에서 실행된 결과를 비동기적으로 반환한다고 한다.

만약 매번 새로운 쓰레드를 생성하는 오버헤드를 방지하고 싶다면 Executors.newFixedThreadPool() 메서드를 사용해 인자로 executor를 제공해줄 수도 있다.

 

 

asyncSupplyStage의 구현은 생각보다 별 거 없고, 그냥 executor를 실행해서 결과를 반환할 뿐이다.

근데 저 안에 있는 new AsyncSupply는 뭐하는 놈인지 모르겠다.

 

 

 

내부 구현을 보면 asuncSupplyStage() 함수에서 새로 생성한 CompletableFuture와 Supplier를 인자로 받는다.

그리고 CompletableFuture와 Supplier가 null이 아닐 경우 d.result가 null인지 아닌지 판단한 뒤 d.completeValue에서 f.get()으로 Supplier의 결과를 반환 받는다.

 

completeValue()는 compareAndSet 알고리즘을 이용해 결과값을 구하는 것 같은데, 이 부분은 잘 이해가 안된다..

해당 객체 내부에서 구한 연산 결과를 구할 때도 굳이 compareAndSet 알고리즘을 활용할 이유가 있나..? 체이닝을 이용해 여러 CompletableFuture 작업이 연결될 때를 위한 것 같긴 한데 자세히는 모르겠음.

 

마지막으로 postComplet()에서는 내부 STACK (ColpletableFuture가 연결되어 있는 경우 종속 작업을 파악하기 위해)의 값을 compareAndSet 알고리즘을 이용해 구하고, 만약 h.tryFire(NESTED)의 결과가 null이라면 (추가적인 작업이 필요 없다면) 마지막 결과를 반환한다.

 

복잡하네..

 

근데 사실 위에서 사용한 방법은 순수 Kotlin이나 Java로 병럴 처리할 때 사용하는 방법이고, 스프링에는 @Async라는 어노테이션이 있어서 supplyAsync()를 직접 사용할 일은 잘 없다.

 

 

 

일반적인 웹/앱서비스에서 병렬 처리를 사용하는 경우는 대부분 외부 API 호출이나 쿼리 결과를 받아와서 결과만 비동기로 전달하는 것이기 때문에 completedFuture()로 완료된 동기 작업을 CompletableFuture로 래핑해서 내보내주기만 하면 되는 것..

 

Async 어노테이션을 붙이게 되면 해당 메서드를 AsyncExecutionAspectSupport에서 실행하게 되는데 내부 구현을 따라가보면 최종적으로 completeAsync() 메서드에서 new AsyncSupply()를 실행하고 있는 것을 확인할 수 있다.

 

 

 

이제 남은 건 실제 서비스에서 이것을 활용해보는 것 뿐이다.

근데 회사 코드를 여기 올릴 수는 없잖아요.

블로깅을 위해 노가다 좀 했습니다.

 

 

 

비동기 로직이 포함된 메서드를 테스트 시 주의할 점이 있는데, 데이터를 삽입하는 트랜잭션과 테스트 트랜잭션을 격리해줘야 한다.

이것 때문에 6시간 날렸다.

 

나도 정확한 이유를 모르겠지만 트랜잭션을 격리하지 않고 테스트를 진행하게 될 경우 비동기로 동작하는 쓰레드가 Commit 되기 전의 데이터베이스를 읽어오게 되는 것으로 추정된다. 그래서 반환값이 계속 비어있는 상태가 됨..

 

이 부분만 주의하면 따로 신경쓸 건 없다.

테스트 시 @Transactional의 자동 롤백 기능을 사용할 수 없게 되어서 매번 AfterEach로 데이터를 삭제해줘야 한다는 불편함이 있다는 정도..?

 

아무튼 직접 굴러보면 님들도 할 수 있게 될테니 테스트 결과나 보자.

 

 

와 대박! 역시 비동기 짱! 엄청 빠르다!

라고 하면 안됨

 

동기 로직에서 이미 데이터를 한 번 다 불러온 상태이기 때문에 해당 데이터들은 DB 캐시 버퍼에 모두 저장되어 있는 상태다.

순서를 바꾸면 상황은 역전된다.

 

나도 테스트코드 다 써놓고 깨달아버림..

올바른 테스트를 위해서는 서로 다른 데이터를 저장하고 따로 테스트하거나 실제 환경에서 성능 테스트를 해봐야함...

근데 내가 블로깅을 위해 더이상 노가다하기는 귀찮아졌음....

 

그래서 결론만 말하자면 우리 회사 서비스 기준 모든 쿼리를 비동기로 처리할 경우 오히려 더 느린 성능을 보였고, 가장 무거운 하나의 쿼리만 비동기 처리했을 때 더 빠른 성능을 보여줬다.

 

병렬 처리를 한다고 해서 성능이 선형적으로 증가하지 않을 거라는 건 예상하고 있었지만, 쿼리 호출은 아무리 빨라도 50ms 이상의 시간이 걸리기 때문에 조금이라도 더 빠른 성능을 보여줄 것이라고 생각했는데 내 예상 밖의 일이였다..

(시간복잡도 O(2^n)의 피보나치 함수 5건을 Completable Future를 이용해 비동기 처리했을 때 fibonacci(35)부터 비동기 처리 로직이 더 빨랐는데, 이 때 fibonacci(35)를 한 건 수행하는데 걸리는 시간이 25ms였다.)

 

실 서비스 환경에서 성능이 떨어진 거라면 병렬 처리로 인한 CPU 부하나 DB Connection Pool 과소비 같은 문제가 있겠지만 대체 이 결과는 뭘까..

 

당장 내 궁금증을 해소할 곳이 없어서 모두의 스승님인 GPT님께 여쭈어보았다.

 

 

 

아쉽게도 별 도움은 되지 않았다.

컨텍스트 스위칭 비용을 감안해도 쿼리를 병렬로 호출하는 게 더 빠를 것 같은데.. 🥲

병렬 처리에 대해서는 좀 더 공부를 해봐야 할 듯.

 

우선 확실한 건 외부 API 호출이나 당장 최적화하기 어려운 무거운 쿼리 수행 시에는 병렬 처리가 큰 도움이 된다.

가벼운 쿼리는 왜 더 느려지는지.. 아직은 몰루..