Skip to Content

4장. 리액티브의 부상과 피로 — 그리고 Loom이 온 이유

만화로 보는 요약 — 먼저 읽어보세요

만화로 보는 요약 — 먼저 읽어보세요

자바는 Node를 보고 두 갈래로 갈라졌습니다. “우리도 코드를 뒤집자”와 “코드를 뒤집는 게 애초에 잘못이다”로요.

면접 실전 질문: ① 리액티브 프로그래밍이 해결한 문제와 새로 만든 문제는 각각 무엇인가요? ② 가상 스레드는 리액티브를 대체하나요? ③ 비동기 코드의 스택 트레이스는 정확히 무엇을 잃나요?


배경 — 같은 청구서, 다른 결론

3장은 이렇게 끝났습니다. Node는 블로킹을 없앤 게 아니라 눈에서 치웠고, 그 대가로 코드를 뒤집어야 했다고요.

자바 진영은 그 청구서를 봤고, 서로 반대되는 두 답을 내놓습니다.

한쪽이 도착한 해에 다른 쪽이 출발했습니다. 2017년, Spring 5가 WebFlux를 내놓으며 리액티브가 자바 웹 개발자의 기본 선택지가 됩니다. 같은 해에 Project Loom이 시작돼요.

그리고 6년을 더 걷습니다. 그 6년 동안 첫 번째 답의 청구서가 쌓였고, 두 번째 답은 그 청구서를 읽으며 도착합니다. 이 장은 그 6년의 이야기입니다.

스토리 — 뒤집고, 지치고, 되돌리다

이 절의 연도·인명은 📄 문서 기반입니다(1차 출처: JEP 425·444, Reactive Streams 스펙, Netty·Spring 릴리스 노트).

도구는 이미 있었다 — 쓰는 사람이 없었을 뿐

자바에도 창구는 일찍 열렸습니다. 2002년 Java 1.4의 NIOSelector를 들고 나왔어요. 2장에서 본 epoll·kqueue를 자바에서 쓰는 창구입니다.

그런데 아무도 안 썼습니다. 정확히는, 직접 쓰기엔 너무 어려웠어요. Selector를 돌리고 ByteBufferflip()·compact()를 손으로 관리하고 부분 읽기를 처리하는 코드는, C로 이벤트 루프를 짜는 것과 난이도가 비슷했습니다.

그래서 2004년 Netty가 나옵니다(이희승). 오늘의 모습을 갖춘 건 2008년 Netty 3부터예요. NIO를 감싸 “채널과 핸들러”라는 다루기 쉬운 모델로 바꾼 프레임워크예요. 오늘날 자바 비동기 네트워크 스택 대부분이 여기 기대 있어요 — gRPC, Spring WebFlux(기본 서버), Elasticsearch가 전부 Netty 위에 서 있습니다.

여기서 자바와 Node의 차이가 드러납니다. Node는 언어 전체가 논블로킹으로 태어났지만, 자바에서 논블로킹은 선택한 사람만 쓰는 별도 세계였어요. 3장에서 본 Twisted·EventMachine과 같은 처지입니다. 이미 쌓인 블로킹 라이브러리 산더미가 그대로 있었으니까요.

리액티브: 표준이 생기다

2013년, 흩어져 있던 비동기 라이브러리들이 Reactive Streams라는 공통 규격 논의를 시작합니다. 1.0 스펙 확정은 2015년, JDK 9의 java.util.concurrent.Flow로 표준 라이브러리에 들어간 건 2017년이에요. 핵심은 단순한 콜백이 아니라 **백프레셔(backpressure)**였어요.

콜백만으로는 답할 수 없는 질문이 있습니다. “생산자가 초당 1만 건을 보내는데 소비자가 1천 건만 처리할 수 있으면?” 콜백은 그냥 쌓입니다. 메모리가 터질 때까지요. 백프레셔는 소비자가 “나 n개만 더 줘”라고 역방향으로 요청하게 만들어 이 문제를 규격화했습니다.

