[Project Loom] Virtual Thread에 봄(Spring)은 왔는가

[Project Loom] Virtual Thread에 봄(Spring)은 왔는가

요약: 카카오페이 머니플랫폼팀이 Virtual Thread를 적용한 경험을 공유합니다. 기존의 Java Platform Thread 모델을 Virtual Thread로 전환하여 성능을 개선하고 자원 소모를 줄였습니다. 성능 테스트 결과 긍정적인 변화가 있었지만, 모든 라이브러리가 Virtual Thread를 완벽히 지원하지 않아 예상치 못한 성능 저하도 경험했습니다. 이 글에서는 Virtual Thread 도입의 장점과 주의사항을 진솔하게 다룹니다.

💡 리뷰어 한줄평

katfun.joy 많은 분들이 관심 있으실 주제를, ro의 경험에 빗대어 소개해 주셨습니다. Virtual Thread에 관심 있으시다면 부디 읽어보시길!

dory.m 요즘 핫한 Virtual Thread에 대해 실무적인 관점에서 접근하여 기술 선택에 도움을 주는 글입니다! Spring 개발자분들이라면 한 번쯤 읽어보시길 추천드려요!

시작하며

안녕하세요, 카카오페이 머니플랫폼파티 Ro입니다.

머니플랫폼파티는 안정성과 속도 두 마리 토끼 중 하나도 놓칠 수 없는 핵심 임무를 맡고 있습니다. 매일 무려 1,500만 건이 넘는 송금, 결제, 적립 등 카카오페이머니를 이용한 모든 거래를 처리합니다. 동시에 수많은 사내 팀에 카카오페이 유저의 머니 연동 상태, 거래내역 등을 제공하는 플랫폼을 관리합니다.

Disclaimer. 경험과 실수를 통해 한걸음 한걸음 성장해 나가는 개발자로서 특정 기술에 대해 편향된 관점을 최대한 지양하기 위해 한 단어 한 단어 수십 번의 썼다지움을 반복하며 마침내 조심스러운 마음으로 이 글을 전합니다.

달리는 폭주기관차 바퀴 갈아 끼우기

카카오페이머니는 전사를 통틀어 손에 꼽을 정도로 오랜 기간 동안 달려온 서비스입니다. 긴 여정을 걸어온 만큼 서비스가 의존하고 있는 프레임워크 및 라이브러리들의 새로운 버전은 하루가 다르게 릴리즈 되어 왔습니다. 그러나 찰나의 멈춤도 용납할 수 없는 폭주기관차의 바퀴를 갈아 끼우는 것은 가능 여부조차 판단하기 어려웠습니다. 그럼에도 불구하고 지금의 낙후된 부품으로는 더 빠르고 안정적인 여정을 유저들에게 제공하는데 한계가 있다는 것을 체감했습니다. 결국 버전업이라는 더 이상 미룰 수 없는 숙제를 청산할 시간을 마주했습니다.

달리는 폭주기관차 바퀴 갈아 끼우기
달리는 폭주기관차 바퀴 갈아 끼우기

그렇게 팀원 모두가 마음 한편에 그 바퀴를 갈아 끼워야 한다는 숙제를 안고 있던 중 타 부서에서 아래와 같은 요청을 합니다.

카카오페이 유저 ID에 매핑된 페이머니 정보가 필요합니다. 근데 이제 엄청 많이 호출할 건데 빠르면서 안정성을 곁들인…

긴 시간 품어온 고민과 언젠가는 마주쳤어야 할 타 부서의 요청은 스노우볼1이 되어 굴러 “이런 좋은 기회에 달리던 기관차는 계속 달리게 두면서 새로운 기관차를 만들어 환승해 봅시다!”라는 결단을 내리게 됩니다.

Java Thread 그리고 Spring

당시 팀에서도 새로운 기관차를 만드는 타이밍에 맞춰 등장한 Java 21에 정식 공개된 Virtual Thread와 Spring Boot 3.2에 많은 관심을 기울이고 있었습니다. 선택의 기로에서의 경험담을 공유하기에 앞서 Java의 Threading Model에 대해 간략하게 설명드리겠습니다.

