여는 글 — CPU는 3%인데 서버가 죽었다
서버가 죽은 게 아닙니다. 자리가 없었을 뿐이에요. 스레드 200개가 전부 자고 있었거든요.
알림이 울린다
새벽 두 시. 응답 시간 30초 알림이 옵니다. 대시보드를 엽니다.
CPU 3%. 메모리도 여유롭습니다. GC 로그도 조용해요. 디스크도 놀고 있습니다. 그런데 헬스체크가 타임아웃 나고, 로드밸런서는 인스턴스를 내리기 시작합니다.
CPU가 놀고 있는데 서버가 응답을 못 합니다. 이 조합이 처음이면 한참을 헤맵니다. 자원은 전부 남는다고 나오니까요. 볼 곳이 없습니다.
재현해봅시다. 하류(DB든 사내 API든)가 3초 걸리는 상황에서 요청 200건이 들어와 스레드 풀을 채웁니다. 그 뒤에 하는 일이 거의 없는 가벼운 요청 20건이 도착해요. 헬스체크 같은 거요.
for (int i = 0; i < 200; i++) ex.submit(() -> Thread.sleep(3000)); // 하류를 기다린다
Thread.sleep(300);
for (int i = 0; i < 20; i++) ex.submit(() -> record(elapsed())); // 가벼운 요청| 가벼운 요청이 실행되기까지 (중앙값) | 그동안 프로세스 CPU | |
|---|---|---|
| 고정 풀 200 | 2,676 ms | 중앙값 0.0% (최대 5% 안팎) |
| 가상 스레드 | 0 ms (최대 1 ms) | — |
✅ 실측 (JDK 25 / Apple M1 8코어 / macOS 26.5, 2026-08, 3회 반복 시 고정 풀 2,6602,680ms / 가상 스레드 01ms. 전체 소스: docs/book/code/async/Ch00Idle.java)
CPU 중앙값이 0.0%입니다. 그런데 아무 일도 안 하는 요청 하나가 2.7초를 기다려요.
2,676ms라는 숫자도 우연이 아닙니다. 하류가 3초 걸리고 제가 300ms 뒤에 요청을 넣었으니 계산상 2,700ms를 기다려야 하죠. 실측이 그보다 20ms쯤 짧은 건 스레드 200개를 만드는 동안 앞선 요청들이 이미 출발했기 때문입니다. 그러니까 저 2,676ms는 딴 게 아니라 먼저 온 누군가가 깨어나 자리를 비켜줄 때까지 걸린 시간이에요.
CPU를 기다린 게 아닙니다. 자리를 기다린 겁니다.
(가상 스레드 쪽 CPU를 비워둔 건 표본이 서너 개뿐이라서입니다. 측정 창이 300ms 남짓이라 잴 시간이 없었어요. 여기서 볼 건 CPU가 아니라 0ms입니다.)
자원 그래프가 못 보는 것
모니터링 대시보드는 대개 이런 걸 보여줍니다. CPU, 메모리, 디스크, 네트워크. 전부 **“쓰고 있는 양”**이에요.
그런데 방금 본 장애의 원인은 쓰는 양이 아니라 점유한 자리 수였습니다. 스레드 200개가 각자 하나씩 자리를 잡고, 아무것도 안 하면서, 하류가 답할 때까지 붙들고 있었던 거죠. 자원 그래프에서 이건 아무것도 안 하는 것처럼 보입니다. 실제로 아무것도 안 하니까요.
이 책은 그 “아무것도 안 하는 상태”를 다룹니다. 스레드가 잠드는 순간에 무슨 일이 일어나고, 왜 자야 하고, 안 자게 하려면 무엇을 포기해야 하는지요.
관통하는 한 문장
먼저 흔한 오해 하나를 정리하고 시작합시다.
비동기는 더 빨리 하는 기술이 아니라, 기다리는 동안 스레드를 놓아주는 기술입니다.
100ms짜리 API 세 곳을 부르는 코드를 네 가지 방식으로 짜서 재보면 이렇게 나옵니다.
| 방식 | 소요 | 그동안 묶여 있는 스레드 |
|---|---|---|
| 순차 호출 | 322 ms | 1 |
스레드 풀 3개 + Future.get() | 108 ms | 4 |
sendAsync() + thenCombine() | 107 ms | 0 |
| 가상 스레드 | 110 ms | 1 |
✅ 실측 (JDK 25 / macOS, 2026-08, 로컬 HTTP 서버 100ms 지연. 출처: CompletableFuture는 어떻게 논블로킹인가의 실측 표. 이 책은 10·11장에서 그 4와 0이 각각 어디서 오는지 뜯어봅니다)
소요 시간을 줄인 건 비동기가 아니라 병렬화입니다. 순차 322ms를 세 개 동시에 부르는 순간 이미 108ms가 돼요. CompletableFuture로 바꿔서 얻은 1ms는 측정 오차입니다.
진짜 차이는 마지막 열에 있습니다. 4개냐 0개냐. 그리고 이게 여는 글 첫 장면의 그 숫자예요. 자리를 몇 개 붙들고 있느냐가 동시에 몇 명을 받을 수 있느냐를 정합니다. 처리량은 거기서 갈립니다.
이 책이 답하려는 것
세 가지 질문으로 요약됩니다.
- 스레드는 왜 자야 하는가 — 커널이
read()안에서 정확히 무슨 일을 하는지 (2부) - 안 자게 할 수 있는가 —
epoll과 이벤트 루프가 실제로 무엇인지, OS에 있는 건지 (2부) - 안 자면 무엇을 잃는가 —
ThreadLocal, 공유 풀, 막힌 루프, 그리고 백프레셔 (4부). 스택 트레이스는 그보다 앞선 4장에서 먼저 잽니다
그리고 마지막으로, 스레드가 싸지면 이 모든 게 없던 일이 되는가(5부).
구성은 이렇습니다.
- 1부 · 기다림의 역사 — 1999년 C10K에서 오늘의 가상 스레드까지. 왜 이 API들이 이 모양으로 태어났는지.
- 2부 · 커널이 스레드를 재우는 법 —
read()한 번의 전 여정. 논블로킹이 사실 플래그 하나라는 것. 이벤트 루프가 OS에 없다는 것. - 3부 · Java는 어떻게 따라왔나 —
Thread에서Future,CompletableFuture, Netty와 WebFlux까지. - 4부 · 비동기가 무너지는 자리 — 사라진 로그, 오염된 전역 풀, 막힌 루프, 폭주한 큐.
- 5부 · 다시 블로킹처럼 — 가상 스레드의 기계를 뜯어보고, 무엇을 언제 쓸지 정합니다.
읽는 법
숫자에는 전부 딱지가 붙어 있습니다.
- ✅ 실측 — 제가 코드를 짜서 돌리고 나온 값입니다. JDK 버전과 OS를 같이 적었고, 대부분 여러 번 돌려 범위나 중앙값을 씁니다. 전체 소스는 링크를 따라가면 있어요.
- 📄 문서 기반 — 커널 내부처럼 직접 계측하지 않은 것입니다. JEP나 소스를 근거로 썼고, 검증하지 않았다고 밝힙니다.
- (추정) — 제 해석입니다. 근거가 약한 곳에 붙습니다.
불편한 측정은 지우지 않고 남겼습니다. 3장에는 제 첫 측정이 왜 틀렸는지 적혀 있고, 12장에는 15회 중 2회가 아예 실패한 것까지 그대로 실려 있습니다. 잘 나온 회차만 골라 쓰면 남은 숫자도 못 믿게 되니까요.
코드는 증거로만 씁니다. 문법 설명은 없어요. 모든 예제는 docs/book/code/async/에 실행 가능한 형태로 있고, 여러분 기계에서 다른 숫자가 나올 겁니다. 그게 정상이에요 — 중요한 건 절대값이 아니라 차이의 방향과 자릿수입니다.
각 장은 배경 → 스토리 → 핵심 세 걸음으로 갑니다. 급하면 각 장 맨 위의 굵은 한 줄과 맨 아래 정리만 읽어도 됩니다.
누구를 위한 책인가
CompletableFuture를 써봤지만 “그래서 이게 왜 논블로킹이지?”에 자신 있게 답하지 못하는 백엔드 개발자를 위해 썼습니다. 면접에서 이 질문을 받아봤다면 더 그렇고요.
정답을 외우는 대신 왜 그렇게 됐는지를 따라가는 쪽을 골랐습니다. 그래서 1999년의 문제부터 시작합니다. 서버 한 대가 연결 1만 개를 감당하지 못하던 시절이요.
그때 사람들이 무엇에 막혔는지 알면, 오늘의 API가 왜 이 모양인지 설명이 됩니다.