Tracing 시리즈
서론
이전 글에서 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 호출을 시작한 후 “응답 오면 알려줘”라고 콜백을 등록하고, 스레드는 즉시 다른 요청을 처리하러 갑니다. 응답이 도착하면 그때 다시 처리를 이어갑니다.
WebFlux에서 ThreadLocal이 실패하는 이유
문제를 코드로 확인해보겠습니다:
@RestController
public class OrderController {
private static final ThreadLocal<String> traceId = new ThreadLocal<>();
@GetMapping("/order/{id}")
public Mono<Order> getOrder(@PathVariable String id) {
// 1. 요청 시작 - reactor-http-nio-1 스레드
traceId.set(generateTraceId());
log.info("[{}] 주문 조회 시작", traceId.get()); // ✅ 정상 출력
return orderRepository.findById(id) // Non-blocking DB 호출
.map(order -> {
// 2. DB 응답 후 - reactor-http-nio-3 스레드 (다른 스레드!)
log.info("[{}] 주문 조회 완료", traceId.get()); // ❌ null!
return order;
});
}
}Code language: PHP (php)
실행 결과:
[abc123] 주문 조회 시작 // reactor-http-nio-1
[null] 주문 조회 완료 // reactor-http-nio-3 (ThreadLocal 값 없음!)Code language: JavaScript (javascript)
스레드가 바뀌면 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);
})
);Code language: JavaScript (javascript)
핵심 차이점:
| 특성 | 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();Code language: JavaScript (javascript)
출력: 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가 유지됩니다.
Context 읽기/쓰기 API
쓰기: contextWrite()
// 방법 1: Function으로 수정
.contextWrite(ctx -> ctx.put("key", "value"))
// 방법 2: ContextView 병합
.contextWrite(Context.of("key1", "value1", "key2", "value2"))Code language: JavaScript (javascript)
읽기: deferContextual() 또는 transformDeferredContextual()
// 방법 1: Mono.deferContextual (가장 일반적)
Mono.deferContextual(ctx -> {
String traceId = ctx.get("traceId");
return someAsyncOperation(traceId);
});
// 방법 2: Flux.deferContextual
Flux.deferContextual(ctx -> {
return Flux.fromIterable(getItems(ctx.get("userId")));
});
// 방법 3: transformDeferredContextual (체인 중간에서)
flux.transformDeferredContextual((original, ctx) -> {
String prefix = ctx.get("prefix");
return original.map(item -> prefix + item);
});Code language: JavaScript (javascript)
실전 예제: WebFlux에서 traceId 전파
@RestController
@RequiredArgsConstructor
public class OrderController {
private final OrderRepository orderRepository;
private final PaymentClient paymentClient;
@GetMapping("/order/{id}")
public Mono<OrderResponse> getOrder(@PathVariable String id) {
String traceId = generateTraceId();
return orderRepository.findById(id)
.flatMap(order -> paymentClient.getPaymentStatus(order.getPaymentId())
.map(payment -> new OrderResponse(order, payment))
)
.transformDeferredContextual((mono, ctx) ->
mono.doOnNext(response ->
log.info("[{}] 주문 조회 완료: {}", ctx.get("traceId"), response)
)
)
.contextWrite(ctx -> ctx.put("traceId", traceId)); // 맨 아래에서 Context 설정
}
}Code language: PHP (php)
이제 스레드가 아무리 바뀌어도 traceId가 유지됩니다!
Reactor Context 내부 동작 원리
입문자 수준을 넘어, Context가 어떻게 스레드 전환에도 살아남는지 내부를 들여다보겠습니다.
Subscriber 체인과 Context
Reactor에서 연산자를 체이닝하면 내부적으로 Subscriber 체인이 만들어집니다. 각 연산자는 자신만의 Subscriber를 생성하고, 이 Subscriber들이 서로 연결됩니다.
Flux.just(1, 2, 3) // FluxArray
.map(i -> i * 2) // FluxMap (내부에 MapSubscriber)
.filter(i -> i > 2) // FluxFilter (내부에 FilterSubscriber)
.subscribe(System.out::println); // LambdaSubscriberCode language: PHP (php)
🤔 FluxMap, FluxFilter는 뭔가요?
Reactor에서 연산자를 호출할 때마다 새로운 Publisher가 생성됩니다.
.map()을 호출하면 원본 Flux를 감싸는FluxMap이 만들어지고,.filter()를 호출하면 그걸 또 감싸는FluxFilter가 만들어집니다.FluxFilter ─감싸고 있음→ FluxMap ─감싸고 있음→ FluxArray그리고
subscribe()를 호출하면, 이 Publisher들이 각각 자신의 Subscriber를 생성합니다. Subscriber 체인은 구독 시점에 만들어지며, 데이터 흐름과 반대 방향으로 연결됩니다.
내부 구조:
flowchart TB
subgraph CODE["코드 작성 순서"]
C1["Flux.just(1,2,3)"]
C2[".map(i -> i * 2)"]
C3[".filter(i -> i > 2)"]
C4[".subscribe(println)"]
end
subgraph PUB["생성되는 Publisher 구조"]
P1["FluxArray [1,2,3]"]
P2["FluxMap"]
P3["FluxFilter"]
end
C1 --> C2 --> C3 --> C4
P3 -->|"source"| P2 -->|"source"| P1
CODE ~~~ PUBsequenceDiagram
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 관련 메서드가 있습니다:
public interface CoreSubscriber<T> extends Subscriber<T> {
// 현재 Subscriber의 Context 반환
default Context currentContext() {
return Context.empty();
}
}Code language: PHP (php)
연산자의 Subscriber 구현을 보면:
// FluxMap 내부의 MapSubscriber (간략화)
class MapSubscriber<T, R> implements CoreSubscriber<T> {
final CoreSubscriber<? super R> actual; // 다음 Subscriber (downstream)
final Function<T, R> mapper;
@Override
public Context currentContext() {
// downstream Subscriber의 Context를 그대로 반환
return actual.currentContext();
}
@Override
public void onNext(T t) {
R result = mapper.apply(t);
actual.onNext(result); // 결과를 downstream으로 전달
}
}Code language: PHP (php)
Context는 Subscriber 체인을 따라 전파됩니다. 각 Subscriber는 downstream의 Context를 참조하고, contextWrite 연산자만 Context를 수정합니다.
실제 코드에서 Context가 어떻게 전파되는지 봅시다:
Flux.just(1, 2, 3)
.map(i -> i * 2)
.filter(i -> i > 2)
.contextWrite(ctx -> ctx.put("traceId", "abc")) // 보통 체인 맨 아래에 위치
.subscribe(System.out::println);Code language: PHP (php)
flowchart BT
subgraph TOP["Context 조회 가능 ✅"]
S1["MapSubscriber"]
S2["FilterSubscriber"]
end
S3["ContextWriteSubscriber<br/>🔑 Context 생성"]
subgraph BOTTOM["Context 조회 불가 ❌"]
S4["LambdaSubscriber<br/>(subscribe 람다)"]
end
S1 -->|"currentContext()"| S2
S2 -->|"currentContext()"| S3
S3 -->|"currentContext()"| S4
S4 -.->|"Context.empty() 반환"| S3
S3 -.->|"Context{traceId=abc} 반환"| S2⚠️ 중요: subscribe 람다에서는 Context가 없습니다!
Flux.just(1, 2, 3) .doOnNext(v -> { // ✅ contextWrite 위 → Context 있음 log.info("[{}]", MDC.get("traceId")); }) .contextWrite(ctx -> ctx.put("traceId", "abc")) .subscribe(v -> { // ❌ contextWrite 아래 → Context 없음! log.info("[{}]", MDC.get("traceId")); // null });Code language: JavaScript (javascript)
contextWrite()는 항상 로깅이나 Context가 필요한 연산자들보다 아래에 배치해야 합니다.
🤔 왜 contextWrite가 체인 아래쪽에 있어야 하나요?
Context는 아래에서 위로 전파됩니다.
currentContext()를 호출하면 downstream(아래쪽) Subscriber에게 물어봅니다.
MapSubscriber.currentContext()→FilterSubscriber에게 물어봄FilterSubscriber.currentContext()→ContextWriteSubscriber에게 물어봄ContextWriteSubscriber.currentContext()→ 여기서 Context 생성해서 반환!그래서
contextWrite()보다 위에 있는 연산자들만 Context를 읽을 수 있습니다. 아래에 있는 연산자들은 Context가 아직 생성되기 전이라 접근할 수 없습니다.
Context 구현체: 최적화의 비밀
Reactor의 Context는 성능을 위해 크기별로 다른 구현체를 사용합니다:
| 크기 | 구현체 | 내부 구조 |
|---|---|---|
| 0개 | Context0 | 싱글톤 (INSTANCE) |
| 1개 | Context1 | 단일 key-value 필드 |
| 2개 | Context2 | 2쌍의 key-value 필드 |
| 3개 | Context3 | 3쌍의 key-value 필드 |
| 4개 | Context4 | 4쌍의 key-value 필드 |
| 5개 | Context5 | 5쌍의 key-value 필드 |
| 6개+ | ContextN | Map<Object, Object> |
// Context1 구현 (간략화)
final class Context1 implements CoreContext {
final Object key;
final Object value;
@Override
public <T> T get(Object key) {
if (this.key.equals(key)) {
return (T) this.value;
}
throw new NoSuchElementException();
}
@Override
public Context put(Object key, Object value) {
// 불변! 새 Context 반환
if (this.key.equals(key)) {
return new Context1(key, value);
}
return new Context2(this.key, this.value, key, value);
}
}Code language: JavaScript (javascript)
🤔 왜 Map을 바로 안 쓰고 이렇게 복잡하게 했나요?
성능 때문입니다. 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핵심 인터페이스:
// ThreadLocal 값에 접근하는 인터페이스
public interface ThreadLocalAccessor<V> {
Object key(); // Context에서 사용할 키
V getValue(); // ThreadLocal에서 값 읽기
void setValue(V value); // ThreadLocal에 값 쓰기
void setValue(); // ThreadLocal 값 제거 (null로)
}
// Reactor Context 등 Map 형태 컨텍스트에 접근하는 인터페이스
public interface ContextAccessor<READ, WRITE> {
Class<? extends READ> readableType();
Class<? extends WRITE> writeableType();
V readValue(READ container, Object key);
WRITE writeValues(Map<Object, Object> values, WRITE container);
}Code language: JavaScript (javascript)
Micrometer는 ObservationThreadLocalAccessor를 제공하고, Reactor는 ReactorContextAccessor를 제공합니다. 이 둘이 협력하여 Context를 양방향으로 전파합니다.
두 가지 모드: Default vs Automatic
Reactor Core 3.5.0부터 Context Propagation을 지원하며, 두 가지 모드가 있습니다:
1. Default 모드 (제한적 복원)
특정 연산자(handle, tap)에서만 ThreadLocal을 복원합니다:
flux.handle((item, sink) -> {
// 이 블록 안에서만 ThreadLocal 복원됨
log.info("traceId = {}", MDC.get("traceId")); // ✅ 동작
sink.next(transform(item));
});Code language: JavaScript (javascript)
2. Automatic 모드 (전체 복원)
모든 연산자에서 ThreadLocal을 자동 복원합니다:
// 애플리케이션 시작 시 활성화
Hooks.enableAutomaticContextPropagation();
flux.map(item -> {
// 모든 연산자에서 ThreadLocal 복원됨!
log.info("traceId = {}", MDC.get("traceId")); // ✅ 동작
return transform(item);
});Code language: JavaScript (javascript)
🤔 Automatic 모드가 좋아 보이는데, 왜 Default가 기본값인가요?
성능 때문입니다. Automatic 모드는 모든 연산자 실행 전후에 ThreadLocal을 저장/복원합니다. 이 오버헤드가 무시할 수 없는 수준일 수 있습니다.
공식 문서에서도 “최고의 확장성과 성능이 목표라면 ThreadLocal에 의존하지 않는 명시적 방식을 고려하라”고 권장합니다. Automatic 모드는 기존 코드 마이그레이션이나 편의성이 우선일 때 사용합니다.
contextCapture()의 역할
contextCapture()는 현재 스레드의 ThreadLocal 값들을 Reactor Context로 캡처합니다:
// ThreadLocal에 값이 있는 상태에서
MDC.put("traceId", "abc123");
Mono.just("data")
.contextCapture() // 현재 ThreadLocal 값들을 Context로 캡처
.flatMap(data -> someAsyncOperation())
.subscribe();Code language: JavaScript (javascript)
주로 명령형 코드에서 리액티브 체인을 시작할 때 사용합니다:
@GetMapping("/order")
public Mono<Order> createOrder(@RequestBody OrderRequest request) {
// Controller에서 ThreadLocal에 traceId가 설정된 상태
return orderService.createOrder(request)
.contextCapture(); // ThreadLocal → Reactor Context
}Code language: PHP (php)
⚠️ subscribe() vs block()의 차이
// block() - 현재 스레드에서 완료까지 대기 Order result = orderService.createOrder(request) .contextCapture() .block(); // 같은 스레드에서 실행, Context 자연스럽게 유지 // subscribe() - 다른 스레드에서 비동기 실행 orderService.createOrder(request) .contextCapture() // 필수! 없으면 Context 유실 .subscribe(result -> log.info("완료"));Code language: JavaScript (javascript)
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) { return orderRepository.findById(id); // Non-blocking }Code language: JavaScript (javascript)
block()은 테스트 코드나 명령형 코드에서 리액티브 체인을 마무리할 때만 사용하세요.
Spring Boot 3 + WebFlux 실전 설정
이론은 충분합니다. 이제 실제로 Spring Boot 3에서 WebFlux + Tracing을 설정해보겠습니다.
의존성 설정
<!-- pom.xml -->
<dependencies>
<!-- WebFlux -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Actuator (Observability 자동 설정) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Micrometer Tracing + OpenTelemetry Bridge -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<!-- OpenTelemetry OTLP Exporter (표준 프로토콜) -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
<!-- Context Propagation (자동으로 포함되지만 명시적으로) -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>context-propagation</artifactId>
</dependency>
</dependencies>Code language: HTML, XML (xml)
🤔 왜 OTLP Exporter인가요?
OTLP(OpenTelemetry Protocol)는 OpenTelemetry의 표준 전송 프로토콜입니다. 예전에는 Zipkin, Jaeger 등 백엔드별로 다른 exporter를 사용했지만, 이제는 OTLP 하나로 통일되는 추세입니다.
- Jaeger: OTLP 네이티브 지원 (4317/4318 포트)
- Zipkin: OTLP Collector를 통해 연동
- Grafana Tempo: OTLP 네이티브 지원
- AWS X-Ray, Datadog 등: OTLP Collector로 연동
OTLP를 사용하면 백엔드를 바꿔도 애플리케이션 코드를 수정할 필요가 없습니다.
application.yml 설정
# application.yml
spring:
application:
name: order-service
# 🔑 핵심 설정: Automatic Context Propagation 활성화
reactor:
context-propagation: auto # Spring Boot 3.2+
management:
tracing:
sampling:
probability: 1.0 # 개발 환경에서는 100% 샘플링
propagation:
type: w3c # W3C Trace Context 사용
baggage:
enabled: true
correlation:
enabled: true
fields:
- user-id
- tenant-id
# OTLP Exporter 설정 (Jaeger, Tempo, Collector 등으로 전송)
otlp:
tracing:
endpoint: http://localhost:4318/v1/traces # OTLP HTTP 엔드포인트
metrics:
export:
enabled: true
endpoint: http://localhost:4318/v1/metrics
logging:
pattern:
console: "%d{HH:mm:ss.SSS} [%X{traceId:-},%X{spanId:-}] [%thread] %-5level %logger{36} - %msg%n"Code language: PHP (php)
🤔 OTLP 포트 번호
- 4317: gRPC 프로토콜
- 4318: HTTP 프로토콜 (Spring Boot 기본)
대부분의 OTLP 수신기(Jaeger, Tempo, OTel Collector)는 두 포트를 모두 지원합니다. Spring Boot는 기본적으로 HTTP를 사용하므로 4318 포트에
/v1/traces경로를 붙입니다.
🤔
spring.reactor.context-propagation=auto가 하는 일Spring Boot 3.2에서 추가된 이 설정은 내부적으로
Hooks.enableAutomaticContextPropagation()을 호출합니다. 이전 버전에서는 직접 호출해야 했습니다:// Spring Boot 3.1 이하 @SpringBootApplication public class MyApplication { public static void main(String[] args) { Hooks.enableAutomaticContextPropagation(); // 직접 호출 SpringApplication.run(MyApplication.class, args); } }Code language: PHP (php)
logback-spring.xml 설정
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%X{traceId:-},%X{spanId:-}] [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>Code language: HTML, XML (xml)
실제 코드 예시
@RestController
@RequiredArgsConstructor
@Slf4j
public class OrderController {
private final OrderRepository orderRepository;
private final InventoryClient inventoryClient;
private final PaymentClient paymentClient;
@PostMapping("/orders")
public Mono<OrderResponse> createOrder(@RequestBody OrderRequest request) {
log.info("주문 생성 시작: {}", request.getProductId());
return orderRepository.save(new Order(request))
.flatMap(order -> {
log.info("주문 저장 완료: {}", order.getId());
// 병렬로 재고 차감 + 결제 처리
return Mono.zip(
inventoryClient.decreaseStock(order.getProductId(), order.getQuantity())
.doOnSuccess(v -> log.info("재고 차감 완료")),
paymentClient.processPayment(order.getId(), order.getTotalAmount())
.doOnSuccess(v -> log.info("결제 처리 완료"))
).map(tuple -> new OrderResponse(order, tuple.getT1(), tuple.getT2()));
})
.doOnSuccess(response -> log.info("주문 생성 완료: {}", response.getOrderId()))
.doOnError(e -> log.error("주문 생성 실패", e));
}
}Code language: PHP (php)
출력 예시:
14:23:45.123 [abc123def456,111aaa] [reactor-http-nio-1] INFO c.e.OrderController - 주문 생성 시작: PROD-001
14:23:45.234 [abc123def456,222bbb] [reactor-http-nio-2] INFO c.e.OrderController - 주문 저장 완료: ORD-123
14:23:45.345 [abc123def456,333ccc] [reactor-http-nio-3] INFO c.e.OrderController - 재고 차감 완료
14:23:45.345 [abc123def456,444ddd] [reactor-http-nio-4] INFO c.e.OrderController - 결제 처리 완료
14:23:45.456 [abc123def456,222bbb] [reactor-http-nio-2] INFO c.e.OrderController - 주문 생성 완료: ORD-123Code language: CSS (css)
스레드가 계속 바뀌지만 traceId(abc123def456)는 일관되게 유지됩니다!
주의사항 및 트러블슈팅
1. Context가 비어있는 경우
증상: MDC.get("traceId")가 null 반환
원인과 해결:
// ❌ 문제: 리액티브 체인 밖에서 subscribe
new Thread(() -> {
orderService.createOrder(request)
.subscribe(); // 새 스레드에서 구독 → Context 없음
}).start();
// ✅ 해결: contextCapture() 사용
new Thread(() -> {
orderService.createOrder(request)
.contextCapture()
.subscribe();
}).start();Code language: JavaScript (javascript)
2. flatMap 내부에서 Context 유실
증상: flatMap 안에서 새로운 Publisher를 생성할 때 Context 유실
// ❌ 문제: 외부 Publisher 직접 사용
.flatMap(data -> externalLibrary.getData()) // 외부 라이브러리가 Context 미지원
// ✅ 해결: Mono.defer + contextCapture
.flatMap(data -> Mono.defer(() -> externalLibrary.getData()).contextCapture())Code language: JavaScript (javascript)
3. 성능 고려사항
Automatic Context Propagation은 편리하지만 성능 비용이 있습니다:
// 모든 연산자에서 이런 일이 발생
// 1. 연산자 실행 전: Context → ThreadLocal 복원
// 2. 사용자 코드 실행
// 3. 연산자 실행 후: ThreadLocal → 이전 상태 복원Code language: JSON / JSON with Comments (json)
Default 모드 + handle()/tap() 사용 예시:
Automatic 모드 대신 Default 모드에서 handle()이나 tap()을 사용하면 필요한 곳에서만 ThreadLocal을 복원할 수 있습니다. 두 연산자는 각각 독립적으로 ThreadLocal을 복원합니다.
| 연산자 | 역할 | 데이터 변환 |
|---|---|---|
handle() | 변환 + 필터링 + 로깅 | ✅ sink.next()로 변환/필터 가능 |
tap() | 사이드 이펙트만 (로깅, 메트릭) | ❌ 데이터 그대로 통과 |
// Default 모드 (spring.reactor.context-propagation 설정 없음)
@GetMapping("/orders")
public Flux<Order> getOrders() {
return orderRepository.findAll()
// handle(): 변환 + 필터링 + 로깅이 모두 필요할 때
.handle((order, sink) -> {
// ✅ 이 블록 안에서 ThreadLocal 복원됨
log.info("[{}] 주문 검증: {}", MDC.get("traceId"), order.getId());
if (order.isValid()) {
sink.next(order); // 유효한 주문만 통과
}
// sink.next()를 호출하지 않으면 필터링됨
})
// tap(): 로깅만 하고 데이터는 그대로 통과시킬 때
.tap(signal -> {
if (signal.isOnNext()) {
// ✅ 이 블록 안에서 ThreadLocal 복원됨
Order order = (Order) signal.get();
log.info("[{}] 주문 통과: {}", MDC.get("traceId"), order.getId());
}
})
.doOnNext(order -> {
// ❌ 여기서는 ThreadLocal 복원 안 됨! (Default 모드에서)
log.info("처리 완료: {}", order.getId()); // traceId 없음
});
}Code language: PHP (php)
🤔 sink와 signal은 뭔가요?
매개변수 타입 역할 sinkSynchronousSink<T>데이터를 다음 연산자로 내보내는 출구 signalSignal<T>현재 발생한 이벤트를 읽는 정보 객체 sink (handle에서 사용):
sink.next(value); // 값을 다음 연산자로 전달 sink.error(exception); // 에러 발생 // sink.next()를 안 부르면 → 해당 항목 필터링Code language: PHP (php)signal (tap에서 사용):
signal.isOnNext() // 데이터가 흘러왔는지 확인 signal.isOnError() // 에러가 발생했는지 확인 signal.isOnComplete() // 스트림이 완료됐는지 확인 signal.get() // onNext인 경우 실제 값 가져오기 signal.getThrowable() // onError인 경우 예외 가져오기Code language: JavaScript (javascript)
🤔 handle() vs tap() 언제 뭘 쓰나요?
- handle(): 데이터를 변환하거나 조건부로 필터링해야 할 때.
map()+filter()+ ThreadLocal 복원을 한번에.- tap(): 데이터는 건드리지 않고 로깅/메트릭만 찍을 때. 데이터가 그대로 다음 연산자로 흘러감.
둘 다 ThreadLocal을 복원하므로, 필요에 따라 선택하면 됩니다.
권장사항:
| 상황 | 권장 모드 | 이유 |
|---|---|---|
| 일반적인 서비스 | Automatic | 편의성 우선, 성능 충분 |
| 고성능 서비스 (높은 TPS) | Default + handle()/tap() | 필요한 곳에서만 복원 |
| 마이그레이션 중 | Automatic | 빠른 전환, 이후 최적화 |
| 로깅이 적은 서비스 | Default | 오버헤드 최소화 |
4. 라이브러리 내부 로깅에서 traceId 유실되는 경우 예시
증상 예시: 애플리케이션 코드의 로그에는 traceId가 잘 나오는데, Reactive Mongo Client, R2DBC 드라이버 등 라이브러리의 DEBUG 로그에서는 traceId가 비어있음
14:23:45.123 [abc123,111aaa] INFO c.e.OrderService - 주문 조회 시작 // ✅ 정상
14:23:45.124 [abc123,111aaa] DEBUG c.m.r.c.internal - Executing query // ✅ 정상 (체인 안)
14:23:45.125 [,] DEBUG c.m.r.c.internal - Socket connected // ❌ 유실 (체인 밖)
14:23:45.126 [,] DEBUG c.m.r.c.internal - Sending bytes // ❌ 유실 (체인 밖)
14:23:45.234 [abc123,222bbb] INFO c.e.OrderService - 주문 조회 완료 // ✅ 정상Code language: JavaScript (javascript)
같은 라이브러리인데 어떤 로그는 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를 전파하는지, 그리고 각 방식의 장단점을 비교해보겠습니다.
5. 테스트 시 Context 설정
@Test
void testWithContext() {
StepVerifier.create(
orderService.createOrder(request)
.contextWrite(ctx -> ctx.put("traceId", "test-trace-id"))
)
.expectNextMatches(response -> response.getOrderId() != null)
.verifyComplete();
}Code language: JavaScript (javascript)
MVC vs WebFlux Context 전파 비교
2편(MVC)과 3편(WebFlux)의 내용을 정리하면:
| 구분 | Spring MVC | Spring WebFlux |
|---|---|---|
| 스레딩 모델 | Thread-per-Request | Event Loop |
| Context 저장소 | ThreadLocal | Reactor Context |
| 스레드 전환 시 | 별도 처리 필요 (@Async 등) | 자동 유지 |
| MDC 연동 | 직접 동작 | Context Propagation 필요 |
| 설정 복잡도 | 낮음 | 중간 |
| 성능 오버헤드 | 낮음 | 중간 (Automatic 모드) |
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 체인을 따라 데이터를 전파합니다.
핵심 포인트를 정리하면:
- Reactor Context는 Subscriber에 바인딩: 스레드가 바뀌어도 구독 체인은 유지되므로 Context도 유지됩니다.
- Bottom-Up 전파: Context는
subscribe()에서 시작해 위로 전파됩니다.contextWrite()는 항상 체인의 아래쪽에 배치하세요. - Context Propagation으로 브릿지: MDC 등 ThreadLocal 기반 라이브러리와 연동하려면 Micrometer Context Propagation을 사용합니다.
- Spring Boot 3.2+는 간단:
spring.reactor.context-propagation=auto한 줄로 자동 설정됩니다.
다음 글에서는 Kotlin Coroutine에서의 Context Propagation을 다룹니다. CoroutineContext와 Reactor Context, ThreadLocal이 어떻게 협력하는지 살펴보겠습니다.
참고 자료
- Context Propagation with Project Reactor 1 – The Basics
- Context Propagation with Project Reactor 2 – The bumpy road of Spring Cloud Sleuth
- Context Propagation with Project Reactor 3 – Unified Bridging
- Reactor Core Reference – Adding a Context to a Reactive Sequence
- Reactor Core Reference – Context-Propagation Support
- Micrometer Context Propagation Documentation
- Spring Boot 3.2 Release Notes