Virtual Thread 도입기: 적용 시 고려사항

배상규·2026년 1월 6일
post-thumbnail

🤔 배치 처리, 그냥 멀티스레드로 돌리면 되는 거 아닐까?

10시간 걸리던 배치 처리를 30분으로 줄였다고 하면, 많은 분들이 이렇게 질문합니다.

“그냥 멀티스레드 쓰면 되는 거 아니야?”

저 역시 처음엔 그렇게 생각했습니다.
스레드 풀을 키우고, 병렬 처리하면 해결될 문제라고 생각했었습니다.

하지만 외부 API 호출이 중심인 배치 작업에서는
플랫폼 스레드 기반 멀티스레드가
생각보다 빠르게 한계에 부딪힌다는 걸 직접 경험했습니다.

이 글에서는 Java 21의 Virtual Thread를 도입하면서
왜 기존 멀티스레드 방식이 잘 맞지 않았는지,
그리고 Virtual Thread가 빛을 발한 조건과 그렇지 않은 조건
이론이 아닌 실무 관점에서 정리해보려 합니다.


1. 문제 상황: 10시간 걸리는 배치

비즈니스 요구사항
저희는 매일 새벽, 차량 담보대출 약 30,000건에 대해 외부 기관 API를 호출해 차량 원부 정보를 조회합니다. 대출 리스크 관리를 위한 필수 작업이죠.

// 기존 코드 (단순화)
for (Loan loan : loans) {
    VehicleInfo info = externalApiClient.getVehicleInfo(loan.getVehicleNumber());
    loan.updateVehicleInfo(info);
    loanRepository.save(loan);
}

30,000건 × 평균 1.2초 = 약 10시간
00시에 시작해도 오전 10 시가 넘어야 끝나는 상황.

2. 1차 시도: Platform Thread 기반 멀티스레드

ExecutorService executor = Executors.newFixedThreadPool(50);

for (Loan loan : loans) {
    executor.submit(() -> {
        VehicleInfo info = externalApiClient.getVehicleInfo(loan.getVehicleNumber());
        loanRepository.save(loan);
    });
}

Platform Thread의 한계

  • Context Switching 비용 증가
  • I/O 대기 시간 동안 스레드가 블로킹되어 낭비

현재우리의 상황

  • I/O 대기 : 95% 이상 (외부 API 응답대기)
    -> CPU-bound가 아닌 I/O bound작업
    -> Platform Thread는 비효율적

3. Virtural Thread 도입

Virtual Thread란?

  • Java 21에서 정식 도입 (Project Loom)
  • 경량 스레드: 1개당 수 KB 메모리
  • 수백만 개 생성 가능
  • I/O 대기 시 자동으로 다른 작업 수행 (Non-blocking)

제약사항

외부 기관 API 제약:

  • 동시 호출 제한
  • Rate Limit 초과 시: 429 에러

→ Virtual Thread로 10,000개 동시 호출하면 안 됨
→ Rate Limiter 필수


4. 실제구현

4-1. Virtual Thread Executor 생성

@Configuration
public class VirtualThreadConfig {
    
    @Bean(name = "vehicleBatchExecutor")
    public ExecutorService vehicleBatchExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}

핵심: newVirtualThreadPerTaskExecutor()는 작업마다 새 Virtual Thread 생성
→ 스레드 풀 크기 걱정 없음
4-2. Rate Limiter 적용

@Component
public class VehicleInfoBatchProcessor {
    
    private final RateLimiter rateLimiter;
    private final ExecutorService virtualThreadExecutor;
    
    public VehicleInfoBatchProcessor(
            ExecutorService executor,
            double rateLimiter) {
        this.virtualThreadExecutor = executor;
        // 초당 요청 설정
        this.rateLimiter = RateLimiter.create(rateLimiter);
    }
    
    public void processBatch(List<Loan> loans) {
        List<CompletableFuture<Void>> futures = loans.stream()
            .map(loan -> CompletableFuture.runAsync(() -> {
                rateLimiter.acquire(); // Rate Limit 대기
                processLoan(loan);
            }, virtualThreadExecutor))
            .toList();
        
        // 모든 작업 완료 대기
        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
            .join();
    }
    
    private void processLoan(Loan loan) {
        try {
            VehicleInfo info = externalApiClient.getVehicleInfo(
                loan.getVehicleNumber()
            );
            loan.updateVehicleInfo(info);
            loanRepository.save(loan);
            
        } catch (Exception e) {
            log.error("Failed to process loan: {}", loan.getId(), e);
            // 실패 건은 별도 기록
            failureRepository.save(new FailedLoan(loan.getId(), e.getMessage()));
        }
    }

5. 결과 및 성능비교

처리시간비교

방식처리시간개선율
동기방식10시간-
Virtual Thread30분96%

6. 실무 적용 시 주의사항

Virtual Thread가 만능은 아니다
1. CPU-bound 작업에는 효과 없음

나쁜 예: CPU 집약적 작업
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 10000; i++) {
    executor.submit(() -> {
        // 복잡한 암호화 연산
        heavyCryptography();
    });
}
// → Platform Thread보다 느릴 수 있음

Virtual Thread도 결국 Platform Thread 위에서 실행됨
CPU 작업은 대기 시간이 없어서 Context Switching만 증가

2. Synchronized 블록 주의

주의: Virtual Thread에서 synchronized
public synchronized void updateCounter() {
    counter++;
}

synchronized는 Platform Thread를 Pinning(고정)시킴
→ Virtual Thread의 장점 상실
해결책: ReentrantLock 사용

private final ReentrantLock lock = new ReentrantLock();

public void updateCounter() {
    lock.lock();
    try {
        counter++;
    } finally {
        lock.unlock();
    }
}

적용하기 좋은 케이스
1. 외부 API 호출이 많은 경우
2. DB I/O가 많은 경우


트러블 슈팅

1. OutOfMemoryError
원인:

Future를 모두 메모리에 보관
List<CompletableFuture<Void>> futures = loans.stream()
    .map(loan -> CompletableFuture.runAsync(...))
    .toList(); // 30,000개의 Future 객체

해결 :
Chunk 단위 처리

개선: 1000개씩 나눠서 처리
int chunkSize = 1000;
for (int i = 0; i < loans.size(); i += chunkSize) {
    List<Loan> chunk = loans.subList(
        i, 
        Math.min(i + chunkSize, loans.size())
    );
    
    List<CompletableFuture<Void>> futures = chunk.stream()
        .map(loan -> CompletableFuture.runAsync(...))
        .toList();
    
    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
        .join(); // 1000개 완료 후 다음 Chunk
}

결론

Virtual Thread를 써야 하는 경우

  • I/O 대기 시간이 긴 작업 (외부 API, DB 조회)
  • 동시 처리가 필요한 대량 작업

Virtual Thread를 쓰면 안 되는 경우

  • CPU 집약적 연산 (암호화, 이미지 처리)
  • 짧은 작업 (수 ms 이내)
  • 레거시 코드에 ThreadLocal이 많은 경우

마지막으로

15시간 걸리던 배치를 30분으로 줄인 건 단순히 Virtual Thread를 적용했기 때문이 아닙니다.
문제의 본질을 파악하고 (I/O-bound)
적절한 도구를 선택하고 (Virtual Thread)
실무 제약사항을 고려한 (Rate Limiter, Chunk 처리)
덕분입니다.
새로운 기술을 도입할 때는 항상 "왜?"를 먼저 습관을 들이는게 중요한것 같습니다.

profile
기록에 성장을

0개의 댓글