Spring Webflux를 통해 개발하다 보면loggingId, 헤더를 통해 들어온 값 등 여러 context 값을 관리하기 위해 MDC를 사용한다.

많은 블로그에서 Spring Webflux에서 어떻게 MDC를 이용할 수 있는지에 대해 잘 설명하고 있다.

따라서 이 글에서는 MDC를 이용하는 방법에 대해 다시 한번 설명하고, 어떤 문제 점이 있는지, coroutine에서는 어떻게 더 활용할 수 있는지에 대해 작성해보고자 한다.


MDC 및 Context를 어떻게 사용하는지에 대해 생각해보면

  1. Tracing ID를 context에 저장해서 log tracing이 가능하게 한다. (불변성 데이터를 context에 넣고 사용)
  2. 한 Request 안에서 호출되는 모든 함수에서 인자를 통해 값을 넘길 필요 없이 context에 저장된 값을 가져와 사용할 수 있게 한다.

먼저, 불변성 데이터는 어떻게 저장해서 사용하는지에 대해 살펴보며 기본적인 MDC 사용법에 대해 설명한다면,

 

1. WebFilter component를 하나 추가하고, contextWrite에서 context에 저장되길 원하는 값을 추가한다.

@Component
class RequestFilter : WebFilter {

    override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
        return chain
            .filter(exchange)
            .contextWrite {
                it.put("loggingId", UUID.randomUUID().toString())
            }
    }
}

2. MDC를 이용해 값을 저장하고 꺼내쓸 수 있는 utility object를 선언한다.

object RequestContextInfoUtils {
    fun setMdcAttributes(m: Map<String, String?>) = MDC.setContextMap(m)

    private fun setMdcAttribute(name: String, value: String?) = MDC.put(name, value)

    fun getMdcAttribute(name: String): String? = MDC.get(name)

    fun getAllMdcAttributes(): MutableMap<String, String> = 
    	MDC.getCopyOfContextMap() ?: ConcurrentHashMap()

    fun clear() = MDC.clear()

    var loggingId: String?
        get() {
            return getMdcAttribute("loggingId")
        }
        set(value) {
            setMdcAttribute("loggingId", value)
        }
}

3. 스레드의 변경이 있을 때마다 context의 값을 MDC에 넣어주기 위한 MDC context lifter 선언한다.

class MdcContextLifter<T>(private val coreSubscriber: CoreSubscriber<T>) : CoreSubscriber<T> {

    override fun onSubscribe(s: Subscription) {
        coreSubscriber.onSubscribe(s)
    }

    override fun onNext(t: T) {
        currentContext().copyContextMapToMdc()
        coreSubscriber.onNext(t)
    }

    override fun onError(t: Throwable?) {
        currentContext().copyContextMapToMdc()
        coreSubscriber.onError(t)
    }

    override fun onComplete() {
        coreSubscriber.onComplete()
    }

    override fun currentContext() = coreSubscriber.currentContext()

    fun Context.copyContextMapToMdc() {
        if (!isEmpty) { // not Context0
            getOrEmpty<String>("loggingId")
                .ifPresent {
                    RequestContextInfoUtils.loggingId = it
                }
        }
    }
}

4. MDC context lifter를 configuration으로 등록한다.

@Configuration
class MdcContextLifterConfiguration {

    val mdcContextReactorKey: String = MdcContextLifterConfiguration::class.java.name

    @PostConstruct
    fun contextOperatorHook() = Hooks.onEachOperator(
        mdcContextReactorKey,
        Operators.lift { _, subscriber -> MdcContextLifter(subscriber) }
    )
}
Mono와 Hook의 동작 원리에 대해서는 다른 많은 블로그에서 자세하게 다뤄주니 패스한다...

위에서 설명한 내용은 context에 넣고 싶은 항목을 하나하나 추가해야 한다는 단점이 존재한다.

예를 들면, loggingId 뿐만 아니라 token에 대한 내용을 추가하고 싶다면 아래와 같이 번거로운 작업을 해줘야 한다.

// RequestFilter.kt
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
    return chain
        .filter(exchange)
        .contextWrite {
            it.put("loggingId", UUID.randomUUID().toString())
            it.put("token", "ABCKSNFIWNSKENFLSDKNFLSDKNF")
        }
}

