본문 바로가기
👨‍💻 프로그래밍/📦 Backend

MDC로 풍부한 로그 쉽게 남기기

by 개발자 진개미 2025. 1. 12.
반응형


기존 로깅의 문제점

로깅 (Logging)은 문제가 발생했을 때 원인을 찾아내고 해결하기 위해 애플리케이션 곳곳에 남기는 정보들입니다. 당연히 이 정보들은 많으면 많을수록 좋습니다. 일반적으로 로그에 남기면 좋은 정보들은 다음과 같은데요.

  • HTTP 요청 1개 1개를 구별할 수 있는 고유한 값
  • (MSA 환경인 경우) 여러 MS 같의 요청을 묶을 수 있는 값
  • (유저가 시작한 요청인 경우) 그 유저의 고유 값 (PK 등)
  • 요청 시작 시간
  • 애플리케이션의 버전
  • 개발 환경 (Live, Dev, Staging 등)

이건 극히 일부의 정보들을 나열한 것 뿐입니다. 문제는 이걸 수기로 남기게 되면 많은 문제들이 있습니다. 일단 로그를 남길 때마다 저 정보를 다 포함해야 한다면... 얼마나 짜증 날지 상상이 되시나요?

private val log = KotlinLogging.logger {}

fun logHttpRequestManually() {
    val uniqueRequestId = "123e4567-e89b-12d3-a456-426614174000"
    val userId = "41862130"
    val requestStartTime = "2025-01-12T10:15:30Z"
    val appVersion = "1.0.0"
    val environment = "Dev"

    log.info { 
        "Request Info - uniqueRequestId: $uniqueRequestId, " +
        "userId: $userId, " +
        "requestStartTime: $requestStartTime, " +
        "appVersion: $appVersion, " +
        "environment: $environment"
    }
}

 
더 큰 문제는 이런 식으로 로깅을 남기면 개발자마다 로깅 정보를 부르는 이름이 다를 수도 있고 (버전을 deployVersion이라 할 수도 있고 appVersion이라 할 수도 있고...) 형식이 다를 수도 있습니다. (시간, 날짜 형식만 해도 몇 개가 있나요? 🤔)
그래서 이런 정보를 당연히 수동으로 남길 수는 없으니 이를 해결해 주기 위한 기술로 흔히 MDC를 씁니다.


MDC가 정확히 뭘까? Java 진영에서만 쓰는 걸까?

🐜 MDC == Mapped Diagnostic Context

Mapped Map 자료구조라는 것. Key-Value로 이루어져 있음
Diagnostic 진단할 때 쓰는 정보라는 것. (디버깅, 모니터링, 트러블슈팅 등)
Context 특정 문맥에 관련된 정보라는 것. (지금 이 요청)

다시 말하면 MDC 자체는 특정 요청과 관련된 디버깅할 때 유용한 정보들을 저장할 수 있는 Map 자료구조의 저장소인데요. 하지만 보통 MDC를 쓴다고 하면 기존 로깅의 문제점인 이 요청과 관련된 정보를 로깅을 남길 때 마다 남겨야 하는 걸 자동으로 해 주는 걸 말합니다. (이 자동화 자체는 로깅 프레임워크들이 구현해서 처리합니다.)
 

🐜 이 용어는 Java 진영에서만 쓴다

MDC 개념 자체는 로깅이 가질 수 있는 일반적인 문제들을 해결하는 것이기 때문에 어디에나 있지만, 이 용어 자체는 Java 진영 (Kotlin, Spring 포함)에서만 쓰는 개념입니다. 한국은 대부분 Java 진영의 기술들을 쓰니 특정 용어들이 일반적인 용어인지, Java 진영에서만 쓰는 용어인지 구분하지 않는 경우가 많은데요. 저는 기술을 공부할 때는 그 기술의 세부 특성에 너무 집중하기보다는 본질을 봐야 한다고 생각하기 때문에 이런 구분을 중요시합니다.
 
🐜 각 로깅 프레임워크들에서 구현한 구체적인 클래스를 말하는 경우도 있다 
MDC가 Java 진영에만 쓰이기 때문에 MDC가 로깅 프레임워크가 자체적으로 만든 class나 interface 그 자체를 말하는 경우도 많이 있습니다. 대표적으로 Log4J의 MDC라는 객체가 있습니다.

 

MDC (Apache Log4j 1.2.17 API)

