광고
~ / posts / developer

리액터 스케줄러 고르는 법, boundedElastic 크기와 flatMap 동시성 계산

웹플럭스에서 블로킹 호출을 감쌀 때 실제 동시 처리 수는 flatMap 동시성·boundedElastic 상한(코어×10)·커넥션 풀 중 가장 작은 값이에요. 판단표, 리틀의 법칙으로 스레드 수 계산, 직접 잰 전환 비용과 장애 확인 순서를 정리했어요.

웹플럭스 서비스에 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(...) 바로 뒤 subscribeOnboundedElastic()커넥션 풀 크기
블로킹 호출 여러 번을 동시에flatMap 안쪽 Mono 에 subscribeOnboundedElastic()flatMap 동시성
이미 받은 값을 블로킹 API 로 처리그 연산자 바로 위 publishOnboundedElastic()prefetch(기본 256)
무거운 CPU 계산 한 줄기계산 위 publishOnparallel()없음
CPU 계산을 진짜 병렬로parallel(n).runOn(...)parallel()레일 수 n
WebClient·R2DBC 응답 처리걸지 않음이벤트 루프 그대로없음

가장 자주 틀리는 칸은 WebClient·R2DBC 줄이에요. 막지 않는 응답 처리에 스케줄러를 걸면 전환 비용만 더해요. 스프링 웹플럭스 동시성 모델 문서도 블로킹 API 를 꼭 써야 할 때만 publishOn 으로 다른 풀로 옮기라고 안내해요.

블로킹 여부, 블로킹 코드의 위치, CPU 계산의 병렬 필요 여부를 차례로 물어 subscribeOn boundedElastic, publishOn boundedElastic, publishOn parallel, parallel runOn, 아무것도 걸지 않음 중 하나로 가는 판단 순서도
위에서부터 물어 처음 '예'가 나온 칸의 방법을 써요

boundedElastic 기본값과 바꿀 수 있는 설정

Schedulers 의 기본값은 모두 JVM 시스템 속성으로 바꿀 수 있어요. 아래 값은 reactor-core 3.8.8 의 Schedulers.java 소스에서 읽고, 8코어 기기에서 실제 상수를 출력해 맞춰 봤어요.

설정(시스템 속성)기본값8코어 값바꿀 때
reactor.schedulers.defaultPoolSize코어 수8컨테이너 CPU 제한이 코어 수와 다를 때
reactor.schedulers.defaultBoundedElasticSize코어 수 × 1080블로킹 동시 수가 상한을 넘을 때
reactor.schedulers.defaultBoundedElasticQueueSize스레드당 100,000800만(전체)대기열을 짧게 끊고 빨리 실패시키고 싶을 때
reactor.schedulers.defaultBoundedElasticOnVirtualThreadsfalsefalseJDK 21 이상에서 가상 스레드로 돌릴 때(3.6.0+)
reactor.bufferSize.small256(최소 16)256publishOn·flatMap 기본 버퍼를 줄일 때
reactor.netty.ioWorkerCountmax(코어 수, 4)8이벤트 루프 수를 조정할 때

마지막 줄은 Reactor Netty 의 LoopResources 소스 기준이에요. boundedElastic 은 상한에 닿으면 기존 스레드의 대기열에 작업을 쌓고, 대기열마저 차면 RejectedExecutionException 을 던져요. 놀던 스레드는 60초 뒤 정리돼요.

boundedElastic() 은 앱 전체가 같이 쓰는 공용 인스턴스예요. 외부 API 하나가 느려져 80개를 다 쥐면 DB 호출까지 밀려요. 느린 의존성이 따로 있으면 Schedulers.newBoundedElastic(threadCap, queuedTaskCap, name) 으로 나눠요. 이름을 주면 스레드 덤프에서 jdbc-12 처럼 바로 구분돼요.

필요한 스레드 수 직접 계산하기, 리틀의 법칙

필요한 동시 수는 초당 요청 수 × 호출 하나가 스레드를 쥐는 시간으로 어림해요. 리틀의 법칙(평균 동시 수 = 도착률 × 체류 시간)이에요. 평균이 아니라 최대 순간 부하를 넣어요.