AS-IS (a.k.a. Platform Thread)

Java의 전통적인 Threading Model은 JVM 내에서 Platform Thread를 생성할 때 Java Native Interface(JNI)를 사용해 OS(Kernel) Thread에 직접 매핑되도록 설계되었습니다. 이 구조는 Context switching을 통해 OS 자체의 리소스를 점유합니다.

Platform Thread
Platform Thread

즉, Thread의 최대 가용 개수는 OS에서 생성할 수 있는 최대 Thread 개수를 초과할 수 없다는 의미입니다. 그러다 보니 이러한 구조는 아래와 같이 명백한 한계가 있습니다.

  • 새로운 Thread 생성 및 Thread 간 Context switch 발생 시 큰 오버헤드
  • 각 Thread의 대량 메모리 차지
  • Spring MVC와 같은 Thread-per-request 구조에서 최대 요청 수용량 제한

그럼에도 불구하고 이러한 Threading Model은 그냥 설계된 게 아닙니다. 많은 Contributor가 여러 세대를 거친 고민을 토대로 Threading Model을 설계했습니다. 개발 당시 하드웨어 성능과 하드웨어 위에서 동작하는 OS 효율을 최대로 끌어내는 것에 집중했습니다.

하지만 과거와는 비교할 수 없을 정도로 하드웨어와 OS의 성능이 개선되었습니다. 현재 상황에서 위 한계를 극복하기 위해 Go에서는 Goroutine, Kotlin에서는 Coroutine과 같이 Kernel 레벨이 아닌 Runtime 레벨에서 Task Scheduling, Context switching을 수행하는 Lightweight Thread(경량 스레드)가 트렌드로 자리 잡고 있습니다.

TO-BE (a.k.a. Virtual Thread)

2018년 Project Loom은 JVM 내에서 경량 스레드를 지원한다는 큰 포부와 함께 출범하였습니다. 2022년 9월 Java 19 Preview Feature를 거쳐 2023년 9월 19일 Java 21의 GA가 공개된 이후 사실상 종료를 선언하였습니다. 이후, Virtual Thread가 공식 기능으로 당당히 자리를 잡았습니다. 그 안을 들여다보면 ‘기존 JVM과 OS 사이에서 일어나던 비싼 Operation들을 줄이기 위해 JVM 내에서 모두 해내겠다’라는 Project Loom 팀의 결연한 의지를 엿볼 수 있습니다.

Virtual Thread
Virtual Thread

위 이미지에서 볼 수 있듯이 기존의 Task 단위 OS Thread 생성이 아닌 JVM 내부에서 관리되는 Virtual Thread를 생성하고 미리 생성된 Carrier Thread로의 스케쥴링을 통해 기존 아키텍처에 비해 리소스 비용을 크게 절감했습니다.

Virtual Thread (단맛 편)

Virtual Thread의 등장은 위에서 언급한 기존 방식의 문제점들을 크게 개선하였습니다.

AS-ISTO-BE
OverheadThread 생성 및 Context switch 간 큰 오버헤드소요시간 약 10배 절감
Memory각 Thread 당 수 MB에 달하는 메모리 차지소요 메모리 약 200배 절감
Capacity최대 가용 Thread는 OS 최대 Thread 수로 제한JVM Heap이 허용하는 한 제한 없이 가용

Spring Boot 3.2 + Virtual Thread 적용기

필자 또한 Virtual Thread 열풍에 편승하여 새로운 프로젝트에 이를 적용해보고자 했습니다.

산전수전을 겪어온 팀원들은 Virtual Thread의 높은 가용성이 과연 하나의 서비스라는 작은 시야를 넘어 넓은 관점으로 시스템 내 안정성을 보장해 줄지에 대한 물음표를 던졌습니다.

팀원들의 물음에 답하기 위해 필자는 성능테스트를 진행하기로 합니다.

성능테스트

Virtual Thread 성능테스트를 검색해보면 굉장히 높은 확률로 아래와 같은 예시를 볼 수 있습니다.

spring:
  threads:
    virtual:
      enabled: true
@RestController
public class TestController {
    @GetMapping
    public void virtualThreadIsAwesome() {
        Thread.sleep(5);
    }
}

