Skip to content

로그 한 줄 없이 서버가 멈췄다 - Virtual Thread와 커넥션 풀 Starvation

로그 한 줄 없이 서버가 멈췄다 - Virtual Thread와 커넥션 풀 Starvation




TL;DR

  • 증상: 개발 서버가 완전히 멈춤, ALB 헬스체크도 응답 없음, 로그 한 줄도 없음
  • 원인: Virtual Thread 100개가 병렬 DB 조회, HikariCP 커넥션 10개 (개발 환경) → 커넥션 풀 고갈로 Deadlock 발생
  • 해결: 개발 환경 커넥션 풀 10 → 50 증가, 병렬 조회 수 10개로 제한, 타임아웃 설정
  • 효과: 커넥션 풀 Starvation 해결, 서버 먹통 현상 사라짐, 모니터링 강화
  • 한계: Virtual Thread 특성상 발견 어려움, 개발 환경만의 문제였지만 운영에서도 발생 가능, 근본 해결은 병렬 처리 제한


환경

  • Java: JDK 21 (Virtual Thread)
  • DB: MySQL 8.0, InnoDB
  • 커넥션 풀: HikariCP
  • 개발 환경: max-pool-size 10, min-idle 5
  • 운영 환경: max-pool-size 50, min-idle 10
  • 데이터 규모: 약 20만 건




글 머리말

어느 날 개발 서버가 완전히 멈췄습니다.

어드민 페이지에서 검색 버튼을 눌렀는데, 아무 반응이 없었습니다. 처음엔 “네트워크 문제겠지” 하고 새로고침을 했습니다. 여전히 반응 없음.


DataDog를 확인했을 때 진짜 이상한 걸 발견했습니다. 제가 버튼을 누른 시간 이후로 모든 요청이 사라졌습니다. 심지어 ALB의 헬스체크조차 찍히지 않았습니다.


서버가 죽은 건가? 하지만 빈스톡에서 확인해보니 CPU, 메모리, 네트워크 I/O, 디스크 모두 정상이었습니다. 컨테이너 안으로 들어가서 자바 프로세스를 확인해봤습니다. 프로세스는 살아있는데, 모든 요청이 그냥 멈춰있었습니다.


가장 당황스러웠던 건 로그가 단 한 줄도 없었다는 것입니다. HikariCP 타임아웃도, 커넥션 리크 경고도, 에러 로그도 없었습니다. 그냥 조용히, 완전히 멈춰있었습니다.


원인을 파헤쳐보니 Virtual Thread, 병렬 DB 검색, 그리고 개발 환경의 작은 커넥션 풀이 만난 완벽한 재앙이었습니다. 이 글은 그 장애의 원인과 해결 과정을 공유합니다.





문제 상황 정리

시스템 구조

먼저 우리 시스템 구조를 간단히 보겠습니다.