org.apache.log4j Class MDC java.lang.Object org.apache.log4j.MDC public class MDCextends Object The MDC class is similar to the NDC class except that it is based on a map instead of a stack. It provides mapped diagnostic contexts. A Mapped Diagnostic Conte

logging.apache.org


 MDC의 작동 원리

MDC의 작동 원리를 이해하기 위해서는 크게 2가지를 이해해야 합니다.

  1. MDC가 ThreadLocal을 사용한다는 것
  2. Logging 프레임워크들이 어떤 식으로 MDC를 사용해 여러 정보를 자동으로 남기는지

 

🐜 1 - MDC는 ThreadLocal을 사용한다

Slf4J를 기준으로 봐 보겠습니다!
우선 MDC라는 객체가 있습니다.

public class MDC {
    static MDCAdapter mdcAdapter;

    public static void put(String key, String val) throws IllegalArgumentException {
        if (key == null) {
            throw new IllegalArgumentException("key parameter cannot be null");
        } else if (mdcAdapter == null) {
            throw new IllegalStateException("MDCAdapter cannot be null. See also http://www.slf4j.org/codes.html#null_MDCA");
        } else {
            mdcAdapter.put(key, val);
        }
    }

    public static String get(String key) throws IllegalArgumentException {
        if (key == null) {
            throw new IllegalArgumentException("key parameter cannot be null");
        } else if (mdcAdapter == null) {
            throw new IllegalStateException("MDCAdapter cannot be null. See also http://www.slf4j.org/codes.html#null_MDCA");
        } else {
            return mdcAdapter.get(key);
        }
    }
}

간단히 보시면 우선 Map 자료구조라면 있어야 될 get, put, clear 등이 있습니다. 그리고 이 모든 동작은 mdcAdaptor를 사용해서 수행합니다.
그럼 MDCAdaptor 안을 살펴보면 되겠네요. 여기서 과연 ThreadLocal을 사용할까요? 우선 MdcAdaptor 자체는 interface입니다.

public interface MDCAdapter {
    void put(String var1, String var2);
    String get(String var1);
    void remove(String var1);
    void clear();
    Map<String, String> getCopyOfContextMap();
    void setContextMap(Map<String, String> var1);
    void pushByKey(String var1, String var2);
    String popByKey(String var1);
    Deque<String> getCopyOfDequeByKey(String var1);
    void clearDequeByKey(String var1);
}

 
MDCAdpator의 구현체는 2가지가 있는데요.

 
NOPMDCAdaptor는 No Operation이라 해서 MDC가 필요 없는 환경을 위해 아무것도 하지 않는 구현체입니다. 실제로 가서 보면 아무것도 없이 비어 있습니다.

public class NOPMDCAdapter implements MDCAdapter {
    public NOPMDCAdapter() {}

    public void clear() {}

    public String get(String key) {
        return null;
    }

    public void put(String key, String val) {}

    public void remove(String key) {}

    public Map<String, String> getCopyOfContextMap() {
        return null;
    }

    public void setContextMap(Map<String, String> contextMap) {}

    public void pushByKey(String key, String value) {}

    public String popByKey(String key) {
        return null;
    }

    public Deque<String> getCopyOfDequeByKey(String key) {
        return null;
    }

    public void clearDequeByKey(String key) {}
}

그래서 당연히 이건 볼 필요가 없고, 중요한 건 BasicMDCAdaptor입니다.

public class BasicMDCAdapter implements MDCAdapter {
    private final ThreadLocalMapOfStacks threadLocalMapOfDeques = new ThreadLocalMapOfStacks();
    private final InheritableThreadLocal<Map<String, String>> inheritableThreadLocalMap = new InheritableThreadLocal<Map<String, String>>() {
        protected Map<String, String> childValue(Map<String, String> parentValue) {
            return parentValue == null ? null : new HashMap(parentValue);
        }
    };

    public BasicMDCAdapter() {}

    public void put(String key, String val) {
        if (key == null) {
            throw new IllegalArgumentException("key cannot be null");
        } else {
            Map<String, String> map = (Map)this.inheritableThreadLocalMap.get();
            if (map == null) {
                map = new HashMap();
                this.inheritableThreadLocalMap.set(map);
            }

            map.put(key, val);
        }
    }