요청률과 블로킹 시간별 필요한 동시 스레드 수 (8코어 기본 상한 80)
초당 100건 0.05초초당 100건 0.05초 · 5개5개초당 200건 0.1초초당 200건 0.1초 · 20개20개초당 300건 0.2초초당 300건 0.2초 · 60개60개초당 500건 0.2초초당 500건 0.2초 · 100개100개초당 1000건 0.3초초당 1000건 0.3초 · 300개300개

100과 300은 기본 상한 80을 넘어서 대기열에 쌓이고 지연이 늘어요

단위: 개 · 자료: 리틀의 법칙으로 직접 계산 (동시 수 = 초당 요청 × 블로킹 시간)
표로 보기
구분필요한 동시 수
초당 100건 0.05초5
초당 200건 0.1초20
초당 300건 0.2초60
초당 500건 0.2초100
초당 1000건 0.3초300

여기서 놓치기 쉬운 게 두 가지예요.

  1. 스레드가 남아도 커넥션이 모자라면 소용없어요. HikariCP README 기준 maximumPoolSize 기본값은 10이고, 풀이 차면 getConnection() 이 connectionTimeout 동안 막혀요. 쿼리 하나가 50ms 면 처리량 상한은 10 ÷ 0.05 = 초당 200건이고, 스레드를 늘려도 그대로예요.
  2. 풀 대기 시간도 스레드를 쥐는 시간이에요. 대기 30ms + 쿼리 50ms 면 계산에 넣을 시간은 80ms 예요.

그래서 커넥션 풀 크기를 먼저 정하고, flatMap 동시성을 그 이하로 묶고, 스레드 상한은 그보다 넉넉하게 둬요. 풀 크기 기준은 HikariCP 위키의 풀 크기 문서가 자세해요.

flatMap 동시성 256, boundedElastic 상한 80, 커넥션 풀 10 세 단계를 깔때기처럼 그려 실제 동시 처리 수가 가장 작은 값인 10이 되고 처리량은 10 나누기 쿼리 시간 0.05초로 초당 200건이 되는 계산 분해도
세 숫자 중 가장 작은 값이 동시 처리 수를 정해요

직접 재 본 flatMap 동시성별 처리 시간

블로킹 호출 160번을 flatMap(i -> Mono.fromCallable(...).subscribeOn(boundedElastic()), 동시성) 으로 돌리며 동시성만 바꿔 봤어요. 블로킹은 Thread.sleep(100) 으로 흉내 냈는데, 이 기기에서는 타이머 해상도 탓에 한 번이 평균 290ms 걸렸어요. 그래서 결과를 블로킹 한 번 시간(T)의 몇 배로 적었어요.

블로킹 호출 160번, flatMap 동시성별 걸린 시간 (블로킹 1회 = 1)
이론값측정값
0510152025동시성 8 · 이론값 20배동시성 8 · 측정값 20.2배동시성 8동시성 16 · 이론값 10배동시성 16 · 측정값 10.2배동시성 16동시성 32 · 이론값 5배동시성 32 · 측정값 5.1배동시성 32동시성 64 · 이론값 3배동시성 64 · 측정값 3배동시성 64동시성 80 · 이론값 2배동시성 80 · 측정값 3배동시성 80동시성 256 · 이론값 2배동시성 256 · 측정값 2.1배동시성 256

이론값은 160 ÷ min(동시성, 80)을 올림한 값이에요. 동시성 1은 161.1배였어요

단위: 배 · 자료: reactor-core 3.8.8, OpenJDK 24, Apple M1 8코어에서 직접 측정 (2026-10-27)
표로 보기
구분이론값측정값
동시성 82020.2
동시성 161010.2
동시성 3255.1
동시성 6433
동시성 8023
동시성 25622.1

읽을 점은 셋이에요.

가상 스레드 모드(defaultBoundedElasticOnVirtualThreads=true)도 재 봤어요. 스레드 이름이 loomBoundedElastic-N 으로 바뀌고 작업마다 새 가상 스레드가 생겼지만, 상한 80은 그대로라 1,000번 호출이 3.68~3.78초였어요. 상한을 1,000으로 같이 올려야 0.29~0.32초가 됐어요. 소스 주석도 이 모드에서는 상한을 올리라고 권해요.

