- Published on
Spring Boot 3에서 Reactor 블로킹 탐지·튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 WebFlux인데도 p99 지연이 튀고, 간헐적으로 reactor-http-nio-* 스레드가 꽉 차며 타임아웃이 발생한다면 가장 먼저 의심해야 할 건 Reactor 이벤트 루프에서의 블로킹입니다. Spring Boot 3는 관측(Observability)과 스레딩 옵션이 좋아졌지만, 블로킹이 섞이는 순간 “논블로킹 아키텍처”의 이점은 빠르게 사라집니다.
이 글에서는 Spring Boot 3에서 Reactor 블로킹을 탐지하고, 원인을 분류한 뒤, 코드/설정/운영 관점에서 튜닝하는 방법을 정리합니다.
1) Reactor에서 블로킹이 치명적인 이유
Reactor 기반 WebFlux의 핵심은 소수의 이벤트 루프 스레드(Netty reactor-http-nio-*)로 많은 요청을 처리하는 것입니다. 이 스레드는 “작업을 오래 잡고 있지 않고” 이벤트를 빠르게 처리해야 합니다.
그런데 다음 같은 호출이 이벤트 루프에서 실행되면 문제가 생깁니다.
- JDBC, JPA 같은 블로킹 DB 호출
- 파일 I/O (
Files.readAllBytes,FileInputStream등) - DNS 조회, 동기 HTTP 클라이언트(
RestTemplate) 호출 Thread.sleep,LockSupport.park, 과도한synchronized경합
이벤트 루프가 막히면 해당 루프가 담당하던 커넥션들의 read/write가 지연되고, 결국 지연이 전파되어 전체 처리량이 떨어지고 tail latency가 증가합니다.
2) 블로킹 탐지 1순위: BlockHound 적용
2.1 BlockHound란
BlockHound는 Reactor 생태계에서 가장 널리 쓰이는 블로킹 탐지 도구로, “이 스레드에서 블로킹 호출을 하면 예외를 던져라”를 강제합니다.
Spring Boot 3 + WebFlux에서는 테스트 단계에서라도 BlockHound를 켜두면 블로킹이 섞이는 순간 바로 잡아낼 수 있습니다.
2.2 의존성 추가
dependencies {
testImplementation "io.projectreactor.tools:blockhound:1.0.8.RELEASE"
}
2.3 JUnit에서 설치
테스트 시작 시 1회 설치하는 패턴이 일반적입니다.
import org.junit.jupiter.api.BeforeAll;
import reactor.blockhound.BlockHound;
class BlockHoundTestSupport {
@BeforeAll
static void install() {
BlockHound.install();
}
}
이후 WebFlux 핸들러/서비스 테스트를 돌리다가 이벤트 루프에서 블로킹이 발생하면 예외 스택트레이스로 위치를 확인할 수 있습니다.
2.4 운영에서 바로 켜도 될까
운영에서 항상 켜는 것은 팀의 성숙도에 따라 다릅니다. BlockHound는 런타임 오버헤드/호환성 이슈가 있을 수 있어 보통은 다음 전략을 권합니다.
- 개발/테스트 환경: 상시 활성화
- 스테이징: 성능 테스트 시 활성화
- 운영: 특정 기간/특정 인스턴스에서 제한적으로 활성화
3) 블로킹의 대표 패턴과 교정법
여기서부터는 “탐지” 이후 실제로 많이 걸리는 패턴을 분류하고, 어떤 튜닝이 효과적인지 정리합니다.
3.1 map 안에서 블로킹 호출
가장 흔한 실수입니다.
Mono<UserDto> mono = userIdMono
.map(id -> jdbcTemplate.queryForObject("select ...", mapper, id));
map은 현재 스레드에서 즉시 실행됩니다. 즉, 이 코드가 WebFlux 요청 처리 중이라면 이벤트 루프에서 JDBC가 실행될 수 있습니다.
해결: boundedElastic로 격리
Mono<UserDto> mono = userIdMono
.publishOn(Schedulers.boundedElastic())
.map(id -> jdbcTemplate.queryForObject("select ...", mapper, id));
publishOn이후 연산을 지정 스케줄러로 옮깁니다.- 블로킹 I/O는 보통
Schedulers.boundedElastic()가 기본 선택입니다.
주의할 점은 “무조건 elastic으로 감싸기”가 아니라, 블로킹이 불가피한 경계에서만 격리해야 한다는 것입니다. 남발하면 컨텍스트 스위칭과 큐잉이 늘어납니다.
3.2 flatMap에서 동기 HTTP 호출
Mono<Order> mono = Mono.just(orderId)
.flatMap(id -> Mono.just(restTemplate.getForObject(url, Order.class)));
형태는 Mono인데 실제로는 즉시 블로킹 호출입니다.
해결: WebClient로 전환
Mono<Order> mono = webClient.get()
.uri(url)
.retrieve()
.bodyToMono(Order.class);
WebClient 자체가 만능은 아니고, 다운스트림이 느리면 결국 대기 시간이 늘어납니다. 하지만 이벤트 루프를 막지는 않으므로 “폭발적 장애”를 피하는 데 중요합니다.
3.3 block() / toFuture().get()
서비스 레이어나 필터에서 block()이 섞이면 대부분 WebFlux의 장점을 잃습니다.
User user = userMono.block();
해결: 체인을 끝까지 리액티브로 유지
Mono<User> userMono = userService.findUser(id);
return userMono.map(UserDto::from);
정말로 블로킹 경계(예: 레거시 API)에서만 block()을 허용하고, 그 경우에도 이벤트 루프가 아닌 전용 스레드에서 실행되도록 분리해야 합니다.
4) 스레드/스케줄러 튜닝 포인트
4.1 이벤트 루프 스레드 수
Reactor Netty 이벤트 루프는 기본적으로 CPU 코어 수 기반으로 설정됩니다. 무작정 늘린다고 성능이 좋아지지 않습니다. 이벤트 루프에서 해야 할 일은 짧고 빠르게 끝나야 하며, 블로킹은 밖으로 빼는 게 정석입니다.
튜닝의 우선순위는 다음입니다.
- 이벤트 루프에서 블로킹 제거
- 다운스트림 호출 타임아웃/리트라이/벌크헤드 정리
- 그 다음에야 이벤트 루프/커넥션 설정 검토
4.2 boundedElastic의 큐잉과 병목
boundedElastic은 블로킹 작업을 위한 풀인데, 트래픽이 높고 블로킹 작업이 많으면 여기서 큐가 쌓이면서 지연이 증가합니다.
대표 증상:
- 이벤트 루프는 한가한데 응답이 느림
- 스레드 덤프에서
boundedElastic-*가 바쁨
이때는 다음을 확인합니다.
- 블로킹 작업의 평균/최악 시간(특히 DB)
- 커넥션 풀 크기와 대기 시간
- 블로킹 작업을 줄이거나(캐시/배치) 논블로킹 드라이버(R2DBC 등)로 전환 가능성
JDBC를 계속 써야 한다면, 가상 스레드(virtual threads)와의 결합도 고려 대상입니다. 다만 WebFlux와 가상 스레드는 “둘 다 쓰면 무조건 좋다”가 아니라, 블로킹 비중/라이브러리 생태계/운영 난이도를 보고 선택해야 합니다. JDBC 커넥션 풀이 병목이 되는 케이스는 아래 글이 참고가 됩니다.
5) 관측으로 블로킹 의심 구간 좁히기
탐지는 BlockHound가 빠르지만, 운영에서 “어디가 느린지”를 찾을 땐 관측이 필수입니다.
5.1 Micrometer + Tracing으로 구간 나누기
Spring Boot 3는 Micrometer Tracing과의 통합이 좋아서, 외부 호출(WebClient), DB 호출, 메시징 호출을 스팬으로 쪼개면 병목 구간이 눈에 보입니다.
추적에서 다음 패턴이 보이면 블로킹을 의심합니다.
- 서버 내부 구간이 길게 늘어지는데 외부 스팬이 없음
- 특정 엔드포인트만 유독 지연이 큼
- 같은 기능인데 트래픽 증가 시 지연이 기하급수로 증가
5.2 스레드 이름으로 1차 판별
로그/스택트레이스에서 다음 스레드에서 블로킹이 나오면 거의 확정입니다.
reactor-http-nio-*parallel-*
반대로 다음은 “블로킹 격리용”이라 상대적으로 안전합니다.
boundedElastic-*
6) 실전 튜닝: 타임아웃, 동시성 제한, 백프레셔
블로킹을 제거했는데도 지연이 크다면, 다음은 “과부하 시 무너지는 패턴”을 막는 튜닝입니다.
6.1 WebClient 타임아웃 기본값 명시
기본 설정에 기대면 장애 시 대기가 길어질 수 있습니다.
import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import java.time.Duration;
import reactor.netty.http.client.HttpClient;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000)
.responseTimeout(Duration.ofSeconds(2))
.doOnConnected(conn -> conn
.addHandlerLast(new ReadTimeoutHandler(2))
.addHandlerLast(new WriteTimeoutHandler(2)));
WebClient webClient = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
6.2 동시성 제한으로 다운스트림 보호
flatMap은 기본적으로 높은 동시성을 만들 수 있습니다. 외부 API나 DB가 약하면 급격히 느려지거나 실패합니다.
Flux<Result> results = ids
.flatMap(id -> callDownstream(id), 32); // 동시성 32로 제한
6.3 버퍼링/프리패치 조절
대량 데이터를 다룰 때는 불필요한 프리패치/버퍼가 메모리와 지연을 키울 수 있습니다.
Flux<Item> flux = source
.limitRate(256);
7) 체크리스트: 장애 재발을 막는 규칙
7.1 코드 리뷰 룰
block()사용 금지(예외는 명시된 경계에서만)map/flatMap내부에서 JDBC/파일 I/O 호출 금지- 동기 클라이언트(
RestTemplate) 신규 사용 금지
7.2 테스트 룰
- WebFlux 통합 테스트에서 BlockHound 활성화
- 성능 테스트에서 p95/p99와
boundedElastic큐잉 관찰
7.3 운영 룰
- 타임아웃 기본값을 코드로 고정
- 다운스트림별 동시성 제한(벌크헤드) 적용
- 스레드 덤프/프로파일링으로
reactor-http-nio-*점유 원인 추적
운영 환경에서 로그 파일이 과도하게 쌓이거나, 로테이트 이후 디스크가 가득 차서 성능이 급락하는 경우도 종종 “지연”으로 관측됩니다. 이런 운영 이슈는 애플리케이션 튜닝과 별개로 반드시 배제해야 합니다.
8) 결론: 블로킹은 탐지보다 ‘경계 설계’가 핵심
Spring Boot 3에서 Reactor 블로킹 문제는 대개 다음 순서로 해결됩니다.
- BlockHound로 블로킹 위치를 “증거 기반”으로 찾기
- 블로킹 경계를
boundedElastic로 격리하거나, 논블로킹 대안(WebClient, R2DBC 등)으로 교체 - 타임아웃/동시성 제한/버퍼 정책으로 과부하에 강하게 만들기
- 관측(Tracing/metrics)으로 재발을 조기에 감지
WebFlux는 올바르게 쓰면 적은 리소스로 높은 동시성을 얻을 수 있지만, 블로킹이 섞이면 오히려 장애가 더 빨리, 더 크게 터집니다. “이벤트 루프는 절대 막지 않는다”는 원칙을 팀 규칙과 테스트로 고정해두는 것이 가장 확실한 튜닝입니다.