본문 바로가기
language, framework, library/kotlin

Kotlin Coroutine에서의 TraceId는 어떻게 주입하나 ? - Context 전파와 MDCContext

by 사바라다 2024. 7. 8.
반응형

코루틴을 사용하면서 발생하는 주요 문제 중 하나는 실행 컨텍스트의 전파입니다. 특히 분산 시스템에서 로깅과 추적을 위해 중요한 역할을 하는 traceId와 MDC(Mapped Diagnostic Context)의 사용이 이에 해당합니다. 이번 글에서는 코루틴 환경에서 traceId와 MDC를 어떻게 효과적으로 전파할 수 있는지 알아보겠습니다.

요약

  • traceId란 무엇이고 어떻게 활용할 수 있는가 ?
    • traceId는 분산 시스템에서 요청을 추적하고 각 서비스간의 연관성을 식별하는 데 사용되는 유니크한 식별자
  • ThreadLocal과 MDC(mapped diagnostic contexts)란 무엇인가?
    • ThreadLocal은 자바에서 제공하는 클래스로, 각 쓰레드가 자신만의 데이터 복사본을 갖도록 해서, 쓰레드 사이에서 데이터 공유 없이 독립적으로 데이터를 유지하게 하는 메커니즘
    • MDC(Mapped Diagnostic Context)는 로깅 프로세스에서 중요한 정보를 관리하는 방법으로, 특히 멀티스레드 환경에서 로그를 더 읽기 쉽고 유용하게 만들기 위해 사용
  • Coroutine에서 ThreadLocal이 통하지 않는 이유
    • 코루틴은 경량 스레드(Light Thread)로, 하나의 스레드에서 여러 코루틴이 실행
  • Coroutine에서의 이 문제 해결을 위한 방법
    • 코루틴에서는 CoroutineContext를 사용하여 실행 컨텍스트를 관리

상세

traceId란 무엇이며 활용성에 대해서

traceId는 분산 시스템에서 요청을 추적하고 각 서비스간의 연관성을 식별하는 데 사용되는 유니크한 식별자입니다. traceId를 설정하고 전파하여 개발자는 다음을 달성할 수 있습니다.

  • 성능 모니터링: 서비스 간 호출의 지연 시간과 성능 병목 현상을 식별할 수 있습니다.
  • 오류 추적: 에러가 발생했을 때, 어느 서비스에서 문제가 시작되었는지 쉽게 파악할 수 있습니다.
  • 로그의 상관관계 분석: 로그 데이터를 통합하여 전체 트랜잭션을 보다 명확하게 이해할 수 있습니다.

traceId는 보통 x-trace-Id 등과 같은 http header를 통해서 같은 값이 전파되고 사용됩니다. 예를 들어 A -> B -> C로 통신이 이루어지는 서비스가 있다고 한다면 A 서버가 만든 TraceId를 B 서버에 header를 통해서 전달하고, B는 C에게 전달하며 로깅을남깁니다. 그리고 elasticsearch 등의 datasource에서 traceId를 통해서 검색을 하면 시간 기반으로 A, B, C의 서비스가 요청에 대해서 어떻게 함께 반응했는지 추적할 수 있는 기반이 되는것입니다.

관련한 정보는 이전에 제가 기술한 [MSA] Spring Cloud Sleuth와 Zipkin을 이용한 분산 시스템 Tracing_1 포스팅을 참조해주시면 그림으로 그려두었기에 더욱 이해하시기 쉬우실 겁니다.

ThreadLocal과 MDC(mapped diagnostic contexts)란 무엇인가?

traceId를 잘 활용하기 위해서는 각 서비스에서 이를 잘 보존하고 있어야하며, 요청에 의한 로깅에 모두 traceId를 함께 노출하여야합니다. 그리고 각 서비스에서 요청이 나갈때 이를 포함해서 전달해야합니다. 서비스에서 요청에 의한 모든 로깅에 traceId를 잘 기록하기 위해서는 요청 단위에 따라 독립적인 traceId를 계속 가지고 있어야합니다. Java의 Servlet 환경에서 이를 가능하게 해주는것이 ThreadLocal과 MDC(mapped diagnostic contexts)라는 것입니다.

