Java Reactive Programming 핵심 정리 시리즈 12편. Reactor Context 완전 정복 — ThreadLocal 한계, Context downstream→upstream 전파 방향, contextWrite 위치 규칙, deferContextual 읽기 패턴, 인증/트레이싱/MDC 실용 패턴까지 비유와 시험 함정을 곁들여 정리.
이 글은 Java Reactive Programming 핵심 정리 시리즈의 열두 번째 편입니다. Spring MVC에서는 ThreadLocal이 요청 범위 데이터(사용자 ID, 인증 토큰, 트레이스 ID)를 전파하는 표준 방법이었어요. 그런데 Reactive 파이프라인에서 스레드가 바뀌는 순간 ThreadLocal 값이 사라집니다. Reactor Context는 그 문제를 정확히 해결하기 위해 만들어진 도구예요.
이 시리즈는 Project Reactor 공식 문서와 Reactive Streams 명세를 포함한 공개 학습 자료를 참고해 한국어 학습 노트로 풀어쓴 자료입니다.
IDE에 reactor-core 의존성을 추가하고 예제 코드를 직접 실행해 보세요.
왜 Reactor Context가 처음엔 어렵게 느껴질까요
이유는 네 가지예요.
첫째, 전파 방향이 직관의 반대입니다. 데이터는 위에서 아래로(Publisher → Subscriber) 흐르는데, Context는 아래에서 위로(Subscriber → Publisher) 전파돼요. 코드를 위에서 아래로 읽는 습관 때문에 contextWrite를 파이프라인 맨 위에 쓰고는 "왜 안 보이지?"라며 혼란스러워집니다.
둘째, contextWrite의 위치가 결과를 완전히 바꿉니다. 같은 코드라도 contextWrite가 읽기 연산자보다 위에 있으면 Context가 전달되지 않아요.
셋째, Context가 불변(immutable)이라는 점을 놓칩니다. ctx.put("key", "value")를 호출하면 기존 ctx가 수정되는 게 아니라 새로운 Context가 반환돼요. 반환값을 무시하면 아무 효과도 없습니다.
넷째, deferContextual과 contextWrite의 역할 구분이 헷갈립니다. 하나는 읽기, 하나는 쓰기인데 처음엔 이름에서 그 차이가 바로 읽히지 않아요.
비유로 잡으면 명확해요. Context = 택배 송장에 붙은 메모 — 흐름과 함께 따라다님. 택배가 출발지(Publisher)에서 도착지(Subscriber)로 흘러갈 때, 송장 메모(Context)는 도착지(Subscriber)에서 출발지(Publisher) 방향으로 붙여집니다. 배달 기사(스레드)가 바뀌어도 송장 메모는 택배 박스에 고정돼 있기 때문에 절대 사라지지 않아요.
ThreadLocal의 한계와 Reactor Context의 등장
ThreadLocal이 왜 Reactive에서 실패하는지 코드로 직접 확인해 봅시다.
// 잘못된 코드: ThreadLocal은 스레드 전환 시 값 손실
ThreadLocal<String> threadLocal = new ThreadLocal<>();
threadLocal.set("Sam");
Mono.just("ok")
.subscribeOn(Schedulers.boundedElastic()) // 스레드 전환!
.map(s -> {
return "User: " + threadLocal.get(); // null 반환!
})
.subscribe(System.out::println);
// 출력: User: null
subscribeOn으로 스레드가 전환되는 순간 ThreadLocal 값은 증발합니다. 반면 Context는 스트림에 종속돼 있어서 스레드가 바뀌어도 안전하게 따라다녀요.
// 올바른 코드: Context는 스레드 전환에도 안전
Mono.just("ok")
.subscribeOn(Schedulers.boundedElastic())
.flatMap(s -> Mono.deferContextual(ctx ->
Mono.just("User: " + ctx.getOrDefault("user", "unknown"))
))
.contextWrite(ctx -> ctx.put("user", "Sam"))
.subscribe(System.out::println);
// 출력: User: Sam (스레드가 바뀌어도 안전!)
| 비교 항목 | ThreadLocal | Context |
|---|---|---|
| 종속 대상 | 스레드 | 리액티브 스트림 |
| 스레드 전환 시 | 데이터 손실 | 안전하게 전파 |
| 불변성 | 가변 | 불변 (새 Context 반환) |
| Reactive 지원 | X | O |
Reactor Context 전파 방향 — downstream에서 upstream으로
가장 중요한 개념입니다. contextWrite는 downstream(Subscriber) → upstream(Publisher) 방향으로 전파됩니다. 코드로 보면 아래에서 위로 흐른다는 의미예요.
// contextWrite 위치 규칙: 읽기 연산자 아래에 위치해야 함
Mono.deferContextual(ctx -> Mono.just(ctx.get("key"))) // 읽기 (위)
.contextWrite(ctx -> ctx.put("key", "value")) // 쓰기 (아래, 올바름!)
.subscribe(System.out::println);
// 출력: value
// 잘못된 코드: contextWrite가 읽기 연산자 위에
Mono.just("start")
.contextWrite(ctx -> ctx.put("key", "value")) // 위에 있으면 전파 안 됨
.flatMap(s -> Mono.deferContextual(ctx ->
Mono.just(ctx.getOrDefault("key", "not found"))
))
.subscribe(System.out::println);
// 출력: not found (Context가 전달되지 않음!)
// 올바른 코드:
Mono.just("start")
.flatMap(s -> Mono.deferContextual(ctx ->
Mono.just(ctx.getOrDefault("key", "not found"))
))
.contextWrite(ctx -> ctx.put("key", "value")) // 아래에 위치
.subscribe(System.out::println);
// 출력: value
여기서 시험 함정이 하나 있어요. Context 흐름 방향은 데이터 흐름 방향과 반대입니다. 데이터는 위→아래, Context는 아래→위(구독 신호 방향). contextWrite는 항상 읽는 연산자보다 코드상 아래에 위치해야 해요.
Context 전파 방향 정리:
- 데이터 흐름: Publisher → Subscriber (위 → 아래)
- Context 전파: Subscriber → Publisher (아래 → 위)
contextWrite는 읽기 연산자보다 코드상 아래에 위치
contextWrite와 deferContextual — 쓰기와 읽기
contextWrite는 Context에 값을 쓰고, deferContextual은 Context에서 값을 읽습니다.
// deferContextual: 구독 시점에 Context 읽어서 동적으로 Publisher 생성
Mono<String> getWelcomeMessage() {
return Mono.deferContextual(context -> {
if (context.hasKey("user")) {
return Mono.just("Welcome " + context.get("user"));
} else {
return Mono.error(new RuntimeException("unauthenticated"));
}
});
}
// Context 제공 → 성공
getWelcomeMessage()
.contextWrite(context -> context.put("user", "Sam"))
.subscribe(
v -> System.out.println(v),
e -> System.out.println("에러: " + e.getMessage())
);
// 출력: Welcome Sam
// Context 없이 → 실패
getWelcomeMessage()
.subscribe(
v -> System.out.println(v),
e -> System.out.println("에러: " + e.getMessage())
);
// 출력: 에러: unauthenticated
여러 contextWrite를 체이닝하면 아래에 있는 것이 먼저 실행됩니다.
getWelcomeMessage()
.contextWrite(ctx -> {
String current = ctx.getOrDefault("user", "guest");
return ctx.put("user", current.toUpperCase()); // 대문자로 변경 (나중)
})
.contextWrite(ctx -> ctx.put("user", "alice")) // alice 설정 (먼저)
.subscribe(System.out::println);
// 출력: Welcome ALICE
transformDeferredContextual을 쓰면 기존 Flux에 Context 접근을 추가할 수 있어요.
Flux<String> dataStream = Flux.just("a", "b", "c");
dataStream
.transformDeferredContextual((flux, ctx) -> {
String prefix = ctx.getOrDefault("prefix", "");
return flux.map(s -> prefix + s);
})
.contextWrite(ctx -> ctx.put("prefix", "item-"))
.subscribe(System.out::println);
// 출력: item-a, item-b, item-c
Reactor Context 불변성 — put은 새 인스턴스 반환
여기서 시험 함정이 하나 있어요. ctx.put(key, value)는 기존 Context를 수정하지 않고 새로운 Context를 반환합니다. 반환값을 무시하면 아무런 변경이 없어요.
// 잘못된 이해
context.put("key", "value"); // 반환값 무시 → 아무 효과 없음!
// 올바른 사용
Context newCtx = context.put("key", "value"); // 새 Context 반환
// contextWrite에서 올바른 사용
.contextWrite(ctx -> ctx.put("key", "value")) // 람다가 새 ctx 반환
실용 패턴 — 인증, 트레이싱, MDC
Reactor Context의 가장 전형적인 실무 활용은 Spring WebFlux 인증 정보 전파와 분산 트레이싱이에요.
// WebFilter에서 인증 정보 Context에 저장
public class UserContextFilter implements WebFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id");
return chain.filter(exchange)
.contextWrite(ctx -> ctx.put("userId", userId));
}
}
// 서비스에서 Context 읽기
public class OrderService {
public Flux<Order> getOrders() {
return Mono.deferContextual(ctx -> {
String userId = ctx.getOrDefault("userId", "anonymous");
return orderRepository.findByUserId(userId);
});
}
}
// 분산 추적 ID 전파
public Mono<String> processRequest(String requestId) {
return doProcess()
.contextWrite(ctx -> ctx.put("traceId", requestId));
}
private Mono<String> doProcess() {
return Mono.deferContextual(ctx -> {
String traceId = ctx.getOrDefault("traceId", "no-trace");
log.info("[{}] 처리 중...", traceId);
return Mono.just("결과");
});
}
자세한 Context API는 Project Reactor 공식 문서에서 확인할 수 있습니다.
핵심 압축 노트 — 시험 직전 20개
여기까지가 Context 편의 핵심입니다. 빠르게 복습할 수 있게 압축 노트로 마무리합니다.
- Reactor Context = 스트림에 종속된 불변 키-값 저장소 (택배 송장 비유)
ThreadLocal은 스레드 전환 시 값 손실 → Reactive에서 사용 금지- Context는 스레드가 바뀌어도 안전하게 전파됨
- Context 전파 방향: downstream → upstream (Subscriber → Publisher)
- 코드상 방향:
contextWrite는 읽기 연산자보다 아래에 위치 contextWrite위에 있으면 → 읽기 연산자에서 Context 못 찾음- Context는 immutable —
put은 새 Context 반환 (기존 수정 아님) - 반환값 무시 = 아무 효과 없음 → 람다에서 반드시 반환
ctx.put(k, v)— 키-값 추가 (새 Context 반환)ctx.get(k)— 없으면NoSuchElementExceptionctx.getOrDefault(k, default)— 없으면 기본값 (안전)ctx.hasKey(k)— 키 존재 여부 확인ctx.delete(k)— 키 삭제 (새 Context 반환)contextWrite— 쓰기 /deferContextual— 읽기- 여러
contextWrite체이닝 — 아래 것이 먼저 실행 transformDeferredContextual— 기존 Flux에 Context 접근 추가Context.of(k, v, k2, v2)— 초기 Context 생성- 인증 토큰 / 트레이스 ID / 테넌트 ID → Context로 전파
- Spring WebFlux WebFilter에서
contextWrite로 요청 범위 데이터 설정 - put/get 키는 타입 안전(type-safe)하지 않음 — 키 상수화 권장
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.
- 1편 — Reactive Programming 입문
- 2편 — Mono 완전 정복
- 3편 — Flux 완전 정복
- 4편 — 연산자 (map·flatMap·filter·reduce 등)
- 5편 — Hot & Cold Publishers
- 6편 — Threading & Schedulers
- 7편 — Backpressure (배압)
- 8편 — Publisher 결합 (zip·merge·concat 등)
- 9편 — Batching·Windowing·Grouping
- 10편 — Repeat & Retry
- 11편 — Sinks
- 12편 — Context (현재 글)
- 13편 — 단위 테스트 (StepVerifier)