graph TB
    User[사용자]
    ALB[AWS ALB]
    WAS1[WAS 인스턴스 #1]
    WAS2[WAS 인스턴스 #2]
    Pool1[HikariCP Pool<br/>최대 50개]
    Pool2[HikariCP Pool<br/>최대 50개]
    DB[(RDS MySQL)]

    User --> ALB
    ALB --> WAS1
    ALB --> WAS2
    WAS1 --> Pool1
    WAS2 --> Pool2
    Pool1 --> DB
    Pool2 --> DB

    style User fill:#e1f5ff
    style ALB fill:#fff4e6
    style WAS1 fill:#e8f5e9
    style WAS2 fill:#e8f5e9
    style Pool1 fill:#fff9c4
    style Pool2 fill:#fff9c4
    style DB fill:#ffebee

핵심 설정:

# application-dev.yml
spring:
  datasource:
    hikari:
      maximum-pool-size: 50 # 인스턴스당 최대 커넥션
      connection-timeout: 30000 # 커넥션 획득 타임아웃 (30초)
      leak-detection-threshold: 0 # 리크 탐지 비활성화 (문제!)

초기 증상

사용자 행동: 관리자 페이지에서 "전체 검색" 버튼 클릭
시간: 14:23:15

14:23:15: 버튼 클릭
14:23:20: 응답 없음 (새로고침 후 재시도)
14:23:25: 여전히 응답 없음
14:23:30: DataDog 확인 → 14:23:15 이후 모든 요청 사라짐


인프라 상태 확인

1. AWS 빈스톡 (Elastic Beanstalk) 확인

CPU 20%, 메모리 40%, 네트워크 I/O 정상. 모든 메트릭이 정상이었습니다.

2. DataDog APM 확인

14:23:15 이후 Request Count 0. ALB 헬스체크조차 응답하지 않는 기이한 현상이 발견되었습니다.

3. 데이터베이스 확인

show processlist 확인 결과, 락(Lock)이 잡힌 쿼리는 없었고 커넥션들은 Sleep 상태로 놀고 있었습니다.

4. 컨테이너 내부 확인

자바 프로세스는 살아있었지만, curl로 헬스체크 요청 시 타임아웃이 발생했습니다. 프로세스는 떠 있는데 모든 요청이 멈춘 상태였습니다.





원인 분석

팀원과의 대화

나: "혹시 최근에 이 검색 API 손대신 거 있으세요?"
팀원: "아, 그거 너무 느려서 병렬로 바꿨습니다."
나: "...병렬로요?"
팀원: "네, Virtual Thread로 100개 테이블을 동시에 검색하게 했어요."
나: "100개요...?"

검색 로직을 확인해봤습니다. 순간 식은땀이 났습니다.


문제의 코드

이 코드는 100개 Repository를 모두 병렬로 검색하려고 합니다.

public SearchResult search(SearchRequest request) {
    // Virtual Thread로 병렬 검색
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        List<CompletableFuture<List<Item>>> futures = repositories.stream()
            .map(repo -> CompletableFuture.supplyAsync(
                () -> repo.search(request),  // 각각 DB 커넥션 필요!
                executor
            ))
            .toList();

        // 모든 결과 대기
        List<Item> allResults = futures.stream()
            .map(CompletableFuture::join)
            .flatMap(List::stream)
            .toList();

        return new SearchResult(allResults);
    }
}

한 번의 검색 요청이 100개의 DB 커넥션을 동시에 획득하려고 합니다.


실행 흐름 시각화

문제가 발생한 순간을 단계별로 보겠습니다.

sequenceDiagram
    participant User as 사용자
    participant API as SearchService
    participant VT as Virtual Threads
    participant Pool as HikariCP Pool<br/>(최대 50개)
    participant DB as MySQL

    User->>API: 검색 요청 (T=0초)
    API->>VT: 100개 Virtual Thread 생성

    Note over VT,Pool: T=0.1초: 동시에 커넥션 요청

    VT->>Pool: Thread 1~50: 커넥션 요청
    Pool->>DB: 50개 커넥션 연결 ✓
    Pool-->>VT: Thread 1~50: 커넥션 획득 성공

    VT->>Pool: Thread 51~100: 커넥션 요청
    Note over Pool: 풀 고갈! (0개 남음)
    Pool--xVT: Thread 51~100: 대기 큐 진입

    Note over VT,Pool: T=0.2초: Deadlock 상태

    rect rgb(255, 240, 240)
        Note over VT: Thread 1~50<br/>커넥션 보유 중<br/>51~100 완료 대기 (join)
        Note over Pool: Thread 51~100<br/>커넥션 대기 중<br/>1~50 반환 대기
    end

    Note over User,DB: 영원한 대기 (서버 멈춤)

왜 Deadlock인가?

graph LR
    A[Thread 1~50<br/>커넥션 보유] -->|join 대기| B[Thread 51~100<br/>완료 필요]
    B -->|커넥션 대기| C[HikariCP Pool<br/>0개 남음]
    C -->|반환 필요| A

    style A fill:#ffcdd2
    style B fill:#fff9c4
    style C fill:#b3e5fc
🚨Deadlock의 원인

순환 의존성 발생:

  1. Thread 1~50: 커넥션 보유 → join() 대기 → 51~100 완료 필요
  2. Thread 51~100: 커넥션 없음 → 대기 중 → 1~50 커넥션 반환 필요

결국 서로가 서로를 기다리며 영원히 멈추게 됩니다.


환경 설정 확인

운영 환경 (application-prod.yml)

spring:
  datasource:
    hikari:
      maximum-pool-size: 255

운영 환경은 최대 255개의 커넥션을 사용할 수 있습니다.


개발 환경 (application-dev.yml)

spring:
  datasource:
    hikari:
      maximum-pool-size: 50

개발 환경은 고작 50개.


문제의 조합

검색 요청 1회 = DB 커넥션 100개 동시 필요
WAS 인스턴스 2대
개발 환경 커넥션 풀: 인스턴스당 50개

계산해보면:

검색 요청 1번만 해도 = 100개 커넥션 필요
하지만 인스턴스당 최대 50개밖에 없음
→ 50개는 획득 성공, 나머지 50개는 대기

대기 중인 50개는 영원히 커넥션을 받을 수 없음
→ 왜? 이미 획득한 50개가 반환되지 않기 때문
→ 전체 요청이 멈춤 (Deadlock)

저는 버튼을 두 번 눌렀습니다. 첫 번째 요청이 50개를 물고 멈췄고, 두 번째 요청도 대기 상태로 진입했습니다. 그 순간부터 모든 요청이 얼어붙었습니다.





Virtual Thread가 뭐길래?

본격적으로 왜 로그가 없었는지 설명하기 전에, Virtual Thread에 대해 간단히 알아보겠습니다.


Platform Thread vs Virtual Thread

기존 Java의 Platform Thread(일반 스레드)는 OS 스레드와 1:1 매핑됩니다.
OS 스레드는 생성 비용이 크고, 개수도 제한적입니다.


// Platform Thread: OS 스레드와 1:1 매핑
Thread platformThread = new Thread(() -> {
    // 무거움, 개수 제한 있음
});

Virtual Thread(Java 21+) 는 다릅니다.
JVM이 관리하는 가벼운 스레드입니다.


// Virtual Thread: JVM이 관리
Thread virtualThread = Thread.ofVirtual().start(() -> {
    // 가벼움, 수백만 개 생성 가능
});

Carrier Thread (운반 스레드)

Virtual Thread는 실제로 실행될 때 Carrier Thread(Platform Thread) 위에서 돌아갑니다.


graph TB
    subgraph VirtualThreads[Virtual Threads - 경량]
        VT1[Virtual Thread 1]
        VT2[Virtual Thread 2]
        VT3[Virtual Thread 3]
        VT4[Virtual Thread 4]
        VT5[Virtual Thread 5]
        VT6[Virtual Thread 6]
    end

    subgraph CarrierThreads[Carrier Threads - Platform Thread]
        CT1[Carrier Thread 1<br/>OS Thread]
        CT2[Carrier Thread 2<br/>OS Thread]
    end

    VT1 -.실행 중.- CT1
    VT2 -.실행 중.- CT1
    VT3 -.대기.- CT1

    VT4 -.실행 중.- CT2
    VT5 -.실행 중.- CT2
    VT6 -.대기.- CT2

    style VT1 fill:#c8e6c9
    style VT2 fill:#c8e6c9
    style VT4 fill:#c8e6c9
    style VT5 fill:#c8e6c9
    style VT3 fill:#ffecb3
    style VT6 fill:#ffecb3
    style CT1 fill:#bbdefb
    style CT2 fill:#bbdefb

핵심 메커니즘:

stateDiagram-v2
    [*] --> Running: Mount
    Running --> Waiting: 블로킹 작업<br/>(DB 커넥션 대기)
    Running --> [*]: 작업 완료
    Waiting --> Running: 리소스 획득<br/>(Remount)

    note right of Running
        Carrier Thread 위에서 실행
        CPU 사용 중
    end note

    note right of Waiting
        Carrier Thread 양보 (Unmount)
        대기 큐에서 조용히 대기
    end note

동작 과정:

  1. Virtual Thread가 블로킹 작업(DB 커넥션 대기) 만남
  2. Carrier Thread를 양보 (Unmount)
  3. 대기 큐로 이동 → 조용히 대기
  4. 다른 Virtual Thread가 Carrier Thread 사용
  5. 블로킹이 끝나면 다시 Carrier Thread에 탑승 (Mount)

이제 이 메커니즘이 왜 문제였는지 알아보겠습니다.





왜 로그가 없었을까?

이 장애의 가장 무서운 점은 완벽하게 조용했다는 것입니다.


일반적인 장애라면 이런 로그가 남았을 겁니다:

ERROR c.z.h.p.HikariPool - Connection is not available, request timed out after 30000ms.
WARN  c.z.h.p.ProxyLeakTask - Connection leak detection triggered for connection...
ERROR o.s.w.s.m.m.a.ExceptionHandlerExceptionResolver - Resolved [SQLException]...

하지만 우리가 본 로그는:

(아무것도 없음)

정상적인 타임아웃이었다면?

만약 Platform Thread 환경이었다면, 이런 스택 트레이스를 볼 수 있었을 겁니다:

java.sql.SQLTransientConnectionException:
  HikariPool-1 - Connection is not available, request timed out after 30012ms.
    at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:696)
    at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:197)
    // ... 스택 트레이스

