- Published on
Spring Boot 3 WebFlux 메모리 폭증, Reactor 조립 추적
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 갑자기 느려지고 RSS가 치솟으며 결국 OOMKilled 혹은 JVM OutOfMemoryError 로 떨어지는 WebFlux 장애는, 원인이 단순 누수라기보다 리액티브 파이프라인의 조립 시점 정보가 사라져 “어디서 만들어진 스트림이 쌓였는지”를 못 찾는 경우가 많습니다.
Spring Boot 3(WebFlux)에서 흔히 보는 패턴은 다음과 같습니다.
- 대량 요청/대량 스트림에서
flatMap동시성 제한이 없어서 버퍼가 폭발 DataBuffer미해제, 혹은 잘못된 변환으로 네이티브/다이렉트 메모리 증가onErrorResume로 오류를 삼켜서 재시도 루프가 조용히 증폭contextWrite나 MDC 브리징이 과도하게 객체를 붙잡아 GC 압박- 무엇보다 “이 Flux/Mono가 어디서 조립됐는지” 로그/스택이 안 남아 추적이 어려움
이 글은 그중 Reactor 조립(Assembly) 추적을 중심으로, 메모리 폭증 상황에서 원인 파이프라인을 특정하는 실전 절차를 설명합니다.
컨테이너에서 OOM이 반복된다면, 먼저 cgroup 관점에서 실제 메모리 한계/스파이크를 확인하는 것도 중요합니다. 관련해서는 K8s OOMKilled 반복? cgroup v2 메모리 진단 글을 함께 보면 장애 해석이 빨라집니다.
1) WebFlux 메모리 폭증의 “진짜” 난이도: 조립 위치가 사라진다
Reactor 파이프라인은 보통 이런 식으로 조립됩니다.
- 컨트롤러/핸들러에서
Mono또는Flux를 만들고 - 여러 연산자를 체이닝한 뒤
- 구독(
subscribe)은 프레임워크가 수행
문제는, 장애 시점에 덤프/로그로 남는 스택이 보통 구독 시점 중심이라서, "이 스트림이 어디서 만들어졌는지"(조립 시점)가 빠져버린다는 점입니다.
예를 들어 아래처럼 작성된 코드가 있다고 합시다.
@GetMapping("/items")
public Flux<ItemDto> items() {
return repository.findAll()
.flatMap(this::enrich) // 외부 호출
.map(this::toDto);
}
메모리 폭증이 났을 때, 우리가 알고 싶은 건 대개 이런 정보입니다.
flatMap동시성이 몇으로 동작했는가enrich가 지연되어 내부 큐/버퍼가 쌓였는가- 어느 엔드포인트/어느 조립 코드에서 시작된 스트림인가
이때 조립 추적이 없으면, 덤프에 남는 단서는 매우 제한적입니다.
2) Reactor 조립 추적의 옵션 3종: 무엇을 언제 쓸까
Reactor가 제공하는 조립 추적은 크게 세 갈래입니다.
Hooks.onOperatorDebug()
- 전역 훅으로 조립 스택을 캡처
- 가장 강력하지만 오버헤드가 큼
checkpoint()
- 특정 지점에 “표식”을 남김
- 필요한 구간에만 넣을 수 있어 비용을 통제하기 좋음
reactor-tools의ReactorDebugAgent
- bytecode instrumentation 기반
- 운영에서 상시 켜는 건 신중해야 하지만, 상황에 따라 유용
결론부터 말하면,
- 운영 상시는
checkpoint()중심 - 장애 재현/스테이징은
Hooks.onOperatorDebug()혹은 DebugAgent
이 조합이 가장 안전합니다.
3) checkpoint() 로 “조립 위치”를 남기는 최소 침습 전략
가장 현실적인 접근은, 의심 구간에 checkpoint() 를 추가해 조립 지점을 남기는 것입니다.
3.1 핸들러/유스케이스 경계에 체크포인트 박기
컨트롤러(또는 RouterFunction)에서 유스케이스를 호출하는 경계는 추적에 특히 유리합니다.
@GetMapping("/items")
public Flux<ItemDto> items() {
return service.items()
.checkpoint("GET /items: service.items()")
.map(this::toDto)
.checkpoint("GET /items: map toDto");
}
장애가 나면 스택/에러에 GET /items: ... 라벨이 함께 찍혀서, “어느 엔드포인트에서 조립된 스트림인지”가 즉시 드러납니다.
3.2 checkpoint(true) 와 비용
checkpoint("label", true) 는 stacktrace를 더 풍부하게 남기지만 비용이 올라갑니다.
return service.items()
.checkpoint("items-usecase", true);
- 재현 환경에서는
true가 큰 도움이 됩니다. - 운영에서는 우선
checkpoint("...")로 시작하고, 정말 필요할 때만true를 고려하세요.
4) Hooks.onOperatorDebug() 로 전체 조립 스택 잡기 (재현용)
장애를 로컬/스테이징에서 재현할 수 있다면, 전역 디버그 훅이 가장 빠릅니다.
import jakarta.annotation.PostConstruct;
import org.springframework.context.annotation.Configuration;
import reactor.core.publisher.Hooks;
@Configuration
public class ReactorDebugConfig {
@PostConstruct
void enableOperatorDebug() {
Hooks.onOperatorDebug();
}
}
주의점:
- 오버헤드가 큽니다. 처리량이 높은 서비스에서 운영 상시 적용은 권장하지 않습니다.
- 대신 “재현용 프로파일”로만 켜고, 문제 파이프라인을 특정한 뒤
checkpoint()로 최소화하세요.
5) DebugAgent(reactor-tools)로 조립 추적 자동화
reactor-tools 의 DebugAgent는 조립 추적을 더 자동화하지만, 적용 방식이 조금 다릅니다.
5.1 의존성 추가
dependencies {
implementation("io.projectreactor:reactor-tools")
}
5.2 애플리케이션 시작 시 설치
import jakarta.annotation.PostConstruct;
import org.springframework.context.annotation.Configuration;
import reactor.tools.agent.ReactorDebugAgent;
@Configuration
public class ReactorDebugAgentConfig {
@PostConstruct
void init() {
ReactorDebugAgent.init();
ReactorDebugAgent.processExistingClasses();
}
}
운영에서의 사용은 다음 기준으로 판단하는 게 안전합니다.
- 트래픽이 낮은 관리용 인스턴스 1대에만 적용
- 장애 시간대에만 임시 활성화
- 성능/메모리 오버헤드를 A/B로 계측
6) 조립 추적으로 “메모리 폭증”을 실제 원인으로 연결하는 법
조립 추적은 “어디서 만들어졌는지”를 알려줄 뿐, 메모리 폭증의 형태는 다양합니다. 아래는 WebFlux에서 자주 맞닥뜨리는 케이스와, 조립 추적을 붙여 원인으로 수렴시키는 방법입니다.
6.1 flatMap 무제한 동시성으로 큐가 쌓이는 케이스
외부 호출이 느려지는 순간, flatMap 이 사실상 무제한으로 in-flight를 늘리면 메모리가 급증합니다.
return sourceFlux
.flatMap(this::callRemote) // 동시성 제한 없음
.checkpoint("remote-call flatMap");
개선:
int concurrency = 32;
int prefetch = 64;
return sourceFlux
.flatMap(this::callRemote, concurrency, prefetch)
.checkpoint("remote-call flatMap limited");
concurrency는 외부 시스템 QPS, 커넥션 풀, CPU를 함께 고려해 결정prefetch가 크면 내부 버퍼가 커질 수 있으니 관찰하며 조정
조립 추적을 켜두면, 폭증 시점에 문제 스트림이 remote-call flatMap 라벨로 반복 등장해 “이 라인이 범인”임을 빠르게 확정할 수 있습니다.
6.2 바디를 메모리에 통째로 올리는 실수
예: 큰 요청/응답을 bodyToMono(String.class) 같은 형태로 한 번에 읽어버리면 힙이 급격히 커집니다.
return webClient.get()
.uri(url)
.retrieve()
.bodyToMono(String.class)
.checkpoint("download as String");
대안은 스트리밍 처리 또는 파일/버퍼 처리로 바꾸는 것입니다. 예를 들어 DataBuffer 스트림을 다루거나, 목적에 맞게 제한을 둡니다.
또한 Spring WebFlux는 DataBuffer 관리가 얽히는 순간 실수가 치명적이므로, “조립 위치”를 남겨두면 어떤 경로에서 큰 바디를 읽고 있는지 즉시 찾기 쉽습니다.
6.3 재시도 루프가 조용히 메모리를 갉아먹는 케이스
retryWhen 을 잘못 쓰면 실패가 계속 누적되고, 지연/백오프 큐가 커지며 메모리와 스레드가 함께 압박됩니다.
return remoteCall()
.retryWhen(spec -> spec) // 사실상 무한
.checkpoint("retry-when suspicious");
개선:
import reactor.util.retry.Retry;
import java.time.Duration;
return remoteCall()
.retryWhen(
Retry.backoff(3, Duration.ofMillis(200))
.maxBackoff(Duration.ofSeconds(2))
.transientErrors(true)
)
.checkpoint("retry-when bounded");
조립 추적이 있으면, 덤프에서 동일한 파이프라인이 대량으로 반복되는지(즉, 실패 후 재시도 객체가 계속 쌓이는지) 패턴을 잡기 좋습니다.
7) 운영에서의 안전한 적용 플랜
조립 추적은 강력하지만 “항상 켜두기”엔 비용이 있습니다. 운영에서 추천하는 순서는 다음과 같습니다.
7.1 1단계: checkpoint() 를 경계에만 추가
- 엔드포인트 경계, 외부 호출 경계, 대량 처리 경계
- 라벨은
GET /path처럼 검색 가능한 문자열로
7.2 2단계: 의심 구간에만 checkpoint(true)
- 특정 API에서만 메모리 스파이크가 난다면 그 경로에만 강화
7.3 3단계: 재현 환경에서 Hooks.onOperatorDebug()
- 부하 테스트로 재현
- 조립 스택을 기반으로 범인 연산자/경로를 특정
- 수정 후 다시
checkpoint()수준으로 회귀
7.4 4단계: 컨테이너/노드 레벨 OOM과 함께 해석
WebFlux 메모리 폭증은 JVM 힙만의 문제가 아닐 수 있습니다.
- 다이렉트 메모리
- 네이티브(SSL, DNS, Netty)
- cgroup 제한으로 인한 조기 OOM
이때는 JVM 옵션만 보기보다, 컨테이너 메모리 그래프와 OOM 이벤트를 같이 봐야 합니다. 필요하면 앞서 언급한 K8s OOMKilled 반복? cgroup v2 메모리 진단 처럼 “플랫폼 레벨”에서 먼저 사실관계를 잡는 것이 좋습니다.
8) 빠르게 써먹는 체크리스트
마지막으로, 메모리 폭증 이슈에서 조립 추적을 붙일 때의 체크리스트입니다.
- 문제 API 경로에
checkpoint("METHOD /path")를 추가했는가 - 대량 연산자(
flatMap,buffer,window) 앞뒤로 체크포인트를 남겼는가 -
flatMap동시성/프리페치가 제한되어 있는가 - 큰 바디를
String/byte[]로 통째로 올리지 않는가 -
retryWhen이 bounded(횟수/시간) 인가 - 재현 환경에서
Hooks.onOperatorDebug()로 조립 스택을 확보했는가
조립 추적은 “원인을 모르는 상태”를 “원인을 특정 가능한 상태”로 바꾸는 도구입니다. WebFlux 메모리 폭증은 대부분 파이프라인 설계(동시성/버퍼링/재시도/바디 처리)에서 시작되므로, 조립 지점을 정확히 찍어두면 수정의 방향이 급격히 선명해집니다.