16장. 아무도 기다리지 않으면 — 타임아웃과 백프레셔의 부재

만화로 보는 요약 — 먼저 읽어보세요
큐에 상한을 없앴더니 지연이 3.6초가 됐습니다. 상한을 1,000으로 두자 465ms로 고정됐고요. 대신 생산자가 3초 중 2,927ms를 막혀 있었습니다.
면접 실전 질문: ① 무한 큐는 왜 위험한가요? ② 백프레셔란 정확히 무엇인가요? ③ 비동기 체인에 타임아웃이 없으면 어떻게 되나요?
배경 — 부족해서가 아니라 넘쳐서
4부는 지금까지 있어야 할 것이 없어서 생기는 문제를 봤습니다. 컨텍스트가 없고(13장), 워커가 없고(14장), 루프에 빈틈이 없고(15장).
15장 마지막 질문이 방향을 뒤집습니다.
“반대로 무언가가 너무 많아서 생기는 문제도 있을까요? 생산자가 소비자보다 빠르면 어떻게 될까요?”
그리고 이건 4장에서 미뤄둔 빚이기도 합니다. 거기서 “가상 스레드가 대체하지 못하는 것” 두 가지를 꼽았죠. 그 첫 번째가 백프레셔였습니다.
블로킹 코드에서는 이 문제가 잘 안 보입니다. 같은 스레드가 생산과 소비를 다 하니까요. 소비가 느리면 그 자리에서 잠들고, 속도는 자동으로 맞춰집니다.
그런데 그 사이에 큐를 하나 끼우는 순간 브레이크가 풀립니다. 생산자는 큐에 던지고 즉시 돌아오거든요. 소비자가 얼마나 밀렸는지 알 방법이 없어요.
비동기라서가 아닙니다 — 큐를 끼웠기 때문이에요. 그 증거가 다음 섹션에 있습니다. 블로킹 시대의 API가 이미 그러고 있었거든요.
그럼 밀린 것들은 어디로 갈까요? 큐에 쌓입니다. 그리고 큐가 무한하면요?
스토리 — 무한 큐라는 조용한 결정
Executors.newFixedThreadPool(10)을 부를 때 우리는 큐를 하나 만들고 있습니다. 큐가 딸려온다는 걸 아는 사람이 의외로 적어요.
public static ExecutorService newFixedThreadPool(int n) {
return new ThreadPoolExecutor(n, n, 0L, MILLISECONDS,
new LinkedBlockingQueue<Runnable>()); // ← 상한이 없다
}이 인용은 요약한 것이고, 핵심은 LinkedBlockingQueue를 인자 없이 만들면 용량이 **Integer.MAX_VALUE**라는 겁니다. 사실상 무한이에요.
무슨 뜻이냐면 — 이 풀은 절대 작업을 거절하지 않습니다. 스레드 10개가 전부 바빠도 submit()은 성공합니다. 큐에 쌓일 뿐이죠. 그리고 부르는 쪽은 그 사실을 모릅니다.
이게 왜 위험한지는 두 가지 방향에서 옵니다.
첫째, 메모리. 큐에 쌓인 작업은 전부 힙에 있습니다. 각 작업이 요청 데이터를 참조하고 있으면 그것도 못 지워요.
둘째, 지연. 이게 더 자주 사고를 냅니다. 큐가 길어지면 큐에 들어간 작업이 실행될 때까지 걸리는 시간이 계속 늘어나요. 9장의 리틀의 법칙 그대로입니다 — 대기 시간 = 큐 길이 ÷ 처리 속도.
그리고 지연이 늘면 보통 상황이 더 나빠집니다. 클라이언트가 타임아웃을 보고 재시도하거든요. 재시도가 큐를 더 채웁니다. 이걸 재시도 폭풍이라고 부릅니다.
재봤습니다.
핵심 — 상한이 있고 없고
생산자가 소비자보다 10배 빠른 상황을 만들었습니다. 생산자는 초당 20,000건, 소비자는 초당 2,000건이에요. 메시지 하나는 4KB고, 힙은 256MB로 제한했습니다.
측정 환경: Apple M1(8코어) / 램 16GB / macOS 26.5 / JDK 25, -Xmx256m. 전체 소스는 docs/book/code/async/Ch16Backpressure.java에 있습니다.
세 가지 큐 정책을 비교했습니다.
- unbounded —
LinkedBlockingQueue(). 상한 없음 - bounded —
ArrayBlockingQueue(1000)+put(). 가득 차면 생산자가 막힘 - drop —
ArrayBlockingQueue(1000)+offer(). 가득 차면 버림
결과
3초 동안 생산하고, 그 뒤 최대 5초를 더 주며 큐를 비웠습니다.
| 정책 | 생산 | 소비 | 버림 | 큐 최대 | 지연 평균 | 지연 최대 | 생산자 대기 | 힙(사용) | 힙(GC 후) |
|---|---|---|---|---|---|---|---|---|---|
| unbounded | 60,061 | 15,715 | 0 | 54,314 | 3,648 ms | 7,223 ms | 26 ms | 232 MB | 180 MB |
| bounded | 6,947 | 6,947 | 0 | 1,000 | 465 ms | 512 ms | 2,927 ms | 29 MB | 4 MB |
| drop | 60,004 | 6,748 | 53,256 | 1,000 | 478 ms | 563 ms | 0 | 133 MB | 4 MB |
✅ 실측 (같은 환경, 2026-08. 3회 반복, 편차 1% 이내. 지연은 큐에 들어간 순간부터 소비될 때까지입니다. 힙은 측정 직후 값과 System.gc() 뒤 값을 따로 찍었어요 — 앞쪽은 아직 수거 안 된 쓰레기를 포함합니다. unbounded의 생산자 대기 26ms는 막힌 시간이 아니라 put() 6만 번의 호출 비용입니다.)
첫 줄이 무한 큐의 청구서입니다
큐가 54,314개까지 자랐습니다. 그리고 세 가지가 동시에 나빠졌어요.
지연이 3.6초입니다. 평균이요. 최대는 7.2초고요. 소비자는 처음과 똑같은 속도로 일하고 있는데, 나중에 들어온 요청은 7초를 기다립니다. 아무도 에러를 안 냈고, 예외 하나 안 났습니다. 그냥 느려졌어요.
GC를 돌려도 180MB가 살아 있습니다. 한도 256MB 중에요. 그리고 이건 초당 80MB씩(4KB × 20,000건) 계속 자라던 중이었습니다. 1초만 더 부하가 왔으면 OOM입니다.
그리고 표에 없는 숫자가 하나 더 있습니다. 3초 생산을 멈춘 뒤 5초를 더 비웠는데도 44,170건이 남았습니다. 부하가 멈춰도 밀린 것은 한참 남아요.
무한 큐는 “거절하지 않는” 대신 “무한히 늦어지는” 쪽을 택한 겁니다. 그리고 그 선택을 아무도 명시적으로 하지 않았어요.
newFixedThreadPool을 불렀을 뿐이죠.
둘째 줄이 백프레셔입니다
상한을 1,000으로 두자 그림이 완전히 달라집니다.
지연 최대가 512ms입니다. 예측값과 거의 정확히 맞아요 — 큐 1,000개를 초당 2,000건으로 비우면 500ms니까요. 평균 465ms가 조금 낮은 건 처음 큐가 차오르는 구간과 마지막에 큐가 비는 구간이 섞여서입니다.
중요한 건 평균과 최대의 차이가 47ms라는 거예요. unbounded는 3,648ms와 7,223ms — 3.6초가 벌어져 있었습니다. 큐 길이에 상한을 두면 지연에도 상한이 생깁니다.
생산이 6,947건으로 줄었습니다. unbounded의 60,061건에서요. 그리고 생산과 소비가 정확히 같습니다. 생산자가 소비자 속도에 맞춰진 거예요.
어떻게요? 생산자 대기 열을 보세요. 2,927ms입니다. 3초 측정 중 98%를 q.put()에서 막혀 있었어요.
그게 백프레셔의 전부입니다. 거창한 프로토콜이 아니라, 소비자가 느리면 생산자를 세우는 것.
6장에서 본 커널이 하던 일과 같습니다. 다만 커널은 두 방식 다 써요 — 블로킹 소켓이면 write()가 여기 put()처럼 잠들고, 논블로킹이면 EAGAIN을 돌려주며 offer()처럼 “네가 정해”라고 넘깁니다. 이 표의 둘째 줄과 셋째 줄이 그 둘이에요.
그리고 힙은 GC 후 4MB — unbounded의 45분의 1입니다.
한 가지 덧붙이면,
unbounded의 소비량(15,715)이bounded(6,947)보다 많은 건 큐가 빨라서가 아닙니다. 소비자가 더 오래 돌았기 때문이에요. 3초 생산이 끝난 뒤 unbounded는 5만 4천 개를 비우느라 5초를 더 돌았고(총 8초), bounded는 1,000개를 0.5초에 비우고 끝났습니다(총 3.5초). 8 ÷ 3.5 = 2.28, 소비량 비도 2.26. 초당 소비 속도는 셋 다 같습니다.
드롭은 89%를 버리고 그 지연을 샀습니다
drop은 지연이 478ms로 bounded와 거의 같습니다. 생산자도 안 막혀요. 좋아 보이죠?
버림 열을 보세요. 53,256건입니다. 생산한 60,004건 중 89%를 버렸습니다.
힙 열도 눈여겨보세요. 사용 힙은 133MB인데 GC 후에는 4MB입니다. 버린 4KB 배열 5만 3천 개가 아직 수거만 안 됐을 뿐, 살아 있는 건 bounded와 같아요. 버린 것은 쌓이지 않습니다.
세 정책을 한 문장으로 정리하면 이렇습니다.
생산자가 소비자보다 빠를 때, 누군가는 반드시 대가를 치릅니다. 무한 큐는 지연과 메모리로 냅니다. 백프레셔는 생산자의 시간으로 냅니다. 드롭은 데이터로 내고요. 공짜인 선택지는 없습니다. 다만 아무것도 안 고르면 무한 큐가 기본값입니다.
그럼 무엇을 골라야 하나
일반 답은 없지만 판단 기준은 분명합니다.
| 상황 | 선택 |
|---|---|
| 생산자를 세울 수 있다 (내부 파이프라인, 배치) | 백프레셔. 지연에 상한이 생깁니다 |
| 생산자를 못 세운다 (외부 트래픽, 센서 데이터) | 드롭 또는 거절. 대신 버렸다는 사실을 반드시 관측하세요 |
| 데이터를 잃으면 안 된다 | 큐를 밖으로 빼세요 (Kafka 같은 지속 큐). 힙은 큐가 아닙니다 |
실무에서 가장 흔한 실수는 세 번째를 힙에서 하려는 겁니다. 무한 큐는 “데이터를 안 잃는” 게 아니라 “OOM으로 전부 잃을 때까지 안 잃는” 거예요.
ThreadPoolExecutor에서는 이렇게 씁니다.
new ThreadPoolExecutor(
10, 10, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(1000), // ← 상한을 명시
new ThreadPoolExecutor.CallerRunsPolicy()); // ← 넘치면 부른 쪽이 직접 실행CallerRunsPolicy가 재미있는 선택입니다. 큐가 차면 제출한 스레드가 그 작업을 직접 실행해요. 그럼 그 스레드는 그동안 새 작업을 못 제출합니다. 자동으로 백프레셔가 걸리는 셈이죠.
단, 이 정책은 풀이 종료 중이면 작업을 조용히 버립니다(if (!e.isShutdown()) r.run();). 예외도 안 나요. 이 장이 통째로 “조용한 실패”에 관한 장이니, 셧다운 경로가 있는 서비스라면 이 한 줄을 기억해두세요.
그리고 백프레셔에는 더 큰 단서가 하나 붙습니다. 백프레셔는 압력을 없애지 않고 위로 밀어 올립니다. 생산자가 내 서비스 안쪽이면 거기서 멈추고 끝이지만, 사슬 끝이 외부 클라이언트면 결국 소켓 백로그와 로드밸런서 큐가 아까 그 무한 큐 역할을 물려받아요. 사슬 어딘가에는 거절하는 지점이 있어야 합니다.
그리고 타임아웃
큐만큼 자주 빠지는 게 타임아웃입니다.
블로킹 코드에는 대개 타임아웃이 있습니다. socket.setSoTimeout(), statement.setQueryTimeout()처럼요. 없으면 스레드가 영영 안 돌아오니 금방 눈에 띄거든요.
비동기 체인에서는 눈에 안 띕니다. 아무도 기다리지 않으니까요. CompletableFuture가 영원히 완료되지 않아도 예외가 안 나고, 로그도 안 남고, 스레드 덤프에도 안 잡힙니다. 그냥 응답이 안 갈 뿐이에요. 그리고 그 미완료 CompletableFuture와 거기 매달린 콜백 체인은 힙에 남습니다.
정확히는 이렇습니다. 그 미완료 CompletableFuture를 완료시킬 누군가가 아직 살아 있는 한 — 응답을 기다리는 I/O 스레드든 타이머든 — CF는 회수되지 않습니다. 그리고 11장에서 본 stack에 매달린 콜백들은 각자 캡처한 요청 데이터를 붙들고 있죠. CF 하나가 그래프 하나를 잡아둡니다.
그래서 규칙은 하나입니다.
cf.orTimeout(3, TimeUnit.SECONDS); // → TimeoutException 으로 예외 완료
cf.completeOnTimeout(fallback, 3, TimeUnit.SECONDS); // → 폴백 값으로 정상 완료체인의 끝에는 반드시 시한을 거세요.
다만 orTimeout은 취소가 아니라 완료 처리입니다. 3초가 지나면 CF는 TimeoutException으로 닫히고 콜백 체인도 풀리지만, 그 아래에서 돌던 HTTP 호출은 그대로 돌고 있어요. 스레드도 커넥션도 안 돌아옵니다. 그래서 클라이언트 자체 타임아웃과 둘 다 걸어야 합니다.
4부가 보여준 것
이 장으로 4부가 끝납니다. 네 장을 한 줄씩 이으면 이렇습니다.
- 13장 — 컨텍스트가 사라집니다.
ThreadLocal은 “요청 하나 = 스레드 하나”에 얹혀 있었어요. - 14장 — 공유 풀이 막힙니다. 블로킹 7개로 무관한 병렬 스트림이 약 7배 느려졌습니다.
- 15장 — 루프가 막힙니다. 요청의 1%가 처리량을 4.4배 떨어뜨렸어요.
- 16장 — 큐가 넘칩니다. 상한이 없으면 지연이 무한히 자랍니다.
네 개가 전부 같은 곳을 가리킵니다. 비동기로 가면서 우리는 스레드를 되찾고 “요청 하나 = 스레드 하나”라는 전제를 팔았습니다. 그 전제 위에 서 있던 것들 — 컨텍스트, 격리, 흐름 제어, 시한 — 이 전부 청구서로 돌아온 거예요.
그럼 전제를 되사올 수는 없을까요? 코드를 뒤집지 않고도 스레드를 아낄 수 있다면요?
정리
- 무한 큐는 명시적으로 고른 선택이 아닙니다.
newFixedThreadPool이LinkedBlockingQueue()를 쓰고, 그 용량은Integer.MAX_VALUE예요. - 실측: 상한을 없애자 큐가 54,314개까지 자랐고 지연이 평균 3.6초·최대 7.2초, GC를 돌려도 180MB가 살아 있었습니다(한도 256MB, 초당 80MB씩 증가 중). 에러는 하나도 안 났어요. 그냥 느려졌습니다.
- 실측: 상한을 1,000으로 두자 지연 최대가 512ms로 고정됐습니다. 큐 1,000 ÷ 초당 2,000건 = 0.5초, 예측과 거의 일치해요. 큐에 상한을 두면 지연에도 상한이 생깁니다.
- 백프레셔는 거창한 게 아닙니다. 생산자가
put()에서 막히는 것 — 실측에서 3초 중 2,927ms를 막혀 있었고, 그래서 생산량이 소비량과 정확히 같아졌습니다. 6장에서 커널이 블로킹 소켓의write()로 하던 일과 같아요(논블로킹EAGAIN은 드롭 쪽에 대응합니다). - 누군가는 반드시 대가를 치릅니다. 무한 큐는 지연과 메모리로, 백프레셔는 생산자의 시간으로, 드롭은 데이터로(실측에서 89%를 버렸습니다). 공짜는 없고, 안 고르면 무한 큐가 기본값입니다.
- 비동기 체인의 타임아웃 부재는 조용합니다. 아무도 안 기다리니 예외도 로그도 없어요.
orTimeout/completeOnTimeout으로 체인 끝에 시한을 걸되, 그건 취소가 아니라 완료 처리라는 걸 기억하세요 — 아래에서 돌던 I/O는 계속 돕니다.
생각해볼 질문
CallerRunsPolicy는 큐가 차면 제출한 스레드가 직접 작업을 실행합니다. 이게 왜 백프레셔가 되나요? 그 스레드가 이벤트 루프 스레드라면 어떻게 될까요?- 4부의 네 문제(컨텍스트 유실·공유 풀 오염·루프 블로킹·큐 폭주) 중, 가상 스레드로 사라지는 것과 여전히 남는 것은 각각 무엇일까요?
- “요청 하나 = 스레드 하나”라는 전제를 되사오려면 무엇이 싸져야 할까요? 8장의 어느 측정이 그 조건을 알려주나요?
2번과 3번이 5부입니다. 1부에서 던진 질문 — “스레드가 싸지면 코드를 뒤집을 이유가 남나요?” — 에 이제 답할 차례예요.
미리 말해두면, 4부의 넷 중 셋은 같은 처방으로 풀립니다. 전제를 되사오는 것 — 스레드를 안 갈아타면 컨텍스트도 안 사라지고, 공유 풀에 블로킹을 던질 이유도, 막힐 루프도 없어지니까요. 딱 하나만은 스레드가 아무리 싸져도 그대로 남습니다. 그게 무엇인지는 18장의 결정표에서 정면으로 다룹니다.
그 전에, 되사올 전제의 값부터 재야죠. 가상 스레드는 정확히 얼마나 싼가 — 그리고 어디서 안 싸지는가.