
10시간 걸리던 배치 처리를 30분으로 줄였다고 하면, 많은 분들이 이렇게 질문합니다.
“그냥 멀티스레드 쓰면 되는 거 아니야?”
저 역시 처음엔 그렇게 생각했습니다.
스레드 풀을 키우고, 병렬 처리하면 해결될 문제라고 생각했었습니다.
하지만 외부 API 호출이 중심인 배치 작업에서는
플랫폼 스레드 기반 멀티스레드가
생각보다 빠르게 한계에 부딪힌다는 걸 직접 경험했습니다.
이 글에서는 Java 21의 Virtual Thread를 도입하면서
왜 기존 멀티스레드 방식이 잘 맞지 않았는지,
그리고 Virtual Thread가 빛을 발한 조건과 그렇지 않은 조건을
이론이 아닌 실무 관점에서 정리해보려 합니다.
비즈니스 요구사항
저희는 매일 새벽, 차량 담보대출 약 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 시가 넘어야 끝나는 상황.
ExecutorService executor = Executors.newFixedThreadPool(50);
for (Loan loan : loans) {
executor.submit(() -> {
VehicleInfo info = externalApiClient.getVehicleInfo(loan.getVehicleNumber());
loanRepository.save(loan);
});
}
Platform Thread의 한계
현재우리의 상황
- I/O 대기 : 95% 이상 (외부 API 응답대기)
-> CPU-bound가 아닌 I/O bound작업
-> Platform Thread는 비효율적
Virtual Thread란?
제약사항
외부 기관 API 제약:
- 동시 호출 제한
- Rate Limit 초과 시: 429 에러
→ Virtual Thread로 10,000개 동시 호출하면 안 됨
→ Rate Limiter 필수
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()));
}
}
처리시간비교
| 방식 | 처리시간 | 개선율 |
|---|---|---|
| 동기방식 | 10시간 | - |
| Virtual Thread | 30분 | 96% |
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를 써야 하는 경우
Virtual Thread를 쓰면 안 되는 경우
15시간 걸리던 배치를 30분으로 줄인 건 단순히 Virtual Thread를 적용했기 때문이 아닙니다.
문제의 본질을 파악하고 (I/O-bound)
적절한 도구를 선택하고 (Virtual Thread)
실무 제약사항을 고려한 (Rate Limiter, Chunk 처리)
덕분입니다.
새로운 기술을 도입할 때는 항상 "왜?"를 먼저 습관을 들이는게 중요한것 같습니다.