문서 읽는 데 53분 · day36

Day 36 — CompletableFuture: 비동기를 조합하자

목차 19
전체 48강 중 36강 · 자바 기초
난이도 · 입문

지난 시간 Day 35 에서 우리는 일감을 스레드풀에 맡기고 그 결과를 영수증처럼 돌려받는 Future 를 배웠어요. 썸네일 너비를 동시에 계산하고, future.get() 으로 결과를 거둬 합쳤죠. 그런데 그때 한 가지 불편함을 살짝 짚고 넘어갔어요. get() 은 결과가 나올 때까지 그 자리에서 막혀서 기다린다(블로킹) 는 거였어요.

한 장이면 괜찮아요. 그런데 외부 서버 세 곳에 정보를 물어보고, "첫 답이 오면 그걸로 두 번째를 물어보고, 그 답이 오면 또…" 같은 흐름을 짜야 한다고 해봐요. get() 으로 하나씩 기다리면, 기다리는 동안 스레드는 멈춰서 아무 일도 못 해요. 게다가 "이게 오면 저걸 하고" 같은 연결을 손으로 엮는 것도 꽤 번거롭죠.

오늘은 그 불편함을 풀어주는 도구, CompletableFuture 를 배워요. 한마디로 "결과가 나오면 막힘 없이 이어서 이걸 해줘" 하고 후속 작업을 미리 등록해 두는, 한 단계 똑똑해진 Future 예요. supplyAsync 로 비동기 작업을 시작하고, thenApply·thenCompose·thenCombine 으로 결과를 줄줄이 이어 붙이고, 여러 호출을 한꺼번에 묶고, 실패까지 우아하게 다뤄볼 거예요.

지난 시간에 Future 로 결과를 미래에 받아본 경험이 있어서, 오늘 이 "조합" 의 즐거움이 더 잘 느껴질 거예요. 그럼 시작해봐요!

🎯 학습 목표

  • 비동기 작업을 시작하는 두 출발점 supplyAsync(결과 있음) 와 runAsync(결과 없음) 를 다뤄요.
  • 결과를 꺼내는 get()join() 의 차이를 알아요.
  • 결과를 이어받는 세 가지 후속 — thenApply·thenAccept·thenRun 으로 조립 라인을 만들어요.
  • 비동기를 비동기로 이어 붙이는 thenCompose 와, 독립된 두 결과를 합치는 thenCombine 을 구분해요.
  • 여러 작업을 한꺼번에 묶는 allOfanyOf 를 익혀요.
  • 비동기 흐름의 실패를 다루는 exceptionally·handle·whenComplete 를 써요.
  • 외부 호출 여러 개를 병렬로 묶어 한 화면을 조립하는 실전 패턴을 만들어요.

오늘의 로드맵

  • Step 1Future 의 한계와 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(런 어싱크) 예요. 한 가지 반가운 점은, 이 작업이 도는 스레드를 우리가 만들 필요가 없다는 거예요. 자바가 미리 마련해 둔 공용 백그라운드 풀에서 알아서 돌아가거든요. 지난 시간엔 풀을 직접 만들었는데, 여기선 그것조차 자바가 챙겨줘요.

Java
// 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 를 구하는 식으로, 변환을 차곡차곡 연결하면 그게 그대로 비동기 조립 라인이 돼요.

Java
// 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 을 더 올린 거예요. thenAcceptthenRun 은 둘 다 돌려줄 값이 없어서, 우리는 누적기에 모인 값을 따로 읽어 확인해요.

💡 결과를 변환해 다음으로 넘기려면 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> 한 겹으로 나와요.

Java
// 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) 두 개인 게 특징이에요.

Java
// 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 ┘

한 가지 주의할 점이 있어요. allOfanyOf 자체는 결과 값을 직접 돌려주지 않아요. allOf 는 "다 끝났다" 는 완료 신호만 줘서, 끝난 뒤에 각 작업의 join() 으로 결과를 따로 거둬야 해요.

Java
// 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 라서, 우리가 넣은 타입으로 캐스팅해서 받아요.

Java
// 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 는 "여러 곳에 같은 걸 물어보고, 제일 빨리 답한 곳 것만 쓴다" 같은 상황에 어울려요.

