이번 시간에는 Spring WebFlux에서 블로킹 호출 몇 개가 상관없는 요청까지 멈추게 하는 현상을 Docker 위의 Spring Boot 앱과 PostgreSQL로 재현하며 배워가는 시간입니다. 원인인 이벤트 루프 스레드가 몇 개인지 확인하고, subscribeOn(Schedulers.boundedElastic())으로 격리했을 때를 비교해 볼게요.
예를 들어서 우리 WebFlux 서버에 바로 응답하는 /fast 엔드포인트가 있다고 해볼게요. 평소에는 0.02초 안팎에 답해요.
그런데 2초 동안 Thread.sleep을 하는 /sleep 요청 네 개를 동시에 보내자, 그 사이에 들어온 /fast가 최대 1.813초를 기다렸어요. /fast는 /sleep과 코드를 하나도 공유하지 않는데도요.
실습 준비
이 글의 명령은 모두 실습 코드의 webflux-blocking/ 디렉터리에서 실행합니다.
compose.yaml: 앱(app), PostgreSQL(db), 부하 도구(load) 서비스env.sh: 빌드·실행·부하·확인 명령을 줄인 셸 함수 모음app/: Spring Boot 앱(build.gradle,settings.gradle,App.java,application.properties)load/: 부하 도구 이미지(Dockerfile)와load.py,get.py
앱 하나에 엔드포인트 일곱 개를 넣고, 모드는 환경 변수와 JVM 옵션으로 바꿔요. 그래서 빌드는 한 번이면 됩니다.
호스트에 JDK나 Gradle을 설치하지 않아도 돼요. gradle:9.8.0-jdk21-noble 이미지 하나로 빌드도 하고 실행도 합니다.
app/build.gradle은 Spring Boot 4.1.1 플러그인과 WebFlux, JDBC, R2DBC 스타터, BlockHound 1.0.17, PostgreSQL JDBC·R2DBC 드라이버를 선언해요.
앱 코드는 app/src/main/java/hong/wflux/App.java 하나예요. 모든 엔드포인트는 자기를 처리한 스레드 이름을 응답으로 돌려줍니다.
엔드포인트별로 하는 일은 이래요.
/fast는 아무것도 기다리지 않고 바로 답해요./sleep?ms=는 요청을 처리하는 스레드에서Thread.sleep을 해요./jdbc?s=는JdbcTemplate으로SELECT pg_sleep(?)를 실행해요.Mono.fromCallable로 감쌌을 뿐 스레드를 바꾸지 않습니다./jdbc-isolated?s=는 같은 호출에subscribeOn(Schedulers.boundedElastic())을 붙였어요./jdbc-publish?s=는Mono.just(s).map(...)안에서 같은 호출을 하고, 그 뒤에publishOn(Schedulers.boundedElastic())을 붙였어요.publishOn앞뒤의 스레드 이름을 같이 돌려줍니다./callable-publish?s=는Mono.fromCallable(...)바로 뒤에publishOn을 붙였어요. 역시 앞뒤 스레드 이름을 돌려줍니다./r2dbc?s=는 R2DBC(스레드를 멈추지 않는 DB 드라이버 규격)의DatabaseClient로 같은 쿼리를 보내요.
main은 시작할 때 CPU 수와 이벤트 루프 수, boundedElastic 기본 한도를 출력해요. 그리고 환경 변수 BLOCKHOUND=1일 때만 BlockHound(블로킹 호출을 실행 중에 찾아 주는 Java 에이전트)를 켭니다. /jdbc, /r2dbc와 BlockHound는 이 글에서는 쓰지 않아요.
compose.yaml에는 app, db, load 서비스가 있어요. 앱은 CPU 2개, 메모리 768MB로 제한하고 힙은 384MB로 둡니다.
컨테이너 메모리 제한을 넘으면 프로세스가 어떻게 끝나는지 더 궁금하시면 컨테이너 메모리 제한을 다룬 글을 참고해 주세요. 실행 조건은 CPUS, BLOCKHOUND, JAVA_OPTS 환경 변수로 바꿔요.
부하 도구 load.py는 블로킹 요청을 동시에 --k개 보내고, 0.3초 뒤부터 /fast를 --interval 간격으로 --fast번 보내요. 요청마다 새 연결을 엽니다. 요청별로 보낸 시간, 걸린 시간, 응답한 스레드 이름을 찍고, 마지막에 중앙값(p50)과 최댓값, 스레드 이름별 개수를 출력해요.
get.py는 경로 몇 개에 요청을 한 번씩 보내고 결과를 한 줄씩 찍어요.
셸 함수는 env.sh에 모아 두었어요. compose 프로젝트 이름은 wflux로 정했습니다.
build: gradle 이미지로 jar를 만들어요. Gradle이 받은 의존성은 이름 있는 볼륨wflux-gradle에 남아서, 코드만 바꾼 재빌드는 다시 받지 않아요.up: 앱 컨테이너를 다시 만들고 시작 로그를 기다린 뒤, 시작할 때 출력한 값을 보여 줘요. 처음 요청들은 JIT 컴파일 전이라 느리니까/fast50개를 먼저 보내고 결과는 버립니다.load,get: 부하 도구를 실행해요.threads:jcmd로 앱 JVM의 스레드 이름을 뽑아요. 앱은 컨테이너의 PID 1입니다.
이제 이미지를 만들고, DB를 띄우고, 앱을 빌드해 볼게요.
source ./env.sh
docker compose build load
docker compose up -d db
build
ls app/build/libs
app.jar
실습 1: 이벤트 루프 스레드는 몇 개인가
WebFlux의 기본 서버인 Reactor Netty는 요청마다 스레드를 만들지 않아요. 몇 개 안 되는 이벤트 루프 스레드가 여러 연결을 나눠 맡고, 각 루프는 자기가 맡은 연결에서 읽을 데이터가 생기면 그 요청을 처리해요.
Spring MVC의 Tomcat이 요청 하나에 작업 스레드 하나를 붙이는 것과는 다른 방식이죠.
루프가 몇 개인지 직접 세어 볼게요. 컨테이너 CPU 제한을 2, 4, 6으로 바꿔 가며 앱을 띄우고, /fast를 동시에 50개 보내 응답한 스레드 이름을 셉니다. 마지막에는 jcmd로 JVM 안의 reactor-http 스레드를 전부 뽑아요.
for c in 2 4 6; do
echo "== CPUS=$c"
CPUS=$c up
load --fast 50 --interval 0 --quiet
threads 'reactor-http'
done
== CPUS=2
availableProcessors=2 ioWorkerCount=4 boundedElasticSize=20 boundedElasticQueueSize=100000
hikari maximumPoolSize=10
fast n=50 p50=0.173s max=0.332s
fast 스레드 4종: reactor-http-nio-1=13, reactor-http-nio-2=12, reactor-http-nio-3=12, reactor-http-nio-4=13
reactor-http-nio-1
reactor-http-nio-2
reactor-http-nio-3
reactor-http-nio-4
== CPUS=4
availableProcessors=4 ioWorkerCount=4 boundedElasticSize=40 boundedElasticQueueSize=100000
hikari maximumPoolSize=10
fast n=50 p50=0.187s max=0.363s
fast 스레드 4종: reactor-http-nio-1=13, reactor-http-nio-2=12, reactor-http-nio-3=12, reactor-http-nio-4=13
reactor-http-nio-1
reactor-http-nio-2
reactor-http-nio-3
reactor-http-nio-4
== CPUS=6
availableProcessors=6 ioWorkerCount=6 boundedElasticSize=60 boundedElasticQueueSize=100000
hikari maximumPoolSize=10
fast n=50 p50=0.175s max=0.334s
fast 스레드 6종: reactor-http-nio-1=8, reactor-http-nio-2=8, reactor-http-nio-3=8, reactor-http-nio-4=9, reactor-http-nio-5=9, reactor-http-nio-6=8
reactor-http-nio-1
reactor-http-nio-2
reactor-http-nio-3
reactor-http-nio-4
reactor-http-nio-5
reactor-http-nio-6
출력을 보면 동시 요청 50개를 스레드 4개나 6개가 나눠 처리했어요. JVM 안에도 reactor-http-nio 스레드는 그만큼만 있고요. 숫자가 정해지는 규칙은 이래요.
availableProcessors: JVM이 보는 CPU 수예요. 컨테이너의 CPU 제한을 읽어 정해요. CPU 제한이 지연에 주는 영향이 더 궁금하시면 컨테이너 CPU 제한을 다룬 글을 참고해 주세요.ioWorkerCount: 이벤트 루프 수예요. Reactor Netty는 CPU 수와 4 중 큰 값을 써요. 그래서 CPU 2개와 4개에서는 똑같이 4개였고, 6개에서 6개가 됐어요. 시스템 속성reactor.netty.ioWorkerCount로 바꿀 수 있습니다.boundedElasticSize,boundedElasticQueueSize: 뒤에서 쓸 boundedElastic 스케줄러의 스레드 수 한도(CPU 수 × 10)와 스레드당 대기열 한도(100,000)예요.
요청 수가 아무리 늘어도 처리하는 스레드는 이 몇 개뿐이에요. 그래서 이 구조는 루프가 일을 오래 붙잡지 않는다는 전제에서만 잘 돌아가요.
Redis도 이벤트 루프 하나가 명령을 차례로 실행하기 때문에, 긴 명령 하나가 다른 클라이언트를 모두 기다리게 해요. 그 모습이 더 궁금하시면 Redis가 빠른 이유를 다룬 글을 참고해 주세요.
실습 2: 블로킹 호출 하나가 루프를 붙잡습니다
블로킹 호출은 결과가 나올 때까지 호출한 스레드를 멈춰 세우는 호출이에요. Thread.sleep, JDBC 쿼리, 파일 읽기, 블로킹 HTTP 클라이언트 호출이 여기에 들어가요.
이벤트 루프에서 이런 호출을 하면, 그동안 그 루프가 맡은 다른 연결의 요청도 처리되지 못해요.
여기부터는 CPU 2개, 이벤트 루프 4개로 실험할게요. 먼저 비교군이에요. 다른 부하 없이 /fast를 0.2초 간격으로 10번 보냅니다.
up
load --fast 10
availableProcessors=2 ioWorkerCount=4 boundedElasticSize=20 boundedElasticQueueSize=100000
hikari maximumPoolSize=10
fast 1 start=0.00s took=0.049s 200 reactor-http-nio-1
fast 2 start=0.20s took=0.010s 200 reactor-http-nio-4
fast 3 start=0.40s took=0.016s 200 reactor-http-nio-2
fast 4 start=0.60s took=0.011s 200 reactor-http-nio-3
fast 5 start=0.80s took=0.023s 200 reactor-http-nio-1
fast 6 start=1.01s took=0.019s 200 reactor-http-nio-4
fast 7 start=1.21s took=0.014s 200 reactor-http-nio-2
fast 8 start=1.41s took=0.017s 200 reactor-http-nio-3
fast 9 start=1.60s took=0.019s 200 reactor-http-nio-1
fast 10 start=1.81s took=0.019s 200 reactor-http-nio-4
fast n=10 p50=0.018s max=0.049s
fast 스레드 4종: reactor-http-nio-1=3, reactor-http-nio-2=2, reactor-http-nio-3=2, reactor-http-nio-4=3
모두 0.05초 안에 끝났어요. 세 번 반복했을 때 최댓값은 0.049~0.291초였어요. 0.29초가 한 번 나온 건 노트북 Docker 환경의 흔들림으로 보고, 중앙값은 0.018~0.038초였습니다.
이번에는 2초 동안 잠드는 /sleep 요청 4개를 동시에 보내고, 0.3초 뒤부터 같은 방식으로 /fast를 보내 볼게요. 4는 이벤트 루프 수와 같은 숫자예요.
load --block '/sleep?ms=2000' --k 4
block 1 start=0.00s took=2.098s 200 reactor-http-nio-3
block 2 start=0.04s took=2.056s 200 reactor-http-nio-2
block 3 start=0.05s took=2.050s 200 reactor-http-nio-1
block 4 start=0.05s took=2.045s 200 reactor-http-nio-4
fast 1 start=0.30s took=1.813s 200 reactor-http-nio-2
fast 2 start=0.50s took=1.614s 200 reactor-http-nio-3
fast 3 start=0.70s took=1.409s 200 reactor-http-nio-1
fast 4 start=0.90s took=1.212s 200 reactor-http-nio-4
fast 5 start=1.10s took=1.014s 200 reactor-http-nio-2
fast 6 start=1.30s took=0.814s 200 reactor-http-nio-3
fast 7 start=1.50s took=0.615s 200 reactor-http-nio-1
fast 8 start=1.70s took=0.414s 200 reactor-http-nio-4
fast 9 start=1.90s took=0.215s 200 reactor-http-nio-2
fast 10 start=2.10s took=0.018s 200 reactor-http-nio-3
block n=4 p50=2.053s max=2.098s
block 스레드 4종: reactor-http-nio-1=1, reactor-http-nio-2=1, reactor-http-nio-3=1, reactor-http-nio-4=1
fast n=10 p50=0.914s max=1.813s
fast 스레드 4종: reactor-http-nio-1=2, reactor-http-nio-2=3, reactor-http-nio-3=3, reactor-http-nio-4=2
block 전체 완료 2.098s
출력을 보면 /sleep 4개가 reactor-http-nio-1부터 -4까지 하나씩 차지했어요. 그 뒤에 들어온 /fast는 어느 루프에 붙든 앞의 /sleep이 끝날 때까지 기다렸고요.
0.3초에 보낸 첫 번째 /fast는 1.813초가 걸렸고, 2.10초에 보낸 마지막 /fast만 0.018초에 끝났어요.
여기서 눈여겨볼 건, 응답 시간을 보낸 시간에 더하면 모두 2.1초 근처라는 점이에요. /sleep이 끝나는 순간 기다리던 요청이 한꺼번에 처리된 거죠.
세 번 반복한 결과를 모으면 이래요.
- 비교군:
/fast최댓값 0.049~0.291초 /sleep4개:/fast최댓값 1.800~1.827초,/sleep전체 완료 2.093~2.131초
블로킹 요청을 루프 수만큼만 보내도 서버 전체가 블로킹 시간만큼 응답하지 못했어요. 요청은 몇 개 되지 않았지만 루프 네 개가 모두 잠들어 있었던 거예요.
작업 스레드 수가 정해진 서버라면 어디서나 같은 모양이 생겨요. 다만 WebFlux는 처음부터 스레드가 적어서 블로킹 요청 몇 개로도 이 상태가 됩니다.
gRPC 서버에서 작업 스레드 4개가 느린 하위 호출을 기다리느라 상관없는 요청까지 늦어지는 모습이 더 궁금하시면 gRPC 데드라인을 다룬 글을 참고해 주세요.
실습 3: boundedElastic으로 격리하기
JDBC처럼 블로킹 API밖에 없는 라이브러리를 바로 바꿀 수 없을 때는, 블로킹 호출을 이벤트 루프가 아닌 다른 스레드에서 실행하게 옮겨요.
Reactor는 이런 용도로 Schedulers.boundedElastic()을 제공해요. 필요할 때 스레드를 만들고, 한동안 쓰지 않은 스레드는 정리하며, 스레드 수와 대기열에 한도가 있는 스케줄러예요.
스레드를 옮기는 연산자는 두 가지예요.
subscribeOn(스케줄러): 구독을 그 스케줄러에서 시작해요. 그래서 체인 맨 앞의 원천, 여기서는fromCallable의 람다가 그 스케줄러 스레드에서 실행돼요. 체인의 어디에 붙여도 원천에 영향을 줍니다.publishOn(스케줄러): 그 아래에 있는 연산자부터 그 스케줄러 스레드에서 실행해요. 위쪽에서 이미 일어난 블로킹 호출은 옮기지 못합니다.
세 가지 코드를 한 번씩 불러 볼게요. /jdbc-publish와 /callable-publish는 JDBC 호출을 한 스레드와 publishOn 뒤의 map을 실행한 스레드를 ->로 이어서 돌려줘요.
up
get '/jdbc-isolated?s=0.1' '/jdbc-publish?s=0.1' '/callable-publish?s=0.1'
availableProcessors=2 ioWorkerCount=4 boundedElasticSize=20 boundedElasticQueueSize=100000
hikari maximumPoolSize=10
/jdbc-isolated?s=0.1 200 boundedElastic-1
/jdbc-publish?s=0.1 200 reactor-http-nio-4 -> boundedElastic-1
/callable-publish?s=0.1 200 boundedElastic-1 -> boundedElastic-1
/jdbc-isolated:subscribeOn덕분에 JDBC 호출이boundedElastic-1에서 실행됐어요./jdbc-publish:Mono.just(s).map(JDBC 호출)뒤에publishOn을 붙였어요. JDBC 호출은 여전히reactor-http-nio-4에서 실행됐고, 그 아래map만 boundedElastic으로 넘어갔어요./callable-publish:Mono.fromCallable(JDBC 호출)바로 뒤에publishOn을 붙였는데, JDBC 호출이 boundedElastic에서 실행됐어요. Reactor Core 3.8.7의Mono.publishOn은 바로 위가Callable이면subscribeOn과 같은 동작으로 바꿔서 조립하거든요. 결과는 맞게 나오지만 이 최적화에 기대지 말고, 블로킹 호출 격리에는subscribeOn을 써 주세요.
이제 /jdbc-isolated?s=2를 8개 보내면서 /fast를 재 볼게요.
load --block '/jdbc-isolated?s=2' --k 8
block 1 start=0.00s took=2.226s 200 boundedElastic-8
block 2 start=0.06s took=2.167s 200 boundedElastic-4
block 3 start=0.07s took=2.138s 200 boundedElastic-1
block 4 start=0.08s took=2.154s 200 boundedElastic-3
block 5 start=0.08s took=2.147s 200 boundedElastic-2
block 6 start=0.09s took=2.131s 200 boundedElastic-7
block 7 start=0.11s took=2.113s 200 boundedElastic-6
block 8 start=0.12s took=2.119s 200 boundedElastic-5
fast 1 start=0.30s took=0.018s 200 reactor-http-nio-3
fast 2 start=0.50s took=0.021s 200 reactor-http-nio-1
fast 3 start=0.71s took=0.015s 200 reactor-http-nio-4
fast 4 start=0.90s took=0.016s 200 reactor-http-nio-2
fast 5 start=1.10s took=0.018s 200 reactor-http-nio-3
fast 6 start=1.30s took=0.017s 200 reactor-http-nio-1
fast 7 start=1.51s took=0.022s 200 reactor-http-nio-4
fast 8 start=1.70s took=0.017s 200 reactor-http-nio-2
fast 9 start=1.90s took=0.017s 200 reactor-http-nio-3
fast 10 start=2.10s took=0.017s 200 reactor-http-nio-1
block n=8 p50=2.143s max=2.226s
block 스레드 8종: boundedElastic-1=1, boundedElastic-2=1, boundedElastic-3=1, boundedElastic-4=1, boundedElastic-5=1, boundedElastic-6=1, boundedElastic-7=1, boundedElastic-8=1
fast n=10 p50=0.017s max=0.022s
fast 스레드 4종: reactor-http-nio-1=3, reactor-http-nio-2=2, reactor-http-nio-3=3, reactor-http-nio-4=2
block 전체 완료 2.235s
JDBC 호출은 boundedElastic-1부터 -8까지 8개 스레드에 하나씩 실렸고, /fast는 최대 0.022초로 비교군과 같은 수준이에요. 세 번 반복한 /fast 최댓값은 0.022~0.032초였어요.
루프는 JDBC 호출을 넘기고 바로 다음 요청을 처리해요. 8개 요청도 루프가 비기를 기다리지 않고 모두 2초대에 끝났습니다.
정리
범위: Spring Boot 4.1.1, JDK 21, Reactor Netty 1.3.7, 앱 컨테이너 CPU 2개(이벤트 루프 4개), PostgreSQL 17의 pg_sleep, 조건마다 3번 반복.
- 이벤트 루프는 CPU 2개와 4개에서 4개, 6개에서 6개였고, 동시 요청 50개도 이 스레드들이 나눠 처리했어요. 이벤트 루프 수는 CPU 수와 4 중 큰 값으로 보고 블로킹 호출의 영향을 가늠해 보세요.
- 2초짜리
/sleep을 루프 수와 같은 4개 보내자/fast최댓값이 0.049~0.291초에서 1.800~1.827초로 늘었어요. 이벤트 루프에서는 블로킹 호출을 하지 마세요. publishOn을 뒤에 붙인/jdbc-publish는 JDBC 호출이reactor-http-nio-4에 남았고,subscribeOn을 붙인/jdbc-isolated8개는/fast최댓값이 0.022~0.032초였어요. 블로킹 호출 격리에는subscribeOn(Schedulers.boundedElastic())을 쓰세요.