하지만 Virtual Thread 환경에서는 이 예외조차 발생하지 않았습니다.


Virtual Thread의 “조용한 블로킹”

Virtual Thread는 블로킹 작업을 만나면 Carrier Thread(Platform Thread)를 양보하고 대기합니다.


// Virtual Thread가 커넥션 획득 시도
Connection conn = dataSource.getConnection(); // 여기서 블로킹

// 이때 일어나는 일:
// 1. Virtual Thread가 Carrier Thread에서 Unmount됨
// 2. Virtual Thread는 대기 큐로 이동
// 3. Carrier Thread는 다른 Virtual Thread 실행
// 4. 하지만... 대기 큐에 있는 Virtual Thread는 조용히 기다림

문제는 모든 Virtual Thread가 동시에 대기 상태에 빠졌다는 것입니다.

Virtual Thread 1~50: 커넥션 획득, 하지만 join() 대기
Virtual Thread 51~100: 커넥션 획득 대기 (블로킹)
                       ↓
            조용히 멈춘 상태
        (로그 출력할 스레드 없음)

HikariCP 타임아웃이 작동하지 않은 이유

HikariCP의 타임아웃은 내부적으로 handoffQueue.poll(timeout) 메서드를 사용합니다.
타임아웃이 발생하면 SQLTransientConnectionException을 던지지만, Virtual Thread 환경에서는 문제가 있습니다.


