Skip to Content

15장. 루프를 막는 한 줄 — WebFlux에 JDBC를 섞은 날

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

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

요청의 1%만 블로킹했는데 나머지 99%의 p50이 39ms에서 129ms가 됐습니다. 그 99%는 아무 잘못이 없었어요.

면접 실전 질문: ① WebFlux에서 JdbcTemplate을 부르면 무슨 일이 벌어지나요? ② 블로킹 코드를 꼭 써야 한다면 어떻게 하나요? ③ 블로킹 호출을 코드 리뷰 말고 자동으로 잡을 방법이 있나요?


배경 — 14장의 질문에 답할 차례

14장 마지막 질문입니다.

“이 장의 규칙은 12장의 이벤트 루프 규칙과 같은 말입니다. 그럼 WebFlux 애플리케이션에서 JdbcTemplate을 부르면, 이 장의 7배에 해당하는 값은 얼마일까요?”

14장에서 commonPool 워커 7개를 막았더니 무관한 병렬 스트림이 약 7배 느려졌습니다. 12장에서는 루프 하나를 100ms 막았더니 500개 전체의 p50이 83ms 밀렸고요.

두 장이 같은 말을 하고 있었어요. 코어 수만큼만 있는, 자면 안 되는 스레드를 공유 자원으로 쓰고 있다. 12장에서는 그걸 이벤트 루프라 불렀고 14장에서는 commonPool이라 불렀을 뿐입니다.

이번 장에서는 루프를 8개로 늘리고 블로킹을 1%로 희석해 그 값을 잽니다. 12장의 극단(루프 1개, 블로킹 1회)이 아니라 실제 서비스가 만나는 비율로요. 소켓은 걷어냈습니다 — 재려는 게 네트워크가 아니라 루프 점유니까요. 그리고 어떻게 빠져나오는지까지 봅니다.

스토리 — 왜 하필 JDBC인가

WebFlux를 쓰기로 한 팀이 가장 먼저 만나는 벽이 데이터베이스입니다.

리액티브 스택은 위에서 아래까지 논블로킹이어야 성립합니다. HTTP는 Reactor Netty가 해줘요. 그런데 JDBC는 근본적으로 블로킹 API입니다. ResultSet을 받으려면 executeQuery()가 돌아올 때까지 기다려야 하고, 그 기다림은 5장에서 본 그 park예요.

왜 JDBC는 논블로킹이 안 될까요? 4장에서 본 함수 색깔 문제 그대로입니다. JDBC 인터페이스 자체가 “값을 돌려주는 메서드”로 설계돼 있어서, 논블로킹으로 만들려면 인터페이스 전체를 다시 그려야 하거든요. 실제로 처음부터 다시 그린 게 R2DBC입니다 — JDBC 호환을 아예 포기한 별개 SPI로, 2018년 발표되고 1.0은 2022년에 나왔어요. 하지만 갈아타려면 드라이버·ORM·트랜잭션 관리까지 전부 바꿔야 합니다. 📄 문서 기반 (미검증)

그래서 현실에서는 이런 코드가 태어납니다.

@GetMapping("/order/{id}") public Mono<Order> getOrder(@PathVariable Long id) { return Mono.just(jdbcTemplate.queryForObject(...)); // ← 여기가 문제 }

Mono로 감쌌으니 리액티브처럼 보입니다. 그런데 queryForObject는 이 줄에서 즉시 블로킹으로 실행됩니다. Mono.just()는 이미 계산된 값을 담을 뿐이거든요. 감싸는 게 아무것도 바꾸지 않아요.

그리고 이 코드가 도는 스레드는 12장의 계층 표에서 봤듯 reactor-http-nio-N — 이벤트 루프 스레드입니다.

얼마나 아픈지 재봤습니다.

핵심 — 1%가 4.4배를 만든다

이벤트 루프 8개짜리 서버를 흉내냈습니다(8코어 기계의 Reactor Netty 기본값). 요청 2,000개를 던지는데, 100건 중 1건만 100ms 블로킹 호출을 합니다. 나머지 99%는 200μs짜리 평범한 요청이에요.

측정 환경: Apple M1(8코어) / 램 16GB / macOS 26.5 / JDK 25. 전체 소스는 docs/book/code/async/Ch15BlockingTheLoop.java에 있습니다.

세 조건을 비교했습니다.

  • none — 블로킹 요청 없음 (기준선)
  • onloop — 1%의 블로킹을 루프 스레드에서 그대로 실행
  • offload — 1%의 블로킹만 별도 풀로 넘김

결과

조건처리량정상 요청 99%의 p50p99
none (기준선)23,529 req/s39 ms72 ms
onloop — 1% 블로킹5,333 req/s129 ms268 ms
offload (풀 64)9,950 req/s42 ms84 ms
offload (풀 4)3,490 req/s41 ms76 ms

✅ 실측 (같은 환경, 2026-08. 각 조건 5회, 중앙값. 루프 8개, 요청 2,000건 중 블로킹 20건(1%), 블로킹 하나당 100ms. p50·p99는 블로킹이 아닌 1,980건만 뽑은 값입니다. 회차 범위 — none 20,83323,810 req/s, onloop 5,2225,376, offload(64) 9,25910,152, offload(4) 3,4013,490.)

두 가지를 미리 밝힙니다. 첫째, 2,000건을 한꺼번에 던지는 버스트라 p50·p99는 대부분 큐 대기 시간입니다(정상 요청 하나의 작업량은 200μs예요). 절대값이 아니라 세 조건의 차이를 보세요. 둘째, “처리량” 열은 2,000 ÷ 전체 소요인데, 전체 소요는 마지막 블로킹 한 건이 끝나는 시각에 묶여 있습니다. 그래서 이 열은 처리 능력이라기보다 배치 완료 시각에 가까워요.

가운데 줄이 이 장의 전부입니다

요청의 1%가 처리량을 4.4배 떨어뜨렸습니다. 23,529 → 5,333 req/s.

그리고 더 중요한 건 세 번째 열이에요. 아무 잘못 없는 99%의 p50이 39ms에서 129ms로 3.3배가 됐습니다. p99는 72ms → 268ms고요.

이게 이벤트 루프 모델의 특징입니다. 9장의 요청당 스레드였다면 느린 요청 하나가 자기 스레드 하나만 잡아먹었어요. 여기서는 자기가 올라탄 루프에 붙은 모든 요청을 인질로 잡습니다.

산수로 보면 이렇습니다. 루프가 8개인데 블로킹 요청 하나가 100ms를 물면, 그동안 처리 능력의 8분의 1이 사라집니다. 블로킹 20건이 2,000건 사이사이에 흩어져 들어오니 루프는 실행 내내 한두 개씩 빠져 있는 상태가 되고, 남은 루프에 큐가 쌓입니다.

1%라는 숫자가 함정입니다. “겨우 1%인데”라고 생각하기 쉽지만, 이벤트 루프에서 문제가 되는 건 비율이 아니라 점유 시간이에요. 200μs짜리 요청 99개와 100ms짜리 요청 1개는 개수로는 99:1이지만 시간으로는 19.8ms : 100ms — 오히려 블로킹이 다섯 배 더 오래 루프를 붙잡습니다.

총 점유 시간으로 예측해볼까요. 정상 요청 1,980 × 200μs = 396ms, 블로킹 20 × 100ms = 2,000ms. 합치면 2,396ms로 기준선의 6.05배입니다. 실측은 4.4배였어요. 차이는 큐 대기와 스케줄링 오버헤드가 두 조건에 다르게 실린 탓으로 보입니다(추정). 예측이 실측보다 나쁘게 나온 건, 8개 루프가 그래도 일부는 겹쳐 처리했다는 뜻이에요.

세 번째 줄이 처방입니다

블로킹만 별도 풀로 넘겼더니 정상 요청의 p50이 42ms로 돌아왔습니다. 기준선 39ms와 사실상 같아요.

다만 p99는 84ms로, 기준선 72ms보다 조금 나쁩니다. 완전한 회복은 아니에요. 그리고 정상 요청 중 최댓값은 정확히 100ms 언저리에 붙습니다 — 블로킹 하나가 오프로드되기 직전 루프를 스치는 순간이 있거든요.

Reactor에서는 이렇게 씁니다.

// ❌ 루프 스레드에서 그대로 블로킹 return Mono.just(jdbcTemplate.queryForObject(...)); // ✅ 블로킹 전용 스케줄러로 넘김 return Mono.fromCallable(() -> jdbcTemplate.queryForObject(...)) .subscribeOn(Schedulers.boundedElastic());

두 가지가 바뀌었습니다.

Mono.justMono.fromCallable. just는 인자를 즉시 평가합니다. 감싸기도 전에 이미 블로킹이 끝나 있어요. fromCallable은 구독될 때 실행합니다.

subscribeOn(Schedulers.boundedElastic()). 그 실행을 블로킹 전용 스케줄러로 옮깁니다. subscribeOn구독이 일어날 스레드를 정해요 — 소스가 실행되는 자리요. 체인 어디에 놓아도 소스에 걸립니다. 이름 그대로 bounded — 무한정 늘어나지 않고 상한이 있어요(기본은 코어 수 × 10, 📄 문서 기반).

네 번째 줄이 진짜 교훈입니다

표의 마지막 줄을 보세요. 오프로드 풀을 64개에서 4개로 줄였더니 처리량이 9,950에서 3,490 req/s로 떨어졌습니다. 2.9배요.

그런데 정상 요청의 p50은 41ms로 그대로입니다. 기준선과 여전히 같아요.

이 두 줄이 오프로드의 정체를 정확히 말해줍니다.

오프로드는 해결이 아니라 격리입니다. 격리는 풀 크기와 무관하게 유지됩니다 — 루프는 어느 쪽이든 안 막히니까요. 반면 블로킹 작업 자체의 처리량은 전용 풀의 크기가 정합니다.

그리고 그 풀 크기가 정하는 방식이 바로 9장의 리틀의 법칙입니다. 블로킹 20건 × 100ms를 풀 4개로 나누면 500ms, 64개로 나누면 100ms 한 번이면 끝나요. 실측의 3,490 vs 9,950이 그 차이입니다.

즉 불을 끈 게 아니라 방화벽을 세운 겁니다. 리액티브 스택에 블로킹이 섞여 있는 한 그 부분의 처리량은 스레드 풀의 법칙을 따릅니다. 9장에서 이미 걸어본 길이에요 — 그 끝에 1장의 4,068개 천장이 있습니다.

진짜 해결은 블로킹을 없애는 것(R2DBC)이거나, 애초에 뒤집지 않는 것(가상 스레드)입니다. 후자가 5부의 주제예요.

코드 리뷰로는 못 잡습니다

이 버그의 마지막 문제는 발견이 어렵다는 겁니다.

블로킹 호출은 Thread.sleep()처럼 대놓고 생기지 않았어요. RestTemplate, JdbcTemplate, Files.readAllBytes, 심지어 로깅 라이브러리의 동기 appender까지 — 평범해 보이는 호출 안에 숨어 있습니다. 서드파티 라이브러리 깊숙이 있으면 코드 리뷰로는 절대 못 봅니다.

그래서 BlockHound 같은 도구가 있습니다. JVM 에이전트로 붙어서 논블로킹으로 표시된 스레드에서 블로킹 호출이 일어나는 순간 예외를 던져요.

BlockHound.install(); // 논블로킹 스레드에서 블로킹이 일어나면 BlockingOperationError

📄 문서 기반 (미검증) — 이 책의 JDK 25 환경에서 직접 돌려보지는 않았습니다. BlockHound는 JDK 13+에서 -XX:+AllowRedefinitionToAddDeleteMethods 플래그가 필요하고, 최신 JDK에서의 동작 보고가 엇갈립니다. 도입 전에 여러분 환경에서 먼저 확인하세요.

13장의 InheritableThreadLocal, 14장의 commonPool 오염, 그리고 이 장의 루프 블로킹. 셋 다 부하가 걸려야 드러나는 버그입니다. 개발 환경에서는 조용해요. 이런 도구가 하는 일은 그걸 개발 시점의 예외로 앞당기는 것입니다.

정리

  • WebFlux 핸들러는 이벤트 루프 스레드에서 직접 돕니다. 12장의 계층 표에서 봤듯 중간에 스레드를 갈아타는 층이 없어요. 그래서 거기서 블로킹하면 루프가 멈춥니다.
  • 실측: 요청의 1%만 블로킹했는데 처리량이 23,529 → 5,333 req/s(4.4배 하락)가 됐습니다. 아무 잘못 없는 나머지 99%의 p50도 39ms → 129ms(3.3배)로 밀렸고요.
  • 1%라는 비율에 속으면 안 됩니다. 200μs 요청 99개와 100ms 요청 1개는 시간으로는 19.8ms : 100ms예요. 이벤트 루프에서 중요한 건 개수가 아니라 점유 시간입니다.
  • 오프로드하면 정상 요청의 p50이 회복됩니다(42ms, 기준선 39ms). p99는 84ms로 완전 회복은 아니고요. Mono.just 대신 Mono.fromCallable + subscribeOn(boundedElastic()).
  • 다만 오프로드는 해결이 아니라 격리입니다. 전용 풀을 64→4로 줄이자 처리량이 2.9배 떨어졌지만 정상 요청 p50은 41ms로 그대로였어요. 격리는 유지되고 처리량만 풀 크기가 정합니다 — 9장의 리틀의 법칙 그대로입니다.
  • 코드 리뷰로는 못 잡습니다. 블로킹은 평범한 호출 안에 숨어 있어요. BlockHound 같은 도구로 개발 시점에 예외로 앞당기세요.

생각해볼 질문

  1. 오프로드 풀을 4에서 64로 키우니 처리량이 2.9배 올랐습니다. 계속 키우면 기준선에 닿을까요? 9장의 어떤 공식이 그 상한을 알려주나요?
  2. 13·14·15장의 세 버그는 공통점이 있습니다. 셋 다 개발 환경에서 재현되지 않아요. 그 이유가 각각 무엇이었나요?
  3. 지금까지 4부는 “무언가가 부족해서” 생긴 문제를 봤습니다. 반대로 무언가가 너무 많아서 생기는 문제도 있을까요? 생산자가 소비자보다 빠르면 어떻게 될까요?

3번이 다음 장입니다. 4부의 마지막이자, 이 책이 4장에서 “가상 스레드가 대체하지 못하는 것”이라고 미뤄둔 그 주제예요.

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

Last updated on