물론 Virtual Thread의 장점을 직관적으로 보여주기에는 더할 나위 없이 좋은 예시입니다. 하지만, 실제로 현업에서 제공하는 기능의 복잡성과는 거리가 굉장히 멀어 보인다고 생각한 필자는 실무에서 요구받은 좀 더 복잡한 Use Case를 토대로 성능 테스트를 실행해 보았습니다.

Use Case

특정 페이 계정 ID에 대한 머니 서비스 정보 조회 API 제공

해당 API를 효과적으로 제공하기 위해 Redis Cache를 활용하였고 Cache Miss가 발생하는 경우 MySQL을 조회하여 응답하되 Cache에 저장하여 이후 호출에 대해 Cache Hit가 보장되도록 설계하였습니다.

테스트 방법

성능테스트는 nGrinder를 활용해 실행하였습니다.

Scenario

  • Web Server
    • 인스턴스 개수: 2
    • Web Framework: Spring Boot 3.2
  • Cache Layer: Redis Standalone
  • DB Layer: MySQL

Appendix

  • TPS2: Transaction per Second (초당 처리 Transaction 수)
  • MTT3: Mean Test Time (테스트 1회 당 평균 수행 시간)

A-1. Cache Layer 조회 (w/ Jedis)

A-2. Cache Layer 조회 (w/ Lettuce)

B. DB Layer 조회

결과 분석

위 테스트 결과를 통해 아래와 같은 결과를 알 수 있습니다.

PlatformVirtual
최대 Tomcat thread 개수 제한으로 인해 자연스럽게 Back Pressure가 수행되어 Pool Borrow 관련 실패 미발생제한되지 않은 대량 트래픽 수용으로 인해 수용된 요청에 대해 균일한 지연 및 Max Pool Borrow Timeout이 발생하여 요청 수행 간 예외 발생
전체적으로 요청 수용량이 제한되므로 Heap Memory 관련 이슈 미발생제한된 JVM Memory 설정 시 Heap Memory 부족으로 인해 잦은 Garbage Collection이 발생하였고 이로 인해 순간적으로 다량의 응답 지연 발생
synchornized 등 pinning 구간에서 Side-effect 미발생-Djdk.tracePinnedThreads=short JVM Option 설정 시 MySQL 및 Jedis 코드 수행 간 Warning 메시지 발생, 이로 인해 예상치 못한 성능 저하 가능성 확인

Virtual Thread (쓴맛 편)

Virtual Thread의 단맛에는 쓴맛이 분명 존재합니다.

단맛
단맛

더 정확히 표현하자면, 서비스가 놓여있는 인프라가 뒷받침되지 않거나 사용하고 있는 라이브러리가 완벽히 대응되지 않은 경우 Virtual Thread의 단맛은 오히려 쓴맛으로 작용할 수 있습니다.

Java 공식 문서에서는 Virtual Thread 사용 시 유의사항에 대해 아래와 같이 설명하고 있습니다.

  • Don’t Pool Virtual Threads → Virtual Thread 사용 시 Pooling을 지양한다.
  • Use Semaphores for Limited Resources → 제한적인 리소스 접근 및 사용 시 Semaphore를 활용한다.
  • Avoid Pinning → synchronized 키워드와 같은 사용으로 인한 Pinning 구간을 지양한다.
  • Review Usage of Thread-Local Variables → Thread-Local 변수 사용을 지양한다.

위 내용이 Virtual Thread 사용 자체의 유의사항이라면 아래는 하나의 서비스, 넓게는 시스템 관점에서 바라본 Virtual Thread 사용 시 유의사항입니다.

  • 현시점에서 모든 Java 라이브러리가 Pinning 구간에 대해 완벽히 대응된 상태가 아니며 의존하고 있는 서비스들에 대해 해당 구간을 모두 확인하는 것은 사실상 불가능하기에 이로 인해 오히려 성능 저하가 발생할 수 있습니다.
  • Virtual Thread의 높은 가용성은 오히려 해당 인프라와 서비스 간 Connection을 획득하지 못하거나 인프라 자체의 제한된 처리량 등에서 발생하는 병목현상으로 인해 모든 요청에 동등한 성능저하 또는 응답실패의 원인이 될 수 있습니다.
  • Virtual Thread의 높은 가용성은 타 서비스로의 과도한 트래픽 전파로 이어져 넓은 범위에서의 시스템 장애로 이어질 수 있습니다.

그래서 그대들은 어떻게 쓸 것 인가?

그대들은 어떻게 쓸 것 인가
그대들은 어떻게 쓸 것 인가

머니플랫폼에서 준비 중인 신규 프로젝트는 위 성능테스트 결과를 토대로 아래와 같이 Virtual Thread를 활용하기로 결정했습니다.

카카오페이머니 서비스는 굉장히 오랜 시간 동안 운영 중에 있습니다. 그만큼 인프라도 많은 서비스와 공유되고 있습니다. 즉, Virtual Thread 가용량만큼 신규 서비스의 인프라 부하를 무작정 늘릴 수는 없다고 판단했습니다. 따라서 Spring Boot 내 기본 설정을 비활성화하는 방향으로 의견을 모았습니다.

spring:
  threads:
    virtual:
      enabled: false

단, I/O를 적극 활용하는 구간에 있어 인프라 또는 타 서비스로의 트래픽 자체에 영향을 주지 않는 선에서 선택적으로 Virtual Thread를 이용해 Executor를 생성하고 Bean으로 지정하여 차츰차츰 Virtual Thread를 활용하기로 결정하였습니다.

@EnableAsync
@Configuration
public class AsyncConfig {
    private static final String ASYNC_THREAD_PREFIX = "some-task-";

    @Bean
    public Executor someTaskExecutor() {
        var executor = new TaskExecutorAdapter(new VirtualThreadTaskExecutor(ASYNC_THREAD_PREFIX));
        executor.setTaskDecorator(new TaskDecorator());
        return executor;
    }
}

마치며

Project Loom 그리고 그 위대한 산물인 Virtual Thread는 분명 Java 생태계에 열풍 그 자체이며 커뮤니티에 엄청난 열풍을 불러일으켰음에 의심할 여지가 없습니다. 또한, 수많은 개발자들이 그 패러다임에 맞춰 발 빠르게 프레임워크 및 라이브러리들을 개선해 나가고 있습니다.

Virtual Thread는 잘못한 것이 없습니다. 오히려 Virtual Thread에는 단맛만이 존재한다고 필자는 생각합니다. 다만, 새로운 패러다임의 등장은 때때로 과하게 비치는 스포트라이트로 인해 넓은 관점에서 그 기술이 적절하게 이용될 수 있는지에 대한 고찰을 심심치 않게 방해합니다.

마침내 Virtual Thread의 시대에 살고 있는 지금 이 순간, 짧은 동면(a.k.a. Thread.sleep(5))에서 깨어난 우리 모두에게 정말 봄(Spring)이 찾아왔는지, 우리가 개발하고 있는 서비스가 그 달콤함을 만끽할 준비가 되었는지 잠시나마 생각해볼 수 있는 작은 여유가 되었기를 바라봅니다.

마지막으로 이 글을 읽고 있는 독자 여러분들과 같은 마음으로 실무에서 반드시 써야 할 Virtual Thread로서의 가까운 미래를 기대하며 이 글을 마칩니다.

참고 자료

Footnotes

  1. 스노우볼 효과란 말 그대로 눈덩이가 커지는 것 처럼 어떤 사건이 작은 출발점에서부터 점점 커지는 과정을 일컫는 것을 이르는 말입니다.

  2. TPS란 Transaction per Second의 약자로서, 1초당 처리할 수 있는 트랜잭션의 개수를 의미합니다. 100만 TPS는 1초당 100만 건의 트랜잭션을 처리할 수 있는 속도를 말합니다.

  3. MTT는 Mean Test Time의 약어이며, 말 그대로 테스트의 평균적인 1회 수행 시간이라고 보면 됩니다.

ro.stradamus
ro.stradamus

카카오페이 모든 유저가 페이머니를 통해 빠르고 안전한 거래를 경험할 수 있도록 스스로에게 끊임없는 질문을 통해 성장하며 서버 개발을 하고 있습니다.