Virtual Thread 환경에서도 이 타임아웃은 작동합니다.
하지만 문제는 순환 의존성(Circular Dependency) 때문에 예외를 throw할 기회가 없다는 것입니다.


타임아웃 예외가 발생하려면:
1. poll(timeout) 대기
2. 타임아웃 시간 경과
3. null 반환
4. 예외 throw

하지만 우리 상황:
- Thread 1~50: 커넥션 보유, join() 대기 (51~100 완료 대기)
- Thread 51~100: poll() 대기 (1~50 반환 대기)
   ↓
순환 의존성으로 타임아웃이 발생해도
join()이 영원히 끝나지 않아 진행 불가

즉, 타임아웃은 발생하지만 순환 의존성 때문에 그 예외를 처리할 수 없습니다.


HikariCP 리크 탐지는?

HikariCP에는 커넥션 리크(Connection Leak)를 탐지하는 기능이 있습니다:

spring:
  datasource:
    hikari:
      leak-detection-threshold: 60000 # 60초

리크 탐지 조건:

1. 커넥션을 획득함 ✓
2. 60초 동안 반환하지 않음 ✓
→ "커넥션 리크 경고" 출력

하지만 우리 상황은 이랬습니다:

1. 커넥션 획득 시도 중 ✗ (대기만 하고 있음)
2. 아직 획득하지 못함 ✗
→ 리크 탐지 조건에 해당 안 됨
→ 경고 없음

쉽게 말하면:

HikariCP 리크 탐지 = "빌려간 물건을 안 돌려줄 때" 경고
우리 상황 = "빌리지도 못하고 대기만 하는 중"
→ 경고할 조건이 아님

왜 이렇게 완벽하게 조용했을까?

