이전 글에서 ThreadLocal과 MDC가 어떻게 Tracing Context를 저장하고 전파하는지 살펴봤습니다. 동기 환경에서는 “한 요청 = 한 스레드”라는 단순한 모델 덕분에 ThreadLocal만으로도 충분했습니다.
하지만 Spring WebFlux로 넘어오면 상황이 완전히 달라집니다. WebFlux는 소수의 스레드가 수천 개의 요청을 동시에 처리하는 Event Loop 모델을 사용합니다. 하나의 요청이 처리되는 동안 스레드가 수시로 바뀔 수 있죠. 이 환경에서 ThreadLocal은 더 이상 신뢰할 수 없습니다.
그렇다면 WebFlux에서는 traceId를 어떻게 유지할까요? 이번 글에서는 Reactor Context가 이 문제를 어떻게 해결하는지, 그리고 Spring Boot 3에서 이를 어떻게 활용하는지 깊이 있게 살펴보겠습니다.
MVC vs WebFlux: 스레딩 모델의 근본적 차이
Spring MVC: Thread-per-Request
Spring MVC는 Thread-per-Request 모델을 사용합니다. 요청이 들어오면 스레드 풀에서 스레드 하나를 할당받고, 응답이 완료될 때까지 그 스레드가 요청을 전담합니다.
sequenceDiagram
participant R as 요청
participant T as Thread-1
participant DB as Database
R->>T: 요청 도착
Note over T: 스레드 할당
T->>DB: DB 쿼리 실행
Note over T: ⏳ Blocking 대기<br/>(스레드가 아무것도 못 함)
DB-->>T: 결과 반환
Note over T: 처리 계속
T->>R: 응답 반환
Note over T: 스레드 반납
이 모델의 특징은 명확합니다:
요청당 스레드 하나가 처음부터 끝까지 담당
DB나 외부 API 호출 시 스레드가 blocking 상태로 대기
동시 요청 수 = 스레드 풀 크기에 제한됨 (보통 200개)
ThreadLocal이 완벽하게 동작하는 이유가 바로 이것입니다. 스레드가 바뀌지 않으니까요.
Spring WebFlux: Event Loop
WebFlux는 완전히 다른 접근 방식을 취합니다. Event Loop 모델에서는 소수의 스레드(보통 CPU 코어 수)가 수천 개의 요청을 비동기적으로 처리합니다.
sequenceDiagram
participant R1 as 요청 1
participant R2 as 요청 2
participant W as Worker Thread<br/>(단 1개)
participant DB as Database
R1->>W: 요청 1 도착
W->>DB: DB 쿼리 (non-blocking)
Note over W: 대기 안 함!<br/>바로 다른 작업 처리
R2->>W: 요청 2 도착
W->>DB: DB 쿼리 (non-blocking)
DB-->>W: 요청 1 결과
W->>R1: 응답 1
DB-->>W: 요청 2 결과
W->>R2: 응답 2
핵심 차이점:
특성
Spring MVC
Spring WebFlux
스레드 모델
Thread-per-Request
Event Loop
기본 스레드 수
~200개
CPU 코어 수 (4~8개)
I/O 대기 방식
Blocking (스레드 점유)
Non-blocking (콜백)
동시 처리 용량
스레드 풀 크기에 제한
수만 연결 가능
스레드 전환
거의 없음
수시로 발생
🤔 왜 WebFlux가 더 적은 스레드로 더 많은 요청을 처리할 수 있나요?
핵심은 **“대기 시간의 활용”**입니다. MVC에서 DB 응답을 기다리는 동안 스레드는 아무것도 못 하고 멈춰있습니다. 반면 WebFlux에서는 DB 호출을 시작한 후 “응답 오면 알려줘”라고 콜백을 등록하고, 스레드는 즉시 다른 요청을 처리하러 갑니다. 응답이 도착하면 그때 다시 처리를 이어갑니다.
스레드가 바뀌면 ThreadLocal 값은 사라집니다. 이것이 WebFlux에서 ThreadLocal을 직접 사용할 수 없는 근본적인 이유입니다.
sequenceDiagram
participant C as Client
participant T1 as Thread-1
participant T2 as Thread-2
participant DB as Database
C->>T1: GET /order/123
Note over T1: ThreadLocal.set("abc123")
T1->>DB: findById(123) [non-blocking]
Note over T1: 스레드 반납 (다른 요청 처리)
DB-->>T2: 결과 반환
Note over T2: ThreadLocal.get() = null ❌
T2->>C: 응답
Reactor Context: 리액티브 세계의 ThreadLocal
Context란 무엇인가
Project Reactor는 이 문제를 해결하기 위해 Context라는 개념을 도입했습니다. Context는 쉽게 말해 Subscriber에 붙어있는 불변(immutable) Map입니다.
// ThreadLocal 방식 (❌ WebFlux에서 실패)
ThreadLocal<String> traceId =new ThreadLocal<>();
traceId.set("abc123");
String value = traceId.get();
// Reactor Context 방식 (✅ WebFlux에서 동작)
Mono.just("data")
.contextWrite(ctx -> ctx.put("traceId", "abc123")) // Context에 저장
.flatMap(data ->
Mono.deferContextual(ctx -> { // Context에서 읽기
String traceId = ctx.get("traceId");
return Mono.just(data +" with "+ traceId);
})
);
핵심 차이점:
특성
ThreadLocal
Reactor Context
바인딩 대상
Thread
Subscriber
스레드 전환 시
값 유실
값 유지 ✅
데이터 구조
가변 (mutable)
불변 (immutable)
전파 방향
없음
Bottom → Top (아래에서 위로)
Context의 전파 방향: Bottom-Up
Reactor Context에서 가장 중요하면서도 헷갈리는 개념이 전파 방향입니다. Context는 아래에서 위로 전파됩니다.
Mono.deferContextual(ctx -> {
// 3. 여기서 읽으면? → "World" (가장 가까운 contextWrite)
log.info("message = {}", ctx.get("message"));
return Mono.just("result");
})
.contextWrite(ctx -> ctx.put("message", "World")) // 2. 두 번째 write
.contextWrite(ctx -> ctx.put("message", "Hello")) // 1. 첫 번째 write
.subscribe();
출력: message = World
왜 “Hello”가 아니라 “World”일까요?
flowchart TB
subgraph " "
A["deferContextual (읽기)"]
B["contextWrite('World')"]
C["contextWrite('Hello')"]
D["subscribe()"]
end
subgraph "Context 전파 방향 (아래→위)"
D2["subscribe()"] -->|"Context 시작"| C2["contextWrite('Hello')"]
C2 -->|"message=Hello"| B2["contextWrite('World')"]
B2 -->|"message=World (덮어씀)"| A2["deferContextual"]
end
A --- A2
B --- B2
C --- C2
D --- D2
규칙: subscribe()에서 시작해서 위로 올라가며, 각 contextWrite가 Context를 수정합니다. 읽기 연산자(deferContextual)는 자신의 바로 아래에 있는contextWrite의 값을 봅니다.
🤔 왜 이렇게 설계했나요?
Reactive Streams의 구독(subscription) 흐름과 일치시키기 위해서입니다. subscribe()를 호출하면 구독 신호가 아래에서 위로 전파됩니다. Context도 이 흐름을 따라 함께 전파되므로, 스레드가 아무리 바뀌어도 Subscriber와 함께 Context가 유지됩니다.
sequenceDiagram
participant USER as .subscribe(println)
participant P3 as FluxFilter
participant P2 as FluxMap
participant P1 as FluxArray
Note over USER: subscribe() 호출
USER->>P3: ① LambdaSubscriber로 구독
P3->>P2: ② FilterSubscriber 생성 후 구독
P2->>P1: ③ MapSubscriber 생성 후 구독
Note over P1,USER: 이제 데이터가 흐르기 시작
P1-->>P2: onNext(1)
P2-->>P3: onNext(2)
P3-->>USER: onNext(2) → println
핵심: subscribe()를 호출하면 Subscriber가 순차적으로 생성되면서 체인이 구성됩니다. LambdaSubscriber가 가장 먼저 만들어지고, 그 다음 FilterSubscriber, MapSubscriber 순서로 만들어집니다.
CoreSubscriber와 currentContext()
Reactor의 모든 Subscriber는 CoreSubscriber 인터페이스를 구현합니다. 이 인터페이스에 Context 관련 메서드가 있습니다:
성능 때문입니다. Tracing에서 주로 사용하는 키는 traceId, spanId, baggage 정도로 5개를 넘기 어렵습니다. 이 범위에서는 전용 필드를 사용하는 게 HashMap보다 훨씬 빠릅니다:
객체 할당 최소화
equals() 호출 횟수 감소
캐시 친화적 메모리 배치
스레드 전환에도 Context가 유지되는 이유
이제 핵심 질문에 답할 수 있습니다. 스레드가 바뀌어도 Context가 유지되는 이유는 무엇일까요?
sequenceDiagram
participant T1 as Thread-1
participant T2 as Thread-2
participant Sub as Subscriber
participant Ctx as Context
Note over Sub,Ctx: Subscriber가 Context를 참조
T1->>Sub: onNext(data)
Sub->>Ctx: currentContext()
Ctx-->>Sub: Context{traceId=abc}
Note over T1: 스레드 전환 발생
T2->>Sub: onNext(data2)
Sub->>Ctx: currentContext()
Ctx-->>Sub: Context{traceId=abc}
Note over T2: 같은 Context!
답: Context는 Thread가 아닌 Subscriber 객체에 연결되어 있기 때문입니다.
subscribe() 시점에 Subscriber 체인이 생성됨
각 Subscriber는 downstream Subscriber에 대한 참조를 가짐
Context는 이 참조 체인을 따라 조회됨
스레드가 바뀌어도 Subscriber 객체는 동일 → Context도 동일
ThreadLocal이 “스레드에 데이터를 저장”한다면, Reactor Context는 “구독 체인에 데이터를 저장”합니다.
Micrometer Context Propagation: 두 세계의 다리
Reactor Context는 완벽해 보이지만 한 가지 문제가 있습니다. 기존 라이브러리들은 여전히 ThreadLocal을 사용한다는 것입니다.
SLF4J MDC → ThreadLocal
Micrometer Tracing → ThreadLocal
Spring Security → ThreadLocal
WebFlux에서 이들을 사용하려면 Reactor Context ↔ ThreadLocal 사이의 브릿지가 필요합니다. 이것이 바로 Micrometer Context Propagation 라이브러리입니다.
동작 원리
flowchart LR
subgraph "Reactor 세계"
RC["Reactor Context<br/>{traceId: 'abc123'}"]
end
subgraph "Context Propagation"
RCA["ReactorContextAccessor"]
TLA["ThreadLocalAccessor"]
end
subgraph "ThreadLocal 세계"
TL["ThreadLocal<br/>MDC.get('traceId')"]
end
RC <-->|"읽기/쓰기"| RCA
RCA <-->|"변환"| TLA
TLA <-->|"읽기/쓰기"| TL
block()은 현재 스레드를 점유하므로 Context 문제가 덜하지만, subscribe()는 다른 스레드에서 실행될 수 있어 contextCapture()가 중요합니다.
🚨 중요: WebFlux에서 block()을 사용하면 Event Loop 스레드를 blocking하게 됩니다. WebFlux의 핵심 장점은 소수의 스레드(보통 4~8개)로 수천 개의 요청을 처리하는 것인데, 이 스레드가 blocking되면 전체 처리량이 급격히 떨어집니다. 사실상 WebFlux를 쓰는 의미가 없어지는 것이죠.
// ❌ 절대 금지: Event Loop 스레드에서 block()
@GetMapping("/order/{id}")
public Mono<Order>getOrder(@PathVariable String id) {
Order order = orderRepository.findById(id).block(); // Event Loop 스레드 blocking!
return Mono.just(order);
}
// ✅ 올바른 방식: 리액티브 체인 유지
@GetMapping("/order/{id}")
public Mono<Order>getOrder(@PathVariable String id) {
14:23:45.234 [abc123,222bbb] INFO c.e.OrderService - 주문 조회 완료 // ✅ 정상
같은 라이브러리인데 어떤 로그는 traceId가 있고, 어떤 로그는 없는 이유가 뭘까요?
원인: 라이브러리 내부 구현에 따라 로그를 찍는 위치가 다를 수 있기 때문입니다.
flowchart TB
subgraph IN["체인 안 ✅ Context 있음"]
A["repository.findById()"]
B["라이브러리 연산자 내부<br/>log.debug('Executing query')"]
end
subgraph OUT["체인 밖 ❌ Context 없음"]
C["네트워크 이벤트 핸들러<br/>log.debug('Socket connected')"]
D["I/O 콜백<br/>log.debug('Sending bytes')"]
end
A --> B
B -.->|"실제 I/O 작업"| C
C --> D
style B fill:#ccffcc,stroke:#00aa00
style C fill:#ffcccc,stroke:#ff0000
style D fill:#ffcccc,stroke:#ff0000
라이브러리 내부에서 로그를 찍을 때:
리액티브 체인의 연산자가 아닌 별도의 콜백이나 이벤트 핸들러에서 실행
해당 시점에는 Reactor Context와 연결되어 있지 않음
Automatic Context Propagation도 리액티브 연산자 경계에서만 동작
해결 방법:
라이브러리 로그 레벨 조정 (임시 방편) logging: level: com.mongodb.reactivestreams: WARN io.r2dbc: WARN
Java Agent 사용 (근본적 해결) OpenTelemetry Java Agent는 바이트코드 조작을 통해 라이브러리 내부까지 계측(instrumentation)합니다. 라이브러리가 Context Propagation을 인식하지 못해도, Agent가 강제로 Context를 주입해줍니다. java -javaagent:opentelemetry-javaagent.jar \ -Dotel.service.name=order-service \ -jar myapp.jar
📌 Java Agent vs Library Instrumentation
이 문제는 **“계측 방식의 차이”**에서 비롯됩니다. Library 방식(Micrometer, Spring Boot Auto-configuration)은 리액티브 체인 경계에서만 동작하지만, Java Agent 방식은 바이트코드 레벨에서 모든 곳에 계측을 삽입합니다.
이 주제는 시리즈의 후속 글 **“Instrumentation 방식 비교: Java Agent vs Library의 동작 원리”**에서 자세히 다루겠습니다. Agent가 어떻게 라이브러리 내부까지 traceId를 전파하는지, 그리고 각 방식의 장단점을 비교해보겠습니다.
flowchart TB
subgraph MVC["Spring MVC"]
direction LR
M1["ThreadLocal에<br/>직접 저장"] --> M2["스레드 유지 →<br/>자연스럽게 전파"] --> M3["@Async 사용 시<br/>별도 처리 필요"]
end
subgraph WebFlux["Spring WebFlux"]
direction LR
W1["Reactor Context에<br/>저장"] --> W2["Subscriber 체인<br/>따라 전파"] --> W3["Context Propagation<br/>으로 MDC 연동"]
end
MVC ~~~ WebFlux
결론
WebFlux의 Event Loop 모델에서는 ThreadLocal을 직접 사용할 수 없습니다. 대신 Reactor Context가 Subscriber 체인을 따라 데이터를 전파합니다.