Java Thread vs CompletableFuture vs Kotlin Coroutine — 셋은 근본적으로 다른 계층의 개념이다

2026. 3. 16. 17:07·개발
반응형

Java Thread vs CompletableFuture vs Kotlin Coroutine

핵심 정리

한 줄 요약

Thread는 실행의 단위(OS 자원), CompletableFuture는 비동기 결과의 컨테이너(API), Coroutine은 중단 가능한 경량 실행 흐름(언어 기능)이다. 같은 "비동기"를 다루지만 추상화 레벨이 완전히 다르다.


세 개념의 위치

┌──────────────────────────────────────────────────────────┐
│                    개발자 코드                              │
│                                                           │
│  ┌─── Kotlin Coroutine ──────────────────────────────┐   │
│  │  suspend fun getOrder(id: Long): Order {           │   │
│  │      val order = repo.findById(id)  // 중단점       │   │
│  │      return order                                   │   │
│  │  }                                                  │   │
│  │  ★ 언어 레벨 추상화 — "중단 가능한 함수"             │   │
│  └────────────────────────────────────────────────────┘   │
│                          │ 내부적으로 사용                  │
│  ┌─── CompletableFuture ─┴───────────────────────────┐   │
│  │  CompletableFuture.supplyAsync(() -> fetchData())  │   │
│  │      .thenApply(data -> process(data))              │   │
│  │      .thenAccept(result -> save(result));            │   │
│  │  ★ API 레벨 추상화 — "미래에 완료될 결과"            │   │
│  └────────────────────────────────────────────────────┘   │
│                          │ 내부적으로 사용                  │
│  ┌─── Thread (OS 스레드) ─┴──────────────────────────┐   │
│  │  new Thread(() -> doWork()).start();                │   │
│  │  ★ OS 레벨 자원 — "실제 실행되는 흐름"              │   │
│  └────────────────────────────────────────────────────┘   │
│                          │                                 │
└──────────────────────────┼─────────────────────────────────┘
                           ▼
┌──────────────────────────────────────────────────────────┐
│              OS Kernel (스케줄링, 컨텍스트 스위칭)          │
└──────────────────────────────────────────────────────────┘

비유로 이해하기:

  • Thread = 요리사 (실제로 일하는 사람)
  • CompletableFuture = 주문서 ("이 요리 완성되면 알려줘")
  • Coroutine = 멀티태스킹 요리사 ("파스타 삶는 동안 샐러드 만들기")

1. Java Thread — 실행의 물리적 단위

// Thread는 OS 스레드와 1:1 매핑되는 실행 단위
Thread thread = new Thread(() -> {
    System.out.println("작업 실행: " + Thread.currentThread().getName());
});
thread.start();  // OS에 스레드 생성 요청
thread.join();   // 끝날 때까지 대기
Thread의 본질:
  ┌────────────────────────────────┐
  │ Java Thread                     │
  │  - 자기만의 스택 메모리 (~1MB)   │
  │  - 자기만의 Program Counter     │
  │  - 자기만의 실행 컨텍스트        │
  │                                │
  │  OS Thread와 1:1 매핑           │
  │  → OS 커널이 스케줄링           │
  │  → 컨텍스트 스위칭 비용 발생     │
  └────────────────────────────────┘

생성 비용: ~1ms + ~1MB 메모리
최대 개수: 수천 개 (OS 한계)
스케줄링: OS 커널 (Preemptive — 강제로 뺏김)

Thread만으로 비동기를 구현하면 생기는 문제:

// ❌ 직접 Thread를 다루는 코드 — 매우 원시적
public Order getOrderWithUser(Long orderId) {
    // 결과를 담을 변수
    AtomicReference userRef = new AtomicReference<>();

    Thread thread = new Thread(() -> {
        User user = userService.findById(orderId);
        userRef.set(user);
    });
    thread.start();

    Order order = orderService.findById(orderId);  // 메인 스레드에서 실행

    thread.join();  // user 스레드 끝날 때까지 대기
    order.setUser(userRef.get());

    return order;
    // 문제: 예외 처리는? 타임아웃은? 여러 작업 조합은?
}

