Java 비동기 처리의 변화: Future to Virtual Thread

dddingzong·2026년 3월 4일

이론

목록 보기
9/11
post-thumbnail

1. Java 5 — Future

Java 5에서 java.util.concurrent 패키지가 등장하면서 ExecutorServiceFuture 인터페이스가 도입되었다. 그 전에는 Thread를 직접 생성하고 Runnable을 구현하는 저수준 방식밖에 없었다.

ExecutorService executor = Executors.newFixedThreadPool(4);

Future<String> future = executor.submit(() -> {
    // 오래 걸리는 작업 (DB 저장 등)
    Thread.sleep(2000);
    return "저장 완료";
});

// 결과가 나올 때까지 현재 스레드가 멈춤 - 2초
String result = future.get(); 
System.out.println(result);

Future의 한계

Future의 가장 큰 문제는 결과를 받는 방법이 get() 뿐이다.

// 결과를 받으려면 무조건 블로킹
String result = future.get(); 

// 타임아웃을 걸 수는 있지만, 여전히 블로킹
String result = future.get(3, TimeUnit.SECONDS);

위치 업데이트 흐름으로 파악

private void processLocationUpdate(String userNumber, LocationUpdateDto location) {
    // 1. 캐시 저장 (빠름)
    cacheService.updateLocation(userNumber, location);

    // 2. WebSocket 브로드캐스트 (빠름)
    messagingTemplate.convertAndSend("/topic/location/" + userNumber, location);

    // 3. DB 저장 — Future로 비동기 실행
    Future<Void> dbFuture = executor.submit(() -> {
        saveLocationToDB(location);
        return null;
    });
}

콜백을 등록할 수 없고(속도문제), 여러 Future를 조합할 수 없고(병렬실행), 예외 처리 체인을 만들 수 없다(예외처리 불편). 이 세 가지 한계가 Java 8에서 CompletableFuture가 탄생한 이유다.


2. Java 8 — CompletableFuture

Java 8에서 등장한 CompletableFutureFutureCompletionStage 인터페이스를 동시에 구현하기 시작

핵심 개선: 콜백 기반 논블로킹

CompletableFuture.supplyAsync(() -> {
        saveLocationToDB(location);
        return "저장 완료";
    })
    .thenAccept(result -> log.info(result)) // 성공 시 콜백
    .exceptionally(error -> {               // 실패 시 콜백
        log.error("DB 저장 실패: {}", error.getMessage());
        return null;
    });
// ← 여기서 블로킹하지 않는다. 즉시 다음 작업으로 넘어간다

get()을 호출할 필요 없이 콜백을 등록해두면, 작업이 완료되었을 때 알아서 실행된다.

여러 비동기 작업 조합

CompletableFuture은 또한 여러 비동기 작업을 조합할 수 있다.

// 여러 비동기 작업을 병렬 실행 후 조합
CompletableFuture<UserLocation> locationFuture =
    CompletableFuture.supplyAsync(() -> findLatestLocation(userNumber));

CompletableFuture<List<Geofence>> geofenceFuture =
    CompletableFuture.supplyAsync(() -> findGeofences(userNumber));

// 두 작업이 모두 완료되면 결합
locationFuture.thenCombine(geofenceFuture, (location, geofences) -> {
    return checkGeofenceViolation(location, geofences);
}).thenAccept(violation -> {
    if (violation != null) {
        notificationService.sendNotificationToSupporters(user, "안전구역 이탈", violation);
    }
});

Future에서는 각각의 get()을 순차적으로 호출해야 했지만, CompletableFuture에서는 병렬 실행이 가능하다.

Java 8 CompletableFuture의 한계

외부 API 호출이 무한정 걸릴 때 타임아웃을 거는 기능이 내장되어 있지 않다.


3. Java 9 — CompletableFuture 개선

orTimeout(): 타임아웃 시 예외 발생

CompletableFuture.supplyAsync(() -> callSlowExternalAPI())
    .orTimeout(3, TimeUnit.SECONDS)  // 3초 안에 완료되지 않으면
    .exceptionally(ex -> {
        log.warn("API 호출 타임아웃: {}", ex.getMessage());
        return fallbackValue;  // TimeoutException 발생
    });

completeOnTimeout(): 타임아웃 시 기본값 반환

// Java 9: 타임아웃 시 기본값으로 우아하게 처리
CompletableFuture<LocationUpdateDto> locationFuture =
    CompletableFuture.supplyAsync(() ->
        fetchLocationFromRemoteService(userNumber)
    )
    .completeOnTimeout(
        getDefaultLocation(userNumber),  // 타임아웃 시 캐시된 마지막 위치 반환
        2, TimeUnit.SECONDS
    );

orTimeout()은 예외를 던지고, completeOnTimeout()은 대체값을 반환

새로운 팩토리 메서드들

// 이미 완료된 Future를 간편하게 생성
CompletableFuture<String> completed = CompletableFuture.completedFuture("완료");
CompletableFuture<String> failed = CompletableFuture.failedFuture(new RuntimeException("에러"));

Flow API — Reactive Streams 표준화

Java 9에서는 java.util.concurrent.Flow 인터페이스도 추가되었다.

이것은 Reactive Streams 사양(Publisher, Subscriber)을 JDK에 내장한 것이다.

// Flow API (Reactive Streams 표준)
Flow.Publisher<LocationUpdateDto> locationPublisher;
Flow.Subscriber<LocationUpdateDto> locationSubscriber;
  • Spring WebFlux, Project Reactor, RxJava 같은 리액티브 라이브러리의 기반 인터페이스 역할을 한다.

  • 리액티브 생태계의 표준을 JDK 레벨에서 정의했다는 데에서 의미가 있다.


4. Java 11 — HTTP Client와 비동기 통신

Java 11에서 정식 출시된 java.net.http.HttpClient는 비동기 HTTP 통신을 JDK 표준으로 만들었다.

HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/geocoding"))
    .build();

// 논블로킹 비동기 요청 — CompletableFuture 반환
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
    .thenApply(HttpResponse::body)
    .thenAccept(body -> log.info("응답: {}", body));
  • 이전에는 외부 API 호출을 비동기로 하려면 Apache HttpClient나 OkHttp 같은 서드파티 라이브러리가 필수였다.

  • Java 11부터는 JDK만으로 HTTP/2, 비동기 요청, WebSocket 클라이언트까지 지원한다.


5. Java 12 — 비동기 예외 처리

Java 12에서는 CompletionStage 인터페이스에 비동기 예외 처리 메서드들이 추가되었다.

// Java 8: exceptionally()는 호출한 스레드에서 실행
CompletableFuture.supplyAsync(() -> riskyOperation())
    .exceptionally(ex -> fallback());  // 어떤 스레드에서 실행될지 제어 불가

// Java 12: exceptionallyAsync()로 실행 스레드 제어 가능
CompletableFuture.supplyAsync(() -> riskyOperation())
    .exceptionallyAsync(ex -> fallback());  // 스레드 풀(ForkJoinPool)에서 예외처리 실행 -> 메인 스레드 보호 가능
    
CompletableFuture.supplyAsync(() -> riskyOperation())
    .exceptionallyAsync(ex -> fallback(), customExecutor);  // 지정한 Executor에서 실행

exceptionallyCompose(): 예외 시 다른 비동기 작업으로 대체

// Java 12: 예외 발생 시 다른 CompletableFuture로 복구
CompletableFuture.supplyAsync(() ->
        geocodingService.convertAddressToCoordinate(address)
    )
    .exceptionallyCompose(ex -> {
        log.warn("1차 지오코딩 실패, 백업 서비스 시도: {}", ex.getMessage());
        return CompletableFuture.supplyAsync(() ->
            backupGeocodingService.convert(address)  // 다른 비동기 작업으로 복구
        );
    });

Java 8의 exceptionally()는 동기적으로만 복구할 수 있었지만, exceptionallyCompose()는 예외 발생 시 또 다른 비동기 작업을 연결할 수 있게 되었다.


6. Java 19~21 — Virtual Thread

  • Java 19에서 등장하고 Java 21에서 정식 출시된 Virtual Thread

기존 스레드 모델의 문제

지금까지의 모든 비동기 처리(@Async, CompletableFuture)는 결국 플랫폼 스레드(OS 스레드)를 사용한다.

플랫폼 스레드 1개 = OS 스레드 1개 = ~1MB 스택 메모리

스레드 풀 200개 = 200MB 메모리 + OS 컨텍스트 스위칭 비용

SafetyFence의 AsyncConfig를 보자:

@Configuration
@EnableAsync
public class AsyncConfig {
    // 별도 설정 없이 Spring 기본 스레드 풀 사용
    // → SimpleAsyncTaskExecutor (매 호출마다 새 스레드 생성) 또는
    //   ThreadPoolTaskExecutor (기본 8개 코어 스레드)
}

동시 접속자가 1,000명이고 각각 1초마다 위치를 전송하면, @Async DB 저장만으로도 초당 1,000개의 비동기 태스크가 발생한다. 스레드 풀이 부족하면 태스크가 큐에 쌓이고, 큐마저 가득 차면 요청이 거부된다.

Virtual Thread

Virtual Thread는 JVM이 관리하는 경량 스레드

Virtual Thread = JVM 관리 = ~수 KB 메모리
100만 개의 Virtual Thread 생성 가능
블로킹 I/O 시 자동으로 캐리어 스레드 해제

캐리어 스레드 = Virtual Thread를 실제로 실행해주는 OS 스레드
// Java 21: Virtual Thread 생성
Thread.startVirtualThread(() -> {
    saveLocationToDB(location); 
    // 블로킹 I/O가 발생하면
    // JVM이 자동으로 캐리어 스레드를 다른 Virtual Thread에 넘긴다
});
  • 핵심은 블로킹 코드를 그대로 작성해도 논블로킹처럼 동작한다.
  • CompletableFuture의 콜백 체이닝이나 리액티브 프로그래밍 없이, 읽기 쉬운 동기식 코드로 높은 동시성을 달성할 수 있다.

Virtual Thread 주의사항

  • synchronized 블록 안에서 블로킹 I/O가 발생하면 캐리어 스레드가 해제되지 않는다.
synchronized (lock) {
    database.save(entity);  // 캐리어 스레드 점유
}

// 대안: ReentrantLock 사용 
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    database.save(entity);
} finally {
    lock.unlock();
}

또한 CPU 집약적 작업에는 Virtual Thread의 이점이 크지 않다. I/O 바운드 작업(DB 쿼리, 외부 API 호출, 파일 읽기)에서 높은 효율성을 나타낸다.

profile
공부 노트 & 회고

0개의 댓글