
Java 5에서 java.util.concurrent 패키지가 등장하면서 ExecutorService와 Future 인터페이스가 도입되었다. 그 전에는 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의 가장 큰 문제는 결과를 받는 방법이 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가 탄생한 이유다.
Java 8에서 등장한 CompletableFuture는 Future와 CompletionStage 인터페이스를 동시에 구현하기 시작
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에서는 병렬 실행이 가능하다.
외부 API 호출이 무한정 걸릴 때 타임아웃을 거는 기능이 내장되어 있지 않다.
CompletableFuture.supplyAsync(() -> callSlowExternalAPI())
.orTimeout(3, TimeUnit.SECONDS) // 3초 안에 완료되지 않으면
.exceptionally(ex -> {
log.warn("API 호출 타임아웃: {}", ex.getMessage());
return fallbackValue; // TimeoutException 발생
});
// 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("에러"));
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 레벨에서 정의했다는 데에서 의미가 있다.
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 클라이언트까지 지원한다.
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에서 실행
// 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()는 예외 발생 시 또 다른 비동기 작업을 연결할 수 있게 되었다.
지금까지의 모든 비동기 처리(@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는 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의 콜백 체이닝이나 리액티브 프로그래밍 없이, 읽기 쉬운 동기식 코드로 높은 동시성을 달성할 수 있다.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 호출, 파일 읽기)에서 높은 효율성을 나타낸다.