Spring Logback MDC 구조화 로깅

구조화 로깅이 필요한 이유

전통적인 텍스트 기반 로그는 사람이 읽기엔 좋지만 기계가 파싱하기엔 최악입니다. 마이크로서비스 환경에서 수십 개 서비스의 로그를 ELK, Loki, Datadog으로 수집할 때, 비정형 텍스트를 파싱하는 것은 grok 패턴 지옥을 의미합니다. 구조화 로깅(Structured Logging)은 로그를 처음부터 JSON 등 기계 친화적 포맷으로 출력하여 이 문제를 근본적으로 해결합니다.

Spring Boot 3.4부터는 logging.structured.format.console 프로퍼티로 네이티브 구조화 로깅을 지원하지만, 실무에서는 Logback의 MDC(Mapped Diagnostic Context)와 커스텀 인코더를 조합하는 방식이 여전히 주류입니다.

Logback JSON 포맷 설정

Logstash Logback Encoder를 사용하면 별도 파싱 없이 JSON 로그를 출력합니다:

<!-- build.gradle.kts -->
dependencies {
    implementation("net.logstash.logback:logstash-logback-encoder:7.4")
}
<!-- logback-spring.xml -->
<configuration>
  <springProfile name="prod">
    <appender name="JSON_CONSOLE"
              class="ch.qos.logback.core.ConsoleAppender">
      <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <includeMdcKeyName>traceId</includeMdcKeyName>
        <includeMdcKeyName>spanId</includeMdcKeyName>
        <includeMdcKeyName>userId</includeMdcKeyName>
        <includeMdcKeyName>requestId</includeMdcKeyName>
        <fieldNames>
          <timestamp>@timestamp</timestamp>
          <version>[ignore]</version>
        </fieldNames>
        <customFields>
          {"service":"order-service","env":"${SPRING_PROFILES_ACTIVE}"}</customFields>
      </encoder>
    </appender>

    <root level="INFO">
      <appender-ref ref="JSON_CONSOLE" />
    </root>
  </springProfile>

  <springProfile name="dev">
    <appender name="TEXT_CONSOLE"
              class="ch.qos.logback.core.ConsoleAppender">
      <encoder>
        <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{traceId}] - %msg%n</pattern>
      </encoder>
    </appender>

    <root level="DEBUG">
      <appender-ref ref="TEXT_CONSOLE" />
    </root>
  </springProfile>
</configuration>

이 설정의 핵심 포인트:

  • springProfile 분리: 로컬 개발에선 사람 읽기 좋은 텍스트, 프로덕션에선 JSON 출력
  • includeMdcKeyName: MDC에 넣은 키만 선택적으로 JSON에 포함
  • customFields: 서비스명, 환경 등 정적 메타데이터 자동 추가

출력 결과:

{"@timestamp":"2026-03-10T07:00:12.345Z","level":"INFO","logger_name":"c.e.o.OrderService","message":"주문 생성 완료","traceId":"abc123","userId":"user-42","orderId":"ORD-2026-001","service":"order-service","env":"prod"}

MDC 활용: 요청 컨텍스트 전파

MDC(Mapped Diagnostic Context)는 ThreadLocal 기반으로 현재 스레드의 로그에 컨텍스트 정보를 자동으로 추가합니다. Spring MVC에서는 필터로 요청 시작 시 MDC를 설정합니다:

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class MdcFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {

        try {
            String requestId = Optional
                .ofNullable(request.getHeader("X-Request-ID"))
                .orElse(UUID.randomUUID().toString().substring(0, 8));

            MDC.put("requestId", requestId);
            MDC.put("method", request.getMethod());
            MDC.put("uri", request.getRequestURI());
            MDC.put("clientIp", getClientIp(request));

            // 인증 정보가 있으면 userId도 추가
            Authentication auth = SecurityContextHolder
                .getContext().getAuthentication();
            if (auth != null && auth.isAuthenticated()) {
                MDC.put("userId", auth.getName());
            }

            response.setHeader("X-Request-ID", requestId);
            chain.doFilter(request, response);

        } finally {
            MDC.clear();  // 반드시 클리어 — 스레드 풀 재사용 시 오염 방지
        }
    }

    private String getClientIp(HttpServletRequest request) {
        String xff = request.getHeader("X-Forwarded-For");
        return xff != null ? xff.split(",")[0].trim()
                           : request.getRemoteAddr();
    }
}