결국 세 가지 안전장치가 모두 무력화됐습니다:

✗ Connection Timeout: Virtual Thread가 대기 큐에서 멈춤
✗ Leak Detection: 커넥션을 획득하지 못해서 리크가 아님
✗ Health Check: 모든 Carrier Thread가 블로킹 상태

⚠️완벽한 침묵의 이유

Virtual Thread + 커넥션 풀 고갈 조합은 완벽한 ‘조용한 장애’를 만듭니다.

  • Timeout 미발생: 대기 큐에서 멈춰서 타임아웃 체크 로직 실행 불가
  • Leak 미탐지: 커넥션을 얻지 못했으므로 리크 아님
  • Health Check 실패: 모든 스레드가 블로킹되어 헬스체크 응답 불가




해결 방법

1. 즉시 조치: 개발 환경 커넥션 풀 증가

배경 설명

우선 서버부터 살려야 했습니다. 커넥션 풀을 늘렸습니다.

설정 변경

# application-dev.yml
spring:
  datasource:
    hikari:
      maximum-pool-size: 100 # 50 → 100

결과

서버가 다시 살아났습니다. 하지만 이건 임시방편일 뿐입니다. 근본 원인을 해결해야 했습니다.


2. 근본 해결: 병렬 처리 개선

배경 설명

당장 커넥션 풀을 늘려서 장애는 해결했지만, 이건 임시방편일 뿐입니다. 병렬 검색 로직 자체를 수정해야 했습니다.

Before (문제의 코드)

public SearchResult search(SearchRequest request) {
    // 100개 Repository를 모두 병렬로
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        List<CompletableFuture<List<Item>>> futures = repositories.stream()
            .map(repo -> CompletableFuture.supplyAsync(
                () -> repo.search(request),
                executor
            ))
            .toList();

        return new SearchResult(futures.stream()
            .map(CompletableFuture::join)
            .flatMap(List::stream)
            .toList());
    }
}

After (개선된 코드)

이 코드는 10개씩 배치로 나눠서 순차 처리합니다.

private static final int MAX_PARALLEL_SEARCHES = 10; // 동시 검색 제한

public SearchResult search(SearchRequest request) {
    List<Item> allResults = new ArrayList<>();

    // 10개씩 묶어서 순차적으로 처리
    for (int i = 0; i < repositories.size(); i += MAX_PARALLEL_SEARCHES) {
        List<SearchRepository> batch = repositories.subList(
            i,
            Math.min(i + MAX_PARALLEL_SEARCHES, repositories.size())
        );

        // 배치 단위로 병렬 처리
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<CompletableFuture<List<Item>>> futures = batch.stream()
                .map(repo -> CompletableFuture.supplyAsync(
                    () -> repo.search(request),
                    executor
                ))
                .toList();

            List<Item> batchResults = futures.stream()
                .map(CompletableFuture::join)
                .flatMap(List::stream)
                .toList();

            allResults.addAll(batchResults);
        }
    }

    return new SearchResult(allResults);
}

변경 내용:

  • 한 번에 100개 → 10개씩 배치 처리
  • 10개씩 묶어서 순차적으로 진행 (총 10번 반복)
  • 요청당 최대 10개 커넥션만 사용
  • 응답 시간은 약간 느려졌지만, 안정성 확보

3. 모니터링 개선

# application.yml
spring:
  datasource:
    hikari:
      maximum-pool-size: 100
      connection-timeout: 10000 # 30초 → 10초로 단축
      leak-detection-threshold: 30000 # 리크 탐지 활성화 (30초)

management:
  metrics:
    enable:
      hikari: true # HikariCP 메트릭 활성화

DataDog 대시보드 추가:

  • HikariCP Active Connections
  • HikariCP Idle Connections
  • HikariCP Pending Threads
  • Connection Acquire Time (P95, P99)




방어책: 이런 장애를 예방하려면

이제 실전에서 쓸 수 있는 구체적인 방어책을 알아보겠습니다.


1. 커넥션 풀 설정 체크리스트

최소 설정 (필수)