스레드 전환 비용, 언제 무시하고 언제 커지나

스케줄러를 거는 게 공짜는 아니에요. 원소 1,000만 개를 map(i -> i + 1) 하나로 흘리며 일곱 번 중 가장 빠른 값을 적었어요.

원소 하나당 처리 시간, 스레드 전환 방식별 (원소 1000만 개)
01020304050전환 없음 · 원소당 시간 17.8ns17.8전환 없음subscribeOn · 원소당 시간 22.7ns22.7subscribeOnpublishOn 256 · 원소당 시간 38.4ns38.4publishOn 256publishOn 32 · 원소당 시간 40ns40publishOn 32publishOn 1 · 원소당 시간 46.4ns46.4publishOn 1

publishOn 뒤 숫자는 prefetch예요. 1은 원소마다 위로 request를 보내요

단위: ns · 자료: reactor-core 3.8.8, OpenJDK 24, Apple M1에서 직접 측정 (2026-10-27)
표로 보기
구분원소당 시간
전환 없음17.8
subscribeOn22.7
publishOn 25638.4
publishOn 3240
publishOn 146.4

그래서 블로킹이 1ms 이상인 호출만 원소 단위로 넘기고, 가벼운 변환은 한 줄기로 둬요. 이 측정은 JIT 가 데운 뒤의 최솟값이라 실제 서비스에서는 더 커질 수 있어요.

광고
광고

운영에서 자주 나는 문제와 확인 순서

증상별로 먼저 볼 곳이에요. 위에서부터 확인해요.

  1. 모든 요청 지연이 같이 오른다 → 이벤트 루프 블로킹이 가장 흔해요. jcmd <pid> Thread.print 로 덤프를 떠서 reactor-http-nio-N 스택에 JDBC·소켓 읽기·Thread.sleep 이 있는지 봐요. 8개 중 하나만 막혀도 그 스레드의 연결 전부가 멈춰요.
  2. subscribeOn 을 걸었는데 로그에 nio 스레드가 찍힌다 → WebClient·R2DBC 같은 비동기 소스면 응답이 도착한 스레드에서 값이 나오니 정상이에요. 그 뒤 블로킹이 있으면 그 위에 publishOn(boundedElastic()) 을 걸어요.
  3. Task capacity of bounded elastic scheduler reached 오류 → 대기열이 찼어요. 스레드 2개·대기열 5개 스케줄러에 작업 20개를 넣자 ReactorRejectedExecutionException 이 이 메시지로 났어요. 상한보다 flatMap 동시성과 느려진 하류부터 봐요.
  4. Flux.interval·delayElements·timeout 이 늦게 울린다 → parallel() 블로킹을 의심해요. reactor-core Flux.java 소스대로 세 연산자는 기본으로 Schedulers.parallel() 을 써요. parallel 8개를 1초씩 막자 10ms 간격 interval 의 첫 신호가 1,197ms 뒤에 왔어요.
  5. 커넥션 타임아웃이 난다 → 동시성이 풀보다 크면 나머지가 풀에서 기다리다 시간 초과가 나요. 동시성을 풀 크기 이하로 묶어요.
  6. 로그 추적 아이디(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만 개라, 찼다면 하류가 이미 크게 밀린 거예요. 늘리면 오류 대신 지연이 길어질 뿐이에요. 오히려 대기열을 줄여 빨리 실패시키는 쪽이 나은 경우가 많아요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 리액터 publishOn·subscribeOn 차이와 스레드 전환 원리에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#리액터 스케줄러 선택#boundedElastic 설정#flatMap 동시성#웹플럭스 블로킹 호출#publishOn subscribeOn 비용#RejectedExecutionException#리액터 가상 스레드#BlockHound
← 이전 글프롬프트 캐싱 비용 계산, TTL 고르는 법과 API·vLLM 비교다음 글 →스프링 배치 파티셔닝 설정 고르는 법, gridSize·스레드·커넥션 풀 계산
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →