[시스템 사고]비동기 처리 방식(CompletableFuture VS Virtual Thread)

배현서·2024년 10월 28일

시스템 사고

목록 보기
2/10

면접 준비하면서 얻은 내용을 하나씩 정리하고자 한다.

우선 나는 비동기 방식을 해야지! 라는 생각만 하면서 CompletableFuture를 도입했다.

이후 내 이력서, 코드를 보면서 궁금증이 들었다. 다음 코드를 봐보자!

public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier) {
        return asyncSupplyStage(asyncPool, supplier);
    }


private static final Executor asyncPool = useCommonPool ?
        ForkJoinPool.commonPool() : new ThreadPerTaskExecutor();

위 코드는 CompletableFuture에서 runAsync나 supplyAsync를 실행하면 동작한다.
그럼 이 함수가 뭔지 알아보자

1. ForkJoinPool.commonPool()

useCommonPool이 true일 때 사용
공유 스레드 풀을 사용하여 작업을 처리
시스템 전체에서 재사용 가능한 스레드들을 관리

2. new ThreadPerTaskExecutor()

useCommonPool이 false일 때 사용
각 작업마다 새로운 스레드를 생성

어쨌든 스레드를 비동기 처리 로직마다 하나씩 할당해준다.
잘 설계된 시스템은 1번 방식으로 쓰레드를 재사용한다고 했을때 이런 가정이 떠올랐다.

시스템 자원

CPU: 4GB RAM
스레드 당 메모리: 1MB
최대 생성 가능 스레드: ~4000개


문제 상황

동시에 10000개의 요청 유입
가용 스레드(4000개) < 요청 수(10000개)
나머지 6000개 요청은 대기 상태로 빠짐

물론
큐잉 시스템 도입 (Kafka, RabbitMQ 등)
로드 밸런싱
Circuit Breaker 패턴 적용
Back Pressure 구현
같은 문제 해결 방식도 있겠지만, 비동기 처리 방법 중 다른 적절한 방법이 있지 않을까에 대한 의구심이 들었다.

그렇기 위해선 비동기 처리 방식이 뭐가 있는지를 알아야했다.

비동기 처리 방식

1. WebFlux

  • Non-blocking I/O로 적은 수의 스레드로 많은 요청 처리
  • Backpressure로 시스템 부하 제어 가능
  • Event-driven 방식으로 메모리 효율적
  • 러닝 커브가 높고 디버깅이 어려움
  • 기존 동기 방식 코드와 호환성 이슈

2. Virtual Thread

  • 일반 스레드(보통 1MB)보다 매우 가벼움(1KB)
  • 기존 동기 코드를 거의 수정 없이 사용 가능
  • 수백만 개의 가상 스레드 생성 가능
  • JDK 21부터 지원 (아직 초기 단계)
  • I/O 작업이 많은 경우 성능이 WebFlux보다 떨어질 수 있음

3. Coroutine

  • 구조화된 동시성으로 코드 가독성이 좋음
  • 경량 스레드와 유사한 성능
  • 중단점으로 자원을 효율적으로 관리
  • Kotlin 사용이 필수
  • JVM 생태계 전체 관점에서는 통합이 어려울 수 있음

결론: 대량의 동시 요청 처리에는 WebFlux가 가장 적합

그런데 나는 리액티브 프로그래밍으로 된 것도 그렇고 Virual Thread를 처음들어봤기에 이 기술과 비교하려고 했다. 나중에 WebFlux를 심도있게 다루면 도움이 될 것 같다. Netty랑 같이

Java의 전통적 스레드와 가상 스레드 성능 비교 분석

1. 전통적 스레드와 가상 스레드의 구조적 차이

전통적 스레드 (Platform Thread)

ExecutorService executorService = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors()
);

전통적 스레드는 OS 수준의 네이티브 스레드와 1:1로 매핑된다. 각 스레드는

  • 고정된 스택 메모리 할당 (보통 1MB)
  • OS 스케줄러에 의해 직접 관리
  • 컨텍스트 스위칭 비용이 큼
  • 생성과 소멸에 상당한 오버헤드

가상 스레드 (Virtual Thread)

Thread virtualThread = Thread.startVirtualThread(() -> {
    // task
});

