Published on

Spring Boot 3에서 Reactor 블로킹 탐지·튜닝

Authors

서버가 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 코어 수 기반으로 설정됩니다. 무작정 늘린다고 성능이 좋아지지 않습니다. 이벤트 루프에서 해야 할 일은 짧고 빠르게 끝나야 하며, 블로킹은 밖으로 빼는 게 정석입니다.

튜닝의 우선순위는 다음입니다.

  1. 이벤트 루프에서 블로킹 제거
  2. 다운스트림 호출 타임아웃/리트라이/벌크헤드 정리
  3. 그 다음에야 이벤트 루프/커넥션 설정 검토

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 블로킹 문제는 대개 다음 순서로 해결됩니다.

  1. BlockHound로 블로킹 위치를 “증거 기반”으로 찾기
  2. 블로킹 경계를 boundedElastic로 격리하거나, 논블로킹 대안(WebClient, R2DBC 등)으로 교체
  3. 타임아웃/동시성 제한/버퍼 정책으로 과부하에 강하게 만들기
  4. 관측(Tracing/metrics)으로 재발을 조기에 감지

WebFlux는 올바르게 쓰면 적은 리소스로 높은 동시성을 얻을 수 있지만, 블로킹이 섞이면 오히려 장애가 더 빨리, 더 크게 터집니다. “이벤트 루프는 절대 막지 않는다”는 원칙을 팀 규칙과 테스트로 고정해두는 것이 가장 확실한 튜닝입니다.