Reactor는 2.0(2015)에서, RxJava는 2.0(2016)에서 이 규격을 받아들입니다. 그리고 2017년 Spring 5가 WebFlux를 내놓으면서 자바 웹 개발자 앞에 리액티브가 기본 선택지로 등장합니다.

성능 이득은 분명했습니다. 2장에서 본 kqueue의 평평한 곡선이 애플리케이션 레벨로 올라온 셈이에요. 다만 그 숫자는 12장에서 직접 재기로 하고, 이 장에서는 대가 쪽만 보겠습니다.

그리고 피로가 왔다

그런데 몇 년 쓰고 나자 현장에서 같은 불평이 반복됩니다.

  • 디버깅이 안 된다. 스택 트레이스에 내 코드가 없다(잠시 뒤 실측합니다).
  • 로그가 끊긴다. ThreadLocal에 담아둔 요청 ID가 사라진다(13장).
  • 블로킹 한 줄이면 다 무너진다. JDBC 드라이버 하나 잘못 쓰면 이벤트 루프가 멈춘다(15장).
  • 배우기 어렵다. map, flatMap, switchIfEmpty, zipWith… 라이브러리 하나를 새 언어처럼 익혀야 한다.

이 불평들의 뿌리는 하나가 아니라 둘입니다.

①②는 실행 흐름이 스택을 떠났기 때문입니다. 스택 트레이스도 ThreadLocal도 try-catch 범위도 전부 스택에 얹혀 있었어요. 흐름이 콜백 체인으로 옮겨가자 이것들이 함께 무너졌습니다.

③은 뿌리가 다릅니다. 소수의 이벤트 루프 스레드를 모두가 공유하기 때문이에요. 그래서 3장에서 본 Twisted의 실패와 정확히 같은 구조입니다 — 모두가 규칙을 지킬 때만 성립하는 모델.

이 구분을 기억해두세요. 가상 스레드가 뭘 되찾고 뭘 못 되찾는지가 여기서 갈립니다.

자바는 왜 async/await를 안 골랐나

3장이 남긴 질문이 이거였습니다. async/await가 코드 모양을 되돌려주는데, 자바는 왜 같은 문법을 안 넣었을까?”

답은 3장이 이미 깔아뒀습니다. 자바스크립트가 백지였기 때문에 가능했던 선택이라고요. 자바는 정확히 그 반대입니다.

async/await에는 악명 높은 성질이 하나 있습니다. async 함수는 async 함수 안에서만 제대로 부를 수 있어요. 흔히 함수 색깔(function color) 문제라고 부릅니다. 하나를 논블로킹으로 바꾸면 그걸 부르는 쪽도, 그걸 부르는 쪽도 전부 바뀌어야 합니다. 색깔이 위로 번져요.

자바에 이걸 넣는다는 건 JDBC부터 파일 I/O, 20년치 라이브러리를 전부 async 버전으로 다시 쓴다는 뜻이었습니다. Node에게 자산이 없었던 게 축복이었다면, 자바에게 자산은 족쇄였어요.

그래서 자바는 문법을 바꾸는 대신 다른 데를 건드리기로 합니다.

Loom: 질문을 뒤집다

2017년경 시작된 Project Loom의 발상은 정반대였습니다.

코드를 논블로킹에 맞추지 말고, 스레드를 논블로킹에 맞추자.

1장에서 본 문제가 뭐였죠? 스레드가 비쌌습니다. 4,068개에서 커널이 거부했고, 자는 스레드 4,000개가 깨우기 비용을 22.5배로 올렸어요. 그래서 우리는 스레드를 아껴 쓰려고 코드를 뒤집었습니다.

Loom은 이렇게 되묻습니다. “스레드가 싸지면, 코드를 뒤집을 이유가 남나요?”

가상 스레드는 JDK 19에서 미리보기(JEP 425)로, **JDK 21에서 정식(JEP 444)**으로 들어왔습니다. 2023년이에요. Node가 질문을 던진 지 14년 만입니다.

