Projects/[Final] Shopping Mall Project

가상 스레드를 적용했는데 왜 성능이 그대로인가

montmer27 2026. 5. 8. 01:11

관련 포스팅

https://montmer27.tistory.com/263

 

가상 스레드 적용 전 테스트 결과 (결제 생성)

scenario-b테스트는 load와 pressure로 나누어 2차례에 걸쳐 실시했다.load test에서는 안정적인 baseline을 확인한다.stress test에서는 headroom(버티는 한계)를 확인한다.가상 스레드 적용 후에는 baseline 성능

montmer27.tistory.com

https://montmer27.tistory.com/264

 

가상 스레드 적용 후 테스트 결과 (결제 생성)

scenario-b테스트 시나리오 코드: 이전과 동일TEST-1test type: load목적: baseline 성능 개선 측정test detailload: [ {target: 10, duration: '1m'}, // 워밍업: 10 RPS까지 증가 {target: 20, duration: '1m'}, {target: 30, duration: '1m'}

montmer27.tistory.com

예상

Spring Boot 3.2부터 가상 스레드를 활성화하면 blocking I/O 구간에서 OS 스레드가 묶이지 않아 동시 처리량이 늘어난다고 알려져 있다. 결제 플로우처럼 DB 조회, 외부 PG 호출, Redis 접근이 겹치는 구조라면 효과가 클 것이라 예상했다. 목표는 p95 500ms SLA 충족이었고, 가상 스레드 적용만으로도 의미 있는 개선이 있을 것이라 기대했다.


결과

k6 부하 테스트 결과 cart_add, order_create, payment_create 세 API 모두 p95가 5초대로 측정됐다. 특정 API 하나만 느린 것이 아니라 구매 플로우 전체가 동시에 밀리는 양상이었다. Insufficient VUs 경고와 함께 dropped_iterations이 5천 건을 넘었고, 500 VUs까지 투입해도 목표 arrival rate를 유지하지 못했다. 가상 스레드 적용 전후 수치 차이가 통계적으로 유의하지 않았다.

가상 스레드 적용 전 pressure 테스트. 요청의 실패 지점이 명확히 드러난다.
가상 스레드 적용 후. 전과 거의 비슷하며 성능 개선은 관측되지 않는다.

지표 적용 전 적용 후 변화
전체 p95 5.03s 5.21s +3.6% 악화
cart_add p95 5.06s 5.25s +3.8% 악화
order_create p95 4.99s 5.16s +3.4% 악화
payment_create p95 5.03s 5.20s +3.4% 악화
평균 응답시간 2.89s 2.99s +3.5% 악화
HTTP RPS 74.71/s 72.89/s -2.4% 감소
iterations 8,399 8,216 -2.2% 감소
dropped iterations 5,130 5,313 +3.6% 악화
iteration p95 14.43s 15.13s +4.9% 악화

 


원인 추정

분석 결과 병목이 스레드 수 부족이 아니었을 가능성이 높다. 가상 스레드는 플랫폼 스레드가 I/O 대기로 묶이는 문제를 해결하는 기술이지, DB 락 경합이나 커넥션 풀 한계를 해결하는 기술이 아니다.

첫째, 이중 락 구조가 직렬화 구간을 두 배로 만들고 있다. BuyerPaymentOrderFacade에서 Redisson 분산 락을 걸고, 내부 OrderService에서 SELECT FOR UPDATE로 DB 비관적 락까지 이중으로 건다. 동일 상품에 대한 요청이 Redis에서 한 번, DB에서 다시 한 번 직렬화된다. 가상 스레드가 수천 개 생겨도 이 직렬화 구간에서는 모두 순서를 기다린다.

둘째, HikariCP 커넥션 풀이 기본값인 10개로 설정돼 있다. 가상 스레드가 수백 개 생성되더라도 DB에 동시 접근할 수 있는 트랜잭션은 10개가 한계다. 나머지는 HikariCP 내부 대기열에 쌓이고, 오히려 tail latency가 커지는 결과로 이어진다.

셋째, 가상 스레드 pinning 문제가 있다. Redisson 내부와 pgjdbc 일부 I/O 경로에 synchronized 블록이 있어, tryLock 호출이나 DB 쿼리 실행 중 carrier thread에 고정(pinned)된다. 이 구간에서는 가상 스레드가 플랫폼 스레드와 동일하게 동작하므로 이점이 사라진다. 동시 처리 수가 결국 carrier thread 수, 즉 OS 스레드 수에 수렴한다.

넷째, confirmPayment 하나의 트랜잭션 안에 SELECT FOR UPDATE, 검증, 상태 변경, JSON 직렬화, Outbox 저장이 모두 포함된다. DB 락 보유 시간이 길어지면 다른 요청의 대기 시간이 함께 늘어난다.

다섯째, 부하 테스트 중 show-sql: true, format_sql: true 설정이 활성화돼 있어 모든 SQL을 포맷팅해서 콘솔에 출력하는 오버헤드가 추가된다.

정리하면, 이번 테스트는 스레드가 모자라서 느린 게 아니라 DB 락 경합과 커넥션 풀이 공유 자원 병목으로 작용한 상황이었다. 이 상태에서는 스레드 모델을 어떻게 바꿔도 병목 자원이 그대로라면 p95가 개선되지 않는다.


다음 실행 계획

단기적으로 가장 효과가 클 것으로 보이는 두 가지를 먼저 적용한다.

이중 락 구조 제거: Redisson 분산 락과 DB 비관적 락 중 하나를 제거한다. DB 비관적 락만으로도 재고 정합성을 보장할 수 있다. 혹은 Redisson 락만 유지하고 DB 쪽은 낙관적 락으로 전환하는 방향도 검토한다. PaymentRetryService에 이미 @Retryable(retryFor = OptimisticLockingFailureException.class)가 있어 재시도 구조는 갖춰져 있다.

HikariCP 커넥션 풀 크기 조정: application.yaml에 아래 설정을 추가하고 부하 테스트로 최적값을 탐색한다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      connection-timeout: 5000

중기적으로는 트랜잭션 범위를 축소한다. confirmPayment에서 JSON 직렬화와 Outbox 저장을 @TransactionalEventListener 또는 별도 @Transactional 메서드로 분리해 DB 락 보유 시간을 단축한다.

pinning 발생 여부는 JVM 옵션으로 진단한다.

-Djdk.tracePinnedThreads=full

pinning이 확인되면 pgjdbc 42.7 이상으로 업그레이드하고 Redisson 버전도 가상 스레드 친화적 버전으로 검토한다.

부하 테스트 시에는 반드시 show-sql과 format_sql을 false로 설정한다.

재고 선차감을 Redis DECRBY로 처리하는 방식은 DB 락을 근본적으로 줄이는 방법으로, 위 개선들이 적용된 이후 단계에서 별도로 검토한다.