2. CompletableFuture — 비동기 결과의 컨테이너

// CompletableFuture는 "미래에 완료될 결과"를 표현하는 객체
// Thread를 직접 다루지 않고 비동기 파이프라인을 선언적으로 구성
CompletableFuture future = CompletableFuture
    .supplyAsync(() -> orderRepo.findById(id))       // ForkJoinPool에서 실행
    .thenApplyAsync(order -> {                        // 결과가 오면 이어서 실행
        order.setUser(userService.findById(order.getUserId()));
        return order;
    });

Order order = future.get();  // 결과 꺼내기 (blocking)
// 또는
future.thenAccept(order -> send(order));  // non-blocking 방식
CompletableFuture의 본질:
  ┌────────────────────────────────────────────────┐
  │ CompletableFuture                            │
  │                                                 │
  │  ★ 자체는 스레드가 아님! 결과를 담는 상자일 뿐   │
  │                                                 │
  │  내부 구조:                                      │
  │  - result: T (완료된 결과 또는 예외)              │
  │  - stack: 완료 시 실행할 콜백 체인               │
  │  - executor: 실행할 스레드풀 (기본 ForkJoinPool) │
  │                                                 │
  │  상태:                                           │
  │  [미완료] → [완료(result)] 또는 [예외(exception)] │
  └────────────────────────────────────────────────┘

실행 주체: ForkJoinPool.commonPool() (기본) 또는 지정한 Executor
자체 메모리: 수십~수백 바이트 (객체 하나)
최대 개수: 수백만 개 가능 (가벼움 — 그냥 객체)
비동기 방식: 콜백 체인 (thenApply, thenCompose, ...)

CompletableFuture가 Thread보다 나은 점:

// ✅ 여러 비동기 작업 조합이 깔끔
CompletableFuture orderFuture = supplyAsync(() -> getOrder(id));
CompletableFuture userFuture = supplyAsync(() -> getUser(userId));
CompletableFuture couponFuture = supplyAsync(() -> getCoupon(userId));

// 세 작업이 동시에 실행되고, 모두 완료되면 합침
CompletableFuture result = orderFuture
    .thenCombine(userFuture, (order, user) -> new OrderWithUser(order, user))
    .thenCombine(couponFuture, (ow, coupon) -> new OrderDetail(ow, coupon));

// 예외 처리도 파이프라인에 포함
result.exceptionally(ex -> {
    log.error("주문 조회 실패", ex);
    return OrderDetail.empty();
});

하지만 CompletableFuture의 한계:

// ❌ 복잡한 흐름은 콜백 지옥이 됨
CompletableFuture.supplyAsync(() -> getOrder(id))
    .thenCompose(order ->
        supplyAsync(() -> getUser(order.getUserId()))
            .thenCompose(user ->
                supplyAsync(() -> getPayment(order.getId()))
                    .thenApply(payment ->
                        new OrderDetail(order, user, payment)
                    )
            )
    )
    .thenCompose(detail ->
        supplyAsync(() -> enrichWithRecommendations(detail))
    )
    .exceptionally(ex -> handleError(ex));
// 읽기 어렵다!

3. Kotlin Coroutine — 중단 가능한 경량 실행 흐름