⚠️ allOfanyOf 는 결과 값을 직접 돌려주지 않아요. allOf 는 "전부 끝남" 신호만, anyOf 는 "가장 먼저 끝난 하나" 를 Object 로 줘요. 그래서 allOf 뒤엔 각 작업을 join() 으로 따로 거두고, anyOf 결과는 원래 타입으로 캐스팅해서 받아요.


Step 6: 실패를 다루기 — exceptionally · handle · whenComplete

비동기 작업도 얼마든지 실패할 수 있어요. 네트워크가 끊기거나, 서버가 에러를 던지거나요. 동기 코드라면 try-catch 로 감쌌겠지만, 점으로 이어 붙인 비동기 조립 라인 안에서는 조금 다른 방식으로 실패를 다뤄요.

도구는 셋이에요. 실패했을 때만 끼어들어 기본값으로 갈아끼우면 exceptionally, 성공이든 실패든 둘 다 받아 갈라 처리하면 handle, 결과를 바꾸지 않고 엿보기만 하면 whenComplete 예요. exceptionally 는 예비 타이어 같아요. 평소엔 안 쓰이다가 펑크(실패) 났을 때만 끼워져요.

Java
// 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 는 결과를 바꾸지 않고 들여다보기만 해요. 끝났을 때 로그를 남기거나 기록만 하고, 값은 그대로 다음으로 흘려보내요.

Java
// 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 가 결과 40seen 에 기록만 하고 값은 그대로 통과시켰어요. handle 이나 exceptionally 가 결과를 "바꿀 수 있는" 도구라면, whenComplete 는 "그대로 흘려보내며 엿보는" 도구예요.

💡 실패 시에만 기본값으로 대체하려면 exceptionally, 성공·실패를 항상 갈라 처리하려면 handle, 결과를 바꾸지 않고 들여다보기만 하려면 whenComplete 예요. 앞 둘은 결과를 바꿀 수 있고, whenComplete 는 그대로 흘려보내요.


Step 7: 실전 — 외부 호출을 병렬로 묶어 프로필 화면 조립하기

이제 배운 걸 한자리에 모아 실전처럼 써볼게요. 프로필에서 게시물 하나를 열면, 화면에 세 가지가 필요하다고 해봐요. 게시물 본문, 댓글 수, 좋아요 수예요. 이 셋을 각각 외부에서 받아와야 해요.

여기서 핵심은 셋이 서로 독립이라는 거예요. 본문을 받아와야 댓글 수를 셀 수 있는 게 아니죠. 그러니 하나씩 순서대로 기다리면 시간이 세 배로 들어요. 대신 셋을 먼저 다 시작해 놓고(병렬), allOf 로 전부 끝나길 기다린 뒤, 각 결과를 거둬 하나의 화면 객체로 조립하면 훨씬 빨라요.

텍스트
 순차로 하면 (느림)
   본문 ─── 댓글 ─── 좋아요 ─── 조립      (앞이 끝나야 다음을 시작)

 병렬로 하면 (빠름)
   본문   ┐
   댓글   ┼─  allOf: 셋이 다 끝나면 ─ 조립
   좋아요 ┘                              (셋을 한꺼번에 시작)

결과를 담을 그릇으로는 Day 29 에서 배운 record 를 써요. 세 값을 한 묶음으로 깔끔하게 들고 다닐 수 있거든요.

Java
// 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 을 참고하세요.

🎯 해결 미션:

  1. GreetingPipeline 같은 클래스에 buildGreeting(int userId) 메서드를 두세요.
  2. supplyAsyncuserId 를 이름(예: "user" + userId)으로 비동기 변환하세요. 실제 이름 조회를 흉내 내는 거예요.
  3. thenApply 로 그 이름을 환영 문구(예: "환영합니다, user7님!")로 변환하세요.
  4. join() 으로 결과를 받아 돌려주고, buildGreeting(7) 이 정해둔 문구를 돌려주는지 확인하세요. 여기서 get() 대신 join() 을 쓰면 무엇이 편한지 한 줄로 적어보세요.

과제 2 (응용): 게시물 카드를 병렬로 조립하기

상황 배경: 게시물 카드 하나에 본문과 작성자 이름을 함께 보여주려 해요. 둘은 서로 독립이라 동시에 받아와 합치면 빨라요. Step 4 의 PostAssembler 를 참고하세요.

🎯 해결 미션:

  1. PostCardAssembler 같은 클래스에 renderCard(int postId) 메서드를 두세요.
  2. 본문을 받아오는 비동기 작업과 작성자 이름을 받아오는 비동기 작업을 각각 supplyAsync 로 만드세요.
  3. thenCombine 으로 둘 다 끝나면 "본문 - by 작성자" 형태로 합치세요.
  4. renderCard(3) 의 결과를 확인하세요. 두 작업을 먼저 다 시작해 둔 덕분에 무엇이 좋아지는지 한 줄로 적어보세요. (순서대로 하나씩 기다렸다면 무엇이 달라졌을까요?)

과제 3 (심화): 피드 좋아요를 병렬 합산하되 실패를 견디기

상황 배경: 여러 게시물의 좋아요 수를 병렬로 받아 합산하는데, 그중 한 게시물의 조회가 실패해도 전체가 무너지지 않게 그 한 건만 0 으로 견디고 나머지는 정상 합산하려 해요. Step 5·6 의 FeedFanout·AsyncErrorHandling 을 합쳐봐요.

🎯 해결 미션:

  1. FeedLikeSummary 같은 클래스에 totalLikes(int[] postIds, int failingPostId) 메서드를 두세요.
  2. postId 마다 supplyAsync 로 좋아요 수(예: postId * 10)를 조회하되, failingPostId 와 같으면 예외를 던지게 하세요.
  3. 각 작업에 exceptionally(ex -> 0) 을 붙여 실패 시 0 으로 대체하세요.
  4. 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 예시답안 — 환영 메시지 비동기 파이프라인 만들기

핵심 접근

두 단계를 점으로 이어 붙이는 게 전부예요. 먼저 supplyAsyncuserId 로 이름("user7")을 비동기로 만들어 내보내고, 그 결과를 thenApply 가 받아 환영 문구("환영합니다, user7님!")로 한 번 더 가공해요. "값을 받아 다른 값으로" 바꾸는 흐름이니 thenApply 가 딱 맞아요. 마지막에 join() 으로 완성된 문구를 거두면 끝이에요.

예시 구현

Java
// 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) -> ... 가 핵심이에요.

예시 구현

Java
// 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 와 나머지 합산은 멀쩡하게 흘러가요.

예시 구현

Java
// 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 전체나 합산을 무너뜨리지 않기 때문 이에요.

채점 포인트

항목 확인
postIdsupplyAsync 로 조회하고 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는 겉보기엔 비슷한데, 왜 굳이 둘로 나눠놨을까?

[문제 상황 요약]

thenApplythenCompose 는 둘 다 "앞 결과를 받아 다음으로 이어 붙이는" 일을 해요. 그런데 자바는 굳이 둘을 나눠놨어요. 왜 하나로 합치지 않았을까요?

[튜터의 가이드 및 해설]

기준은 "후속 작업이 무엇을 돌려주느냐" 예요. 후속이 평범한 값(숫자·문자열)을 돌려주면 thenApply, 또 다른 CompletableFuture 를 돌려주면 thenCompose 예요.

만약 thenApply 하나로 둘을 다 처리하려 하면 문제가 생겨요. 후속 작업이 CompletableFuture<Integer> 를 돌려주는데 thenApply 로 이으면, 결과가 CompletableFuture<CompletableFuture<Integer>> 처럼 미래 속에 미래가 든 이중 포장이 돼버려요. 껍데기를 두 번 벗겨야 결과를 꺼낼 수 있죠. thenCompose 는 그 포장을 한 겹 벗겨 평평하게 펴줘서, 결과가 깔끔한 CompletableFuture<Integer> 한 겹으로 나와요.

이미 비슷한 걸 배운 적이 있어요. Day 26 에서 StreammapflatMap 을 구분했죠. map 은 값을 값으로 바꾸고, flatMap 은 한 겹 더 들어간 구조를 평평하게 펴줬어요. thenApplymap, thenComposeflatMap 에 해당한다고 보면, 왜 둘을 나눠놨는지 자연스럽게 이해돼요. "변환" 과 "평평하게 이어 붙이기" 는 다른 일이거든요.

🎯 면접관을 홀리는 핵심 멘트

"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 에 넘기거나, 막혀도 값싼 경량 스레드로 처리하는 편이 낫습니다. 병렬화 자체보다 '무엇이 스레드를 얼마나 오래 점유하는가' 를 먼저 봐야 한다고 생각합니다."

전체 목록 자바 기초