이전 글에서 Reactor Context가 WebFlux의 Event Loop 환경에서 어떻게 traceId를 유지하는지 살펴봤습니다. Subscriber 체인에 Context를 바인딩하는 방식으로 스레드 전환 문제를 해결했죠.
그런데 Kotlin을 사용한다면 상황이 조금 더 복잡해집니다. Kotlin Coroutine은 CoroutineContext라는 자체 Context 시스템을 가지고 있기 때문입니다. 이제 우리가 다뤄야 할 Context가 세 가지가 되었습니다.
flowchart LR
subgraph "세 가지 Context"
TL["ThreadLocal<br/>(MDC, TraceContext)"]
RC["Reactor Context<br/>(WebFlux)"]
CC["CoroutineContext<br/>(Kotlin Coroutine)"]
end
TL <-->|"Context Propagation"| RC
RC <-->|"ReactorContext"| CC
TL <-->|"ThreadContextElement"| CC
Spring WebFlux에서 Kotlin Coroutine을 사용하면, 이 세 가지 Context가 모두 얽히게 됩니다. traceId가 제대로 전파되려면 이들 사이의 브릿지가 잘 설정되어야 합니다.
이번 글에서는 CoroutineContext의 동작 원리를 이해하고, Spring Boot 3에서 세 Context를 자연스럽게 연결하는 방법을 알아보겠습니다.
Coroutine에서 ThreadLocal이 안 되는 이유
suspend와 스레드 전환
Coroutine의 핵심은 중단(suspend)과 재개(resume) 입니다. suspend 함수가 실행되다가 I/O 등으로 대기가 필요하면 잠시 중단되고, 결과가 준비되면 다시 재개됩니다. 문제는 재개될 때 다른 스레드에서 실행될 수 있다는 것입니다.
🤔 Coroutine도 WebFlux처럼 Event Loop인가요?
아닙니다. Coroutine은 Dispatcher가 스레드 풀에서 가용한 스레드를 할당하는 방식입니다. suspend 후 resume될 때 같은 스레드가 비어있지 않으면 다른 스레드가 할당될 수 있습니다. Event Loop와는 다른 메커니즘이지만, **“스레드가 바뀔 수 있다”**는 점에서 ThreadLocal 문제가 동일하게 발생합니다.
suspendfunprocessOrder(orderId: String) {
// 1️⃣ Thread-1에서 실행
MDC.put("traceId", "abc123")
log.info("[${MDC.get("traceId")}] 주문 처리 시작") // ✅ abc123
delay(100) // suspend point! 💤
// 2️⃣ Thread-2에서 재개될 수 있음
log.info("[${MDC.get("traceId")}] 주문 처리 완료") // ❌ null!
}
실행 결과:
[abc123] 주문 처리 시작 // Thread-1
[null] 주문 처리 완료 // Thread-2 (ThreadLocal 값 없음!)
sequenceDiagram
participant T1 as Thread-1
participant T2 as Thread-2
participant C as Coroutine
T1->>C: 실행 시작
Note over T1: MDC.put("traceId", "abc123")
T1->>C: delay(100) - suspend
Note over T1: 스레드 반납
Note over C: 💤 대기 중...
T2->>C: resume (재개)
Note over T2: MDC.get("traceId") = null ❌
T2->>C: 실행 완료
Reactor Context와 같은 문제, 다른 해결책
3편에서 본 Reactor의 문제와 본질적으로 같습니다. 스레드가 바뀌면 ThreadLocal 값이 사라지죠. 하지만 해결 방식은 다릅니다.
환경
문제 원인
해결 방식
Reactor
연산자마다 스레드 전환
Reactor Context (Subscriber에 바인딩)
Coroutine
suspend/resume 시 스레드 전환
CoroutineContext (Coroutine에 바인딩)
Coroutine은 자체적인 Context 시스템이 있고, 이것이 Coroutine의 전체 생명주기 동안 유지됩니다.
CoroutineContext 이해하기
Context는 Element들의 집합
CoroutineContext는 여러 Element들의 집합입니다. 각 Element는 고유한 Key를 가지며, Context에서 Key로 Element를 조회할 수 있습니다.
// CoroutineContext의 주요 Element들
val context: CoroutineContext=
Job() +// 코루틴 생명주기 관리
Dispatchers.IO +// 실행 스레드 결정
CoroutineName("order-worker") // 디버깅용 이름
// Key로 Element 조회
val job = context[Job]
val dispatcher = context[CoroutineDispatcher]
val name = context[CoroutineName]
flowchart LR
CC["CoroutineContext"]
CC --> E1["Job"]
CC --> E2["Dispatcher"]
CC --> E3["CoroutineName"]
CC --> E4["..."]
각 Element는 고유한 Key로 조회할 수 있습니다: context[Job], context[CoroutineDispatcher], context[CoroutineName]
Context 결합: + 연산자
Context는 + 연산자로 결합할 수 있습니다. 같은 Key의 Element가 있으면 오른쪽 것이 왼쪽을 덮어씁니다.
val base = Dispatchers.Default +CoroutineName("base")
// base = {Dispatcher: Default, CoroutineName: "base"}
SecurityContextHolder.setContext(oldState) // 원래 값 복원
}
}
}
💡 핵심 포인트
SecurityCoroutineContext()를 호출할 때마다 새 인스턴스가 생성되고, 그 시점의 SecurityContext가 캡처됩니다.
Coroutine이 resume될 때 → 캡처해둔 값을 ThreadLocal에 설정
Coroutine이 suspend될 때 → ThreadLocal을 원래 값으로 복원
스레드가 바뀌어도 CoroutineContext에 저장된 값이 매번 ThreadLocal에 복원됩니다.
참고: SecurityContextHolder는 Spring Security의 ThreadLocal 기반 싱글톤입니다. getContext()로 현재 스레드의 SecurityContext를 가져오고, setContext()로 설정합니다.
동작 흐름:
sequenceDiagram
participant App as 애플리케이션
participant SCE as SecurityCoroutineContext
participant TL as ThreadLocal<br/>(SecurityContextHolder)
participant Code as Coroutine 코드
App->>SCE: 인스턴스 생성
Note over SCE: 현재 SecurityContext 캡처
App->>Code: launch(SecurityCoroutineContext()) 시작 (Thread-1)
SCE->>TL: updateThreadContext()
Note over TL: 캡처한 값 설정<br/>기존 값 oldState로 반환
Code->>Code: 코드 실행
Note over Code: SecurityContextHolder<br/>.getContext() ✅
Code->>Code: delay() - suspend
SCE->>TL: restoreThreadContext(oldState)
Note over TL: 기존 값으로 복원
Note over Code: Thread-1 반납
rect rgb(255, 245, 238)
Note over Code: Thread-2에서 resume
end
SCE->>TL: updateThreadContext()
Note over TL: 캡처한 값 다시 설정
Code->>Code: 코드 계속 실행
Note over Code: SecurityContextHolder<br/>.getContext() ✅
Code->>App: Coroutine 완료
SCE->>TL: restoreThreadContext(oldState)
Note over TL: 기존 값으로 복원
사용 예시:
// 현재 SecurityContext가 캡처됨
launch(SecurityCoroutineContext()) {
// Thread-1에서 실행
val userName = SecurityContextHolder.getContext().authentication.name
log.info("사용자: $userName") // ✅ 정상 출력
delay(100) // 스레드 전환 발생!
// Thread-2에서 실행 - ThreadContextElement가 SecurityContext를 복원해줌
val sameUser = SecurityContextHolder.getContext().authentication.name
log.info("여전히 같은 사용자: $sameUser") // ✅ 동일한 값!
}
ThreadContextElement가 없었다면 delay() 이후 SecurityContextHolder.getContext()는 빈 SecurityContext를 반환하거나 예외가 발생했을 것입니다.
MDCContext()는 생성 시점의 MDC 값을 캡처합니다. suspension 후 재개될 때, 캡처해둔 값을 ThreadLocal에 복원합니다. Coroutine 내부에서 변경한 값은 캡처본에 반영되지 않습니다.
sequenceDiagram
participant Code as Coroutine 코드
participant MDCCtx as MDCContext
participant MDC as MDC (ThreadLocal)
Note over MDCCtx: 생성 시 {traceId: abc123} 캡처
Code->>MDC: MDC.put("traceId", "xyz789")
Note over MDC: 현재 값: xyz789
Code->>Code: delay(100) - suspend
Note over MDCCtx: resume 시 캡처본 복원
MDCCtx->>MDC: MDC.put("traceId", "abc123")
Note over MDC: 현재 값: abc123 (변경 유실!)
해결: withContext(MDCContext())로 새 캡처본 생성
변경된 MDC 값을 유지하려면 withContext(MDCContext())로 새로운 캡처본을 만들어야 합니다.
MDC.put("traceId", "abc123")
launch(MDCContext()) {
log.info("[${MDC.get("traceId")}]") // abc123
MDC.put("traceId", "xyz789")
// 새 MDCContext 생성 → 현재 MDC 값(xyz789) 캡처
withContext(MDCContext()) {
delay(100)
log.info("[${MDC.get("traceId")}]") // ✅ xyz789
}
}
💡 실무 팁
일반적인 tracing에서는 요청 시작 시 설정된 traceId를 그대로 사용합니다. Coroutine 내부에서 MDC를 변경할 일은 거의 없으므로, 이 문제를 겪을 일은 많지 않습니다. 하지만 baggage 같은 동적 값을 다룬다면 주의가 필요합니다.
Reactor Context ↔ Coroutine 통합
WebFlux + Coroutine 조합의 복잡성
Spring WebFlux에서 Kotlin Coroutine을 사용하면, 두 가지 Context 시스템이 만납니다:
Reactor Context: WebFlux의 Subscriber 체인에 바인딩
CoroutineContext: Kotlin Coroutine에 바인딩
@RestController
classOrderController {
@GetMapping("/orders/{id}")
suspendfungetOrder(@PathVariable id: String): Order {
val mono =mono(ReactorContext(Context.of("traceId", "abc123"))) {
// ...
}
Spring WebFlux의 자동 통합
좋은 소식은, Spring WebFlux가 이 통합을 자동으로 처리한다는 것입니다. suspend 함수로 선언된 Controller 메서드는 내부적으로 Reactor의 mono { } 빌더로 감싸지고, Reactor Context가 자동으로 CoroutineContext에 주입됩니다.
맞습니다! Spring은 suspend 함수의 결과를 Mono로 변환해서 Reactor 파이프라인에 연결합니다. 이 과정에서 mono 빌더가 자동으로 현재 Reactor Context를 CoroutineContext에 주입합니다. 결과적으로 suspend 함수 내부에서 Reactor Context에 접근할 수 있게 됩니다.
flowchart TB
subgraph "HTTP 요청 처리 흐름"
A["HTTP 요청"] --> B["WebFilter<br/>(Observation 생성)"]
B --> C["Reactor Context에<br/>traceId 저장"]
C --> D["Controller 호출"]
end
subgraph "Spring 내부 변환"
D --> E["suspend fun을<br/>mono { }로 감싸기"]
E --> F["ReactorContext를<br/>CoroutineContext에 주입"]
end
subgraph "Coroutine 실행"
F --> G["suspend 함수 실행"]
G --> H["coroutineContext[ReactorContext]로<br/>Reactor Context 접근 가능"]
end
Spring WebFlux는 Flow를 Flux로 변환해서 처리하며, 이 과정에서 Context가 자동으로 전파됩니다.
실행 결과 (getOrder):
14:23:45.123 [abc123,111aaa] INFO OrderController - 주문 조회 시작: 123
14:23:45.230 [abc123,111aaa] INFO OrderController - DB 조회 전
14:23:45.456 [abc123,111aaa] INFO OrderController - 주문 조회 완료
실행 결과 (getAllOrders – Flow):
14:24:01.100 [def456,222bbb] INFO OrderController - 전체 주문 조회 시작
14:24:01.150 [def456,222bbb] INFO OrderController - 주문 emit: order-1
14:24:01.160 [def456,222bbb] INFO OrderController - 주문 emit: order-2
14:24:01.170 [def456,222bbb] INFO OrderController - 주문 emit: order-3
🤔 왜 MDCContext()를 직접 설정하지 않아도 되나요?
Spring Boot 3.2+에서 spring.reactor.context-propagation=auto를 설정하면 다음 과정이 자동으로 이루어집니다:
요청 시작 시 Micrometer Observation이 traceId/spanId 생성
이 값들이 Reactor Context에 저장됨
Spring이 suspend 함수를 호출할 때 ReactorContext(Element)가 CoroutineContext에 주입됨
Coroutine이 실행/재개될 때, Context Propagation 라이브러리가 Reactor Context의 값을 읽어서 ThreadLocal(MDC)에 복원
Coroutine이 suspend될 때, ThreadLocal 값 정리
핵심은 4번입니다. Context Propagation 라이브러리가 Reactor Context → ThreadLocal 방향의 복원을 자동으로 처리합니다. CoroutineContext에 ReactorContext가 주입되어 있기 때문에, Coroutine 세계에서도 이 메커니즘이 동작하는 것입니다.
즉, MDCContext는 Coroutine 자체 메커니즘이고, Context Propagation은 Reactor Context ↔ ThreadLocal 자동 연결입니다. Spring Boot 3에서는 후자가 기본으로 활성화되어 있어서 별도 설정이 필요 없습니다.
이 과정이 모두 자동화되어 있어서, 개발자는 그냥 suspend 함수를 작성하면 됩니다.
전체 Context 전파 흐름 정리
HTTP 요청부터 Coroutine 내부까지 Context가 어떻게 전파되는지 전체 흐름을 정리해보겠습니다.
sequenceDiagram
participant Client
participant Filter as WebFilter
participant RC as Reactor Context
participant Spring as Spring Framework
participant CC as CoroutineContext
participant CP as Context Propagation
participant TL as ThreadLocal (MDC)
participant Code as suspend 함수
Client->>Filter: HTTP 요청 (Thread-1)
Filter->>RC: Observation 생성, traceId 저장
RC->>Spring: Controller 호출
Spring->>CC: ReactorContext(Element) 주입
Note over Spring,CC: mono(Dispatchers.Unconfined) { ... }
Note over CC: CoroutineContext에<br/>ReactorContext 포함됨
CC->>CP: Coroutine 실행 시작
CP->>TL: Reactor Context → ThreadLocal 복원
TL->>Code: suspend 함수 실행 (Thread-1)
Note over Code: MDC.get("traceId") ✅
Code->>Code: delay() - suspend
CP->>TL: ThreadLocal 정리
Note over Code: Thread-1 반납
rect rgb(255, 245, 238)
Note over RC: Reactor 스케줄러가<br/>Thread-2 할당
Note over CC: CoroutineContext는<br/>그대로 유지됨<br/>(ReactorContext 포함)
end
CC->>CP: Coroutine resume (Thread-2)
CP->>TL: Reactor Context → ThreadLocal 복원
Note over CP: ReactorContext에서<br/>Reactor Context 읽어서<br/>ThreadLocal에 설정
TL->>Code: 계속 실행 (Thread-2)
Note over Code: MDC.get("traceId") ✅
Code->>Client: 응답
핵심 포인트: 스레드가 바뀌어도 CoroutineContext는 그대로 유지됩니다. CoroutineContext 안에 ReactorContext (Element) 가 있고, 그 안에 Reactor Context (저장소) 가 있습니다. Coroutine이 새 스레드에서 resume될 때, Context Propagation 라이브러리가 Reactor Context의 값을 읽어서 ThreadLocal에 복원합니다.
ReactorContext: kotlinx-coroutines-reactor가 제공하는 CoroutineContext.Element (kotlinx.coroutines.reactor.ReactorContext)
이름이 비슷해서 헷갈릴 수 있습니다:
Reactor Context = 저장소 (traceId 등의 값이 들어있음)
ReactorContext = 그 저장소를 CoroutineContext에 담아서 전달하는 Element(래퍼)
Spring이 suspend 함수를 호출할 때, ReactorContext (Element) 를 CoroutineContext에 주입합니다. 그 안에 Reactor Context (저장소) 가 들어있어서, Coroutine 세계에서도 Reactor Context에 접근할 수 있습니다.
주의사항과 트러블슈팅
1. GlobalScope.launch는 Context를 상속하지 않습니다
GlobalScope는 빈 Context를 가지므로, 부모의 Context가 전파되지 않습니다.
@GetMapping("/orders/{id}")
suspendfungetOrder(@PathVariable id: String): Order {
log.info("시작") // ✅ traceId 있음
// ❌ 잘못된 사용!
GlobalScope.launch {
log.info("비동기 작업") // ❌ traceId 없음!
}
return orderService.findById(id)
}
해결: coroutineScope 또는 주입받은 CoroutineScope를 사용하세요.
@GetMapping("/orders/{id}")
suspendfungetOrder(@PathVariable id: String): Order {
coroutineScope {
launch {
log.info("비동기 작업") // ✅ traceId 상속됨
}
}
return orderService.findById(id)
}
2. runBlocking 사용 시 Context 전달
runBlocking으로 Coroutine을 시작할 때는 Context를 명시적으로 전달해야 합니다.
ThreadContextElement로 ThreadLocal 전파: asContextElement()나 MDCContext를 사용하면 suspend/resume 시에도 ThreadLocal 값이 복원됩니다.
ReactorContext로 두 세계 연결: Spring WebFlux가 자동으로 Reactor Context를 CoroutineContext에 주입합니다.
Spring Boot 3.2+ 자동 설정: spring.reactor.context-propagation=auto 한 줄로 대부분의 상황에서 자동 전파가 됩니다.
GlobalScope 사용 금지: Context가 상속되지 않으므로, coroutineScope나 structured concurrency를 사용하세요.
flowchart LR
subgraph "Context 전파 체인"
TL["ThreadLocal<br/>(MDC)"]
RC["Reactor<br/>Context"]
CC["Coroutine<br/>Context"]
end
TL <-->|"Context<br/>Propagation"| RC
RC <-->|"Reactor<br/>Context"| CC
TL <-->|"Thread<br/>ContextElement"| CC
style TL fill:#e1f5fe
style RC fill:#fff3e0
style CC fill:#f3e5f5
다음 글에서는 Java Agent vs Library Instrumentation을 다룹니다. 3편에서 언급한 “라이브러리 내부 로깅에서 traceId 유실” 문제를 Java Agent가 어떻게 해결하는지, 그리고 두 접근 방식의 장단점을 비교해보겠습니다.