// Coroutine은 "코드를 중단점에서 멈췄다가 재개할 수 있는" 언어 기능
suspend fun getOrderDetail(id: Long): OrderDetail {
    val order = orderRepo.findById(id)          // 중단점 1: 여기서 멈춤
    val user = userRepo.findById(order.userId)   // 중단점 2: 여기서 멈춤
    return OrderDetail(order, user)              // 재개 후 반환
}
Coroutine의 본질:
  ┌────────────────────────────────────────────────┐
  │ Coroutine                                       │
  │                                                 │
  │  ★ OS 스레드가 아님! JVM 객체(상태 머신)일 뿐     │
  │  ★ suspend 지점에서 스레드를 반환하고 중단        │
  │  ★ 재개 시 아무 스레드에서나 이어서 실행 가능      │
  │                                                 │
  │  내부 구조:                                      │
  │  - state: 현재 중단 지점 (0, 1, 2...)            │
  │  - locals: 로컬 변수 저장 (스택 대신 힙에)        │
  │  - continuation: 재개 콜백                       │
  │                                                 │
  │  실행 흐름:                                      │
  │  코드 실행 → suspend 만남 → 상태 저장 → 스레드 반환│
  │  → (I/O 완료) → 상태 복원 → 스레드 할당 → 이어 실행│
  └────────────────────────────────────────────────┘

실행 주체: Dispatcher가 배정한 스레드 (IO, Default, Main 등)
자체 메모리: 수 KB (힙에 상태 머신 객체)
최대 개수: 수십만~수백만 개
비동기 방식: suspend/resume (컴파일러가 상태 머신으로 변환)

같은 작업을 Coroutine으로 하면:

// ✅ 동기 코드처럼 읽히지만 내부적으로 비동기
suspend fun getOrderDetail(id: Long, userId: Long): OrderDetail =
    coroutineScope {  // ★ async를 쓰려면 coroutineScope이 필요!
        // 이 두 작업을 병렬로 실행
        val order = async { orderRepo.findById(id) }
        val user = async { userRepo.findById(userId) }
        val coupon = async { couponRepo.findById(userId) }

        // 모두 완료되면 합침
        OrderDetail(order.await(), user.await(), coupon.await())
    }

// CompletableFuture의 thenCombine과 같은 효과지만 훨씬 읽기 쉽다

근본적 차이 비교표

Thread CompletableFuture Coroutine
정체 OS 자원 (실행 단위) Java 객체 (결과 컨테이너) 언어 기능 (중단 가능한 함수)
추상화 레벨 가장 낮음 (OS 근접) 중간 (API) 가장 높음 (언어)
메모리 ~1MB (스택) 수십 바이트 (객체) 수 KB (상태 머신)
생성 비용 높음 (~1ms) 거의 없음 (객체 생성) 매우 낮음
최대 개수 수천 수백만 (객체일 뿐) 수십만~수백만
스케줄링 OS 커널 (Preemptive) Executor에 위임 Dispatcher (Cooperative)
코드 스타일 명령형 (join, wait) 콜백 체이닝 (then) 동기 코드처럼 (suspend)
예외 처리 try-catch + join exceptionally/handle try-catch (자연스러움)
취소 interrupt (불확실) cancel (제한적) Structured Concurrency (확실)
언어 Java Java 8+ Kotlin

같은 문제를 세 가지 방식으로 풀기

"주문 + 사용자 + 쿠폰을 병렬로 조회해서 합치기"

Thread

// ★ 람다에서 로컬 변수에 할당 불가 → AtomicReference 사용
AtomicReference order = new AtomicReference<>();
AtomicReference user = new AtomicReference<>();
AtomicReference coupon = new AtomicReference<>();

Thread t1 = new Thread(() -> order.set(orderRepo.findById(id)));
Thread t2 = new Thread(() -> user.set(userRepo.findById(userId)));
Thread t3 = new Thread(() -> coupon.set(couponRepo.findById(userId)));

t1.start(); t2.start(); t3.start();
t1.join(); t2.join(); t3.join();  // 모두 끝날 때까지 대기

return new OrderDetail(order, user, coupon);
// 문제: 예외 처리 없음, 변수 가시성 문제, 타임아웃 없음

CompletableFuture

var orderF = supplyAsync(() -> orderRepo.findById(id));
var userF = supplyAsync(() -> userRepo.findById(userId));
var couponF = supplyAsync(() -> couponRepo.findById(userId));