ThreadLocal은 자바에서 제공하는 클래스로, 각 쓰레드가 자신만의 데이터 복사본을 갖도록 해서, 쓰레드 사이에서 데이터 공유 없이 독립적으로 데이터를 유지하게 하는 메커니즘입니다. 이는 멀티스레드 환경에서 쓰레드 간의 데이터 격리를 필요로 할 때 매우 유용하게 사용됩니다. [java] ThreadLocal에 관하여 자료를 참고해주시면 이를 좀 더 잘 이해하실 수 있을것입니다.

MDC(Mapped Diagnostic Context)는 로깅 프로세스에서 중요한 정보를 관리하는 방법으로, 특히 멀티스레드 환경에서 로그를 더 읽기 쉽고 유용하게 만들기 위해 사용됩니다. MDC는 각 로그 이벤트와 관련된 데이터를 저장하는 키-값 저장소로, 로그 메시지에 추가적인 컨텍스트를 제공하여 분석과 디버깅을 쉽게 할 수 있도록 합니다.

MDC는 Key-Value 쌍(Map)을 사용하여 로깅하는 동안 필요한 정보를 저장합니다. 예를 들어, 사용자 ID(UserID)나 TraceID와 같은 정보는 각 요청 또는 작업과 함께 로그에 자동으로 삽입되면 사용범위가 좋을 수 있습니다. 이는 개발자가 코드에서 수동으로 로그에 이러한 정보를 추가하는 대신, MDC를 설정해두면 로거가 자동으로 해당 정보를 로그 메시지에 포함하게 합니다. 로그 메시지 생성 시점에만 MDC에서 정보를 가져와 사용하므로, 성능에 미치는 영향을 최소화하면서도 유용한 정보를 로그에 추가할 수 있습니다.

import org.slf4j.MDC;

// 요청 처리 시작 시
MDC.put("userId", user.getId());
MDC.put("transactionId", transaction.getId());

// 이후 발생하는 모든 로그에는 자동으로 userId와 transactionId가 포함됩니다.
logger.info("Processing started"); // {"message" : "Processing started", "userId" : "1234", "transactionId" : "djfksdfhvcvkdce"}

// 요청 처리 완료 후
MDC.clear();

Coroutine에서 ThreadLocal이 통하지 않는 이유

코루틴은 경량 스레드(Light Thread)로, 하나의 스레드에서 여러 코루틴이 실행될 수 있습니다. 반대로 하나의 비지니스 또한 여러 스레드에 걸쳐서 실행됩니다. 따라서 Blocking 없는 실행이 가능합니다. 하지만 이로인해 전통적인 ThreadLocal 방식은 코루틴에 적합하지 않습니다. 코루틴이 다른 스레드로 이동하거나, 스레드 간에 자유롭게 전환되면서 ThreadLocal에 저장된 데이터가 손실되거나 예상치 못한 방식으로 공유될 위험이 있기 때문입니다.

ThreadLocal은 데이터를 스레드의 생명 주기와 연결합니다. 각 스레드는 자신만의 ThreadLocal 데이터를 가지고, 다른 스레드는 이 데이터에 접근할 수 없습니다. 하지만 코루틴은 여러 스레드에서 수행될 수 있으며, 하나의 코루틴이 여러 스레드로 이동하면서 실행될 수 있습니다. 따라서, 코루틴이 스레드 간에 전환될 때 ThreadLocal에 저장된 데이터가 올바르게 전달되지 않을 수 있습니다.

예를 들어, 하나의 코루틴이 스레드 A에서 시작해 ThreadLocal 변수에 값을 저장하고, I/O 작업 후 스레드 B에서 재개된다면, 스레드 B에서는 ThreadLocal 변수의 값을 복원할 수 없습니다. 이는 ThreadLocal의 값이 스레드 A에만 존재하기 때문입니다.