핵심 — 뒤집힌 코드가 잃는 것, 숫자로

“디버깅이 안 된다”는 불평은 정성적으로 들립니다. 측정해봤습니다.

같은 논리를 세 방식으로 짜고, 똑같은 지점에서 예외를 던졌습니다. 주문을 조회하고, 결제를 조회하고(여기서 터짐), 합치는 코드예요.

static String loadPayment() { throw new IllegalStateException("결제 시스템 장애"); } // ① 평범한 블로킹 static String blocking() { String o = loadOrder(); String p = loadPayment(); // 여기서 터진다 return o + p; } // ② CompletableFuture 체인 static CompletableFuture<String> reactive(ExecutorService pool) { return CompletableFuture.supplyAsync(Ch04StackTrace::loadOrder, pool) .thenApplyAsync(o -> o + loadPayment(), pool); // 여기서 터진다 } // ③ 가상 스레드에 ①을 그대로 제출 — 코드는 ①과 똑같습니다. 그게 요점이에요 vex.submit(Ch04StackTrace::blocking);

결과는 join()으로 꺼냈습니다. get()이면 ExecutionException이 나와요 — 이름만 다를 뿐 한 겹 감싸인다는 건 같습니다.

예외를 잡아 스택을 뜯어봤습니다.

방식잡히는 예외프레임 수터진 지점(loadPayment)부른 쪽(main)
① 블로킹IllegalStateException9✅ 있음✅ 있음
CompletableFuture — 잡히는 예외CompletionException8없음❌ 없음
CompletableFuturegetCause()IllegalStateException7✅ 있음❌ 없음
③ 가상 스레드 + 블로킹 코드IllegalStateException5✅ 있음❌ 없음
평범한 플랫폼 스레드 풀 + 블로킹 코드IllegalStateException6✅ 있음❌ 없음

※ ③은 Future.get()이 씌우는 ExecutionException을 벗긴 뒤의 예외입니다. 래핑 자체는 ②와 같아요.

✅ 실측 (JDK 25 / Apple M1 / macOS 26.5, 2026-08. 타이밍이 아니라 스택 구조를 재는 측정이라 회차마다 동일합니다. 전체 소스: docs/book/code/async/Ch04StackTrace.java)

두 번째 줄이 이 장의 핵심입니다

CompletableFuture 체인에서 잡히는 건 CompletionException이고, 그 스택에는 터진 지점이 아예 없습니다. 8개 프레임 중 앞 5줄이 이렇습니다.

at java.base/...CompletableFuture.wrapInCompletionException(CompletableFuture.java:323) at java.base/...CompletableFuture.encodeThrowable(CompletableFuture.java:359) at java.base/...CompletableFuture.completeThrowable(CompletableFuture.java:364) at java.base/...CompletableFuture$UniApply.tryFire(CompletableFuture.java:670) at java.base/...CompletableFuture$Completion.run(CompletableFuture.java:503) ... (총 8프레임)

내 코드가 한 줄도 없습니다. 프레임워크 내부만 있어요. getCause()를 한 겹 벗겨야 비로소 loadPayment가 나옵니다.

그런데 벗겨도 완전하지 않습니다. 원인 예외의 스택은 이렇게 끝나요.

at Ch04StackTrace.loadPayment(Ch04StackTrace.java:7) ← 터진 지점은 있다 at Ch04StackTrace.lambda$reactive$0(Ch04StackTrace.java:17) at java.base/...CompletableFuture$UniApply.tryFire(CompletableFuture.java:667) at java.base/...CompletableFuture$Completion.run(CompletableFuture.java:503) at java.base/...ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090) ... (총 7프레임)

“누가 이 작업을 시켰는가”가 없습니다. 람다 한 프레임을 지나면 곧장 풀 워커 스레드로 건너뛰어요. 어느 요청, 어느 컨트롤러, 어느 사용자 때문에 이 코드가 돌았는지 스택은 말해주지 않습니다.

정리하면 이렇습니다.

비동기 스택 트레이스는 “어디서 깨졌는가”는 알려주지만 “누가 시켰는가”는 알려주지 않습니다. 그리고 장애 대응에서 급한 건 대개 후자입니다.

가상 스레드는 절반을 되찾는다

세 번째 줄을 보세요. 먼저 오해를 하나 걷어내야 합니다. 가상 스레드도 래핑은 그대로입니다. Future.get()을 쓰는 한 ExecutionException으로 한 겹 감싸여요. 표의 3행이 IllegalStateException인 건 측정 코드에서 getCause()로 벗겨 다시 던졌기 때문입니다.

진짜 차이는 래핑이 아니라 스택의 연속성에 있습니다. 두 스택을 나란히 놓으면 보여요.

② getCause(): loadPayment → lambda$reactive$0 → UniApply.tryFire → ... ↑ 람다 하나에서 끊긴다. 이걸 부른 blocking()이 없다 ③ 가상 스레드: loadPayment → blocking → FutureTask.run → ... → VirtualThread.run ↑ 내 호출 체인이 통째로 남는다

②에서 건지는 건 loadPayment 한 프레임뿐입니다. 그 함수를 부른 게 누구인지가 없어요. ③에는 loadPayment ← blocking이 이어져 있습니다.

그런데 여기서 공을 엉뚱한 데 돌리면 안 됩니다. 표의 ④를 보세요. 평범한 플랫폼 스레드 풀에 같은 블로킹 코드를 제출해도 호출 체인은 똑같이 남습니다.

④ 플랫폼 풀: loadPayment → blocking → FutureTask.run → ThreadPoolExecutor.runWorker → ...

✅ 실측 (같은 환경)

즉 ②와 ③을 가른 건 “가상 스레드냐”가 아니라 **“코드를 블로킹 스타일로 짰냐”**입니다. 스택을 되찾아주는 건 블로킹 스타일이에요. 문제는 그 스타일을 쓰면 1장의 4,068개 천장에 부딪힌다는 거였죠.

그래서 정확한 문장은 이렇습니다. 가상 스레드가 한 일은 스택을 되찾은 게 아니라, 스택이 남는 코드 스타일을 수만 개 규모에서 쓸 수 있게 만든 겁니다. 이 장의 논지(“Loom은 코드가 아니라 스레드를 바꿨다”)와 정확히 같은 말이에요.

다만 main은 어느 쪽에도 없습니다. 작업을 다른 스레드에 제출한 이상, 제출한 쪽 스택은 거기서 끊어집니다.

그리고 가상 스레드에도 무너지는 자리가 있습니다.

  • 네이티브 코드(JNI·FFM) 안에서 블로킹하면 캐리어 스레드를 물고 늘어집니다(pinning).
  • ThreadLocal을 대량으로 쓰면 스레드가 싸다는 전제가 흔들립니다.
  • CPU 바운드 작업에는 이득이 없습니다. 코어 수만큼만 도니까요.

여기서 흔한 오해를 하나 정리해둡시다. synchronized 블록 안에서 블로킹하면 pin된다”는 말은 JDK 23까지의 이야기입니다. JDK 24의 JEP 491이 걷어냈어요. 직접 확인했습니다 — 캐리어를 1개로 묶고, 스레드마다 다른 락을 준 뒤(경합 0) synchronized 안에서 200ms씩 자는 가상 스레드 64개를 던졌습니다.

조건소요
synchronized + sleep(200ms) × 64210 ms
락 없이 sleep(200ms) × 64 (기준선)214 ms

✅ 실측 (JDK 25 / Apple M1 / macOS 26.5, 2026-08. -Djdk.virtualThreadScheduler.maxPoolSize=1). pin됐다면 순차 실행이라 64 × 200 = 약 12,800ms가 나왔어야 합니다. 기준선과 같다는 건 전부 언마운트됐다는 뜻이에요.

17장에서 기계를 뜯어보며 하나씩 짚습니다.

그럼 리액티브는 죽었나

아닙니다. 여기서 이 장의 트레이드오프를 분명히 해둡시다.