가상 스레드는 JVM 수준에서 관리되는 경량 스레드이다

  • 매우 작은 메모리 사용 (약 1KB)
  • JVM 스케줄러에 의해 관리
  • 캐리어 스레드(Platform Thread)에 M:N 매핑
  • 생성과 소멸이 매우 가벼움

2. I/O 바운드 작업에서의 성능 비교

테스트 결과

[10개 파일 업로드]
전통방식: 446ms
가상스레드: 325ms

[50개 파일 업로드]
전통방식: 1369ms
가상스레드: 326ms

[100개 파일 업로드]
전통방식: 2627ms
가상스레드: 316ms

성능 차이의 원인

  1. 스레드 풀 크기 제한
// 전통적 방식
ExecutorService executorService = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors()
);
  • 전통적 방식은 CPU 코어 수만큼만 스레드를 생성
  • 대기 중인 작업은 큐에서 대기
  • I/O 작업 중에도 스레드가 블로킹됨
  1. 가상 스레드의 효율적인 전환
// 가상 스레드 방식
Thread.startVirtualThread(() -> {
    // I/O 작업 수행 시
    // 다른 가상 스레드로 즉시 전환
});
  • I/O 작업 시 캐리어 스레드를 해제
  • 다른 가상 스레드가 즉시 실행 가능
  • 매우 효율적인 리소스 사용

3. CPU 바운드 작업에서의 성능 비교

테스트 결과

[4개 작업]
전통방식: 43ms
가상스레드: 31ms

[8개 작업]
전통방식: 34ms
가상스레드: 32ms

[16개 작업]
전통방식: 56ms
가상스레드: 79ms

성능 차이의 원인

  1. CPU 리소스 경쟁
private static long performCpuIntensiveTask(int number) {
    // 피보나치 계산
    long result = calculateFibonacci(number);
    // 소수 판별
    for (int i = 2; i < 100000; i++) {
        isPrime(i);
    }
    return result;
}
  • 실제 CPU 연산이 필요한 작업
  • 물리적 CPU 코어 수가 제한 요소
  • I/O 대기가 없어 스레드 전환의 이점이 없음
  1. 스케줄링 오버헤드
// 가상 스레드는 작업당 새로운 스레드 생성
for (int i = 0; i < taskCount; i++) {
    Thread virtualThread = Thread.startVirtualThread(() -> {
        performCpuIntensiveTask(i);
    });
}
  • 가상 스레드의 잦은 생성과 전환
  • 불필요한 컨텍스트 스위칭 발생
  • CPU 코어 수 이상의 동시 실행은 오히려 성능 저하

4. 내부 동작 방식 비교

전통적 스레드 풀의 작업 처리

  1. 작업 제출
CompletableFuture<Void> future = CompletableFuture.runAsync(
    task,
    executorService
);
  1. 스레드 풀 내부 동작
    • 고정된 수의 워커 스레드 유지
    • 작업 큐에 태스크 저장
    • 가용 스레드가 큐에서 작업 가져와 실행

가상 스레드의 작업 처리

  1. 작업 시작
Thread virtualThread = Thread.startVirtualThread(() -> {
    task.run();
});
  1. 내부 동작
    • 작업당 새로운 가상 스레드 생성
    • ForkJoinPool의 캐리어 스레드에 마운트
    • 블로킹 발생 시 언마운트 후 다른 가상 스레드 실행

5. 결론 및 권장 사항

적합한 사용 케이스

  1. 가상 스레드에 적합한 경우

    • 대량의 동시 I/O 작업
    • 네트워크 요청
    • 데이터베이스 작업
    • 파일 업로드/다운로드
  2. 전통적 스레드에 적합한 경우

    • CPU 집약적 연산
    • 병렬 처리가 필요한 알고리즘
    • 실시간 데이터 처리
    • 스트림 처리

최적의 사용을 위한 고려사항

  1. 작업의 특성 파악

    • I/O 대기 시간 비율
    • CPU 사용 강도
    • 동시 처리 필요 작업 수
  2. 시스템 리소스

    • 가용 CPU 코어 수
    • 메모리 제한
    • 운영체제 스레드 제한

이러한 특성을 고려하여 적절한 스레드 처리 방식을 선택하면, 애플리케이션의 전반적인 성능을 최적화할 수 있다!!!!!

1개의 댓글

comment-user-thumbnail
2024년 11월 21일

설명이 좋네요~

답글 달기