Coroutine에서의 문제 해결을 위한 방법

코루틴에서는 CoroutineContext를 사용하여 실행 컨텍스트를 관리할 수 있습니다. 이를 통해 코루틴이 쓰레드 간에 이동하더라도 필요한 정보가 유지될 수 있도록 합니다. CoroutineContext는 여러 요소를 포함할 수 있으며, 이 중 Job과 CoroutineDispatcher는 코루틴의 실행과 스케줄링에 직접적으로 관련됩니다.

Kotlin 코루틴 라이브러리는 이러한 문제를 해결하기 위해 CoroutineContext를 제공합니다. CoroutineContext는 코루틴의 실행 상태를 포함한 메타데이터를 담고 있으며, 코루틴이 실행되는 동안 일관되게 유지됩니다.

코루틴 스코프나 특정 코루틴 내에서 데이터를 공유하고 싶다면, ThreadLocal.asContextElement 확장 함수를 사용하여 ThreadLocal 값을 코루틴 컨텍스트 요소로 변환할 수 있습니다. 이 방법을 사용하면 코루틴이 다른 스레드로 이동할 때 ThreadLocal의 값이 적절히 복원되어 코루틴이 계속해서 같은 값을 사용할 수 있습니다. 아래는 코루틴으로 실제로 테스트한 코드입니다.

runBlocking {
    val threadLocal = ThreadLocal<String>().apply { set("initial") }

    println("[${currentCoroutineContext()}] Main thread: ${threadLocal.get()}")  // 출력: initial

    val job = launch(Dispatchers.Default + threadLocal.asContextElement()) { // threadLocal.asContextElement를 제외하면 이하 값들의 출력은 null
        println("[${currentCoroutineContext()}] Launch start: ${threadLocal.get()}")  // 출력: initial
        yield()
        println("[${currentCoroutineContext()}] After yield: ${threadLocal.get()}")  // 출력: initial
    }
    job.join()
    println("[${currentCoroutineContext()}] Main thread after launch: ${threadLocal.get()}")  // 출력: initial
}

Coroutine에서의 MDCContext 코드 예제

위의 coroutineContext을 직접 사용하여 context를 넘겨주어도 좋지만 이미 coroutine 환경에서 쉽게 사용할 수 있는 coroutineContext 기반의 MDC를 적용한 MDCContext라는 것이 있습니다. MDCContext는 Kotlin 코루틴과 관련하여 ThreadLocal의 데이터를 코루틴 컨텍스트로 전파하는 메커니즘을 구현하는 것을 포함하며, 로깅에 특히 유용하게 사용됩니다. 위의 예제에서 보여준 ThreadLocal.asContextElement 방식과 비교하여, MDCContext는 특히 로깅 라이브러리의 MDC (Mapped Diagnostic Context) 기능을 코루틴과 함께 사용할 때 유용합니다.

MDC 정보는 로그에 매우 중요한 컨텍스트를 제공하며, 이 정보가 로그와 정확히 일치해야 합니다. 코루틴이 여러 스레드에 걸쳐 실행될 수 있기 때문에, MDC 정보가 정확하게 유지되지 않으면 로그가 잘못된 정보를 반영할 수 있습니다.

MDCContext를 사용하면 코루틴이 스레드를 변경할 때 자동으로 MDC 정보가 올바르게 관리되고 유지됩니다. 이를 통해 로그 데이터의 일관성과 정확성을 보장할 수 있으며, 문제 추적 및 분석이 용이해집니다.

runBlocking {
    MDC.put("requestId", "123abc")
    println("Main thread: ${MDC.get("requestId")}")  // 출력: 123abc

    val job = launch(Dispatchers.Default + MDCContext()) {
        println("Launch start: ${MDC.get("requestId")}")  // 출력: 123abc
        yield()
        println("After yield: ${MDC.get("requestId")}")  // 출력: 123abc
    }
    job.join()
    println("Main thread after launch: ${MDC.get("requestId")}")  // 출력: 123abc
}

참조

반응형

댓글