# application.yml
spring:
  datasource:
    hikari:
      # 1. 커넥션 풀 크기 설정
      maximum-pool-size: 100 # 환경별로 조정 필요
      minimum-idle: 10 # 최소 유휴 커넥션

      # 2. 타임아웃 설정
      connection-timeout: 10000 # 10초 (기본 30초는 너무 길다)
      validation-timeout: 5000 # 커넥션 검증 타임아웃

      # 3. 리크 탐지 활성화
      leak-detection-threshold: 30000 # 30초 이상 점유 시 경고

      # 4. 커넥션 수명 관리
      max-lifetime: 1800000 # 30분 (DB wait_timeout보다 짧게)
      idle-timeout: 600000 # 10분

2. Virtual Thread 사용 시 필수 패턴

패턴 1: Semaphore로 동시 실행 제한

이 코드는 Semaphore로 최대 30개까지만 동시 실행하도록 제한합니다.

// 커넥션 풀 크기의 60~70% 정도로 설정
private static final int MAX_CONCURRENT = 30;
private final Semaphore semaphore = new Semaphore(MAX_CONCURRENT);

public SearchResult search(SearchRequest request) {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        List<CompletableFuture<List<Item>>> futures = repositories.stream()
            .map(repo -> CompletableFuture.supplyAsync(() -> {
                try {
                    semaphore.acquire();  // 최대 30개까지만 동시 실행
                    try {
                        return repo.search(request);
                    } finally {
                        semaphore.release();
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    throw new RuntimeException(e);
                }
            }, executor))
            .toList();

        return new SearchResult(futures.stream()
            .map(CompletableFuture::join)
            .flatMap(List::stream)
            .toList());
    }
}

Semaphore를 사용하면 동시에 실행되는 작업 수를 제한할 수 있습니다. 커넥션 풀 크기의 60~70% 정도로 설정하는 것이 안전합니다.


패턴 2: Batch 처리 (권장)

이 코드는 10개씩 배치로 나눠서 순차 처리합니다.

@Value("${search.batch-size:10}")
private int batchSize;

public SearchResult search(SearchRequest request) {
    List<Item> allResults = new ArrayList<>();

    // Batch 단위로 순차 처리
    for (int i = 0; i < repositories.size(); i += batchSize) {
        List<SearchRepository> batch = repositories.subList(
            i,
            Math.min(i + batchSize, repositories.size())
        );

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<CompletableFuture<List<Item>>> futures = batch.stream()
                .map(repo -> CompletableFuture.supplyAsync(
                    () -> repo.search(request),
                    executor
                ))
                .toList();

            List<Item> batchResults = futures.stream()
                .map(CompletableFuture::join)
                .flatMap(List::stream)
                .toList();

            allResults.addAll(batchResults);
        }
    }

    return new SearchResult(allResults);
}

배치 처리는 가장 안전한 방법입니다. 요청당 사용하는 커넥션 수를 명확히 제한할 수 있습니다.


3. 모니터링 필수 설정

Actuator Endpoint 활성화

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus
  endpoint:
    health:
      show-details: always
  metrics:
    tags:
      application: ${spring.application.name}

Custom Health Indicator

이 코드는 커넥션 풀 사용률을 실시간으로 모니터링합니다.

@Override
public Health health() {
    HikariPoolMXBean poolMXBean = dataSource.getHikariPoolMXBean();
    int activeConnections = poolMXBean.getActiveConnections();
    int maxPoolSize = dataSource.getMaximumPoolSize();
    double usage = (double) activeConnections / maxPoolSize * 100;

    Health.Builder builder = Health.up()
        .withDetail("active", activeConnections)
        .withDetail("max", maxPoolSize)
        .withDetail("usage", String.format("%.1f%%", usage));

    if (usage >= 95) {
        return builder.down()
            .withDetail("message", "Connection pool is nearly exhausted")
            .build();
    }

    if (usage >= 80) {
        return builder.status("WARNING")
            .withDetail("message", "Connection pool usage is high")
            .build();
    }

    return builder.build();
}

이 Health Indicator를 사용하면 커넥션 풀 사용률을 실시간으로 모니터링할 수 있습니다.





교훈

1. Virtual Thread의 함정

Virtual Thread가 가볍다고 해서 무한정 만들어도 된다는 뜻이 아닙니다.


Virtual Thread는 많이 만들 수 있지만, DB 커넥션 같은 리소스는 여전히 제한적입니다.


// Bad: 리소스 제약 무시
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    futures = repositories.stream()  // 100개
        .map(repo -> CompletableFuture.supplyAsync(...))
        .toList();
    // → 커넥션 풀 고갈
}

// Good: 리소스 제약 고려
Semaphore semaphore = new Semaphore(10);  // 최대 10개로 제한
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    futures = repositories.stream()
        .map(repo -> CompletableFuture.supplyAsync(() -> {
            semaphore.acquire();
            try {
                return repo.search(request);
            } finally {
                semaphore.release();
            }
        }))
        .toList();
}

핵심 원칙:

Virtual Thread 개수는 무한대에 가깝다
하지만 DB 커넥션, 파일 디스크립터, 메모리는 여전히 유한하다
→ 병목은 항상 외부 리소스에서 발생한다

2. 조용한 장애가 가장 위험하다

로그 한 줄 없이 서버가 멈추는 장애가 가장 디버깅하기 어렵습니다.


Virtual Thread + 커넥션 풀 고갈 조합은 특히 조용합니다:

  • HikariCP 타임아웃: 작동 안 함 (Carrier Thread가 없음)
  • 리크 탐지: 작동 안 함 (커넥션 획득 실패)
  • 에러 로그: 없음

이런 조용한 장애를 막는 방법:

1. 사전 예방
   - 병렬 처리에 Semaphore/배치 제한 걸기
   - 커넥션 풀 크기 충분히 확보하기

2. 빠른 감지
   - HikariCP 메트릭 모니터링
   - 커넥션 풀 사용률 80% 경보
   - Pending connections 경보

3. 디버깅 대비
   - leak-detection-threshold 활성화
   - connection-timeout 짧게 (10초 이하)
   - Thread Dump 수집 자동화

3. 개발 환경은 운영의 축소판이 아니다

개발 환경에서만 터지는 버그가 있습니다.


운영 환경: 커넥션 풀 255개 → 100개 병렬 처리 OK
개발 환경: 커넥션 풀 50개 → 100개 병렬 처리

리소스가 적은 개발 환경에서 먼저 터지는 건 오히려 다행입니다.
운영에서 터지면 훨씬 큰 문제니까요.


권장 사항:

개발 환경 = 운영 환경의 30~50% 리소스 (최소한)

예시 (우리 경우):
- 운영 환경: 커넥션 풀 255개
- 현재 개발 환경: 50개 (약 20% - 너무 작음!)
- 권장 개발 환경: 최소 75~125개 (30~50%)

→ 커넥션 풀만이 아니라 메모리, CPU도 비슷한 비율로 유지

왜 30~50%인가?
- 개발에서 발견 → 수정 비용 낮음 + 빠른 피드백
- 운영에서 발견 → 수정 비용 높음 + 장애 발생
- 너무 작으면 문제를 숨기고, 너무 크면 비용 낭비




시스템 점검 체크리스트

저도 배포 전에 이 항목들을 꼭 확인합니다. Virtual Thread를 사용한다면 참고하시면 좋을 것 같습니다.

  • 커넥션 풀 크기: 병렬 작업 수를 고려하여 충분한 커넥션 풀을 설정했는가? (Virtual Thread 수 ≤ Connection Pool)
  • 타임아웃 설정: HikariCP connection-timeout을 설정하여 무한 대기를 방지했는가?
  • 병렬 작업 제한: parallelStream이나 병렬 처리 수를 제한했는가? (무제한 생성 방지)
  • 환경별 설정: 개발/스테이징/운영 환경에서 커넥션 풀 크기가 적절한가?
  • 모니터링: HikariCP 메트릭스(active connections, idle connections)를 모니터링하는가?



마무리

이번 장애를 겪으면서 깨달은 게 있습니다.

최신 기술에는 항상 숨겨진 대가가 있다는 것을요.


Virtual Thread가 나왔을 때 저도 흥분했습니다. “드디어 스레드 걱정 없이 마음껏 병렬 처리를 할 수 있겠구나!” 팀원도 비슷하게 생각했을 겁니다. 그래서 100개 테이블을 동시에 검색하는 코드를 짰겠죠.

근데 막상 터지고 나서 팀원과 대화할 때가 가장 난감했습니다.

나: "왜 100개를 동시에 검색하신 거예요?"
팀원: "Virtual Thread니까 괜찮은 줄 알았습니다..."
나: "..."

팀원을 탓할 수 없었습니다. 저도 똑같이 생각했으니까요.

Oracle 문서에도, Spring 블로그에도 “Virtual Thread는 가볍다”, “수백만 개를 만들어도 괜찮다”고 적혀있습니다. 맞는 말입니다. 근데 그게 전부는 아니더군요.

Virtual Thread는 아무리 가벼워도 DB 커넥션이라는 물리적 한계 앞에서는 무력합니다. 스레드 100만 개를 만들어도 커넥션 풀이 50개면 소용없습니다.


더 무서웠던 건 “조용함”이었습니다.

보통 장애가 터지면 로그라도 남습니다. “Connection timeout”, “Pool exhausted”, 뭐라도 남죠. 근데 이번엔 아무것도 없었습니다. 서버가 그냥 조용히 멈췄습니다.

Virtual Thread가 Carrier Thread를 양보하고 대기 큐로 들어가는 순간, 타임아웃 체크 로직조차 실행되지 않았습니다. HikariCP의 리크 탐지도 작동하지 않았습니다. 커넥션을 획득하지도 못했으니 리크가 아니거든요.

완벽한 침묵 속에서 서버가 죽어가고 있었습니다.


그리고 또 하나 깨달은 게 있습니다.

개발 환경은 운영의 축소판이 아니라, 오히려 더 취약한 환경이라는 것.

운영 환경은 커넥션 풀이 255개였습니다. 100개 병렬 처리해도 문제없었을 겁니다. 근데 개발 환경은 50개밖에 없었습니다. 그래서 개발 환경에서 먼저 터진 거죠.

처음엔 “왜 개발에서만 터지지?”라고 생각했습니다. 근데 지금 생각하면 다행이었습니다. 만약 제가 그날 검색 버튼을 누르지 않았다면? 이 코드는 운영에 배포됐을 겁니다. 그리고 피크 타임에, 트래픽이 몰렸을 때, 똑같이 터졌겠죠.

그때는 훨씬 많은 사용자가 영향을 받았을 겁니다.


이 장애로 우리 팀이 배운 것들:

  1. Virtual Thread ≠ 무한 리소스

    • 스레드는 가볍지만 DB 커넥션은 여전히 유한하다
    • 병렬 처리에는 반드시 Semaphore나 배치 제한이 필요하다
  2. 조용한 장애가 가장 위험하다

    • 로그가 없는 장애는 디버깅이 불가능하다
    • 모니터링 없이는 아무것도 모른다
  3. 개발 환경도 중요하다

    • 개발 환경이 너무 작으면 오히려 문제를 못 찾는다
    • 운영의 50% 이상은 유지해야 한다
  4. 새 기술에는 숨겨진 함정이 있다

    • 문서가 다 알려주지 않는다
    • 직접 부딪혀봐야 안다

저희 팀은 이제 모든 병렬 처리 코드에 Semaphore나 배치 제한을 겁니다. 귀찮지만, 조용히 멈추는 장애를 다시 겪는 것보다는 훨씬 낫습니다.

Virtual Thread를 쓰고 계신다면, 한 번쯤 검토해보시는 것도 좋을 것 같습니다.

“이게 정말 무한 동시성을 보장할까?”
“커넥션 풀은 충분한가?”
“개발 환경에서도 괜찮을까?”

비슷한 상황이라면 참고하시면 도움이 될 겁니다.





참고 자료

https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html
> https://github.com/brettwooldridge/HikariCP
> https://spring.io/blog/2022/10/11/embracing-virtual-threads
Java 동시성 프로그래밍 (브라이언 게츠)




읽어주셔서 감사합니다.🖐


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#소개 페이지#Blog OpenSource Github#Blog OpenSource Demo Site