이 필터 하나로 해당 요청의 모든 로그 라인에 requestId, method, uri, clientIp가 자동으로 붙습니다. 별도로 로거에 파라미터를 전달할 필요가 없습니다.

비동기 환경에서 MDC 전파

MDC는 ThreadLocal 기반이므로, @AsyncCompletableFuture로 스레드가 바뀌면 MDC가 소실됩니다. TaskDecorator로 해결합니다:

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        executor.setTaskDecorator(new MdcTaskDecorator());
        executor.initialize();
        return executor;
    }
}

public class MdcTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        // 호출 시점의 MDC 스냅샷 캡처
        Map<String, String> contextMap = MDC.getCopyOfContextMap();

        return () -> {
            try {
                if (contextMap != null) {
                    MDC.setContextMap(contextMap);
                }
                runnable.run();
            } finally {
                MDC.clear();
            }
        };
    }
}

이제 @Async 메서드 내부의 로그에도 원래 요청의 requestId, userId 등이 그대로 출력됩니다.

Virtual Thread 환경 대응

Spring Boot 3.2+의 Virtual Thread를 사용한다면 주의가 필요합니다. Virtual Thread는 기존 플랫폼 스레드와 달리 캐리어 스레드 간 마운트/언마운트되지만, ScopedValue(JDK 21 preview)가 안정화되기 전까지는 MDC의 ThreadLocal이 정상 동작합니다:

# application.yml
spring:
  threads:
    virtual:
      enabled: true

# Virtual Thread에서도 MDC TaskDecorator 동일 적용
# 단, Executor를 SimpleAsyncTaskExecutor로 교체
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
    SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor();
    executor.setVirtualThreads(true);
    executor.setTaskDecorator(new MdcTaskDecorator());
    return executor;
}

StructuredArguments로 의미 있는 로그 작성

Logstash Logback Encoder의 StructuredArguments를 사용하면 로그 메시지와 JSON 필드를 동시에 설정할 수 있습니다:

import static net.logstash.logback.argument.StructuredArguments.*;

@Service
@RequiredArgsConstructor
public class OrderService {

    private static final Logger log = LoggerFactory.getLogger(OrderService.class);

    public Order createOrder(CreateOrderRequest request) {
        log.info("주문 생성 시작: {}, 상품 수: {}",
            keyValue("customerId", request.getCustomerId()),
            keyValue("itemCount", request.getItems().size()));

        Order order = processOrder(request);

        log.info("주문 생성 완료: {}, 총액: {}",
            keyValue("orderId", order.getId()),
            keyValue("totalAmount", order.getTotalAmount()));

        return order;
    }

    public void cancelOrder(Long orderId) {
        log.warn("주문 취소 요청: {}, 사유: {}",
            keyValue("orderId", orderId),
            keyValue("reason", "customer_request"));
    }
}

텍스트 로그에서는 주문 생성 완료: orderId=ORD-001, 총액: totalAmount=59000으로 출력되고, JSON에서는 "orderId":"ORD-001","totalAmount":59000이 별도 필드로 들어갑니다.

로그 레벨 동적 변경

Spring Actuator를 활용하면 재시작 없이 런타임에 로그 레벨을 변경할 수 있습니다:

# 현재 로거 레벨 조회
curl http://localhost:8080/actuator/loggers/com.example.order

# 런타임 레벨 변경 (재시작 불필요)
curl -X POST http://localhost:8080/actuator/loggers/com.example.order 
  -H "Content-Type: application/json" 
  -d '{"configuredLevel": "DEBUG"}'

# 원복
curl -X POST http://localhost:8080/actuator/loggers/com.example.order 
  -H "Content-Type: application/json" 
  -d '{"configuredLevel": null}'

프로덕션에서 특정 이슈를 디버깅할 때, 해당 패키지만 일시적으로 DEBUG로 전환하고 분석 후 원복하는 패턴이 매우 유용합니다.

민감 정보 마스킹

구조화 로깅에서 반드시 고려해야 할 것이 개인정보 마스킹입니다. Logback의 커스텀 컨버터로 처리합니다:

