웹플럭스 서비스에 JDBC나 옛 SDK 호출이 섞여 있으면, "어느 스케줄러에, 몇 개까지 동시에 보낼지"를 정해야 해요. 결론부터 말하면 블로킹 호출은 boundedElastic 에 보내고, 동시에 처리되는 수는 flatMap 동시성·boundedElastic 스레드 상한(기본 CPU 코어 × 10)·DB 커넥션 풀 크기 중 가장 작은 값으로 정해져요. 그래서 세 숫자를 같이 맞추는 게 설정의 전부예요.
이 글은 내부 구조 대신 운영에서 고를 숫자를 다뤄요. 판단표, 스레드 수 계산, reactor-core 3.8.8 로 직접 잰 동시성·전환 비용, 장애 확인 순서를 Apple M1(8코어)·OpenJDK 24 에서 정리했어요(2026-10-27).
리액터 스케줄러 선택 판단표, 작업 종류별로
스케줄러를 고를 때 먼저 물을 것은 "이 코드가 스레드를 막는가"와 "막는 코드가 소스 안에 있나, 아래쪽 연산자 안에 있나" 두 가지예요. Reactor 레퍼런스의 스케줄러 장과 FAQ 의 블로킹 호출 감싸기 항목을 기준으로 작업 종류별 선택을 표로 옮기면 이래요.
| 작업 | 어디에 거나 | 스케줄러 | 따로 정할 숫자 |
|---|---|---|---|
| JDBC·옛 SDK 한 번 호출 | Mono.fromCallable(...) 바로 뒤 subscribeOn | boundedElastic() | 커넥션 풀 크기 |
| 블로킹 호출 여러 번을 동시에 | flatMap 안쪽 Mono 에 subscribeOn | boundedElastic() | flatMap 동시성 |
| 이미 받은 값을 블로킹 API 로 처리 | 그 연산자 바로 위 publishOn | boundedElastic() | prefetch(기본 256) |
| 무거운 CPU 계산 한 줄기 | 계산 위 publishOn | parallel() | 없음 |
| CPU 계산을 진짜 병렬로 | parallel(n).runOn(...) | parallel() | 레일 수 n |
| WebClient·R2DBC 응답 처리 | 걸지 않음 | 이벤트 루프 그대로 | 없음 |
가장 자주 틀리는 칸은 WebClient·R2DBC 줄이에요. 막지 않는 응답 처리에 스케줄러를 걸면 전환 비용만 더해요. 스프링 웹플럭스 동시성 모델 문서도 블로킹 API 를 꼭 써야 할 때만 publishOn 으로 다른 풀로 옮기라고 안내해요.
boundedElastic 기본값과 바꿀 수 있는 설정
Schedulers 의 기본값은 모두 JVM 시스템 속성으로 바꿀 수 있어요. 아래 값은 reactor-core 3.8.8 의 Schedulers.java 소스에서 읽고, 8코어 기기에서 실제 상수를 출력해 맞춰 봤어요.
| 설정(시스템 속성) | 기본값 | 8코어 값 | 바꿀 때 |
|---|---|---|---|
reactor.schedulers.defaultPoolSize | 코어 수 | 8 | 컨테이너 CPU 제한이 코어 수와 다를 때 |
reactor.schedulers.defaultBoundedElasticSize | 코어 수 × 10 | 80 | 블로킹 동시 수가 상한을 넘을 때 |
reactor.schedulers.defaultBoundedElasticQueueSize | 스레드당 100,000 | 800만(전체) | 대기열을 짧게 끊고 빨리 실패시키고 싶을 때 |
reactor.schedulers.defaultBoundedElasticOnVirtualThreads | false | false | JDK 21 이상에서 가상 스레드로 돌릴 때(3.6.0+) |
reactor.bufferSize.small | 256(최소 16) | 256 | publishOn·flatMap 기본 버퍼를 줄일 때 |
reactor.netty.ioWorkerCount | max(코어 수, 4) | 8 | 이벤트 루프 수를 조정할 때 |
마지막 줄은 Reactor Netty 의 LoopResources 소스 기준이에요. boundedElastic 은 상한에 닿으면 기존 스레드의 대기열에 작업을 쌓고, 대기열마저 차면 RejectedExecutionException 을 던져요. 놀던 스레드는 60초 뒤 정리돼요.
boundedElastic() 은 앱 전체가 같이 쓰는 공용 인스턴스예요. 외부 API 하나가 느려져 80개를 다 쥐면 DB 호출까지 밀려요. 느린 의존성이 따로 있으면 Schedulers.newBoundedElastic(threadCap, queuedTaskCap, name) 으로 나눠요. 이름을 주면 스레드 덤프에서 jdbc-12 처럼 바로 구분돼요.
필요한 스레드 수 직접 계산하기, 리틀의 법칙
필요한 동시 수는 초당 요청 수 × 호출 하나가 스레드를 쥐는 시간으로 어림해요. 리틀의 법칙(평균 동시 수 = 도착률 × 체류 시간)이에요. 평균이 아니라 최대 순간 부하를 넣어요.
여기서 놓치기 쉬운 게 두 가지예요.
- 스레드가 남아도 커넥션이 모자라면 소용없어요. HikariCP README 기준
maximumPoolSize기본값은 10이고, 풀이 차면getConnection()이connectionTimeout동안 막혀요. 쿼리 하나가 50ms 면 처리량 상한은 10 ÷ 0.05 = 초당 200건이고, 스레드를 늘려도 그대로예요. - 풀 대기 시간도 스레드를 쥐는 시간이에요. 대기 30ms + 쿼리 50ms 면 계산에 넣을 시간은 80ms 예요.
그래서 커넥션 풀 크기를 먼저 정하고, flatMap 동시성을 그 이하로 묶고, 스레드 상한은 그보다 넉넉하게 둬요. 풀 크기 기준은 HikariCP 위키의 풀 크기 문서가 자세해요.
직접 재 본 flatMap 동시성별 처리 시간
블로킹 호출 160번을 flatMap(i -> Mono.fromCallable(...).subscribeOn(boundedElastic()), 동시성) 으로 돌리며 동시성만 바꿔 봤어요. 블로킹은 Thread.sleep(100) 으로 흉내 냈는데, 이 기기에서는 타이머 해상도 탓에 한 번이 평균 290ms 걸렸어요. 그래서 결과를 블로킹 한 번 시간(T)의 몇 배로 적었어요.
읽을 점은 셋이에요.
- 동시성 64까지는 이론대로 줄어요. 두 배로 올리면 시간이 절반이 됐어요. 블로킹 대기는 CPU 를 안 쓰니 코어 수보다 훨씬 많이 겹쳐도 돼요.
- 동시성 80은 이론값 2배가 아니라 3배였어요. 소스 주석대로 스레드가 다 찼을 때의 배치가 "최선 노력(best effort)"이라 일부 스레드에 작업이 겹쳤어요. 상한에 딱 맞춘 동시성은 기대만큼 안 나와요.
- 상한을 올리면 바로 풀려요. 같은 측정을 1,000번 호출·동시성 1,000으로 하면 기본 상한 80에서는 3.83초(약 13배)였고,
defaultBoundedElasticSize=1000으로 올리자 0.29~0.53초(1~1.8배)로 줄었어요.
가상 스레드 모드(defaultBoundedElasticOnVirtualThreads=true)도 재 봤어요. 스레드 이름이 loomBoundedElastic-N 으로 바뀌고 작업마다 새 가상 스레드가 생겼지만, 상한 80은 그대로라 1,000번 호출이 3.68~3.78초였어요. 상한을 1,000으로 같이 올려야 0.29~0.32초가 됐어요. 소스 주석도 이 모드에서는 상한을 올리라고 권해요.
스레드 전환 비용, 언제 무시하고 언제 커지나
스케줄러를 거는 게 공짜는 아니에요. 원소 1,000만 개를 map(i -> i + 1) 하나로 흘리며 일곱 번 중 가장 빠른 값을 적었어요.
subscribeOn은 거의 공짜예요. 구독을 한 번 넘길 뿐이라 원소당 5ns 정도 차이였어요.publishOn은 원소당 약 20ns 가 더 들어요. 큐를 거치기 때문인데, 1ms I/O 에 비하면 5만분의 1이라 무시해도 돼요. prefetch 256과 32는 차이가 작았어요.- 원소마다 boundedElastic 으로 넘기면 얘기가 달라요. 20만 개를
flatMap(i -> Mono.fromCallable(...).subscribeOn(boundedElastic()))으로 돌리니 1.35초(원소당 6,760ns), 전환 없이 돌리면 17ms(85ns)였어요. 80배 차이예요.
그래서 블로킹이 1ms 이상인 호출만 원소 단위로 넘기고, 가벼운 변환은 한 줄기로 둬요. 이 측정은 JIT 가 데운 뒤의 최솟값이라 실제 서비스에서는 더 커질 수 있어요.
운영에서 자주 나는 문제와 확인 순서
증상별로 먼저 볼 곳이에요. 위에서부터 확인해요.
- 모든 요청 지연이 같이 오른다 → 이벤트 루프 블로킹이 가장 흔해요.
jcmd <pid> Thread.print로 덤프를 떠서reactor-http-nio-N스택에 JDBC·소켓 읽기·Thread.sleep이 있는지 봐요. 8개 중 하나만 막혀도 그 스레드의 연결 전부가 멈춰요. subscribeOn을 걸었는데 로그에 nio 스레드가 찍힌다 → WebClient·R2DBC 같은 비동기 소스면 응답이 도착한 스레드에서 값이 나오니 정상이에요. 그 뒤 블로킹이 있으면 그 위에publishOn(boundedElastic())을 걸어요.Task capacity of bounded elastic scheduler reached오류 → 대기열이 찼어요. 스레드 2개·대기열 5개 스케줄러에 작업 20개를 넣자ReactorRejectedExecutionException이 이 메시지로 났어요. 상한보다flatMap동시성과 느려진 하류부터 봐요.Flux.interval·delayElements·timeout이 늦게 울린다 →parallel()블로킹을 의심해요. reactor-core Flux.java 소스대로 세 연산자는 기본으로Schedulers.parallel()을 써요. parallel 8개를 1초씩 막자 10ms 간격interval의 첫 신호가 1,197ms 뒤에 왔어요.- 커넥션 타임아웃이 난다 → 동시성이 풀보다 크면 나머지가 풀에서 기다리다 시간 초과가 나요. 동시성을 풀 크기 이하로 묶어요.
- 로그 추적 아이디(MDC)가 중간에 비어 있다 → 스레드가 바뀌면
ThreadLocal도 바뀌어요. Reactor 컨텍스트 전파 문서대로 3.5.0 부터io.micrometer:context-propagation을 지원해요. MDC 를 그 라이브러리에 등록하고 앱 시작 때Hooks.enableAutomaticContextPropagation()을 켜면Context값이ThreadLocal로 다시 채워져요. 훅은 켠 뒤의 새 구독에만 적용돼요.
1번은 테스트 단계에서 자동으로 잡는 게 좋아요. BlockHound는 논블로킹 스레드에서 블로킹 호출이 일어나면 오류를 던지는 자바 에이전트예요. BlockHound 빠른 시작 문서대로 JDK 13 이상에서는 -XX:+AllowRedefinitionToAddDeleteMethods JVM 옵션을 같이 줘야 해요. 테스트 JVM 에 에이전트를 붙일 때 나는 경고 처리는 Mockito 목 메이커 선택표와 javaagent 경고 해결 순서에서 다룬 방식과 같아요.
도입 전에 따질 것, boundedElastic 말고 다른 길
블로킹이 몇 군데뿐이면 감싸는 게 가장 싸고, 비중이 크면 구조부터 다시 봐요.
| 방법 | 맞는 경우 | 비용·한계 |
|---|---|---|
| boundedElastic 으로 감싸기 | 블로킹 호출이 일부 경로에만 있음 | 스레드 상한·커넥션 풀을 직접 맞춰야 함 |
| R2DBC 같은 논블로킹 드라이버 | DB 호출이 대부분이고 드라이버가 지원됨 | 기존 JPA 코드를 다시 써야 함 |
| 블로킹 전용 스케줄러 분리 | 느린 외부 의존성이 따로 있음 | 스케줄러마다 상한을 관리해야 함 |
| 스프링 MVC + 가상 스레드 | 서비스 대부분이 블로킹 I/O | 스트리밍·배압 같은 리액티브 기능을 포기 |
스프링 부트 작업 실행 문서대로 JDK 21 이상에서 spring.threads.virtual.enabled=true 를 주면 기본 작업 실행기가 가상 스레드를 써요. 웹플럭스를 쓴 이유가 스레드 절약뿐이었다면 블로킹 코드를 다 감싸는 것보다 이쪽이 단순할 수 있어요.
플랫폼 스레드로 상한을 크게 올릴 때는 메모리도 봐요. 이 기기의 OpenJDK 24 는 ThreadStackSize 기본값이 2,048KB 였어요. 실제로 쓴 만큼만 물리 메모리를 차지하지만, 스레드 1,000개면 예약 주소 공간만 2GB 예요.
자주 묻는 질문
boundedElastic 상한은 얼마로 잡아야 하나요?
최대 순간 부하의 "초당 요청 × 블로킹 시간"보다 조금 넉넉하게 잡아요. 그 값이 커넥션 풀보다 크면 늘려도 처리량은 그대로라, 풀과 flatMap 동시성을 먼저 정해요.
flatMap 기본 동시성은 몇인가요?
따로 주지 않으면 Queues.SMALL_BUFFER_SIZE, 기본 256이에요. 8코어 상한 80보다 커서 상한과 커넥션 풀이 병목이 돼요. 블로킹을 감싸는 flatMap 에는 숫자를 꼭 적어요.
가상 스레드 모드를 켜면 상한이 없어지나요?
아니에요. 3.8.8 에서 재 보니 작업마다 새 가상 스레드가 생겨도 동시 실행 수는 defaultBoundedElasticSize(코어 × 10)로 묶였어요. 이 속성도 같이 올려야 해요.
parallel 스케줄러에서 블로킹하면 어떻게 되나요?
코어 수만큼만 있어서 같은 풀을 쓰는 interval·delayElements·timeout 이 밀려요. 8개를 모두 막자 타이머 첫 신호가 1초 넘게 늦었어요. 블로킹은 boundedElastic 으로 보내요.
RejectedExecutionException 이 나면 대기열을 늘리면 되나요?
기본이 스레드당 10만 개라, 찼다면 하류가 이미 크게 밀린 거예요. 늘리면 오류 대신 지연이 길어질 뿐이에요. 오히려 대기열을 줄여 빨리 실패시키는 쪽이 나은 경우가 많아요.
정리
- 블로킹 호출은
Mono.fromCallable(...).subscribeOn(boundedElastic()), 여러 개면flatMap안쪽에 걸고 동시성 숫자를 꼭 적어요. 막지 않는 응답 처리에는 스케줄러를 걸지 않아요. - 실제 동시 처리 수는
flatMap동시성·boundedElastic 상한(코어 × 10)·커넥션 풀 중 가장 작은 값이에요. 필요한 수는 초당 요청 × 블로킹 시간으로 계산해요. - 상한에 딱 맞춘 동시성은 배치가 고르지 않아 이론보다 느렸고, 가상 스레드 모드도 상한을 같이 올려야 빨라졌어요.
- 장애 때는 이벤트 루프 → 비동기 소스 → 대기열 → parallel → 커넥션 풀 → 컨텍스트 순으로 보고, BlockHound 로 미리 잡아요.
- 응답 자체가 이상할 때의 확인 순서는 API 응답 디버깅 6단계에 정리해 뒀어요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 리액터 publishOn·subscribeOn 차이와 스레드 전환 원리에서 도식과 코드로 단계별로 풀었어요.