// MdcContextLifter.kt
fun Context.copyContextMapToMdc() {
    if (!isEmpty) { // not Context0
        getOrEmpty<String>("loggingId")
            .ifPresent {
                RequestContextInfoUtils.loggingId = it
            }
        getOrEmpty<String>("token")
            .ifPresent {
                RequestContextInfoUtils.token = it
            }
    }
}
간혹 stream을 이용해 context의 값을 옮겨주는 코드가 보이곤 하는데, 이는 성능을 저하시키기 때문에 지양하기 바란다...

 

또한 Context의 성격은 Context에 값이 하나씩 추가될 때마다 Context1, Context2, ... , ContextN 객체를 새로 생성하는 방식이기 때문에 조금 찝찝한 경향이 있다.

 

따라서 아래처럼 context에 저장해서 사용할 Map을 하나 선언하고, 이를 context에 추가한 다음 MDC에서는 그 Map을 통째로 copy 하는 방식을 많이 채택한다.

@Component
class RequestFilter : WebFilter {

    override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
        
        val contextMap = ConcurrentHashMap(
            mapOf(
                "loggingId" to UUID.randomUUID().toString(),
                "token" to "ABCKSNFIWNSKENFLSDKNFLSDKNF"
            )
        )
        
        return chain
            .filter(exchange)
            .contextWrite {
                it.put("context-map", contextMap)
            }
    }
}
class MdcContextLifter<T>(private val coreSubscriber: CoreSubscriber<T>) : CoreSubscriber<T> {

    ...

    fun Context.copyContextMapToMdc() {
        if (!isEmpty) { // not Context0
            getOrEmpty<Map<String, String>>("context-map")
                .ifPresent {
                    RequestContextInfoUtils.setMdcAttributes(it)
                }
        }
    }
}

다음과 같은 방식을 사용했을 때의 장점은 context 값 관리를 위한 코드가 단순해지고, Context 객체의 변경 없이 새로운 값을 넣고 빼는 것이 가능해진다.

 

여기까지가 대부분 많이 사용하는 방식일 것이다. 하지만 위와 같은 방식에는 문제점이 하나 존재하는데, upstream thread에서 MDC를 통해 값을 변경시켰을 경우 downstream의 thread는 변경된 값을 알지 못한다는 단점이 존재한다.

 

예를 들면 아래와 같은 코드의 출력 값은 어떻게 될까?

suspend fun test() {
    println(RequestContextInfoUtils.token)
    
    Mono.just("1")
        .publishOn(Schedulers.boundedElastic())
        .map {
            println(RequestContextInfoUtils.token)
            RequestContextInfoUtils.token = "ABC2"
            println(RequestContextInfoUtils.token)
        }
        .awaitSingleOrNull()
        
    println(RequestContextInfoUtils.token)
}

정답은 아래와 같이 upstream에서 "ABC2"로 바꾼 값은 downstream에서는 알지 못한다. (전파가 되지 않는다.)

ABCKSNFIWNSKENFLSDKNFLSDKNF
ABCKSNFIWNSKENFLSDKNFLSDKNF
ABC2
ABCKSNFIWNSKENFLSDKNFLSDKNF

 

왜냐하면 MDC에 Map 객체를 삽입할 때 Map 객체를 그대로 가져가는 것이 아니라 Map의 내용물만 가져가기 때문이다.

MDC.setContextMap(m)
public void setContextMap(Map<String, String> contextMap) {
    lastOperation.set(WRITE_OPERATION);
    Map<String, String> newMap = Collections.synchronizedMap(new HashMap<String, String>());
    newMap.putAll(contextMap);
    // the newMap replaces the old one for serialisation's sake
    copyOnThreadLocal.set(newMap);
}

따라서 위와 같은 문제를 해결하고 싶었다. 

 

제목에서 보다시피 우리의 프로젝트는 Webflux + Coroutine이다. Coroutine의 suspend fun에서만 사용할 수 있는 기능을 통해 다음과 같은 문제를 해결해보고자 한다.

 

관련 게시글은 바로 다음 2편에서..

https://tw-you.tistory.com/entry/Spring-Webflux-Coroutine에서-Context-관리하기-downstream으로-context-변경-전파하기-2편

복사했습니다!