    public String get(String key) {
        Map<String, String> map = (Map) this.inheritableThreadLocalMap.get();
        return map != null && key != null ? (String) map.get(key) : null;
    }
}

결국 get과 put 모두 InheritableThreadLocal이라는 아이를 사용하고 있네요. 이건 Java에서 기본으로 제공하는 객체입니다. 객체에 가 보면 ThreadLocal을 상속하고 있는 걸 볼 수 있습니다!

public class InheritableThreadLocal<T> extends ThreadLocal<T> {
    protected T childValue(T parentValue) {
        return parentValue;
    }

    ThreadLocalMap getMap(Thread t) {
       return t.inheritableThreadLocals;
    }

    void createMap(Thread t, T firstValue) {
        t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
    }
}

 

🐜 2 - Logging 프레임워크들이 MDC를 사용해 자동화하는 방식

자동화라고는 해도, 유저가 로그를 남길 때 MDC에 있는 정보들을 같이 포함시켜 남기는 게 다 이긴 합니다. 대표적인 Slf4J의 구현체인 Logback을 봐 보겠습니다.

public final class Logger implements org.slf4j.Logger, LocationAwareLogger, LoggingEventAware, AppenderAttachable<ILoggingEvent>, Serializable {}

요 객체인데요. 무료 832줄이나 되지만 MDC를 호출하는 부분은 찾아볼 수 없습니다. 그건 얘가 간접적으로 MDC를 호출하기 때문입니다. buildLoggingEventAndAppend를 보시면 됩니다.

private void buildLoggingEventAndAppend(final String localFQCN, final Marker marker, final Level level,
        final String msg, final Object[] params, final Throwable t) {
    LoggingEvent le = new LoggingEvent(localFQCN, this, level, msg, t, params);
    le.addMarker(marker);
    callAppenders(le);
}

요기서 LoggingEvent를 만드는데요.
이 LoggingEvent를 보시면 getMDCPropertyMap이라는 Method가 있습니다.

public Map<String, String> getMDCPropertyMap() {
    if (mdcPropertyMap == null) {
        MDCAdapter mdcAdapter = loggerContext.getMDCAdapter();
        if (mdcAdapter instanceof LogbackMDCAdapter)
            mdcPropertyMap = ((LogbackMDCAdapter) mdcAdapter).getPropertyMap();
        else
            mdcPropertyMap = mdcAdapter.getCopyOfContextMap();
    }
    if (mdcPropertyMap == null)
        mdcPropertyMap = Collections.emptyMap();

    return mdcPropertyMap;
}

고려해 볼 것: Multi Thread 환경?

MDC가 내부적으로 Thread Local을 사용한다면, Coroutine이나 Virtual Thread, @Async 같은 기술을 사용하는 Multi Thread 환경에서는 어떻게 로깅을 처리할 수 있을까요?
사실 MDC가 ThreadLocal을 사용하는 이유 자체가 로그에 남겨야 하는 여러 정보들을 직접 주고받는 게 번거로워서 Java에서 제공하는 ThreadLocal을 쓰는 것뿐입니다. 같은 Thread에 있으면 언제 어디서나 접근 가능한 저장소니 간편하기도 하구요! 그럼 ThreadLocal을 사용할 수 없게 된다면 이런 정보를 직접 전달해 주면 끝날 일입니다. 당연히 대부분의 상황에서는 이걸 저희가 직접 구현할 필요는 없습니다.
대표적으로 Coroutine을 보면, MDCContext()를 전달해 줄 수가 있습니다.

launch(MDCContext()) {
    println("TraceId in coroutine: ${MDC.get("traceId")}")
}

 
물론 말씀드렸다시피 내부적인 원리는 ThreadLocal이든 뭐든 로그에 남길 정보를 전달하면 되는 거기 때문에 직접 전달해 줘도 똑같이 동작합니다.

val contextMap = MDC.getCopyOfContextMap()

launch(Dispatchers.Default + mdcContext(contextMap)) {
    logWithMdc()
}

 
실제로 MDCContext도 내부적으로 보면 제가 위에서 직접 한 동작을 똑같이 하고 있습니다.

public typealias MDCContextMap = Map<String, String>?

public class MDCContext(
    public val contextMap: MDCContextMap = MDC.getCopyOfContextMap() // 요기 보세요!!
) : ThreadContextElement<MDCContextMap>, AbstractCoroutineContextElement(Key) {}

반응형