return CompletableFuture.allOf(orderF, userF, couponF)
    .thenApply(v -> new OrderDetail(orderF.join(), userF.join(), couponF.join()))
    .exceptionally(ex -> { log.error("실패", ex); return null; })
    .get(5, TimeUnit.SECONDS);  // 타임아웃 포함

Coroutine

suspend fun getOrderDetail(id: Long, userId: Long): OrderDetail =
    coroutineScope {
        val order = async { orderRepo.findById(id) }
        val user = async { userRepo.findById(userId) }
        val coupon = async { couponRepo.findById(userId) }

        OrderDetail(order.await(), user.await(), coupon.await())
        // 하나라도 실패하면 나머지 자동 취소 (Structured Concurrency)
    }

Virtual Thread는 어디에 해당하나?

Java Virtual Thread (Java 21+):
  - Thread의 진화 버전
  - OS 스레드가 아닌 JVM이 관리하는 경량 스레드
  - Coroutine과 비슷한 포지션이지만 "기존 Thread API 그대로 사용"

  ┌────────────────────────────────────────────────┐
  │  Virtual Thread ≈ Thread의 옷을 입은 Coroutine  │
  │                                                 │
  │  - Thread API 호환 (기존 코드 수정 불필요)       │
  │  - 내부적으로 Carrier Thread 위에서 mount/unmount │
  │  - blocking I/O 만나면 자동으로 unmount           │
  │  - 메모리: 수 KB (Coroutine과 비슷)              │
  │  - 개수: 수백만 가능                             │
  └────────────────────────────────────────────────┘
// Virtual Thread — 기존 코드 그대로 사용 가능
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    var f1 = executor.submit(() -> orderRepo.findById(id));
    var f2 = executor.submit(() -> userRepo.findById(userId));
    var f3 = executor.submit(() -> couponRepo.findById(userId));

    return new OrderDetail(f1.get(), f2.get(), f3.get());
    // blocking이지만 Virtual Thread라서 OK
    // JVM이 알아서 Carrier Thread를 다른 Virtual Thread에 할당
}

헷갈렸던 포인트

Q1. CompletableFuture는 새 스레드를 만드는 건가?

아니다. CompletableFuture 자체는 스레드를 만들지 않는다. 실행은 Executor에 위임한다.

// supplyAsync()의 기본 동작:
CompletableFuture.supplyAsync(() -> doWork());
// → ForkJoinPool.commonPool()의 기존 스레드에서 실행

// Executor를 지정하면 해당 풀의 스레드에서 실행:
CompletableFuture.supplyAsync(() -> doWork(), myExecutor);

// ★ CompletableFuture = "누군가 해줄 작업"을 등록하는 API
// ★ 실제 실행은 스레드풀(Executor)이 담당

Q2. Coroutine도 결국 스레드 위에서 돌아가는 거 아닌가?

맞다. Coroutine은 스레드를 없애는 게 아니라 효율적으로 공유하는 것이다.

OS Thread 4개로 Coroutine 10만 개를 실행:

Thread-1: [Coroutine A 실행] → [A suspend] → [Coroutine D 실행] → [D suspend] → [A resume] → ...
Thread-2: [Coroutine B 실행] → [B suspend] → [Coroutine E 실행] → ...
Thread-3: [Coroutine C 실행] → ...
Thread-4: [Coroutine F 실행] → ...

★ Coroutine이 suspend되면 스레드를 놓아줌
★ 다른 Coroutine이 그 스레드를 사용
★ 스레드 4개로 10만 개 작업을 처리하는 원리

핵심 차이: Thread는 blocking 시 스레드가 잠김, Coroutine은 suspend 시 스레드를 반환.

Q3. CompletableFuture의 ForkJoinPool은 뭔가?

ForkJoinPool.commonPool():
  - JVM 전체에서 공유하는 기본 스레드풀
  - 크기: Runtime.getRuntime().availableProcessors() - 1
  - 4코어 CPU → 3개 스레드

★ 위험: 여러 곳에서 supplyAsync() 기본값을 쓰면
  → 모두 같은 3개 스레드를 공유
  → 한 곳에서 느린 작업이 전체를 블로킹

★ 해결: 용도별 Executor를 분리
  ExecutorService ioPool = Executors.newFixedThreadPool(10);
  CompletableFuture.supplyAsync(() -> dbQuery(), ioPool);

Q4. 그러면 뭘 써야 하나?

Java만 사용:
  - Java 21+ → Virtual Thread (기존 코드 유지, 성능 향상)
  - Java 8~20 → CompletableFuture (비동기 파이프라인)
  - Thread 직접 사용 → 거의 안 함 (너무 저수준)

Kotlin 사용:
  - Coroutine (당연한 선택)
  - CompletableFuture → Coroutine으로 대체

Spring:
  - Spring MVC + @Async → ThreadPoolTaskExecutor + CompletableFuture
  - Spring MVC + Java 21 → Virtual Thread 활성화
  - Spring WebFlux → Reactor (Mono/Flux) 또는 Coroutine

Q5. Coroutine의 Structured Concurrency가 CompletableFuture보다 나은 점은?

// Coroutine: 부모가 취소되면 자식도 자동 취소
coroutineScope {
    val order = async { getOrder(id) }       // 자식 1
    val user = async { getUser(userId) }     // 자식 2
    // user에서 예외 발생 → order도 자동 취소!
    // coroutineScope 전체가 예외로 종료
}
// CompletableFuture: 수동으로 취소해야 함
var orderF = supplyAsync(() -> getOrder(id));
var userF = supplyAsync(() -> getUser(userId));

// userF에서 예외 발생해도 orderF는 계속 실행 중!
// 명시적으로 orderF.cancel(true) 해야 함
// → 취소 누락 시 리소스 낭비

참고 자료

  • JEP 444: Virtual Threads
  • Java CompletableFuture Javadoc
  • Kotlin Coroutines Guide
  • Structured Concurrency — Roman Elizarov
반응형
저작자표시 비영리 동일조건 (새창열림)

'개발' 카테고리의 다른 글

프록시(Proxy)와 리버스 프록시(Reverse Proxy)  (0) 2026.03.20
컨테이너 vs 가상머신 — Docker, Kubernetes, 그리고 왜 컨테이너인가  (0) 2026.03.19
Cache Stampede 해결기 — 주기적 DB 부하 급증의 원인을 찾아서  (0) 2026.03.12
JVM 메모리 구조  (0) 2026.03.09
JVM 동작원리  (0) 2026.03.07
'개발' 카테고리의 다른 글
  • 프록시(Proxy)와 리버스 프록시(Reverse Proxy)
  • 컨테이너 vs 가상머신 — Docker, Kubernetes, 그리고 왜 컨테이너인가
  • Cache Stampede 해결기 — 주기적 DB 부하 급증의 원인을 찾아서
  • JVM 메모리 구조
senyalog
senyalog
개발 블로그 https://github.com/iamyunjuda
  • senyalog
    Senya의 개발 블로그
    senyalog
  • 전체
    오늘
    어제
    • 분류 전체보기 (96)
      • 일상 (6)
      • 알고리즘 이론 (14)
        • 백준 (8)
      • 개발 (41)
        • Server (10)
        • 인턴 (11)
        • Javascript (0)
      • 경제 (8)
  • 블로그 메뉴

    • Github
    • 홈
  • 링크

    • Github
  • 공지사항

  • 인기 글

  • 태그

    깃
    GIT
    서버
    공대생
    대학생인턴
    알고리즘
    인턴
    알고리즘공부
    카페
    대학생
    MSA
    jvm
    컴공
    CS
    코틀린
    백준문제
    컴퓨터공학
    백엔드
    nestjs
    일상
    자바
    코딩
    개발자
    백준
    경제
    백준풀이
    공부
    개발
    생각
    이마고웍스
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
senyalog
Java Thread vs CompletableFuture vs Kotlin Coroutine — 셋은 근본적으로 다른 계층의 개념이다
상단으로

티스토리툴바