가상 스레드가 대체하는 것 — “스레드를 아끼려고” 리액티브를 쓰던 경우. 대부분의 CRUD 서버가 여기 해당합니다. 1장의 천장(4,068개)이 실질적으로 문제가 안 되는 높이로 올라가니(정확히 얼마나 올라가는지는 17장에서 잽니다) 굳이 코드를 뒤집을 이유가 없어요.

가상 스레드가 대체하지 못하는 것 — 두 가지입니다. 첫째는 백프레셔예요. 가상 스레드는 “스레드를 싸게” 만들 뿐, “생산자가 소비자보다 빠를 때 어떻게 할지”는 답하지 않습니다. 오히려 스레드가 싸져서 더 쉽게 과부하를 만들 수 있어요. 스트리밍, 이벤트 파이프라인, 속도 차이가 큰 시스템 연결에는 리액티브의 흐름 제어가 여전히 답입니다.

둘째는 선언적 조합입니다. retryWhen, timeout, window, merge — 흐름을 조립하는 연산자들이죠. 가상 스레드로 돌아가면 이 조립은 다시 try/catch와 반복문의 몫입니다.

이 구분은 16장(타임아웃과 백프레셔)과 18장(결정표)에서 다시 다룹니다.

정리

  • 도구는 있었지만 쓰기 어려웠습니다. NIO(2002)의 Selector를 직접 다루는 건 C로 이벤트 루프를 짜는 것과 비슷했고, Netty(2004)가 그 간극을 메웠습니다.
  • 규격과 대중화는 그다음이었습니다. Reactive Streams(2013~2015)가 백프레셔를 규격화했고, Spring WebFlux(2017)가 자바 웹 개발자에게 기본 선택지로 올렸습니다.
  • 리액티브가 새로 만든 문제는 뿌리가 둘입니다. 스택 트레이스와 ThreadLocal이 깨진 건 실행 흐름이 스택을 떠났기 때문이고, 블로킹 한 줄이 전부를 무너뜨리는 건 소수의 이벤트 루프를 모두가 공유하기 때문입니다.
  • 실측: CompletableFuture 체인에서 잡히는 예외의 스택 8프레임에는 내 코드가 한 줄도 없습니다. getCause()를 벗겨야 터진 지점이 나오고, 그래도 “누가 시켰는가”는 끝내 없습니다.
  • 가상 스레드(JDK 21, 2023)는 질문을 뒤집었습니다. 코드를 논블로킹에 맞추는 대신 스레드를 싸게 만들었어요. 스택의 연속성(내 코드 안의 호출 체인)이 남는 블로킹 스타일을, 이제 수만 개 규모에서 쓸 수 있습니다. 다만 “누가 시켰는가”는 여전히 없습니다.
  • 다만 백프레셔는 대체하지 못합니다. 스레드가 싸진다고 흐름 제어 문제가 사라지지 않아요.

생각해볼 질문

  1. 스택 트레이스가 “누가 시켰는가”를 잃는다면, 분산 추적(trace ID)은 그 자리를 대신할 수 있을까요? 그 ID는 어디에 담아 옮겨야 할까요?
  2. 가상 스레드로 코드를 되돌리면 1장의 세 측정 중 무엇이 해결되고 무엇이 남을까요?
  3. “스레드가 싸지면 코드를 뒤집을 이유가 없다”는 Loom의 논지가 성립하려면, 가상 스레드는 정확히 얼마나 싸야 할까요?

1부는 여기서 끝납니다. 지금까지는 왜 이런 도구들이 생겼는지를 따라왔어요. 1999년의 질문에서 2023년의 답까지, 네 걸음을 봤습니다.

2부부터는 한 층 내려갑니다. 지금까지 “커널이 스레드를 재운다”, “준비되면 알려준다”고 말해온 것들이 실제로 어떻게 일어나는지 볼 차례예요. 1번 질문의 답도 거기서 시작합니다.

5장. read() 한 번의 전 여정 — 잠드는 순간과 깨는 순간

Last updated on