Day 36 — CompletableFuture: 비동기를 조합하자
목차 19
지난 시간 Day 35 에서 우리는 일감을 스레드풀에 맡기고 그 결과를 영수증처럼 돌려받는 Future 를 배웠어요. 썸네일 너비를 동시에 계산하고, future.get() 으로 결과를 거둬 합쳤죠. 그런데 그때 한 가지 불편함을 살짝 짚고 넘어갔어요. get() 은 결과가 나올 때까지 그 자리에서 막혀서 기다린다(블로킹) 는 거였어요.
한 장이면 괜찮아요. 그런데 외부 서버 세 곳에 정보를 물어보고, "첫 답이 오면 그걸로 두 번째를 물어보고, 그 답이 오면 또…" 같은 흐름을 짜야 한다고 해봐요. get() 으로 하나씩 기다리면, 기다리는 동안 스레드는 멈춰서 아무 일도 못 해요. 게다가 "이게 오면 저걸 하고" 같은 연결을 손으로 엮는 것도 꽤 번거롭죠.
오늘은 그 불편함을 풀어주는 도구, CompletableFuture 를 배워요. 한마디로 "결과가 나오면 막힘 없이 이어서 이걸 해줘" 하고 후속 작업을 미리 등록해 두는, 한 단계 똑똑해진 Future 예요. supplyAsync 로 비동기 작업을 시작하고, thenApply·thenCompose·thenCombine 으로 결과를 줄줄이 이어 붙이고, 여러 호출을 한꺼번에 묶고, 실패까지 우아하게 다뤄볼 거예요.
지난 시간에 Future 로 결과를 미래에 받아본 경험이 있어서, 오늘 이 "조합" 의 즐거움이 더 잘 느껴질 거예요. 그럼 시작해봐요!
🎯 학습 목표
- 비동기 작업을 시작하는 두 출발점
supplyAsync(결과 있음) 와runAsync(결과 없음) 를 다뤄요. - 결과를 꺼내는
get()과join()의 차이를 알아요. - 결과를 이어받는 세 가지 후속 —
thenApply·thenAccept·thenRun으로 조립 라인을 만들어요. - 비동기를 비동기로 이어 붙이는
thenCompose와, 독립된 두 결과를 합치는thenCombine을 구분해요. - 여러 작업을 한꺼번에 묶는
allOf와anyOf를 익혀요. - 비동기 흐름의 실패를 다루는
exceptionally·handle·whenComplete를 써요. - 외부 호출 여러 개를 병렬로 묶어 한 화면을 조립하는 실전 패턴을 만들어요.
오늘의 로드맵
- Step 1 —
Future의 한계와CompletableFuture의 등장:supplyAsync·runAsync. - Step 2 — 결과를 이어받기:
thenApply·thenAccept·thenRun. - Step 3 — 비동기를 비동기로 이어 붙이기:
thenCompose. - Step 4 — 독립된 두 결과를 합치기:
thenCombine. - Step 5 — 여러 작업을 한꺼번에 묶기:
allOf·anyOf. - Step 6 — 실패를 다루기:
exceptionally·handle·whenComplete. - Step 7 — 실전: 외부 호출을 병렬로 묶어 프로필 화면 조립하기.
Step 1: "막힘 없이 이어서" — Future의 한계와 CompletableFuture의 등장
지난 시간 future.get() 을 떠올려봐요. 결과가 나올 때까지 그 자리에서 막혀 기다렸죠. 택배를 받으려고 문 앞에 서서 오도 가도 못 하고 기다리는 것과 같아요. 그동안 다른 일은 하나도 못 해요.
CompletableFuture 는 발상을 바꿔요. "도착하면 경비실에 맡겨두고, 그때 이렇게 처리해줘" 라고 미리 부탁 해두는 거예요. 그러면 나는 기다리지 않고 내 할 일을 계속하고, 택배가 오면 부탁한 일이 알아서 처리돼요. 이렇게 "끝나면 이어서 할 일" 을 미리 등록하는 게 오늘의 핵심이에요.
Future.get() — 막혀서 기다림
결과 요청 ──→ [────── 대기 ──────] ──→ 결과 받음
(기다리는 동안 그 스레드는 아무 일도 못 해요)
CompletableFuture — 끝나면 이어서
작업 시작 ──→ "끝나면 이거 해줘" 등록 ──→ 흐름은 계속 진행
(결과가 오면 등록해 둔 후속이 알아서 실행돼요)
비동기 작업을 시작하는 출발점은 두 가지예요. 결과를 돌려받고 싶으면 supplyAsync(서플라이 어싱크), 돌려줄 결과 없이 부수효과만 내면 runAsync(런 어싱크) 예요. 한 가지 반가운 점은, 이 작업이 도는 스레드를 우리가 만들 필요가 없다는 거예요. 자바가 미리 마련해 둔 공용 백그라운드 풀에서 알아서 돌아가거든요. 지난 시간엔 풀을 직접 만들었는데, 여기선 그것조차 자바가 챙겨줘요.
// com/instagram/javabasic/concurrent/CompletableFutureIntro.java
// supplyAsync: 결과를 돌려주는 비동기 작업. join() 으로 그 결과를 받아요.
public static String supplyGreeting(String username) {
return CompletableFuture.supplyAsync(() -> "안녕하세요, " + username + "님!").join();
}
// runAsync: 돌려줄 결과가 없는 작업. 여기선 카운터를 1 올리는 "부수효과" 만 내요.
public static int runAsyncThenCount() {
AtomicInteger counter = new AtomicInteger();
CompletableFuture.runAsync(() -> counter.incrementAndGet()).join();
return counter.get();
}
supplyGreeting("jaehoon") 을 실행하면 "안녕하세요, jaehoon님!" 이 나와요. supplyAsync 안의 람다가 백그라운드에서 문자열을 만들어 돌려주고, join() 이 그 결과를 받아온 거예요. 반면 runAsync 는 돌려줄 값이 없어요. runAsyncThenCount() 는 카운터를 1 올리는 부수효과만 내고, join() 은 "그 작업이 끝났다" 는 것만 확인해줘서 결과는 counter.get() 으로 따로 읽어 1 을 얻어요.
여기서 결과를 꺼낼 때 get() 이 아니라 join() 을 쓴 게 보이죠? 둘 다 결과가 나올 때까지 기다린다는 점은 같아요. 차이는 예외 처리에 있어요. get() 은 try-catch 를 강제하는 checked 예외를 던져서 코드가 번거로워지는데, join() 은 try-catch 가 강제되지 않는 unchecked 예외를 던져요. 그래서 작업을 줄줄이 이어 붙이는 오늘 같은 상황에선 join() 이 훨씬 깔끔해요.
💡
CompletableFuture는 "결과가 나오면 이어서 할 일" 을 미리 등록해 두는 똑똑한Future예요.supplyAsync는 결과가 있는 작업을,runAsync는 결과 없는 작업을 시작해요. 결과는 보통join()으로 받는데,get()과 달리 try-catch 를 강제하지 않아 체인을 짤 때 편해요.
🙋 학생 질문 — "튜터님, supplyAsync 안의 작업은 대체 어느 스레드에서 도는 거예요?"
좋은 질문이에요. 지난 시간엔 우리가 Executors.newFixedThreadPool(3) 처럼 풀을 직접 만들어 일감을 던졌죠. 그런데 supplyAsync 는 풀을 안 넘기면, 자바가 프로그램 시작 때 미리 만들어 두는 공용 백그라운드 풀 에서 알아서 작업을 돌려요. 그래서 우리가 스레드를 만들 필요도, 닫을 필요도 없어요.
물론 원하면 supplyAsync(작업, 내가만든풀) 처럼 두 번째 인자로 직접 만든 풀을 넘길 수도 있어요. 작업 성격에 맞춰 풀을 따로 관리하고 싶을 때 그렇게 해요. 다만 오늘은 조합하는 법에 집중하려고 공용 풀에 맡길게요. 이 공용 풀에도 한계가 있는데, 그건 마지막에 "생각해볼 주제" 에서 한 번 더 짚어볼게요.
Step 2: 결과를 이어받기 — thenApply · thenAccept · thenRun
이제 CompletableFuture 의 진짜 매력으로 들어가요. 결과가 나오면 막힘 없이 다음 단계로 흘려보내는, 작은 조립 라인을 만드는 거예요. 공장 컨베이어 벨트를 떠올려봐요. 한쪽에서 재료가 들어오면, 가공하고, 다음 칸으로 넘기고, 또 가공하죠. 결과를 손으로 받아 다시 넣는 게 아니라, 벨트 위에서 자동으로 흘러가요.
후속 작업을 붙이는 방법은 셋이에요. 결과를 받아 다른 값으로 변환 해 넘기면 thenApply, 결과를 받아 소비만 하고 끝내면 thenAccept, 앞 결과를 받지도 않고 그냥 끝나면 이것도 해줘 면 thenRun 이에요.
thenApply : (값) ──→ (다른 값) 변환해서 다음으로 넘김
thenAccept : (값) ──→ (없음) 받아서 소비만, 돌려줄 값 없음
thenRun : (없음) ──→ (없음) 앞 결과 안 받고 그냥 후속 실행
thenApply 는 여러 번 이어 붙일 수 있어요. "user7" 을 만들고 → 대문자 "USER7" 로 바꾸고 → 길이 5 를 구하는 식으로, 변환을 차곡차곡 연결하면 그게 그대로 비동기 조립 라인이 돼요.
// com/instagram/javabasic/concurrent/AsyncPipeline.java
// thenApply 를 두 번 이어 붙여요: 문자열 만들기 → 대문자 → 길이.
public static int profileNameLength(int userId) {
return CompletableFuture.supplyAsync(() -> "user" + userId)
.thenApply(String::toUpperCase)
.thenApply(String::length)
.join();
}
// thenAccept 는 결과를 소비만 하고 Void 를 돌려줘요. 그 뒤 thenRun 은 입력 없이 후속 작업만.
public static int acceptThenRun() {
AtomicInteger log = new AtomicInteger();
CompletableFuture.supplyAsync(() -> 100)
.thenAccept(v -> log.addAndGet(v))
.thenRun(() -> log.incrementAndGet())
.join();
return log.get();
}
profileNameLength(7) 은 5 를 돌려줘요. supplyAsync 가 만든 "user7" 이 벨트를 타고 흘러가면서, 첫 thenApply 가 대문자 "USER7" 로 바꾸고, 둘째 thenApply 가 길이 5 로 바꾼 거예요. 우리가 중간 결과를 손으로 받아 다시 넣은 적이 한 번도 없죠? 점(.) 으로 이어 붙이기만 했어요.
acceptThenRun() 은 101 을 돌려줘요. thenAccept 가 결과 100 을 받아 누적기에 더하고(소비), 그 뒤 thenRun 이 앞 결과는 받지 않은 채 1 을 더 올린 거예요. thenAccept 와 thenRun 은 둘 다 돌려줄 값이 없어서, 우리는 누적기에 모인 값을 따로 읽어 확인해요.
💡 결과를 변환해 다음으로 넘기려면
thenApply(값 → 값), 받아서 쓰기만 하려면thenAccept(값 → 소비), 앞 결과 없이 후속만 실행하려면thenRun(후속만). 점으로 이어 붙이면 중간 결과를 손으로 나르지 않아도 돼요.
Step 3: 비동기를 비동기로 이어 붙이기 — thenCompose
앞에서 thenApply 는 "값을 받아 다른 값으로" 바꿨어요. 그런데 후속 작업 자체가 또 비동기라면 어떨까요? 예를 들어 "유저 ID 로 프로필 ID 를 받아오고(비동기), 그 프로필 ID 로 팔로워 수를 받아온다(또 비동기)" 처럼, 외부에 두 번 물어봐야 하고 첫 답이 와야 두 번째를 물어볼 수 있는 경우예요.
이때 thenApply 를 쓰면 문제가 생겨요. 후속 작업이 CompletableFuture 를 돌려주니까, 결과가 CompletableFuture<CompletableFuture<Integer>> 처럼 미래 속에 미래가 또 들어간 이중 포장 이 돼버려요. 선물 상자를 열었더니 안에 또 포장된 상자가 든 셈이라, 껍데기를 두 번 벗겨야 하죠.
thenApply 로 이으면 ──→ CompletableFuture<CompletableFuture<Integer>> (미래 속 미래, 이중 포장)
thenCompose 로 이으면 ──→ CompletableFuture<Integer> (한 겹 벗겨 평평하게)
thenCompose(덴 컴포즈) 가 이걸 해결해요. 후속 작업이 CompletableFuture 를 돌려줄 때, 그 포장을 한 겹 벗겨서 평평하게(flat) 이어 붙여줘요. 그래서 결과가 깔끔한 CompletableFuture<Integer> 한 겹으로 나와요.
// com/instagram/javabasic/concurrent/ProfileChain.java
// 유저 ID 로 프로필 ID 를 받아오는 비동기 작업.
public static CompletableFuture<Integer> fetchProfileId(int userId) {
return CompletableFuture.supplyAsync(() -> userId + 1000);
}
// 프로필 ID 로 팔로워 수를 받아오는 비동기 작업.
public static CompletableFuture<Integer> fetchFollowerCount(int profileId) {
return CompletableFuture.supplyAsync(() -> profileId * 2);
}
// thenCompose 로 "비동기 → 비동기" 를 이어 붙여요. (7+1000) → 1007, 1007*2 → 2014.
public static int followerCountOf(int userId) {
return fetchProfileId(userId)
.thenCompose(ProfileChain::fetchFollowerCount)
.join();
}
followerCountOf(7) 은 2014 를 돌려줘요. 먼저 fetchProfileId(7) 이 비동기로 1007 을 받아오고, 그 결과가 곧바로 fetchFollowerCount(1007) 로 흘러가 2014 를 받아온 거예요. 두 번의 외부 호출이 자연스럽게 한 줄로 이어졌죠. 만약 thenApply 로 이었다면 join() 한 번으로는 안쪽 미래가 안 벗겨져서, 결과를 한 번 더 풀어야 했을 거예요.
🙋 학생 질문 — "튜터님, thenApply 랑 thenCompose 를 언제 구분해서 써요?"
기준은 딱 하나예요. 후속 작업이 무엇을 돌려주느냐 를 보면 돼요.
후속 작업이 그냥 평범한 값(숫자·문자열 등)을 돌려주면 thenApply 예요. "user7" 을 받아 길이 5 를 돌려주는 것처럼요. 반대로 후속 작업이 또 다른 CompletableFuture 를 돌려주면 thenCompose 예요. 프로필 ID 를 받아 "팔로워 수를 비동기로 가져오는 작업" 을 돌려주는 것처럼요.
기억하기 쉽게 정리하면, "값으로 변환" 은 thenApply, "비동기 작업으로 이어 붙이기" 는 thenCompose 예요. 헷갈리면 후속 작업의 반환 타입을 보세요. 거기에 CompletableFuture 가 보이면 thenCompose 가 맞아요.
Step 4: 독립된 두 결과를 합치기 — thenCombine
thenCompose 는 "앞 결과가 있어야 뒤가 시작되는" 순서가 있는 작업이었어요. 그런데 서로 상관없는 두 작업을 동시에 시작해 놓고, 둘 다 끝나면 합치고 싶을 때가 있어요.
게시물 화면을 떠올려봐요. 본문 글과 좋아요 수는 서로를 기다릴 이유가 전혀 없어요. 본문을 받아와야 좋아요를 셀 수 있는 게 아니니까요. 그러니 둘을 동시에 받아온 뒤, 마지막에 "본문 (좋아요 N)" 처럼 한 줄로 합치면 돼요.
fetchPostBody ──┐
fetchLikeCount ──┴──→ thenCombine(본문, 좋아요) ──→ "post#3 본문 (좋아요 30)"
(본문과 좋아요 수는 서로 안 기다리고 동시에 진행돼요)
thenCombine(덴 컴바인) 이 바로 이거예요. 두 개의 독립된 CompletableFuture 를 받아, 둘 다 끝나면 두 결과를 함께 넘겨주는 합치기 함수를 실행해요. 인자가 (결과1, 결과2) 두 개인 게 특징이에요.
// com/instagram/javabasic/concurrent/PostAssembler.java
// 게시물 본문을 받아오는 비동기 작업.
public static CompletableFuture<String> fetchPostBody(int postId) {
return CompletableFuture.supplyAsync(() -> "post#" + postId + " 본문");
}
// 좋아요 수를 받아오는 비동기 작업. 본문과 서로 독립적이에요.
public static CompletableFuture<Integer> fetchLikeCount(int postId) {
return CompletableFuture.supplyAsync(() -> postId * 10);
}
// thenCombine 으로 본문과 좋아요 수를 함께 받아 한 줄로 합쳐요.
public static String renderPost(int postId) {
return fetchPostBody(postId)
.thenCombine(fetchLikeCount(postId),
(body, likes) -> body + " (좋아요 " + likes + ")")
.join();
}
renderPost(3) 은 "post#3 본문 (좋아요 30)" 을 돌려줘요. fetchPostBody(3) 과 fetchLikeCount(3) 이 동시에 시작해서 각각 "post#3 본문" 과 30 을 받아오고, 둘 다 끝나자 합치기 함수 (body, likes) -> ... 가 한 줄로 묶은 거예요. 순서대로 기다린 게 아니라 나란히 진행했으니, 둘 중 더 오래 걸리는 쪽만큼만 시간이 들어요.
💡
thenCompose는 "앞이 끝나야 뒤가 시작되는" 순서 있는 연결이고,thenCombine은 "서로 독립인 둘을 동시에 진행해 합치는" 연결이에요. 앞 결과가 뒤에 필요하면thenCompose, 서로 상관없으면thenCombine이에요.
Step 5: 여러 작업을 한꺼번에 묶기 — allOf · anyOf
지금까지는 작업 한두 개를 이어 붙이거나 합쳤어요. 그런데 피드처럼 게시물이 열 개, 스무 개라면요? 그걸 일일이 thenCombine 으로 둘씩 묶는 건 끔찍하겠죠. 이럴 땐 여러 작업을 한꺼번에 시작해 놓고 통째로 묶어서 기다려요.
묶음 도구는 둘이에요. 넘긴 작업이 전부 다 끝날 때까지 기다리면 allOf(올 오브), 그중 가장 먼저 끝난 하나만 받으면 anyOf(애니 오브) 예요. 단체 사진은 전원이 모여야 찍으니 allOf, 가장 빨리 도착한 택배 하나만 쓰면 anyOf 라고 생각하면 쉬워요.
allOf — 전부 끝나야 다음으로
작업1 ┐
작업2 ┼─→ 셋 다 도착하면 ─→ 진행
작업3 ┘
anyOf — 가장 먼저 끝난 하나만
작업1 ┐
작업2 ┼─→ 제일 빠른 하나 ─→ 진행
작업3 ┘
한 가지 주의할 점이 있어요. allOf 와 anyOf 자체는 결과 값을 직접 돌려주지 않아요. allOf 는 "다 끝났다" 는 완료 신호만 줘서, 끝난 뒤에 각 작업의 join() 으로 결과를 따로 거둬야 해요.
// com/instagram/javabasic/concurrent/FeedFanout.java
// 각 게시물의 좋아요 수를 동시에 받아온 뒤, allOf 로 전부 끝나길 기다렸다가 합산해요.
public static int totalLikesOfFeed(int[] postIds) {
// 1) 먼저 전부 supplyAsync 로 시작해서 모아둬요. 이러면 동시에 일해요.
List<CompletableFuture<Integer>> futures = new ArrayList<>();
for (int postId : postIds) {
futures.add(CompletableFuture.supplyAsync(() -> postId * 10));
}
// 2) allOf 로 "전부 끝남" 을 기다려요. (allOf 는 값이 아니라 완료 신호만 줘요.)
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
// 3) 다 끝났으니 각 join() 으로 결과를 거둬 합쳐요.
int total = 0;
for (CompletableFuture<Integer> f : futures) {
total += f.join();
}
return total;
}
totalLikesOfFeed(new int[]{1, 2, 3}) 은 60 을 돌려줘요. 좋아요 10, 20, 30 을 세는 세 작업이 동시에 시작하고, allOf 가 셋 다 끝나길 기다린 뒤, 각 join() 으로 결과를 거둬 합친 거예요. 게시물이 백 개여도 구조는 똑같아요. 먼저 다 던지고, allOf 로 한 번에 기다리고, 거두면 끝이에요.
anyOf 는 조금 달라요. 가장 먼저 끝난 하나를 주는데, 어느 게 먼저 끝날지는 그때그때 달라요(비결정적). 그리고 결과 타입이 Object 라서, 우리가 넣은 타입으로 캐스팅해서 받아요.
// anyOf 로 "가장 먼저 끝난" 작업의 결과를 받아요. 결과 타입은 Object 라 캐스팅해요.
public static int firstReady(int[] postIds) {
List<CompletableFuture<Integer>> futures = new ArrayList<>();
for (int postId : postIds) {
futures.add(CompletableFuture.supplyAsync(() -> postId));
}
return (int) CompletableFuture.anyOf(futures.toArray(new CompletableFuture[0])).join();
}
firstReady(new int[]{1, 2, 3}) 은 1, 2, 3 중 그때 가장 빨리 끝난 하나를 줘요. 실행할 때마다 답이 달라질 수 있어요. 그래서 anyOf 는 "여러 곳에 같은 걸 물어보고, 제일 빨리 답한 곳 것만 쓴다" 같은 상황에 어울려요.
⚠️
allOf와anyOf는 결과 값을 직접 돌려주지 않아요.allOf는 "전부 끝남" 신호만,anyOf는 "가장 먼저 끝난 하나" 를Object로 줘요. 그래서allOf뒤엔 각 작업을join()으로 따로 거두고,anyOf결과는 원래 타입으로 캐스팅해서 받아요.
Step 6: 실패를 다루기 — exceptionally · handle · whenComplete
비동기 작업도 얼마든지 실패할 수 있어요. 네트워크가 끊기거나, 서버가 에러를 던지거나요. 동기 코드라면 try-catch 로 감쌌겠지만, 점으로 이어 붙인 비동기 조립 라인 안에서는 조금 다른 방식으로 실패를 다뤄요.
도구는 셋이에요. 실패했을 때만 끼어들어 기본값으로 갈아끼우면 exceptionally, 성공이든 실패든 둘 다 받아 갈라 처리하면 handle, 결과를 바꾸지 않고 엿보기만 하면 whenComplete 예요. exceptionally 는 예비 타이어 같아요. 평소엔 안 쓰이다가 펑크(실패) 났을 때만 끼워져요.
// com/instagram/javabasic/concurrent/AsyncErrorHandling.java
// exceptionally: 실패하면 -1 이라는 기본값으로 대체해요. 성공하면 그대로 통과.
public static int loadLikesOrDefault(int postId, boolean fail) {
return CompletableFuture.supplyAsync(() -> {
if (fail) {
throw new IllegalStateException("네트워크 오류");
}
return postId * 10;
}).exceptionally(ex -> -1).join();
}
// handle: 성공(ex == null) 과 실패를 한자리에서 갈라 처리해요. 둘 다 문자열을 돌려줘요.
public static String handleBoth(int postId, boolean fail) {
return CompletableFuture.supplyAsync(() -> {
if (fail) {
throw new IllegalStateException("네트워크 오류");
}
return postId * 10;
}).handle((result, ex) -> ex == null ? ("좋아요 " + result) : "실패: 기본값 사용").join();
}
loadLikesOrDefault(5, false) 는 성공해서 50 을, loadLikesOrDefault(5, true) 는 일부러 예외를 던지게 했으니 exceptionally 가 끼어들어 -1 을 돌려줘요. 작업이 실패해도 프로그램 전체가 무너지지 않고, 미리 정한 기본값으로 부드럽게 이어가는 거예요.
handle 은 한 걸음 더 나가요. 성공·실패를 한자리에서 받아서, 예외(ex)가 null 이면 성공, 아니면 실패로 갈라 처리해요. handleBoth(5, false) 는 "좋아요 50", handleBoth(5, true) 는 "실패: 기본값 사용" 을 돌려줘요. exceptionally 가 "실패할 때만" 끼어든다면, handle 은 "성공이든 실패든 항상" 마지막을 책임지는 셈이에요.
마지막 whenComplete 는 결과를 바꾸지 않고 들여다보기만 해요. 끝났을 때 로그를 남기거나 기록만 하고, 값은 그대로 다음으로 흘려보내요.
// whenComplete: 결과를 바꾸지 않고 엿보기만 해요. 여기선 결과를 seen 에 기록만 해요.
public static int peekWithWhenComplete(int postId) {
AtomicInteger seen = new AtomicInteger();
CompletableFuture.supplyAsync(() -> postId * 10)
.whenComplete((res, ex) -> seen.set(res))
.join();
return seen.get();
}
peekWithWhenComplete(4) 는 40 을 돌려줘요. whenComplete 가 결과 40 을 seen 에 기록만 하고 값은 그대로 통과시켰어요. handle 이나 exceptionally 가 결과를 "바꿀 수 있는" 도구라면, whenComplete 는 "그대로 흘려보내며 엿보는" 도구예요.
💡 실패 시에만 기본값으로 대체하려면
exceptionally, 성공·실패를 항상 갈라 처리하려면handle, 결과를 바꾸지 않고 들여다보기만 하려면whenComplete예요. 앞 둘은 결과를 바꿀 수 있고,whenComplete는 그대로 흘려보내요.
Step 7: 실전 — 외부 호출을 병렬로 묶어 프로필 화면 조립하기
이제 배운 걸 한자리에 모아 실전처럼 써볼게요. 프로필에서 게시물 하나를 열면, 화면에 세 가지가 필요하다고 해봐요. 게시물 본문, 댓글 수, 좋아요 수예요. 이 셋을 각각 외부에서 받아와야 해요.
여기서 핵심은 셋이 서로 독립이라는 거예요. 본문을 받아와야 댓글 수를 셀 수 있는 게 아니죠. 그러니 하나씩 순서대로 기다리면 시간이 세 배로 들어요. 대신 셋을 먼저 다 시작해 놓고(병렬), allOf 로 전부 끝나길 기다린 뒤, 각 결과를 거둬 하나의 화면 객체로 조립하면 훨씬 빨라요.
순차로 하면 (느림)
본문 ───→ 댓글 ───→ 좋아요 ───→ 조립 (앞이 끝나야 다음을 시작)
병렬로 하면 (빠름)
본문 ┐
댓글 ┼─→ allOf: 셋이 다 끝나면 ─→ 조립
좋아요 ┘ (셋을 한꺼번에 시작)
결과를 담을 그릇으로는 Day 29 에서 배운 record 를 써요. 세 값을 한 묶음으로 깔끔하게 들고 다닐 수 있거든요.
// com/instagram/javabasic/concurrent/ProfileScreenLoader.java
// 화면에 필요한 세 값을 한 묶음으로 담는 그릇.
public record ProfileScreen(String postBody, int commentCount, int likeCount) {}
// 세 호출을 먼저 전부 시작(병렬) → allOf 로 다 끝나길 기다림 → join() 으로 조립.
public static ProfileScreen loadScreen(int postId) {
// 1) 세 작업을 먼저 다 시작해요. 이 순간 셋이 동시에 일하기 시작해요.
CompletableFuture<String> bodyFuture = fetchPostBody(postId);
CompletableFuture<Integer> commentFuture = fetchCommentCount(postId);
CompletableFuture<Integer> likeFuture = fetchLikeCount(postId);
// 2) 셋이 전부 끝나길 한 번에 기다려요.
CompletableFuture.allOf(bodyFuture, commentFuture, likeFuture).join();
// 3) 다 끝났으니 각 결과를 거둬 하나의 화면으로 조립해요.
return new ProfileScreen(bodyFuture.join(), commentFuture.join(), likeFuture.join());
}
loadScreen(2) 는 ProfileScreen("post#2 본문", 10, 20) 을 돌려줘요. 세 비동기 호출이 동시에 출발해 각각 본문·댓글 수 10·좋아요 수 20 을 받아왔고, allOf 로 셋이 다 끝나길 기다린 뒤 record 한 줄로 묶은 거예요.
여기서 순서가 중요해요. 세 fetch... 를 먼저 다 호출 해서 변수에 받아 둔 다음 allOf 로 기다렸죠. 만약 본문을 join() 으로 먼저 받고 나서 댓글을 시작했다면, 셋이 줄을 서버려서 병렬의 이점이 사라져요. "먼저 다 시작, 그다음 거두기" — 이 순서가 비동기 조합의 핵심이에요.
💡 외부 호출 여러 개를 빠르게 묶는 비결은 "먼저 전부 시작해 놓고, 그다음에 거두기" 예요. 받자마자
join()하면 줄을 서게 되니,CompletableFuture들을 먼저 다 만들어 둔 뒤allOf로 한 번에 기다려요.
마무리
오늘은 Future 의 블로킹 한계 위에 "결과가 오면 막힘 없이 이어서" 를 얹는 CompletableFuture 를 배웠어요. 지난 시간 get() 으로 하나씩 막혀 기다리던 것과 달리, 작업을 점으로 줄줄이 이어 붙이는 즐거움을 맛봤죠.
supplyAsync·runAsync— 비동기 작업의 출발점. 결과가 있으면supplyAsync, 없으면runAsync. 결과는 보통join()으로 받아요.thenApply·thenAccept·thenRun— 결과를 이어받는 세 후속. 변환·소비·후속 실행으로 조립 라인을 만들어요.thenCompose— 비동기를 비동기로 이어 붙여 이중 포장을 평평하게 펴요.thenCombine— 독립된 두 작업을 동시에 진행해 결과를 합쳐요.allOf·anyOf— 여러 작업을 한꺼번에 묶어 전부 기다리거나, 가장 빠른 하나만 받아요.exceptionally·handle·whenComplete— 비동기 흐름의 실패를 기본값으로 대체하거나, 갈라 처리하거나, 엿봐요.- 실전 조립 — 외부 호출을 먼저 전부 시작해 놓고
allOf로 거둬 한 화면으로 묶어요.
이 "먼저 다 시작, 그다음 거두기" 감각은 나중에 서버를 다룰 때 — 한 요청 안에서 여러 외부 시스템에 동시에 물어보고 응답을 조립하는 상황에서 — 그대로 살아나요.
다음 시간엔 — 가상 스레드
오늘 내내 강조한 핵심은 "결과를 기다리며 스레드를 막지 않는 것" 이었어요. 그런데 한 가지 더 생각해볼 게 있어요. 그 스레드 하나하나가 사실 꽤 비싼 자원이라는 점이에요. 지난 시간에 스레드를 만드는 데 시간과 메모리가 든다고 했죠. 그래서 수천·수만 개를 동시에 만들면 메모리가 휘청여요. CompletableFuture 로 아무리 안 막아도, 결국 일하는 스레드가 비싸면 한계가 있어요.
그래서 다음 시간엔 가상 스레드(Virtual Thread) 를 배워요. 막혀도 부담 없을 만큼 가볍게 만든 스레드예요. 수만 개를 만들어도 끄떡없을 만큼 가벼워서, 최신 자바가 이걸 왜 거의 기본처럼 권하는지 알게 될 거예요. 오늘 "스레드를 막지 않으려고" 애쓴 이야기가, 다음 시간 "막혀도 괜찮은 스레드" 이야기로 자연스럽게 이어져요. 수고 많으셨어요!
과제
오늘 배운 조합 도구들을 직접 이어 붙여볼 시간이에요. 셋 다 인스타그램에서 실제로 일어날 법한 상황이에요. 막히면 본문의 데모 코드를 참고하되, 먼저 스스로 흐름을 그려보세요.
과제 1 (기초): 환영 메시지 비동기 파이프라인 만들기
상황 배경: 새 회원이 가입하면, 회원 ID 로 이름을 비동기로 조회한 뒤 "환영합니다, OO님!" 문구를 만들어 보여주려 해요. 두 단계를 점으로 이어 붙여봐요. Step 1·2 의 CompletableFutureIntro·AsyncPipeline 을 참고하세요.
🎯 해결 미션:
GreetingPipeline같은 클래스에buildGreeting(int userId)메서드를 두세요.supplyAsync로userId를 이름(예:"user" + userId)으로 비동기 변환하세요. 실제 이름 조회를 흉내 내는 거예요.thenApply로 그 이름을 환영 문구(예:"환영합니다, user7님!")로 변환하세요.join()으로 결과를 받아 돌려주고,buildGreeting(7)이 정해둔 문구를 돌려주는지 확인하세요. 여기서get()대신join()을 쓰면 무엇이 편한지 한 줄로 적어보세요.
과제 2 (응용): 게시물 카드를 병렬로 조립하기
상황 배경: 게시물 카드 하나에 본문과 작성자 이름을 함께 보여주려 해요. 둘은 서로 독립이라 동시에 받아와 합치면 빨라요. Step 4 의 PostAssembler 를 참고하세요.
🎯 해결 미션:
PostCardAssembler같은 클래스에renderCard(int postId)메서드를 두세요.- 본문을 받아오는 비동기 작업과 작성자 이름을 받아오는 비동기 작업을 각각
supplyAsync로 만드세요. thenCombine으로 둘 다 끝나면"본문 - by 작성자"형태로 합치세요.renderCard(3)의 결과를 확인하세요. 두 작업을 먼저 다 시작해 둔 덕분에 무엇이 좋아지는지 한 줄로 적어보세요. (순서대로 하나씩 기다렸다면 무엇이 달라졌을까요?)
과제 3 (심화): 피드 좋아요를 병렬 합산하되 실패를 견디기
상황 배경: 여러 게시물의 좋아요 수를 병렬로 받아 합산하는데, 그중 한 게시물의 조회가 실패해도 전체가 무너지지 않게 그 한 건만 0 으로 견디고 나머지는 정상 합산하려 해요. Step 5·6 의 FeedFanout·AsyncErrorHandling 을 합쳐봐요.
🎯 해결 미션:
FeedLikeSummary같은 클래스에totalLikes(int[] postIds, int failingPostId)메서드를 두세요.- 각
postId마다supplyAsync로 좋아요 수(예:postId * 10)를 조회하되,failingPostId와 같으면 예외를 던지게 하세요. - 각 작업에
exceptionally(ex -> 0)을 붙여 실패 시0으로 대체하세요. allOf로 전부 끝나길 기다린 뒤 각join()으로 거둬 합산하세요. 실패한 한 건만0으로 빠지고 나머지는 정상 합산되는지 확인하세요.exceptionally를 (allOf보다) 각 작업에 먼저 붙인 이유를 한 줄로 적어보세요.
생각해볼 주제
혼자 고민해도 좋고, 동료와 토론해도 좋아요. 정답을 외우기보다 "나라면 어떻게 설명할까" 를 떠올리며 읽어보세요.
1. 결국 마지막엔 join()으로 막고 기다리는데, 그럼 CompletableFuture는 Future랑 뭐가 다를까?
오늘 우리도 체인 끝에서 join() 으로 결과를 기다렸어요. 그 순간만 보면 지난 시간 get() 과 똑같이 막혀 기다리죠.
그렇다면 CompletableFuture 의 진짜 이점은 "기다림을 없앤 것" 이 아니라 다른 데 있을 거예요. 중간의 변환과 연결(thenApply·thenCompose·thenCombine)이 어떻게 흘러갔는지 떠올려보세요. 무엇이 "막힘 없이" 이어졌고, 막는 건 언제 단 한 번뿐이었는지 짚어보면, 두 도구의 차이가 분명해질 거예요.
2. thenApply와 thenCompose는 겉보기엔 비슷한데, 왜 굳이 둘로 나눠놨을까?
둘 다 "앞 결과를 받아 다음으로 이어 붙이는" 일을 해요. 그런데 하나로 합치지 않고 굳이 이름을 나눠둔 데는 이유가 있어요.
후속 작업이 돌려주는 게 "그냥 값" 일 때와 "또 다른 CompletableFuture" 일 때, 결과의 포장이 어떻게 달라지는지 그려보세요. 만약 thenApply 하나로 둘 다 처리하려 한다면 어떤 일이 생길지 상상해보면, 자바가 왜 둘을 구분했는지 보일 거예요.
3. 그럼 모든 걸 다 병렬로 던지면 항상 빠를까?
오늘 우리는 작업들을 supplyAsync 로 마구 던졌어요. 그런데 이 작업들은 우리가 만들지 않은 공용 백그라운드 풀에서 돌아갔죠. 그 풀의 일꾼 수는 보통 컴퓨터의 CPU 코어 수만큼으로 정해져 있어요.
이제 그 풀에 수천 개의 작업을 한꺼번에 던지면 어떻게 될지 떠올려보세요. 특히 각 작업이 외부 응답을 기다리느라 오래 막혀 있다면요. 일꾼은 몇 명뿐인데 다들 막혀서 기다리고만 있다면, 뒤에 줄 선 작업들은 어떻게 될까요? 병렬이 만능이 아닌 이유를 짚어보면, 다음 시간에 배울 "막혀도 괜찮은 가벼운 스레드" 가 왜 반가운지 미리 느껴질 거예요.
✅ 예시 답안정답 보기
오늘 과제는 결과를 점으로 이어 붙이는 흐름이에요. 과제 1 은 supplyAsync 로 만든 이름을 thenApply 로 가공해 환영 문구를 만들고, 과제 2 는 본문과 작성자를 thenCombine 으로 동시에 받아 합치고, 과제 3 은 allOf 로 여러 좋아요 조회를 묶되 exceptionally 로 실패 한 건을 0 으로 견뎌요. concurrent 패키지의 오늘 예제(CompletableFutureIntro·AsyncPipeline·PostAssembler·FeedFanout·AsyncErrorHandling)를 옆에 두고 비교하면 편해요.
과제 예시답안
과제 1 예시답안 — 환영 메시지 비동기 파이프라인 만들기
핵심 접근
두 단계를 점으로 이어 붙이는 게 전부예요. 먼저 supplyAsync 가 userId 로 이름("user7")을 비동기로 만들어 내보내고, 그 결과를 thenApply 가 받아 환영 문구("환영합니다, user7님!")로 한 번 더 가공해요. "값을 받아 다른 값으로" 바꾸는 흐름이니 thenApply 가 딱 맞아요. 마지막에 join() 으로 완성된 문구를 거두면 끝이에요.
예시 구현
// com/instagram/javabasic/concurrent/solution/day36/GreetingPipeline.java
public static String buildGreeting(int userId) {
return CompletableFuture.supplyAsync(() -> "user" + userId)
.thenApply(name -> "환영합니다, " + name + "님!")
.join();
}
buildGreeting(7) 은 "환영합니다, user7님!" 을 돌려줘요. "get() 대신 join() 을 쓰면 무엇이 편한가" 의 답은 한 줄로 — join() 은 try-catch 를 강제하는 checked 예외를 안 던져서, 체인을 짤 때 코드가 깔끔해져요.
채점 포인트
| 항목 | 확인 |
|---|---|
supplyAsync 로 이름을 비동기 변환했는가 |
✅ |
thenApply 로 이름을 환영 문구로 변환해 이어 붙였는가 |
✅ |
join() 으로 결과를 받아 돌려줬는가 |
✅ |
get() 대신 join() 을 쓰는 이점(checked 예외 없음)을 설명했는가 |
✅ |
흔한 실수
- 후속이 "값 → 값" 변환인데
thenCompose를 쓰면, 타입이 안 맞아 컴파일 에러가 나요. 후속이 평범한 값을 돌려주면thenApply, 또 다른CompletableFuture를 돌려주면thenCompose라는 기준을 떠올리세요. join()을 빼고CompletableFuture<String>을 그대로 돌려주면, 문자열이 아니라 "미래" 가 반환돼요. 결과 문자열이 필요하면 마지막에 거두는 단계를 꼭 거쳐야 해요.
과제 2 예시답안 — 게시물 카드를 병렬로 조립하기
핵심 접근
본문과 작성자는 서로를 기다릴 이유가 없어요. 그래서 둘을 각각 supplyAsync 로 따로 시작해 동시에 진행시키고, thenCombine 으로 둘 다 끝났을 때 (본문, 작성자) 를 함께 받아 한 줄로 합쳐요. 인자가 두 개인 합치기 함수 (body, author) -> ... 가 핵심이에요.
예시 구현
// com/instagram/javabasic/concurrent/solution/day36/PostCardAssembler.java
public static CompletableFuture<String> fetchBody(int postId) {
return CompletableFuture.supplyAsync(() -> "post#" + postId + " 본문");
}
public static CompletableFuture<String> fetchAuthor(int postId) {
return CompletableFuture.supplyAsync(() -> "user" + postId);
}
public static String renderCard(int postId) {
return fetchBody(postId)
.thenCombine(fetchAuthor(postId),
(body, author) -> body + " - by " + author)
.join();
}
renderCard(3) 은 "post#3 본문 - by user3" 을 돌려줘요. "두 작업을 먼저 다 시작한 덕분에 무엇이 좋아지는가" 의 답은 한 줄로 — 둘이 나란히 진행돼서, 더 오래 걸리는 쪽만큼만 시간이 들어요(순서대로 했다면 둘을 합친 시간이 들었겠죠).
채점 포인트
| 항목 | 확인 |
|---|---|
본문과 작성자를 각각 supplyAsync 로 만들었는가 |
✅ |
thenCombine 으로 둘 다 끝나면 합치는 함수를 줬는가 |
✅ |
renderCard(3) 이 "post#3 본문 - by user3" 을 돌려주는가 |
✅ |
| 먼저 다 시작해 둔 덕분의 이점(병렬 진행)을 설명했는가 | ✅ |
흔한 실수
fetchBody(postId).join()으로 본문을 먼저 받고 나서 작성자를 시작하면, 둘이 줄을 서버려 병렬의 이점이 사라져요. 두CompletableFuture를 먼저 만들어 둔 뒤thenCombine으로 묶어야 해요.- 순서가 있는
thenCompose와 헷갈리기 쉬워요. 본문이 있어야 작성자를 구할 수 있는 게 아니므로(서로 독립), 여기선thenCombine이 맞아요.
과제 3 예시답안 — 피드 좋아요를 병렬 합산하되 실패를 견디기
핵심 접근
두 도구를 합쳐요. allOf 로 여러 좋아요 조회를 한꺼번에 묶어 전부 끝나길 기다리고, 각 작업에 exceptionally(ex -> 0) 을 미리 달아 실패 한 건을 0 으로 메워요. 핵심은 exceptionally 를 각 작업에 붙이는 거예요. 그래야 한 게시물이 실패해도 그 작업만 0 으로 마무리되고, allOf 와 나머지 합산은 멀쩡하게 흘러가요.
예시 구현
// com/instagram/javabasic/concurrent/solution/day36/FeedLikeSummary.java
public static int totalLikes(int[] postIds, int failingPostId) {
// 1) 게시물마다 비동기 작업을 만들되, exceptionally 로 실패 시 0 을 돌려주게 해요.
List<CompletableFuture<Integer>> futures = new ArrayList<>();
for (int postId : postIds) {
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
if (postId == failingPostId) {
throw new IllegalStateException("조회 실패");
}
return postId * 10;
}).exceptionally(ex -> 0); // 실패하면 그 게시물 좋아요는 0 으로 친다
futures.add(future);
}
// 2) allOf 로 "전부 끝남" 을 기다려요. (allOf 는 값이 아니라 완료 신호만 줘요.)
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
// 3) 다 끝났으니 각 join() 으로 결과를 거둬 합쳐요. 실패한 건은 0 이라 안전해요.
int total = 0;
for (CompletableFuture<Integer> f : futures) {
total += f.join();
}
return total;
}
totalLikes(new int[]{1, 2, 3}, 2) 는 40 을 돌려줘요(10 + 0 + 30). 2번 게시물만 실패해 0 으로 빠지고, 나머지는 정상 합산됐죠. 실패가 없으면(failingPostId 가 -1) 60 이 나와요. "exceptionally 를 각 작업에 먼저 붙인 이유" 의 답은 한 줄로 — 실패를 각 작업 안에서 0 으로 흡수해야, 그 한 건이 allOf 전체나 합산을 무너뜨리지 않기 때문 이에요.
채점 포인트
| 항목 | 확인 |
|---|---|
각 postId 를 supplyAsync 로 조회하고 failingPostId 면 예외를 던졌는가 |
✅ |
각 작업에 exceptionally(ex -> 0) 을 붙여 실패를 0 으로 대체했는가 |
✅ |
allOf 로 전부 기다린 뒤 각 join() 으로 거둬 합산했는가 |
✅ |
totalLikes({1,2,3}, 2) 가 40, 실패 없을 때 60 이 나오는가 |
✅ |
흔한 실수
exceptionally를 각 작업이 아니라allOf결과에만 붙이면, 한 작업이 실패한 순간allOf자체가 실패로 끝나고 합산이 무너져요. 실패는 그게 난 작업 안에서 흡수해야 해요.allOf의 결과를 그대로 합산에 쓰려 하면 안 돼요.allOf는 값이 아니라 "전부 끝남" 신호만 주니, 합산은 각 작업을join()으로 따로 거둬야 해요.
생각해볼 주제 예시답안
생각해볼 주제 1 예시답안 — 결국 마지막엔 join()으로 막고 기다리는데, 그럼 CompletableFuture는 Future랑 뭐가 다를까?
[문제 상황 요약]
오늘도 체인 끝에서 join() 으로 결과를 기다렸어요. 그 순간만 보면 지난 시간 get() 과 똑같이 막혀 기다리죠. 그렇다면 CompletableFuture 의 진짜 이점은 어디에 있을까요?
[튜터의 가이드 및 해설]
이점은 "기다림을 없앤 것" 이 아니라 "조합 을 막힘 없이 만든 것" 에 있어요. 오늘 우리가 thenApply·thenCompose·thenCombine 으로 이어 붙인 흐름을 떠올려보세요. 중간 결과를 손으로 꺼내 다시 넣은 적이 한 번도 없었죠. "결과가 오면 이어서 이걸 해줘" 라고 미리 등록해 두면, 그 연결 과정은 우리 스레드를 막지 않고 알아서 흘러가요.
막는 건 딱 한 군데, 맨 마지막 join() 뿐이에요. 그전까지의 변환·연결·합치기는 전부 "결과가 도착하면 실행될 후속" 으로 등록된 거라, 우리는 그 사이에 다른 일을 할 수도 있어요. 반면 Future 만 쓰면, 작업을 이어 붙이려 할 때마다 get() 으로 꺼내고 다시 submit 하고 또 get() 하는 식으로, 중간중간 계속 막아 세워야 해요.
그래서 둘의 차이는 "결과를 받는 출발점이냐(Future), 결과를 조합하는 도구냐(CompletableFuture)" 로 정리하면 깔끔해요. 최종 결과가 정말 필요한 순간에 한 번만 기다리고, 그전까지의 연결은 막지 않는다 — 이게 핵심이에요.
🎯 면접관을 홀리는 핵심 멘트
"Future 와 CompletableFuture 둘 다 최종 결과를 받을 땐 블로킹할 수 있습니다. 차이는 그 사이의 조합에 있습니다. Future 는 작업을 연결하려면 get 으로 꺼내 다시 제출하는 동기적 거듭을 반복해야 하지만, CompletableFuture 는 thenApply·thenCompose·thenCombine 으로 '결과가 오면 이어서' 를 선언적으로 등록해, 중간 연결이 호출 스레드를 막지 않습니다. 막는 지점을 파이프라인 끝의 join 한 번으로 미루는 것 — 그게 CompletableFuture 의 본질이라고 봅니다."
생각해볼 주제 2 예시답안 — thenApply와 thenCompose는 겉보기엔 비슷한데, 왜 굳이 둘로 나눠놨을까?
[문제 상황 요약]
thenApply 와 thenCompose 는 둘 다 "앞 결과를 받아 다음으로 이어 붙이는" 일을 해요. 그런데 자바는 굳이 둘을 나눠놨어요. 왜 하나로 합치지 않았을까요?
[튜터의 가이드 및 해설]
기준은 "후속 작업이 무엇을 돌려주느냐" 예요. 후속이 평범한 값(숫자·문자열)을 돌려주면 thenApply, 또 다른 CompletableFuture 를 돌려주면 thenCompose 예요.
만약 thenApply 하나로 둘을 다 처리하려 하면 문제가 생겨요. 후속 작업이 CompletableFuture<Integer> 를 돌려주는데 thenApply 로 이으면, 결과가 CompletableFuture<CompletableFuture<Integer>> 처럼 미래 속에 미래가 든 이중 포장이 돼버려요. 껍데기를 두 번 벗겨야 결과를 꺼낼 수 있죠. thenCompose 는 그 포장을 한 겹 벗겨 평평하게 펴줘서, 결과가 깔끔한 CompletableFuture<Integer> 한 겹으로 나와요.
이미 비슷한 걸 배운 적이 있어요. Day 26 에서 Stream 의 map 과 flatMap 을 구분했죠. map 은 값을 값으로 바꾸고, flatMap 은 한 겹 더 들어간 구조를 평평하게 펴줬어요. thenApply 가 map, thenCompose 가 flatMap 에 해당한다고 보면, 왜 둘을 나눠놨는지 자연스럽게 이해돼요. "변환" 과 "평평하게 이어 붙이기" 는 다른 일이거든요.
🎯 면접관을 홀리는 핵심 멘트
"thenApply 는 결과를 값으로 변환하고, thenCompose 는 후속이 돌려주는 CompletableFuture 를 한 겹 평탄화해 이어 붙입니다. Stream 의 map 과 flatMap 관계와 같습니다. 후속이 또 다른 비동기 작업을 반환할 때 thenApply 를 쓰면 CompletableFuture 가 중첩돼 이중으로 풀어야 하므로, 그 경우엔 thenCompose 로 평탄화하는 게 맞습니다. 후속의 반환 타입이 CompletableFuture 인지 아닌지로 구분합니다."
생각해볼 주제 3 예시답안 — 그럼 모든 걸 다 병렬로 던지면 항상 빠를까?
[문제 상황 요약]
오늘 우리는 작업들을 supplyAsync 로 마구 던졌어요. 그런데 이 작업들은 우리가 만들지 않은 공용 백그라운드 풀에서 돌았죠. 그 풀에 수천 개를, 그것도 오래 막히는 작업을 던지면 어떻게 될까요?
[튜터의 가이드 및 해설]
supplyAsync 에 풀을 따로 안 넘기면, 자바가 미리 마련해 둔 공용 풀(ForkJoinPool) 에서 작업이 돌아요. 이 풀의 일꾼 수는 보통 컴퓨터의 CPU 코어 수 정도로 정해져 있어요. 코어가 8개면 일꾼도 그쯤이라는 뜻이에요.
여기서 함정이 보여요. 만약 각 작업이 외부 서버 응답을 기다리느라 오래 막혀 있다면요? 일꾼은 여덟뿐인데 여덟 작업이 모두 응답을 기다리며 멈춰 있으면, 뒤에 줄 선 작업 수천 개는 일꾼이 풀릴 때까지 시작조차 못 해요. "병렬로 던졌으니 빠르겠지" 했는데, 실제로는 일꾼이 다 막혀 줄줄이 대기하는 거예요. CPU 계산처럼 막히지 않고 빨리 끝나는 작업엔 공용 풀이 좋지만, 오래 막히는 작업엔 어울리지 않아요.
해법은 두 가지예요. 하나는 막히는 작업 전용으로 큰 풀을 따로 만들어 supplyAsync(작업, 내풀) 처럼 넘기는 거예요. 다른 하나는 — 다음 시간에 배울 — 막혀도 부담이 거의 없는 가벼운 스레드를 쓰는 거예요. "막지 않으려" 아무리 애써도 일하는 스레드 자체가 비싸고 수가 적으면 한계가 있다는 걸, 이 함정이 잘 보여줘요.
🎯 면접관을 홀리는 핵심 멘트
"supplyAsync 의 기본 실행자는 공용 ForkJoinPool 이고, 병렬도는 대략 CPU 코어 수입니다. CPU 바운드 작업엔 적합하지만, I/O 로 오래 블로킹되는 작업을 대량으로 던지면 적은 수의 풀 스레드가 모두 대기에 묶여 후속 작업이 굶습니다. 그래서 블로킹 작업은 전용 Executor 를 supplyAsync 에 넘기거나, 막혀도 값싼 경량 스레드로 처리하는 편이 낫습니다. 병렬화 자체보다 '무엇이 스레드를 얼마나 오래 점유하는가' 를 먼저 봐야 한다고 생각합니다."