☕ Java의 CompletableFuture로 비동기 프로그래밍 마스터하기: 코드의 미래를 선점하는 비법

📅 2025년 3월 기준 최신 Java 비동기 프로그래밍 가이드
안녕! 오늘은 Java의 강력한 비동기 프로그래밍 도구인 CompletableFuture에 대해 함께 알아볼 거야. 복잡한 개념을 쉽고 재미있게 풀어볼 테니 끝까지 함께해줘! 🚀
📚 목차
- 비동기 프로그래밍이 뭐길래?
- CompletableFuture 소개와 기본 개념
- CompletableFuture 기본 사용법
- CompletableFuture 고급 기능 활용하기
- 실전 예제로 배우는 CompletableFuture
- Java 21의 가상 스레드와 CompletableFuture
- 성능 최적화와 모범 사례
- CompletableFuture와 리액티브 프로그래밍
- 마무리 및 추가 학습 자료
1. 비동기 프로그래밍이 뭐길래? 🤔
프로그래밍 세계에서 '동기(Synchronous)'와 '비동기(Asynchronous)'라는 용어를 많이 들어봤을 거야. 이 개념을 쉽게 이해해보자!
동기 vs 비동기 프로그래밍
동기 프로그래밍: 작업이 순차적으로 실행돼. 한 작업이 완료될 때까지 다음 작업은 대기해야 해. 마치 은행 창구에서 줄 서서 기다리는 것처럼!
비동기 프로그래밍: 작업을 시작만 해두고 결과를 기다리지 않고 다른 작업을 수행할 수 있어. 나중에 작업이 완료되면 알려줘! 마치 카페에서 주문하고 진동벨이 울릴 때까지 다른 일을 하는 것처럼!
왜 비동기 프로그래밍이 중요할까? 🧐
- 성능 향상: CPU가 놀지 않고 계속 일할 수 있어 전체 처리 속도가 빨라져.
- 사용자 경험 개선: 웹사이트나 앱에서 데이터를 기다리는 동안 UI가 멈추지 않아.
- 자원 효율성: 한정된 시스템 자원을 더 효율적으로 사용할 수 있어.
- 확장성: 더 많은 요청을 동시에 처리할 수 있어 시스템 확장이 용이해.
현대 애플리케이션에서는 네트워크 요청, 파일 I/O, 데이터베이스 쿼리 같은 작업이 많아. 이런 작업들은 CPU 계산보다 대기 시간이 긴 경우가 많아서 비동기 처리가 효율적이야. 특히 재능넷 같은 플랫폼에서는 수많은 사용자의 요청을 동시에 처리해야 하기 때문에 비동기 프로그래밍이 필수적이지! 🚀
2. CompletableFuture 소개와 기본 개념 🌟
Java에서 비동기 프로그래밍을 위한 도구는 여러 가지가 있어. 그중에서도 CompletableFuture는 Java 8부터 도입된 강력한 비동기 프로그래밍 API야.
📜 Java 비동기 프로그래밍의 역사
- Java 1.0 - Thread: 가장 기본적인 동시성 도구지만 직접 관리가 복잡해.
- Java 5 - ExecutorService: 스레드 풀을 통한 작업 관리가 가능해졌어.
- Java 5 - Future: 비동기 작업의 결과를 나중에 받을 수 있게 되었지만 기능이 제한적이야.
- Java 8 - CompletableFuture: 함수형 스타일의 강력한 비동기 프로그래밍 지원!
- Java 9+ - CompletableFuture 개선: 지속적인 기능 추가와 개선이 이루어지고 있어.
- Java 21 - 가상 스레드: 경량 스레드로 비동기 프로그래밍의 새로운 지평을 열었어!
CompletableFuture란? 🤓
CompletableFuture
는 Future
를 확장한 클래스로, 비동기 계산 결과를 표현해. 기존 Future
의 한계를 극복하고 다음과 같은 강력한 기능을 제공해:
✅ 조합 가능성(Composability): 여러 비동기 작업을 연결하고 조합할 수 있어.
✅ 파이프라이닝(Pipelining): 한 작업의 결과를 다음 작업의 입력으로 전달할 수 있어.
✅ 예외 처리: 비동기 작업 중 발생한 예외를 우아하게 처리할 수 있어.
✅ 콜백(Callback): 작업 완료 시 실행할 코드를 등록할 수 있어.
✅ 타임아웃 지원: 작업이 지정된 시간 내에 완료되지 않으면 대체 작업을 실행할 수 있어.
CompletableFuture의 핵심 개념 🔑
1. 스테이지(Stage): CompletableFuture의 각 작업 단계를 의미해. 여러 스테이지를 연결해서 파이프라인을 구성할 수 있어.
2. 완료(Completion): 작업이 정상적으로 완료되거나 예외가 발생해 종료되는 것을 말해.
3. 비동기 실행(Asynchronous Execution): 작업을 별도의 스레드에서 실행하는 것을 의미해.
4. 조합(Composition): 여러 CompletableFuture를 조합해 새로운 CompletableFuture를 만드는 것을 말해.
이제 CompletableFuture가 어떤 녀석인지 대략 감이 왔을 거야. 다음 섹션에서는 실제로 어떻게 사용하는지 알아볼게! 🚀
3. CompletableFuture 기본 사용법 💻
이제 CompletableFuture를 실제로 어떻게 사용하는지 알아볼게. 코드를 통해 기본적인 사용법을 익혀보자!
CompletableFuture 생성하기 🏗️
CompletableFuture를 생성하는 방법은 여러 가지가 있어:
// 1. 이미 완료된 Future 생성
CompletableFuture<String> completedFuture = CompletableFuture.completedFuture("결과값");
// 2. 비동기 작업 실행 (ForkJoinPool의 commonPool() 사용)
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
// 시간이 걸리는 작업 수행
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
return "작업 결과";
});
// 3. 커스텀 Executor 사용
ExecutorService executor = Executors.newFixedThreadPool(5);
CompletableFuture<String> futureWithExecutor = CompletableFuture.supplyAsync(() -> {
return "커스텀 스레드풀에서 실행";
}, executor);
// 4. 빈 CompletableFuture 생성 후 나중에 완료
CompletableFuture<String> manualFuture = new CompletableFuture<>();
// 나중에 어딘가에서 완료시킴
manualFuture.complete("수동으로 완료된 결과");
💡 알아두면 좋은 팁!
supplyAsync()
와 runAsync()
의 차이점:
- •
supplyAsync()
: 결과값을 반환하는 작업에 사용 (Supplier<T>) - •
runAsync()
: 결과값이 없는 작업에 사용 (Runnable)
결과 가져오기 🎁
CompletableFuture에서 결과를 가져오는 방법은 여러 가지가 있어:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Hello");
// 1. get() - 블로킹 방식으로 결과 가져오기 (예외 처리 필요)
try {
String result = future.get(); // 작업이 완료될 때까지 현재 스레드 블로킹
System.out.println(result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
// 2. get(timeout, unit) - 타임아웃 설정
try {
String result = future.get(1, TimeUnit.SECONDS); // 1초 동안만 대기
System.out.println(result);
} catch (InterruptedException | ExecutionException | TimeoutException e) {
e.printStackTrace();
}
// 3. getNow(defaultValue) - 즉시 결과 반환 (완료되지 않았으면 기본값 반환)
String result = future.getNow("기본값");
System.out.println(result);
// 4. join() - 블로킹 방식이지만 checked 예외를 던지지 않음
String result = future.join(); // 예외 발생 시 unchecked 예외로 감싸서 던짐
System.out.println(result);
⚠️ 주의사항
get()
과 join()
은 블로킹 메서드야. 메인 스레드나 UI 스레드에서 호출하면 응답성이 떨어질 수 있으니 주의해야 해!
결과 처리하기 (콜백) 🔄
CompletableFuture의 강력한 기능 중 하나는 작업 완료 후 콜백을 등록할 수 있다는 거야:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Hello");
// 1. thenApply() - 결과를 변환 (Function 사용)
CompletableFuture<Integer> lengthFuture = future.thenApply(s -> s.length());
// 2. thenAccept() - 결과를 소비 (Consumer 사용)
future.thenAccept(s -> System.out.println("결과: " + s));
// 3. thenRun() - 결과 무시하고 작업 실행 (Runnable 사용)
future.thenRun(() -> System.out.println("작업 완료!"));
// 4. 비동기 콜백 (별도 스레드에서 실행)
future.thenApplyAsync(s -> s.length())
.thenAcceptAsync(length -> System.out.println("길이: " + length));
예외 처리하기 🛡️
비동기 작업에서 예외 처리는 매우 중요해. CompletableFuture는 예외 처리를 위한 다양한 방법을 제공해:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
if (Math.random() > 0.5) {
throw new RuntimeException("에러 발생!");
}
return "성공";
});
// 1. exceptionally() - 예외 발생 시 대체 값 제공
future.exceptionally(ex -> {
System.out.println("예외 발생: " + ex.getMessage());
return "기본값";
}).thenAccept(System.out::println);
// 2. handle() - 정상 결과와 예외 모두 처리
future.handle((result, ex) -> {
if (ex != null) {
System.out.println("예외 발생: " + ex.getMessage());
return "에러 대체값";
} else {
return result + " 처리 완료";
}
}).thenAccept(System.out::println);
// 3. whenComplete() - 결과 변환 없이 작업 완료 시 실행
future.whenComplete((result, ex) -> {
if (ex != null) {
System.out.println("예외 발생: " + ex.getMessage());
} else {
System.out.println("결과: " + result);
}
});
ℹ️ 예외 처리 메서드 비교
exceptionally(): 예외가 발생한 경우에만 호출되며, 대체 값을 반환해.
handle(): 정상 완료와 예외 발생 모두 호출되며, 결과를 변환할 수 있어.
whenComplete(): 정상 완료와 예외 발생 모두 호출되지만, 결과를 변환할 수 없어.
이제 CompletableFuture의 기본적인 사용법을 알게 되었어! 다음 섹션에서는 더 고급 기능들을 살펴볼 거야. 🚀
4. CompletableFuture 고급 기능 활용하기 🔥
기본 사용법을 익혔다면 이제 CompletableFuture의 더 강력한 기능들을 알아볼 차례야! 여러 비동기 작업을 조합하고 제어하는 방법을 배워보자.
여러 CompletableFuture 조합하기 🧩
실제 애플리케이션에서는 여러 비동기 작업을 조합해야 하는 경우가 많아. CompletableFuture는 이를 위한 다양한 메서드를 제공해:
// 두 개의 독립적인 CompletableFuture
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "World");
// 1. thenCombine() - 두 작업의 결과를 조합
CompletableFuture<String> combinedFuture = future1.thenCombine(future2, (result1, result2) ->
result1 + " " + result2);
// 결과: "Hello World"
// 2. thenCompose() - 한 작업의 결과를 다음 작업의 입력으로 전달 (flatMap과 유사)
CompletableFuture<String> composedFuture = future1.thenCompose(result ->
CompletableFuture.supplyAsync(() -> result + " Composed"));
// 결과: "Hello Composed"
// 3. allOf() - 여러 작업이 모두 완료될 때까지 대기
CompletableFuture<Void> allFuture = CompletableFuture.allOf(future1, future2);
allFuture.thenRun(() -> System.out.println("모든 작업 완료!"));
// allOf() 결과 값 가져오기 (약간의 트릭이 필요)
CompletableFuture<List<String>> allResultsFuture = allFuture.thenApply(v -> {
List<String> results = new ArrayList<>();
results.add(future1.join());
results.add(future2.join());
return results;
});
// 4. anyOf() - 여러 작업 중 하나라도 완료되면 완료
CompletableFuture<Object> anyFuture = CompletableFuture.anyOf(
CompletableFuture.supplyAsync(() -> {
try { Thread.sleep(1000); } catch (InterruptedException e) {}
return "느린 작업";
}),
CompletableFuture.supplyAsync(() -> "빠른 작업")
);
// 결과: "빠른 작업" (더 빨리 완료된 작업의 결과)
💡 thenCombine vs thenCompose 차이점
thenCombine: 두 개의 독립적인 CompletableFuture 결과를 조합 (AND 연산과 유사)
thenCompose: 한 CompletableFuture의 결과를 기반으로 다른 CompletableFuture를 생성 (체이닝)
Stream API의 map
과 flatMap
의 관계와 유사해!
타임아웃 처리하기 ⏱️
비동기 작업에서는 타임아웃 처리가 중요해. Java 9부터는 CompletableFuture에 타임아웃 기능이 추가되었어:
// Java 9 이상에서 사용 가능한 타임아웃 메서드
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(2000); // 2초 대기
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
return "결과";
});
// 1초 타임아웃 설정 (Java 9+)
CompletableFuture<String> futureWithTimeout = future.orTimeout(1, TimeUnit.SECONDS);
// 타임아웃 시 대체값 제공 (Java 9+)
CompletableFuture<String> futureWithDefault = future
.completeOnTimeout("타임아웃 기본값", 1, TimeUnit.SECONDS);
// Java 8에서의 타임아웃 구현 방법
CompletableFuture<String> java8Timeout = new CompletableFuture<>();
future.whenComplete((result, error) -> {
if (error == null) {
java8Timeout.complete(result);
} else {
java8Timeout.completeExceptionally(error);
}
});
// 별도의 스케줄러로 타임아웃 설정
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.schedule(() -> {
java8Timeout.completeExceptionally(
new TimeoutException("작업이 1초 내에 완료되지 않았습니다."));
}, 1, TimeUnit.SECONDS);
작업 취소하기 🚫
진행 중인 비동기 작업을 취소해야 할 때도 있어:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(5000); // 오래 걸리는 작업
} catch (InterruptedException e) {
// 인터럽트 처리
Thread.currentThread().interrupt();
throw new CompletionException(e);
}
return "완료";
});
// 작업 취소 (true: 인터럽트 시도, false: 인터럽트 시도하지 않음)
boolean canceled = future.cancel(true);
// 취소 여부 확인
if (future.isCancelled()) {
System.out.println("작업이 취소되었습니다.");
}
// 완료 여부 확인
if (future.isDone()) {
System.out.println("작업이 완료되었습니다 (정상 완료, 예외 발생, 취소 모두 포함).");
}
⚠️ 주의사항
CompletableFuture의 cancel()
메서드는 이미 시작된 작업을 실제로 중단시키지 않을 수 있어. 작업 자체가 인터럽트에 반응하도록 구현되어 있어야 해!
커스텀 스레드 풀 사용하기 🧵
기본적으로 CompletableFuture는 ForkJoinPool.commonPool()
을 사용하지만, 커스텀 스레드 풀을 사용하면 더 세밀한 제어가 가능해:
// 커스텀 스레드 풀 생성
ExecutorService executor = Executors.newFixedThreadPool(4, r -> {
Thread t = new Thread(r);
t.setDaemon(true); // 데몬 스레드로 설정
t.setName("custom-executor-" + t.getId());
return t;
});
// 커스텀 스레드 풀로 비동기 작업 실행
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
System.out.println("실행 스레드: " + Thread.currentThread().getName());
return "커스텀 스레드 풀에서 실행";
}, executor);
// 콜백도 커스텀 스레드 풀에서 실행
future.thenApplyAsync(result -> {
System.out.println("콜백 스레드: " + Thread.currentThread().getName());
return result.toUpperCase();
}, executor);
// 사용 완료 후 스레드 풀 종료
executor.shutdown();
ℹ️ 스레드 풀 선택 가이드
ForkJoinPool: CPU 집약적 작업에 적합 (작업을 작은 단위로 분할하여 병렬 처리)
FixedThreadPool: 안정적인 처리량이 필요한 경우 적합
CachedThreadPool: 짧은 비동기 작업이 많은 경우 적합 (필요에 따라 스레드 생성)
ScheduledThreadPool: 주기적인 작업이나 지연 실행이 필요한 경우 적합
가상 스레드(Java 21+): I/O 작업이 많은 경우 매우 효율적
이제 CompletableFuture의 고급 기능들을 알게 되었어! 다음 섹션에서는 실전 예제를 통해 이러한 기능들을 어떻게 활용하는지 살펴볼 거야. 🚀
5. 실전 예제로 배우는 CompletableFuture 🛠️
이론은 충분히 배웠으니 이제 실전 예제를 통해 CompletableFuture를 어떻게 활용할 수 있는지 알아보자! 실제 개발 상황에서 자주 마주치는 시나리오들을 살펴볼게.
예제 1: 외부 API 병렬 호출하기 🌐
여러 외부 API를 동시에 호출하여 결과를 조합해야 하는 상황은 매우 흔해. CompletableFuture를 사용하면 이런 작업을 효율적으로 처리할 수 있어:
public class ProductService {
// 상품 정보 조회 API
public CompletableFuture<ProductInfo> getProductInfo(long productId) {
return CompletableFuture.supplyAsync(() -> {
// 외부 API 호출 시뮬레이션
sleep(500);
return new ProductInfo(productId, "상품 " + productId, "상품 설명...");
});
}
// 가격 정보 조회 API
public CompletableFuture<PriceInfo> getPriceInfo(long productId) {
return CompletableFuture.supplyAsync(() -> {
// 외부 API 호출 시뮬레이션
sleep(300);
return new PriceInfo(productId, 50000, "KRW");
});
}
// 재고 정보 조회 API
public CompletableFuture<InventoryInfo> getInventoryInfo(long productId) {
return CompletableFuture.supplyAsync(() -> {
// 외부 API 호출 시뮬레이션
sleep(400);
return new InventoryInfo(productId, 100);
});
}
// 리뷰 정보 조회 API
public CompletableFuture<List<Review>> getReviews(long productId) {
return CompletableFuture.supplyAsync(() -> {
// 외부 API 호출 시뮬레이션
sleep(600);
return Arrays.asList(
new Review(1, "좋아요", 5),
new Review(2, "괜찮아요", 4)
);
});
}
// 모든 정보를 조합하여 상품 상세 정보 반환
public CompletableFuture<ProductDetails> getProductDetails(long productId) {
CompletableFuture<ProductInfo> productInfoFuture = getProductInfo(productId);
CompletableFuture<PriceInfo> priceInfoFuture = getPriceInfo(productId);
CompletableFuture<InventoryInfo> inventoryInfoFuture = getInventoryInfo(productId);
CompletableFuture<List<Review>> reviewsFuture = getReviews(productId);
// 모든 Future가 완료될 때까지 기다린 후 결과 조합
return CompletableFuture.allOf(
productInfoFuture,
priceInfoFuture,
inventoryInfoFuture,
reviewsFuture
)
.thenApply(v -> {
// 모든 Future가 완료되면 결과 조합
ProductInfo productInfo = productInfoFuture.join();
PriceInfo priceInfo = priceInfoFuture.join();
InventoryInfo inventoryInfo = inventoryInfoFuture.join();
List<Review> reviews = reviewsFuture.join();
return new ProductDetails(
productInfo,
priceInfo,
inventoryInfo,
reviews
);
});
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
// 데이터 클래스들 (실제로는 별도 파일로 분리)
record ProductInfo(long id, String name, String description) {}
record PriceInfo(long productId, double price, String currency) {}
record InventoryInfo(long productId, int stockQuantity) {}
record Review(long id, String comment, int rating) {}
record ProductDetails(ProductInfo info, PriceInfo price,
InventoryInfo inventory, List<Review> reviews) {}
}
위 예제를 사용하는 코드:
ProductService service = new ProductService();
long productId = 123;
// 비동기로 상품 상세 정보 조회
service.getProductDetails(productId)
.thenAccept(details -> {
System.out.println("상품명: " + details.info().name());
System.out.println("가격: " + details.price().price() + " " + details.price().currency());
System.out.println("재고: " + details.inventory().stockQuantity() + "개");
System.out.println("리뷰 수: " + details.reviews().size() + "개");
})
.join(); // 예제를 위한 블로킹 호출 (실제로는 비동기로 처리)
ℹ️ 이 예제의 장점
1. 모든 API 호출이 병렬로 실행되어 총 소요 시간이 크게 줄어들어 (순차 실행: 1800ms, 병렬 실행: ~600ms)
2. 코드가 선언적이고 가독성이 높아 (중첩된 콜백 없음)
3. 각 API의 실패를 독립적으로 처리할 수 있어 (예외 처리 추가 가능)
예제 2: 비동기 작업 파이프라인 구축하기 🔄
여러 단계의 작업을 순차적으로 처리해야 하는 파이프라인을 구축해보자:
public class ImageProcessingService {
// 이미지 다운로드
public CompletableFuture<byte[]> downloadImage(String url) {
return CompletableFuture.supplyAsync(() -> {
System.out.println("이미지 다운로드 중... " + url);
sleep(1000); // 네트워크 지연 시뮬레이션
return new byte[1024]; // 다운로드된 이미지 데이터
});
}
// 이미지 크기 조정
public CompletableFuture<byte[]> resizeImage(byte[] imageData, int width, int height) {
return CompletableFuture.supplyAsync(() -> {
System.out.println("이미지 크기 조정 중... " + width + "x" + height);
sleep(500); // 처리 시간 시뮬레이션
return new byte[width * height]; // 크기 조정된 이미지 데이터
});
}
// 이미지 필터 적용
public CompletableFuture<byte[]> applyFilter(byte[] imageData, String filterType) {
return CompletableFuture.supplyAsync(() -> {
System.out.println("필터 적용 중... " + filterType);
sleep(300); // 처리 시간 시뮬레이션
return imageData; // 필터 적용된 이미지 데이터
});
}
// 이미지 저장
public CompletableFuture<String> saveImage(byte[] imageData, String fileName) {
return CompletableFuture.supplyAsync(() -> {
System.out.println("이미지 저장 중... " + fileName);
sleep(700); // 디스크 I/O 시뮬레이션
return "/images/" + fileName; // 저장된 이미지 경로
});
}
// 전체 이미지 처리 파이프라인
public CompletableFuture<String> processImage(String imageUrl, String fileName) {
return downloadImage(imageUrl)
.thenCompose(imageData -> resizeImage(imageData, 800, 600))
.thenCompose(resizedData -> applyFilter(resizedData, "SEPIA"))
.thenCompose(filteredData -> saveImage(filteredData, fileName))
.exceptionally(ex -> {
System.err.println("이미지 처리 중 오류 발생: " + ex.getMessage());
return "error";
});
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
}
위 예제를 사용하는 코드:
ImageProcessingService service = new ImageProcessingService();
service.processImage("https://example.com/image.jpg", "processed_image.jpg")
.thenAccept(savedPath -> {
if ("error".equals(savedPath)) {
System.out.println("이미지 처리에 실패했습니다.");
} else {
System.out.println("이미지가 성공적으로 처리되었습니다: " + savedPath);
}
})
.join(); // 예제를 위한 블로킹 호출
💡 파이프라인 패턴의 장점
1. 코드의 가독성: 각 단계가 명확하게 구분되어 있어 이해하기 쉬워.
2. 유지보수성: 각 단계를 독립적으로 수정하거나 새 단계를 추가하기 쉬워.
3. 에러 처리: 파이프라인 어느 단계에서든 발생한 오류를 일관되게 처리할 수 있어.
4. 비동기 처리: 각 단계가 완료되는 즉시 다음 단계가 시작되어 효율적이야.
예제 3: 타임아웃과 폴백 처리하기 ⏱️
외부 서비스 호출 시 타임아웃과 폴백(fallback) 처리는 매우 중요해. 다음 예제를 통해 알아보자:
public class WeatherService {
// 주 날씨 API 호출 (가끔 느릴 수 있음)
public CompletableFuture<String> getPrimaryWeatherForecast(String city) {
return CompletableFuture.supplyAsync(() -> {
System.out.println("주 날씨 API 호출 중...");
// 랜덤하게 지연 발생 시뮬레이션
int delay = new Random().nextInt(3000);
sleep(delay);
if (delay > 1500) {
throw new RuntimeException("주 날씨 API 응답 시간 초과");
}
return city + "의 날씨: 맑음, 기온: 25°C";
});
}
// 보조 날씨 API 호출 (더 안정적이지만 덜 정확함)
public CompletableFuture<String> getBackupWeatherForecast(String city) {
return CompletableFuture.supplyAsync(() -> {
System.out.println("보조 날씨 API 호출 중...");
sleep(500); // 항상 빠르게 응답
return city + "의 날씨: 대체로 맑음, 기온: 약 23-27°C";
});
}
// 캐시에서 날씨 정보 가져오기
public String getCachedWeatherForecast(String city) {
return city + "의 날씨: 캐시된 정보 (1시간 전), 대체로 맑음";
}
// 타임아웃과 폴백이 적용된 날씨 정보 조회
public CompletableFuture<String> getWeatherForecast(String city) {
// 주 API 호출 (1초 타임아웃 설정)
CompletableFuture<String> primaryForecast = getPrimaryWeatherForecast(city)
.completeOnTimeout("TIMED_OUT", 1000, TimeUnit.MILLISECONDS);
// 주 API가 실패하면 보조 API 호출
return primaryForecast.thenCompose(result -> {
if ("TIMED_OUT".equals(result)) {
System.out.println("주 API 타임아웃, 보조 API 사용");
return getBackupWeatherForecast(city);
}
return CompletableFuture.completedFuture(result);
}).exceptionally(ex -> {
// 모든 API가 실패하면 캐시 사용
System.out.println("모든 API 실패, 캐시 사용: " + ex.getMessage());
return getCachedWeatherForecast(city);
});
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
}
위 예제를 사용하는 코드:
WeatherService service = new WeatherService();
service.getWeatherForecast("서울")
.thenAccept(forecast -> {
System.out.println("최종 날씨 정보: " + forecast);
})
.join(); // 예제를 위한 블로킹 호출
ℹ️ 회복성 있는 시스템 구축하기
위 예제는 서킷 브레이커 패턴의 간단한 구현이야. 이 패턴은 외부 시스템 장애가 내 애플리케이션 전체로 전파되는 것을 방지해.
실제 프로덕션 환경에서는 Resilience4j나 Hystrix 같은 라이브러리를 사용하면 더 강력한 회복성을 구현할 수 있어!
이런 실전 예제들을 통해 CompletableFuture가 실제 개발에서 어떻게 활용되는지 감을 잡았을 거야. 재능넷 같은 플랫폼에서도 이런 비동기 프로그래밍 패턴을 활용하면 사용자 경험을 크게 향상시킬 수 있어! 다음 섹션에서는 Java 21의 가상 스레드와 CompletableFuture의 관계에 대해 알아볼게. 🚀
6. Java 21의 가상 스레드와 CompletableFuture 🧵
2023년 9월에 출시된 Java 21은 가상 스레드(Virtual Threads)라는 혁신적인 기능을 도입했어. 2025년 3월 현재, 이 기능은 많은 프로덕션 환경에서 활발히 사용되고 있지. 가상 스레드가 CompletableFuture와 어떻게 연관되는지 알아보자!
가상 스레드란? 🤔
가상 스레드는 기존의 플랫폼 스레드(OS 스레드)와 달리 JVM에 의해 관리되는 경량 스레드야. 다음과 같은 특징이 있어:
✅ 경량성: 가상 스레드는 매우 가볍고 수백만 개를 생성할 수 있어.
✅ 저비용 블로킹: 가상 스레드가 블로킹되면 OS 스레드를 차단하지 않아.
✅ 쉬운 프로그래밍 모델: 비동기 코드를 동기 스타일로 작성할 수 있어.
✅ 기존 코드와의 호환성: 기존 스레드 API와 호환돼.
가상 스레드와 CompletableFuture 함께 사용하기 🔄
가상 스레드는 CompletableFuture와 함께 사용하면 더욱 강력해져. 다음과 같은 방법으로 함께 사용할 수 있어:
// 가상 스레드 기반 ExecutorService 생성
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 가상 스레드로 CompletableFuture 실행
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
// I/O 작업 (블로킹 작업)
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "가상 스레드에서 실행: " + Thread.currentThread();
}, executor);
// 결과 처리
future.thenAccept(System.out::println).join();
}
ℹ️ 가상 스레드의 장점
1. 높은 처리량: 수천 개의 동시 요청을 효율적으로 처리할 수 있어.
2. 낮은 지연 시간: 스레드 전환 비용이 매우 낮아 응답 시간이 개선돼.
3. 간결한 코드: 복잡한 비동기 코드 대신 간단한 동기 스타일 코드를 작성할 수 있어.
4. 리소스 효율성: 적은 수의 OS 스레드로 많은 동시 작업을 처리할 수 있어.
CompletableFuture vs 구조적 동시성 🤔
Java 21에서는 가상 스레드와 함께 구조적 동시성(Structured Concurrency)이라는 개념도 프리뷰 기능으로 도입되었어. 이는 CompletableFuture와 어떻게 다를까?
// Java 21 구조적 동시성 예제 (프리뷰 기능)
try (var scope = StructuredTaskScope.ShutdownOnFailure()) {
// 두 작업을 병렬로 실행
Subtask<String> user = scope.fork(() -> fetchUser(userId));
Subtask<List<Order>> orders = scope.fork(() -> fetchOrders(userId));
// 모든 작업이 완료될 때까지 대기 (최대 1초)
scope.joinUntil(Instant.now().plusSeconds(1));
// 결과 처리
return new UserOrders(user.get(), orders.get());
} catch (Exception e) {
throw new RuntimeException("Failed to fetch user data", e);
}
구조적 동시성과 CompletableFuture 비교:
- • 유연한 비동기 작업 조합
- • 함수형 스타일의 API
- • 복잡한 의존성 그래프 표현 가능
- • 예외 전파가 명시적으로 처리되어야 함
- • 명확한 부모-자식 관계
- • 자동 리소스 정리
- • 자연스러운 예외 전파
- • try-with-resources 패턴과 유사
💡 어떤 것을 선택해야 할까?
두 접근 방식은 상호 배타적이지 않아! 상황에 따라 적절한 도구를 선택하면 돼:
• CompletableFuture: 복잡한 비동기 워크플로우, 이벤트 기반 시스템에 적합
• 구조적 동시성: 명확한 경계가 있는 병렬 작업, 자원 관리가 중요한 경우에 적합
• 두 가지 혼합: 구조적 동시성 내에서 CompletableFuture를 사용하거나 그 반대도 가능!
가상 스레드와 CompletableFuture를 함께 사용하면 비동기 프로그래밍의 복잡성은 줄이면서 성능은 높일 수 있어. 특히 재능넷과 같이 많은 동시 사용자를 처리해야 하는 플랫폼에서는 이러한 기술 조합이 큰 도움이 될 거야! 🚀
7. 성능 최적화와 모범 사례 🚀
CompletableFuture를 효과적으로 사용하려면 성능 최적화와 모범 사례를 알아두는 것이 중요해. 이 섹션에서는 실제 프로덕션 환경에서 CompletableFuture를 사용할 때 알아두면 좋은 팁들을 알아볼게!
스레드 풀 최적화하기 🧵
CompletableFuture의 성능은 사용하는 스레드 풀에 크게 영향을 받아. 적절한 스레드 풀을 선택하고 구성하는 방법을 알아보자:
// I/O 작업을 위한 스레드 풀
ExecutorService ioExecutor = Executors.newFixedThreadPool(
// CPU 코어 수의 몇 배로 설정 (I/O 작업은 대부분 대기 시간이 있으므로)
Runtime.getRuntime().availableProcessors() * 10,
new ThreadFactory() {
private final AtomicInteger counter = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r);
thread.setName("io-thread-" + counter.getAndIncrement());
thread.setDaemon(true); // 데몬 스레드로 설정
return thread;
}
}
);
// CPU 집약적 작업을 위한 스레드 풀
ExecutorService cpuExecutor = Executors.newFixedThreadPool(
// CPU 코어 수에 맞춰 설정 (CPU 작업은 병렬성 제한이 있으므로)
Runtime.getRuntime().availableProcessors(),
r -> {
Thread thread = new Thread(r);
thread.setName("cpu-thread-" + thread.getId());
thread.setDaemon(true);
return thread;
}
);
// Java 21+ 가상 스레드 풀 (I/O 작업에 이상적)
ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();
💡 스레드 풀 선택 가이드
I/O 바운드 작업:
- • Java 21 이상: 가상 스레드 사용 (Executors.newVirtualThreadPerTaskExecutor())
- • Java 21 미만: 코어 수보다 많은 스레드를 가진 FixedThreadPool 사용
CPU 바운드 작업:
- • 코어 수에 맞춘 스레드 풀 사용 (ForkJoinPool 또는 FixedThreadPool)
메모리 누수 방지하기 🧹
CompletableFuture를 사용할 때 주의하지 않으면 메모리 누수가 발생할 수 있어. 주요 원인과 해결 방법을 알아보자:
// 문제: 완료되지 않은 CompletableFuture
CompletableFuture<String> future = new CompletableFuture<>();
// future.complete()가 호출되지 않으면 메모리 누수 가능성
// 해결 1: 타임아웃 설정
future.orTimeout(30, TimeUnit.SECONDS);
// 해결 2: 명시적으로 예외 처리
try {
// 작업 수행
future.complete("결과");
} catch (Exception e) {
future.completeExceptionally(e);
} finally {
// future가 아직 완료되지 않았다면 강제로 완료
if (!future.isDone()) {
future.completeExceptionally(
new TimeoutException("작업이 완료되지 않았습니다."));
}
}
⚠️ 주의사항: 메모리 누수 원인
1. 완료되지 않은 Future: complete() 또는 completeExceptionally()가 호출되지 않은 Future
2. 체인에서의 예외 처리 누락: 예외가 발생했지만 처리되지 않은 경우
3. 스레드 풀 종료 실패: 사용자 정의 스레드 풀을 종료하지 않은 경우
예외 처리 모범 사례 🛡️
비동기 코드에서 예외 처리는 매우 중요해. 다음은 CompletableFuture에서 예외를 처리하는 모범 사례야:
// 1. 각 단계마다 예외 처리
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
// 위험한 작업
if (Math.random() < 0.5) {
throw new RuntimeException("첫 번째 단계 실패");
}
return "첫 번째 단계 성공";
}).thenApply(result -> {
// 두 번째 위험한 작업
if (Math.random() < 0.5) {
throw new RuntimeException("두 번째 단계 실패");
}
return result + " -> 두 번째 단계 성공";
}).exceptionally(ex -> {
// 모든 예외를 여기서 처리
System.err.println("오류 발생: " + ex.getMessage());
return "기본값";
});
// 2. 특정 예외 유형에 따라 다르게 처리
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
double random = Math.random();
if (random < 0.3) {
throw new IllegalArgumentException("잘못된 인수");
} else if (random < 0.6) {
throw new IllegalStateException("잘못된 상태");
}
return "성공";
}).handle((result, ex) -> {
if (ex == null) {
return result;
}
// 예외 유형에 따라 다르게 처리
if (ex.getCause() instanceof IllegalArgumentException) {
return "인수 오류 대체값";
} else if (ex.getCause() instanceof IllegalStateException) {
return "상태 오류 대체값";
} else {
return "알 수 없는 오류 대체값";
}
});
// 3. 로깅과 함께 예외 처리
CompletableFuture<String> future3 = CompletableFuture.supplyAsync(() -> {
// 위험한 작업
if (Math.random() < 0.5) {
throw new RuntimeException("작업 실패");
}
return "성공";
}).whenComplete((result, ex) -> {
// 결과나 예외를 로깅하지만 값은 변경하지 않음
if (ex != null) {
System.err.println("오류 발생 (전파됨): " + ex.getMessage());
} else {
System.out.println("성공: " + result);
}
}).exceptionally(ex -> {
// 로깅 후 대체값 반환
System.err.println("복구 중: " + ex.getMessage());
return "복구된 값";
});
성능 모니터링과 디버깅 📊
CompletableFuture 기반 애플리케이션의 성능을 모니터링하고 디버깅하는 방법을 알아보자:
// 1. 작업 이름 지정 및 MDC 활용
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
// 현재 스레드에 컨텍스트 정보 추가
MDC.put("operation", "data-fetch");
MDC.put("requestId", UUID.randomUUID().toString());
try {
// 작업 수행
return "결과";
} finally {
// 컨텍스트 정보 정리
MDC.clear();
}
});
// 2. 실행 시간 측정
long startTime = System.nanoTime();
CompletableFuture<String> timedFuture = CompletableFuture.supplyAsync(() -> {
// 시간이 걸리는 작업
return "결과";
}).whenComplete((result, ex) -> {
long endTime = System.nanoTime();
long durationMs = TimeUnit.NANOSECONDS.toMillis(endTime - startTime);
System.out.println("작업 실행 시간: " + durationMs + "ms");
});
// 3. 디버깅을 위한 중간 상태 로깅
CompletableFuture<String> debugFuture = CompletableFuture.supplyAsync(() -> {
System.out.println("1단계 시작");
String result = "첫 번째 결과";
System.out.println("1단계 완료: " + result);
return result;
}).thenApply(result -> {
System.out.println("2단계 시작: " + result);
String newResult = result + " -> 변환됨";
System.out.println("2단계 완료: " + newResult);
return newResult;
});
ℹ️ 성능 모니터링 도구
1. JDK Flight Recorder (JFR): JVM 내부 이벤트를 기록하여 성능 분석
2. Java Mission Control (JMC): JFR 데이터를 시각화하여 분석
3. VisualVM: 스레드 덤프, 메모리 사용량, CPU 사용량 등 모니터링
4. Micrometer + Prometheus + Grafana: 애플리케이션 메트릭 수집 및 시각화
CompletableFuture 사용 시 주의사항 ⚠️
CompletableFuture를 사용할 때 흔히 발생하는 실수와 주의사항을 알아보자:
1. 블로킹 코드 사용 주의
CompletableFuture 내에서 get()
이나 join()
같은 블로킹 메서드를 호출하면 데드락이 발생할 수 있어.
// 잘못된 예
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "결과1");
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
// 같은 스레드 풀에서 블로킹 호출 - 데드락 위험!
return future1.join() + " + 추가 작업";
});
// 올바른 예
CompletableFuture<String> betterFuture = future1.thenApply(result -> result + " + 추가 작업");
2. 예외 전파 이해하기
CompletableFuture 체인에서 예외는 exceptionally
나 handle
로 처리하지 않으면 계속 전파돼.
// 예외가 전파되는 예
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> { throw new RuntimeException("오류"); })
.thenApply(s -> s.toUpperCase()) // 실행되지 않음
.thenApply(s -> s + "!!!"); // 실행되지 않음
// 예외를 처리하는 예
CompletableFuture<String> handledFuture = CompletableFuture
.supplyAsync(() -> { throw new RuntimeException("오류"); })
.exceptionally(ex -> "기본값") // 여기서 예외 처리
.thenApply(s -> s.toUpperCase()) // "기본값"에 대해 실행됨
.thenApply(s -> s + "!!!"); // "기본값"에 대해 실행됨
3. 스레드 컨텍스트 이해하기
CompletableFuture의 각 단계는 다른 스레드에서 실행될 수 있어. ThreadLocal 변수나 보안 컨텍스트가 유지되지 않을 수 있어.
// ThreadLocal 컨텍스트가 유지되지 않는 예
ThreadLocal<String> context = new ThreadLocal<>();
context.set("원래 컨텍스트");
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
// 다른 스레드에서는 컨텍스트를 볼 수 없음
String value = context.get(); // null일 가능성 높음
return "컨텍스트: " + value;
});
// 해결책: 컨텍스트를 명시적으로 전달
String contextValue = context.get();
CompletableFuture<String> betterFuture = CompletableFuture.supplyAsync(() -> {
return "컨텍스트: " + contextValue; // 값을 직접 전달
});
이러한 모범 사례와 주의사항을 염두에 두면 CompletableFuture를 더 효과적으로 사용할 수 있어. 재능넷과 같은 대규모 플랫폼에서는 이런 최적화가 사용자 경험과 시스템 안정성에 큰 영향을 미칠 수 있어! 🚀
8. CompletableFuture와 리액티브 프로그래밍 🔄
CompletableFuture는 비동기 프로그래밍의 한 방식이지만, 최근에는 리액티브 프로그래밍(Reactive Programming)이 많은 관심을 받고 있어. 이 섹션에서는 CompletableFuture와 리액티브 프로그래밍의 관계, 그리고 언제 어떤 접근 방식을 선택해야 하는지 알아볼게!
리액티브 프로그래밍이란? 🤔
리액티브 프로그래밍은 데이터 스트림과 변화의 전파에 중점을 둔 프로그래밍 패러다임이야. 비동기 데이터 스트림을 처리하는 데 초점을 맞추고 있지.
리액티브 프로그래밍의 핵심 개념
1. 비동기(Asynchronous): 블로킹 없이 작업 실행
2. 데이터 스트림(Data Streams): 연속적인 데이터 흐름 처리
3. 백프레셔(Backpressure): 데이터 생산자와 소비자 간의 속도 조절
4. 선언적(Declarative): 어떻게가 아닌 무엇을 할지 정의
5. 조합 가능(Composable): 복잡한 데이터 흐름을 조합하여 구성
CompletableFuture vs 리액티브 라이브러리 🥊
Java 생태계에서 리액티브 프로그래밍을 위한 주요 라이브러리로는 RxJava, Project Reactor, Akka Streams 등이 있어. 이들과 CompletableFuture를 비교해보자:
CompletableFuture와 리액티브 라이브러리 함께 사용하기 🤝
CompletableFuture와 리액티브 라이브러리는 상호 변환이 가능해. 다음은 CompletableFuture와 Project Reactor의 Mono 간 변환 예제야:
// Project Reactor 의존성 필요
import reactor.core.publisher.Mono;
// CompletableFuture -> Mono 변환
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Hello");
Mono<String> mono = Mono.fromFuture(future);
// Mono -> CompletableFuture 변환
Mono<String> anotherMono = Mono.just("World");
CompletableFuture<String> anotherFuture = anotherMono.toFuture();
// RxJava와의 변환 (RxJava 의존성 필요)
import io.reactivex.Single;
// CompletableFuture -> Single 변환
Single<String> single = Single.fromFuture(future);
// Single -> CompletableFuture 변환
Single<String> anotherSingle = Single.just("RxJava");
CompletableFuture<String> futureFromSingle = anotherSingle.toCompletableFuture();
💡 언제 무엇을 선택해야 할까?
CompletableFuture를 선택하는 경우:
- • 단일 결과를 반환하는 간단한 비동기 작업
- • 외부 라이브러리 의존성을 최소화하고 싶을 때
- • Java 표준 API만 사용하고 싶을 때
- • 학습 곡선이 완만한 솔루션이 필요할 때
리액티브 라이브러리를 선택하는 경우:
- • 연속적인 데이터 스트림 처리가 필요할 때
- • 복잡한 이벤트 조합과 변환이 필요할 때
- • 백프레셔 메커니즘이 중요할 때
- • 이미 Spring WebFlux 같은 리액티브 프레임워크를 사용 중일 때
실제 사례: 하이브리드 접근법 🔄
실제 애플리케이션에서는 CompletableFuture와 리액티브 프로그래밍을 함께 사용하는 하이브리드 접근법이 효과적일 수 있어:
// 서비스 계층 (CompletableFuture 사용)
public class UserService {
public CompletableFuture<User> getUserById(String userId) {
return CompletableFuture.supplyAsync(() -> {
// 데이터베이스에서 사용자 조회
return new User(userId, "사용자 " + userId);
});
}
public CompletableFuture<List<Order>> getUserOrders(String userId) {
return CompletableFuture.supplyAsync(() -> {
// 데이터베이스에서 주문 목록 조회
return Arrays.asList(
new Order("O1", userId, 100.0),
new Order("O2", userId, 200.0)
);
});
}
}
// 웹 계층 (Spring WebFlux + Reactor 사용)
@RestController
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/users/{userId}")
public Mono<UserWithOrders> getUserWithOrders(@PathVariable String userId) {
// CompletableFuture를 Mono로 변환하여 사용
Mono<User> userMono = Mono.fromFuture(userService.getUserById(userId));
Mono<List<Order>> ordersMono = Mono.fromFuture(userService.getUserOrders(userId));
// 리액티브 방식으로 두 결과 조합
return Mono.zip(userMono, ordersMono, (user, orders) -> {
return new UserWithOrders(user, orders);
});
}
// 데이터 클래스들
record User(String id, String name) {}
record Order(String id, String userId, double amount) {}
record UserWithOrders(User user, List<Order> orders) {}
}
ℹ️ 하이브리드 접근법의 장점
1. 점진적 마이그레이션: 기존 CompletableFuture 코드를 유지하면서 리액티브 패러다임 도입 가능
2. 적재적소 활용: 각 기술의 강점을 활용할 수 있음
3. 유연성: 다양한 API와 라이브러리를 통합할 수 있음
4. 실용성: 완벽한 리액티브 시스템보다 실용적인 접근 가능
재능넷과 같은 플랫폼에서는 다양한 비동기 처리 요구사항이 있을 수 있어. 단순한 작업에는 CompletableFuture를, 복잡한 이벤트 스트림 처리에는 리액티브 라이브러리를 선택적으로 활용하면 더 효율적인 시스템을 구축할 수 있을 거야! 🚀
9. 마무리 및 추가 학습 자료 📚
지금까지 Java의 CompletableFuture를 활용한 비동기 프로그래밍에 대해 깊이 있게 알아봤어. 이제 CompletableFuture의 기본 개념부터 고급 기능, 실전 예제, 성능 최적화, 그리고 리액티브 프로그래밍과의 관계까지 이해했을 거야!
📝 핵심 요약
- 비동기 프로그래밍은 현대 애플리케이션에서 성능과 사용자 경험을 향상시키는 필수 기술이야.
- CompletableFuture는 Java 8부터 도입된 강력한 비동기 프로그래밍 API로, 다양한 비동기 작업을 조합하고 제어할 수 있어.
- 기본 사용법으로는 작업 생성, 결과 처리, 예외 처리 등이 있어.
- 고급 기능으로는 여러 작업 조합, 타임아웃 처리, 커스텀 스레드 풀 사용 등이 있어.
- 실전 예제를 통해 외부 API 호출, 파이프라인 구축, 타임아웃과 폴백 처리 방법을 배웠어.
- Java 21의 가상 스레드는 CompletableFuture와 함께 사용하면 더욱 효율적인 비동기 프로그래밍이 가능해.
- 성능 최적화와 모범 사례를 통해 메모리 누수 방지, 예외 처리, 성능 모니터링 방법을 알아봤어.
- 리액티브 프로그래밍은 CompletableFuture의 대안이자 보완재로, 각각의 장단점과 적합한 사용 사례가 있어.
추가 학습 자료 📚
📖 책
- • "Modern Java in Action" - Raoul-Gabriel Urma, Mario Fusco, Alan Mycroft
- • "Java Concurrency in Practice" - Brian Goetz
- • "Reactive Programming with RxJava" - Tomasz Nurkiewicz, Ben Christensen
🌐 온라인 자료
- • Oracle 공식 문서: CompletableFuture API 문서
- • Baeldung 튜토리얼: Guide to CompletableFuture
- • Inside Java 블로그: Project Loom (가상 스레드) 관련 글
🎓 강의 및 워크숍
- • Pluralsight: "Java Concurrency: CompletableFuture and Async API"
- • Udemy: "Reactive Programming in Modern Java using Project Reactor"
- • 재능넷: 다양한 Java 관련 강의와 튜토리얼을 찾아보세요!
다음 단계로 배울 만한 주제들 🚀
마치며 💭
비동기 프로그래밍은 현대 소프트웨어 개발에서 필수적인 기술이 되었어. CompletableFuture는 Java 개발자가 비동기 코드를 간결하고 강력하게 작성할 수 있게 해주는 훌륭한 도구야.
이 글이 재능넷 커뮤니티와 Java 개발자들에게 도움이 되길 바라! 비동기 프로그래밍의 세계는 계속 발전하고 있으니, 새로운 기술과 패턴을 계속 탐구하는 것을 잊지 마!
더 많은 프로그래밍 지식과 튜토리얼은 재능넷에서 찾아볼 수 있어. 다양한 재능을 공유하고 배울 수 있는 재능넷에서 당신의 프로그래밍 여정을 계속해보세요! 🚀
📚 목차
- 비동기 프로그래밍이 뭐길래?
- CompletableFuture 소개와 기본 개념
- CompletableFuture 기본 사용법
- CompletableFuture 고급 기능 활용하기
- 실전 예제로 배우는 CompletableFuture
- Java 21의 가상 스레드와 CompletableFuture
- 성능 최적화와 모범 사례
- CompletableFuture와 리액티브 프로그래밍
- 마무리 및 추가 학습 자료
1. 비동기 프로그래밍이 뭐길래? 🤔
프로그래밍 세계에서 '동기(Synchronous)'와 '비동기(Asynchronous)'라는 용어를 많이 들어봤을 거야. 이 개념을 쉽게 이해해보자!
동기 vs 비동기 프로그래밍
동기 프로그래밍: 작업이 순차적으로 실행돼. 한 작업이 완료될 때까지 다음 작업은 대기해야 해. 마치 은행 창구에서 줄 서서 기다리는 것처럼!
비동기 프로그래밍: 작업을 시작만 해두고 결과를 기다리지 않고 다른 작업을 수행할 수 있어. 나중에 작업이 완료되면 알려줘! 마치 카페에서 주문하고 진동벨이 울릴 때까지 다른 일을 하는 것처럼!
왜 비동기 프로그래밍이 중요할까? 🧐
- 성능 향상: CPU가 놀지 않고 계속 일할 수 있어 전체 처리 속도가 빨라져.
- 사용자 경험 개선: 웹사이트나 앱에서 데이터를 기다리는 동안 UI가 멈추지 않아.
- 자원 효율성: 한정된 시스템 자원을 더 효율적으로 사용할 수 있어.
- 확장성: 더 많은 요청을 동시에 처리할 수 있어 시스템 확장이 용이해.
현대 애플리케이션에서는 네트워크 요청, 파일 I/O, 데이터베이스 쿼리 같은 작업이 많아. 이런 작업들은 CPU 계산보다 대기 시간이 긴 경우가 많아서 비동기 처리가 효율적이야. 특히 재능넷 같은 플랫폼에서는 수많은 사용자의 요청을 동시에 처리해야 하기 때문에 비동기 프로그래밍이 필수적이지! 🚀
2. CompletableFuture 소개와 기본 개념 🌟
Java에서 비동기 프로그래밍을 위한 도구는 여러 가지가 있어. 그중에서도 CompletableFuture는 Java 8부터 도입된 강력한 비동기 프로그래밍 API야.
📜 Java 비동기 프로그래밍의 역사
- Java 1.0 - Thread: 가장 기본적인 동시성 도구지만 직접 관리가 복잡해.
- Java 5 - ExecutorService: 스레드 풀을 통한 작업 관리가 가능해졌어.
- Java 5 - Future: 비동기 작업의 결과를 나중에 받을 수 있게 되었지만 기능이 제한적이야.
- Java 8 - CompletableFuture: 함수형 스타일의 강력한 비동기 프로그래밍 지원!
- Java 9+ - CompletableFuture 개선: 지속적인 기능 추가와 개선이 이루어지고 있어.
- Java 21 - 가상 스레드: 경량 스레드로 비동기 프로그래밍의 새로운 지평을 열었어!
CompletableFuture란? 🤓
CompletableFuture
는 Future
를 확장한 클래스로, 비동기 계산 결과를 표현해. 기존 Future
의 한계를 극복하고 다음과 같은 강력한 기능을 제공해:
✅ 조합 가능성(Composability): 여러 비동기 작업을 연결하고 조합할 수 있어.
✅ 파이프라이닝(Pipelining): 한 작업의 결과를 다음 작업의 입력으로 전달할 수 있어.
✅ 예외 처리: 비동기 작업 중 발생한 예외를 우아하게 처리할 수 있어.
✅ 콜백(Callback): 작업 완료 시 실행할 코드를 등록할 수 있어.
✅ 타임아웃 지원: 작업이 지정된 시간 내에 완료되지 않으면 대체 작업을 실행할 수 있어.
CompletableFuture의 핵심 개념 🔑
1. 스테이지(Stage): CompletableFuture의 각 작업 단계를 의미해. 여러 스테이지를 연결해서 파이프라인을 구성할 수 있어.
2. 완료(Completion): 작업이 정상적으로 완료되거나 예외가 발생해 종료되는 것을 말해.
3. 비동기 실행(Asynchronous Execution): 작업을 별도의 스레드에서 실행하는 것을 의미해.
4. 조합(Composition): 여러 CompletableFuture를 조합해 새로운 CompletableFuture를 만드는 것을 말해.
이제 CompletableFuture가 어떤 녀석인지 대략 감이 왔을 거야. 다음 섹션에서는 실제로 어떻게 사용하는지 알아볼게! 🚀
3. CompletableFuture 기본 사용법 💻
이제 CompletableFuture를 실제로 어떻게 사용하는지 알아볼게. 코드를 통해 기본적인 사용법을 익혀보자!
CompletableFuture 생성하기 🏗️
CompletableFuture를 생성하는 방법은 여러 가지가 있어:
// 1. 이미 완료된 Future 생성
CompletableFuture<String> completedFuture = CompletableFuture.completedFuture("결과값");
// 2. 비동기 작업 실행 (ForkJoinPool의 commonPool() 사용)
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
// 시간이 걸리는 작업 수행
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
return "작업 결과";
});
// 3. 커스텀 Executor 사용
ExecutorService executor = Executors.newFixedThreadPool(5);
CompletableFuture<String> futureWithExecutor = CompletableFuture.supplyAsync(() -> {
return "커스텀 스레드풀에서 실행";
}, executor);
// 4. 빈 CompletableFuture 생성 후 나중에 완료
CompletableFuture<String> manualFuture = new CompletableFuture<>();
// 나중에 어딘가에서 완료시킴
manualFuture.complete("수동으로 완료된 결과");
💡 알아두면 좋은 팁!
supplyAsync()
와 runAsync()
의 차이점:
- •
supplyAsync()
: 결과값을 반환하는 작업에 사용 (Supplier<T>) - •
runAsync()
: 결과값이 없는 작업에 사용 (Runnable)
결과 가져오기 🎁
CompletableFuture에서 결과를 가져오는 방법은 여러 가지가 있어:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Hello");
// 1. get() - 블로킹 방식으로 결과 가져오기 (예외 처리 필요)
try {
String result = future.get(); // 작업이 완료될 때까지 현재 스레드 블로킹
System.out.println(result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
// 2. get(timeout, unit) - 타임아웃 설정
try {
String result = future.get(1, TimeUnit.SECONDS); // 1초 동안만 대기
System.out.println(result);
} catch (InterruptedException | ExecutionException | TimeoutException e) {
e.printStackTrace();
}
// 3. getNow(defaultValue) - 즉시 결과 반환 (완료되지 않았으면 기본값 반환)
String result = future.getNow("기본값");
System.out.println(result);
// 4. join() - 블로킹 방식이지만 checked 예외를 던지지 않음
String result = future.join(); // 예외 발생 시 unchecked 예외로 감싸서 던짐
System.out.println(result);
⚠️ 주의사항
get()
과 join()
은 블로킹 메서드야. 메인 스레드나 UI 스레드에서 호출하면 응답성이 떨어질 수 있으니 주의해야 해!
결과 처리하기 (콜백) 🔄
CompletableFuture의 강력한 기능 중 하나는 작업 완료 후 콜백을 등록할 수 있다는 거야:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Hello");
// 1. thenApply() - 결과를 변환 (Function 사용)
CompletableFuture<Integer> lengthFuture = future.thenApply(s -> s.length());
// 2. thenAccept() - 결과를 소비 (Consumer 사용)
future.thenAccept(s -> System.out.println("결과: " + s));
// 3. thenRun() - 결과 무시하고 작업 실행 (Runnable 사용)
future.thenRun(() -> System.out.println("작업 완료!"));
// 4. 비동기 콜백 (별도 스레드에서 실행)
future.thenApplyAsync(s -> s.length())
.thenAcceptAsync(length -> System.out.println("길이: " + length));
예외 처리하기 🛡️
비동기 작업에서 예외 처리는 매우 중요해. CompletableFuture는 예외 처리를 위한 다양한 방법을 제공해:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
if (Math.random() > 0.5) {
throw new RuntimeException("에러 발생!");
}
return "성공";
});
// 1. exceptionally() - 예외 발생 시 대체 값 제공
future.exceptionally(ex -> {
System.out.println("예외 발생: " + ex.getMessage());
return "기본값";
}).thenAccept(System.out::println);
// 2. handle() - 정상 결과와 예외 모두 처리
future.handle((result, ex) -> {
if (ex != null) {
System.out.println("예외 발생: " + ex.getMessage());
return "에러 대체값";
} else {
return result + " 처리 완료";
}
}).thenAccept(System.out::println);
// 3. whenComplete() - 결과 변환 없이 작업 완료 시 실행
future.whenComplete((result, ex) -> {
if (ex != null) {
System.out.println("예외 발생: " + ex.getMessage());
} else {
System.out.println("결과: " + result);
}
});
ℹ️ 예외 처리 메서드 비교
exceptionally(): 예외가 발생한 경우에만 호출되며, 대체 값을 반환해.
handle(): 정상 완료와 예외 발생 모두 호출되며, 결과를 변환할 수 있어.
whenComplete(): 정상 완료와 예외 발생 모두 호출되지만, 결과를 변환할 수 없어.
이제 CompletableFuture의 기본적인 사용법을 알게 되었어! 다음 섹션에서는 더 고급 기능들을 살펴볼 거야. 🚀
- 지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
재능넷은 최신 AI 기술과 법률에 기반하여 자사의 지적 재산권을 적극적으로 보호하며,
무단 사용 및 침해 행위에 대해 법적 대응을 할 권리를 보유합니다.
© 2025 재능넷 | All rights reserved.
댓글 0개