운영 서버에 Virtual Thread를 켰다. 성능 테스트에서 처리량이 3배 올랐다. 팀 전체가 기분 좋았다. 그리고 다음 날 새벽, 슬랙 알람이 터졌다.

뭐가 문제였나

Java 21의 Virtual Thread는 스레드를 거의 무제한으로 만들어준다. 기존 플랫폼 스레드 200개 제한이 사라지니까, 동시 요청이 수천 개까지 올라간다. 문제는 DB 커넥션 풀이 그 속도를 못 따라간다는 거다.

HikariCP 기본 maximumPoolSize는 10이다. 플랫폼 스레드 200개 시절에는 괜찮았다. 동시에 DB를 때리는 스레드가 최대 200개니까, 커넥션 10개로도 대기 시간이 관리 가능했다. Virtual Thread가 동시에 3,000개 요청을 만들면?

HikariPool-1 - Connection is not available, request timed out after 30000ms.

이 로그가 수백 줄 찍히면서 서비스가 멈춘다.

숫자로 보면 명확하다

플랫폼 스레드 환경에서는 동시 요청 약 200개, 커넥션 풀 10개, 경쟁 비율 20:1이다. 평균 쿼리 시간이 50ms면 대기 시간은 약 1초 수준으로 관리 가능하다.

Virtual Thread로 바꾸면 동시 요청이 3,000개로 뛴다. 커넥션 풀은 그대로 10개. 경쟁 비율 300:1. 같은 쿼리인데 대기 시간이 15초를 넘기면서 timeout이 터진다.

커넥션 풀 사이즈를 안 건드렸으니 당연한 결과다.

풀을 늘리면 되나?

단순하게 maximumPoolSize=100으로 올리면? PostgreSQL max_connections 기본값이 100이다. MySQL도 151이다. 서버 3대에 풀 100이면 커넥션 300개. DB가 감당 못 한다.

이게 Virtual Thread의 함정이다. 애플리케이션 레이어 동시성은 폭발하는데, DB 커넥션은 물리적 자원이라 같이 안 늘어난다.

실전에서 적용한 것들

Semaphore로 DB 접근 제한

private final Semaphore dbLimiter = new Semaphore(50);

public Result query(Request req) {
    dbLimiter.acquire();
    try {
        return repository.findByCondition(req.condition());
    } finally {
        dbLimiter.release();
    }
}

Virtual Thread는 수천 개 돌리되, 실제 DB를 때리는 건 Semaphore로 50개 이내로 잡는다. Virtual Thread의 Semaphore.acquire()는 캐리어 스레드를 블로킹하지 않으니까 이 패턴이 잘 먹힌다.

HikariCP 타임아웃 줄이기

spring:
  datasource:
    hikari:
      maximum-pool-size: 30
      connection-timeout: 5000
      minimum-idle: 10

connection-timeout을 30초 기본값에서 5초로 줄인 게 핵심이다. 30초간 매달려 있다가 한꺼번에 에러 폭탄이 터지는 것보다, 빨리 실패하고 재시도하는 편이 시스템 전체 안정성에 낫다.

읽기는 레플리카로

쓰기는 Primary, 읽기는 Replica. @Transactional(readOnly = true)가 붙은 메서드는 읽기 전용 DataSource로 라우팅해서 커넥션 풀 경합을 절반으로 줄였다. 이건 Virtual Thread 도입 전에도 해야 하는 건데, 도입하고 나서야 필요성을 절감했다.

모니터링 없으면 새벽에 안다

Grafana에 이 쿼리 하나 올려놓으면 된다:

hikaricp_connections_active / hikaricp_connections_max > 0.8

활성 커넥션이 풀 사이즈의 80%를 넘으면 알람. 이게 없으면 꼭 새벽 3시에 PagerDuty가 울린다.

Virtual Thread는 좋은 기술이다. 근데 "켜기만 하면 빨라진다"는 환상이 위험하다. 병목은 항상 가장 느린 자원에서 생기고, 대부분의 백엔드 서비스에서 그게 DB다.