public class MaskingPatternLayout extends PatternLayout {

    private Pattern multilinePattern;
    private final List<String> maskPatterns = new ArrayList<>();

    public void addMaskPattern(String pattern) {
        maskPatterns.add(pattern);
        buildPattern();
    }

    private void buildPattern() {
        multilinePattern = Pattern.compile(
            String.join("|", maskPatterns),
            Pattern.MULTILINE
        );
    }

    @Override
    public String doLayout(ILoggingEvent event) {
        return maskMessage(super.doLayout(event));
    }

    private String maskMessage(String message) {
        if (multilinePattern == null) return message;

        StringBuilder sb = new StringBuilder(message);
        Matcher matcher = multilinePattern.matcher(sb);
        while (matcher.find()) {
            if (matcher.group().length() > 4) {
                int start = matcher.start() + 2;
                int end = matcher.end() - 2;
                for (int i = start; i < end; i++) {
                    sb.setCharAt(i, '*');
                }
            }
        }
        return sb.toString();
    }
}
<!-- logback-spring.xml에 마스킹 패턴 추가 -->
<layout class="com.example.logging.MaskingPatternLayout">
  <maskPattern>d{3}-d{4}-d{4}</maskPattern>        <!-- 전화번호 -->
  <maskPattern>[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+</maskPattern> <!-- 이메일 -->
  <maskPattern>d{6}-d{7}</maskPattern>               <!-- 주민번호 -->
</layout>

성능 최적화: 비동기 Appender

JSON 인코딩은 텍스트 포맷보다 CPU를 더 사용합니다. 프로덕션에서는 AsyncAppender로 로그 기록을 별도 스레드에서 처리합니다:

<appender name="ASYNC_JSON"
          class="ch.qos.logback.classic.AsyncAppender">
  <appender-ref ref="JSON_CONSOLE" />
  <queueSize>1024</queueSize>
  <discardingThreshold>0</discardingThreshold>
  <includeCallerData>false</includeCallerData>
  <neverBlock>true</neverBlock>
</appender>

<root level="INFO">
  <appender-ref ref="ASYNC_JSON" />
</root>
설정 설명
queueSize 1024 비동기 큐 크기 (기본 256은 부족)
discardingThreshold 0 큐가 차도 로그를 버리지 않음
includeCallerData false 스택트레이스 수집 비활성화 (성능 향상)
neverBlock true 큐 초과 시 호출 스레드 블로킹 방지

Spring Boot 3.4 네이티브 구조화 로깅

Spring Boot 3.4부터는 서드파티 의존성 없이 구조화 로깅을 지원합니다:

# application.yml (Spring Boot 3.4+)
logging:
  structured:
    format:
      console: logstash   # 또는 ecs (Elastic Common Schema)
    ecs:
      service:
        name: order-service
        version: 2.3.1
        environment: production

이 한 줄이면 Logstash 호환 JSON 또는 ECS(Elastic Common Schema) 형식으로 로그가 출력됩니다. 기존 Logstash Logback Encoder를 쓰던 프로젝트는 점진적으로 마이그레이션할 수 있습니다.

실전 체크리스트

  • ✅ 프로덕션: JSON 포맷 / 개발: 텍스트 포맷 분리
  • ✅ MDC 필터로 requestId, userId 자동 주입
  • ✅ @Async 환경: MdcTaskDecorator 적용
  • ✅ StructuredArguments로 검색 가능한 필드 추가
  • ✅ 민감 정보 마스킹 패턴 설정
  • ✅ AsyncAppender로 I/O 병목 방지
  • ✅ Actuator loggers 엔드포인트 활성화 (런타임 레벨 변경)
  • ✅ ELK/Loki 수집 시 별도 파서 불필요 확인

정리

구조화 로깅은 단순한 포맷 변경이 아니라 관측 가능성(Observability)의 기반입니다. MDC로 요청 컨텍스트를 전파하고, StructuredArguments로 비즈니스 필드를 추가하며, JSON 포맷으로 출력하면 — 별도 파싱 없이 Elasticsearch에서 requestId:"abc123" AND level:ERROR로 즉시 검색할 수 있습니다. 마이크로서비스 환경에서 장애 대응 시간을 획기적으로 단축하는 핵심 인프라입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux