본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
9장 : 성능 측정과 확장

가상 스레드와 부하 한계

가상 스레드는 blocking 코드의 구조를 유지하면서 많은 대기 작업을 적은 플랫폼 스레드 위에서 처리하게 합니다. 하지만 CPU, DB connection, 외부 API 허용량까지 늘려 주지는 않습니다. 스레드가 싸졌다는 이유로 무제한 요청을 받아들이면 병목 앞의 대기열만 더 길어질 수 있습니다.


스레드 수와 외부 자원 수는 같은 문제가 아니다

가상 스레드는 파일·소켓·DB 응답을 기다리는 동안 carrier thread를 양보할 수 있어 I/O 중심 동시성에 유리합니다. 반면 Hikari pool이 20이면 동시에 DB 작업을 수행할 수 있는 요청은 여전히 20개 안팎입니다. 나머지는 connection을 기다립니다.

따라서 queue 상한, timeout, semaphore, rate limit 같은 backpressure가 함께 필요합니다. 시스템이 감당할 수 없는 요청을 끝없이 기다리게 하는 것보다 빠르고 관측 가능한 실패를 반환하는 편이 전체 복구에 유리합니다.


가상 스레드 실행기를 명시적으로 사용한다

여러 과목의 원격 보고서를 독립 작업으로 제출합니다. task는 즉시 시작되지만 결과를 모으는 호출은 모든 작업이 끝날 때까지 기다립니다. 부분 실패를 허용할지 전체 요청을 실패시킬지는 업무 정책으로 따로 결정해야 합니다.

src/main/java/com/andongmin/studylog/report/ReportLoadException.java
package com.andongmin.studylog.report;

public class ReportLoadException extends RuntimeException {
    public ReportLoadException(Throwable cause) {
        super("parallel report load failed", cause);
    }
}
src/main/java/com/andongmin/studylog/report/ParallelReportLoader.java
package com.andongmin.studylog.report;

import java.util.List;
import java.util.concurrent.Executors;
import org.springframework.stereotype.Service;

@Service
public class ParallelReportLoader {
    private final ReportPort reports;

    public ParallelReportLoader(ReportPort reports) {
        this.reports = reports;
    }

    public List<ReportPort.SubjectReport> load(List<String> subjects)
            throws InterruptedException {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var tasks = subjects.stream()
                    .map(subject -> executor.submit(() -> reports.findBySubject(subject)))
                    .toList();
            try {
                return tasks.stream().map(task -> {
                    try {
                        return task.get();
                    } catch (Exception failure) {
                        throw new ReportLoadException(failure);
                    }
                }).toList();
            } finally {
                executor.shutdownNow();
            }
        }
    }
}
src/main/resources/application.properties
spring.threads.virtual.enabled=true
spring.main.keep-alive=true
spring.datasource.hikari.maximum-pool-size=20

첫 두 속성은 Boot가 관리하는 task executor와 scheduler를 가상 스레드 기반으로 바꾸고 JVM이 가상 스레드만 남았을 때 조기 종료되지 않게 합니다. 예제의 newVirtualThreadPerTaskExecutor()는 명시적으로 별도 executor를 만들므로, 두 접근을 무심코 중첩하지 말고 애플리케이션 전체 정책과 국소 병렬 작업 중 하나를 의도적으로 선택합니다.


DB pool 대기와 처리량 변화를 부하로 확인한다

가상 스레드를 켜기 전후로 같은 부하를 주고 throughput뿐 아니라 Hikari pending connection, CPU, 오류율, p95를 함께 비교합니다. 서버 실행과 부하 도구는 서로 다른 터미널에서 실행합니다.

./mvnw -q -DskipTests compile
./mvnw -q spring-boot:run
Backpressure 확인 결과
blocking I/O 동시 처리량은 늘어날 수 있지만 DB 동시 쿼리는 Hikari 최대 20개로 제한되고 CPU 작업은 빨라졌다고 단정하지 않아야 합니다.

pinning과 긴 CPU 작업을 구분한다

가상 스레드가 synchronized 블록 안에서 blocking하거나 native 호출에 오래 머물면 carrier thread를 붙잡는 pinning이 발생할 수 있습니다. JFR과 thread dump로 확인하고, CPU 집약 작업은 가상 스레드 수를 늘리는 대신 제한된 executor나 작업 큐로 격리합니다.


성능 한계를 수치로 확인했으니 다음 장에서는 큰 입력, 비밀 값, 프록시 신뢰처럼 악의적이거나 비정상적인 조건에서도 그 한계를 지키도록 구성합니다.