Spring Boot

Spring Boot 로깅 기본: SLF4J + Logback 빠르게 세팅 (레벨/패턴/MDC/마스킹)

IT Lab 2026. 3. 7. 23:08

Spring Boot 로깅 기본: SLF4J + Logback 빠르게 세팅 (레벨/패턴/MDC/마스킹)
Spring Boot 3에서 SLF4J+Logback으로 로그 레벨과 패턴을 정리하고, MDC로 요청 추적을 붙인 뒤 민감정보 마스킹 포인트까지 실무 기준으로 빠르게 세팅합니다.

도입 (문제 상황)

운영 장애가 났는데 로그에 “뭔가”는 찍혀 있지만, 정작 어떤 요청이었는지 추적이 안 될 때가 있습니다. 반대로 로그를 자세히 찍었더니 비밀번호/토큰 같은 민감정보가 섞여서 보안 이슈가 되기도 해요. 이번 글에서는 Spring Boot에서 SLF4J + Logback을 “딱 실무에서 필요한 만큼” 빠르게 세팅해 봅니다.

핵심 개념 — Spring Boot 로깅에서 꼭 잡아야 하는 4가지

Spring Boot 3.x는 기본 로깅 구현체로 Logback을 사용하고, 애플리케이션 코드는 SLF4J API로 로깅하는 구성이 표준입니다. 중요한 건 “로깅 프레임워크를 아는 것”보다, 아래 4가지를 일관된 기준으로 잡는 일이에요.

1) 로그 레벨: “정보”가 아니라 “의도”로 나누기

레벨은 단순히 많고 적음이 아니라 운영에서 어떤 액션을 유도하는지가 기준입니다.

  • ERROR: 즉시 조치 필요(알람 대상). 예외 스택트레이스 포함이 일반적
  • WARN: 지금은 동작하지만 위험 신호(리트라이/타임아웃/외부 의존성 불안정)
  • INFO: 비즈니스 흐름의 주요 이벤트(서버 시작, 배치 완료, 주문 생성 등)
  • DEBUG/TRACE: 원인 분석용(운영 상시 ON은 비용 큼)

특히 org.springframeworkorg.hibernate 같은 프레임워크 로그는 필요할 때만 올리도록 패키지별 레벨을 분리해 두는 게 좋습니다.

2) 로그 패턴: “읽기 쉬움” + “머신이 파싱하기 쉬움”

운영에서는 사람이 보는 콘솔 로그도 중요하지만, 대부분은 수집(ELK/Cloud Logging 등)을 전제로 합니다. 그래서 패턴에는 보통 다음을 포함합니다.

  • 시간(밀리초 포함), 레벨, 스레드, 로거명
  • 요청 추적 키(예: traceId, requestId) → 이게 없으면 “로그는 많은데 연결이 안 됨” 상태가 됩니다.

3) MDC 맛보기: 요청 단위로 로그를 묶는 “꼬리표”

MDC로 요청 로그를 묶는 개념도

MDC(Mapped Diagnostic Context)는 “현재 스레드 컨텍스트에 키-값을 붙여서” 로그 패턴에서 꺼내 쓰는 방식입니다.
비유하자면, 택배 상자(로그)마다 송장번호(traceId) 스티커를 붙여 두는 느낌이에요. 그러면 여러 줄에 흩어진 로그도 같은 송장번호로 검색해서 한 번에 모을 수 있습니다.

주의할 점은 요청이 끝나면 반드시 MDC를 비워야 한다는 겁니다. 안 비우면 스레드 재사용(톰캣 스레드풀) 때문에 다른 요청 로그에 섞일 수 있어요.

4) 민감정보 마스킹: “로그를 줄이는 것”보다 “어디서 새는지”를 막기

마스킹은 만능이 아닙니다. 가장 효과적인 전략은 보통 이 순서예요.

  1. 애초에 민감정보를 로그에 남기지 않기(DTO toString()/request body 로깅 금지)
  2. 불가피한 경우에만 필터/컨버터/로거에서 마스킹
  3. 마지막 보루로 Logback 레이아웃/필터에서 패턴 기반 마스킹(오탐/누락 가능성 있음)

아래 코드 예제에서는 “실무에서 최소한으로 안전장치를 거는” 수준으로, 요청 ID(MDC) + Authorization 헤더 마스킹 포인트를 함께 잡아봅니다.

코드 예제 — Spring Boot 3에서 Logback 패턴 + MDC + 헤더 마스킹

아래 예제는 그대로 복붙해서 실행할 수 있는 구성입니다.

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    // 기본으로 Logback 포함 (spring-boot-starter-logging)
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

application.yml (레벨 설정)

spring:
  application:
    name: logging-demo

logging:
  level:
    root: INFO
    com.example: DEBUG
    org.springframework.web: INFO
    org.hibernate.SQL: WARN

logback-spring.xml (패턴 + MDC 출력)

src/main/resources/logback-spring.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <!-- Spring Boot 기본값 일부 재사용 -->
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

    <!-- 콘솔 로그 패턴: traceId/requestId를 MDC에서 꺼내 출력 -->
    <property name="CONSOLE_LOG_PATTERN"
              value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - traceId=%X{traceId:-} requestId=%X{requestId:-} %msg%n%ex"/>

    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>

</configuration>
  • %X{traceId:-}: MDC에 traceId가 없으면 - 출력
  • %ex: 예외 스택트레이스(운영 분석에 매우 중요)

MDC를 심는 Filter (요청 시작/종료에서 세팅/정리)

src/main/java/com/example/loggingdemo/logging/MdcFilter.java

package com.example.loggingdemo.logging;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Optional;
import java.util.UUID;

@Component
public class MdcFilter extends OncePerRequestFilter {

    private static final String TRACE_ID = "traceId";
    private static final String REQUEST_ID = "requestId";
    private static final String HEADER_REQUEST_ID = "X-Request-Id";

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

        // 외부에서 X-Request-Id를 주면 재사용, 없으면 생성
        String requestId = Optional.ofNullable(request.getHeader(HEADER_REQUEST_ID))
                .filter(v -> !v.isBlank())
                .orElse(UUID.randomUUID().toString());

        // traceId는 단순 예시로 UUID 사용 (실무에선 분산추적 도구/게이트웨이 연동 고려)
        String traceId = UUID.randomUUID().toString();

        MDC.put(REQUEST_ID, requestId);
        MDC.put(TRACE_ID, traceId);

        // 응답에도 실어주면 클라이언트/게이트웨이에서 추적이 쉬워집니다.
        response.setHeader(HEADER_REQUEST_ID, requestId);

        try {
            filterChain.doFilter(request, response);
        } finally {
            // 중요: 스레드 재사용 때문에 반드시 정리
            MDC.clear();
        }
    }
}

민감정보 마스킹 포인트: “헤더 로깅”을 한다면 여기서 안전하게

아래는 요청 로그를 남길 때 Authorization 헤더는 토큰 전체를 찍지 않도록 마스킹하는 예시입니다.
(참고로, 요청 바디까지 찍는 로깅은 운영에서 위험/비용이 커서 신중히 선택하시는 편이 좋습니다.)

src/main/java/com/example/loggingdemo/web/RequestLoggingInterceptor.java

package com.example.loggingdemo.web;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import java.util.Optional;

@Component
public class RequestLoggingInterceptor implements HandlerInterceptor {

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

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String auth = Optional.ofNullable(request.getHeader("Authorization")).orElse("");
        String maskedAuth = maskAuthHeader(auth);

        log.info("HTTP {} {} auth={}",
                request.getMethod(),
                request.getRequestURI(),
                maskedAuth);

        return true;
    }

    private String maskAuthHeader(String auth) {
        if (auth == null || auth.isBlank()) return "-";
        // 예: "Bearer eyJhbGciOi..." -> "Bearer ***"
        if (auth.toLowerCase().startsWith("bearer ")) return "Bearer ***";
        // 그 외는 길이만 남기는 정도의 보수적 마스킹
        return "***(" + auth.length() + ")";
    }
}

Interceptor 등록

src/main/java/com/example/loggingdemo/web/WebConfig.java

package com.example.loggingdemo.web;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final RequestLoggingInterceptor requestLoggingInterceptor;

    public WebConfig(RequestLoggingInterceptor requestLoggingInterceptor) {
        this.requestLoggingInterceptor = requestLoggingInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(requestLoggingInterceptor)
                .addPathPatterns("/**");
    }
}

테스트용 Controller

src/main/java/com/example/loggingdemo/web/HelloController.java

package com.example.loggingdemo.web;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

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

    @GetMapping("/hello")
    public String hello() {
        log.debug("Hello endpoint called");
        return "ok";
    }
}

실행 후:

curl -H "Authorization: Bearer secret-token-value" http://localhost:8080/hello

로그에서 traceId=... requestId=...가 항상 붙고, auth=Bearer ***로 마스킹되는지 확인해 보세요.

flowchart LR
  A["Client"] --> B["Servlet Filter: MdcFilter"]
  B --> C["Spring MVC: DispatcherServlet"]
  C --> D["HandlerInterceptor: RequestLoggingInterceptor"]
  D --> E["Controller"]
  E --> F["Logback Pattern: %X{traceId} %X{requestId}"]

요청이 들어오면 Filter에서 MDC를 세팅하고, 이후 모든 로그가 같은 traceId/requestId로 묶입니다.

(정리) 무엇을 어디서 설정할지 한눈에 보기

항목 권장 위치 이유 주의점
로그 레벨(root/패키지별) application.yml 환경별로 쉽게 조절 DEBUG 상시 운영은 비용 큼
로그 패턴(MDC 포함) logback-spring.xml 포맷을 중앙에서 통제 과도한 정보 출력 금지
MDC 세팅/정리 OncePerRequestFilter 요청 시작/종료가 명확 MDC.clear() 누락 주의
민감정보 마스킹 “로그를 남기는 지점(Interceptor/Service)” 가장 정확하고 안전 레이아웃 기반 마스킹은 오탐/누락 가능

실무 팁

💡 실무에서는: “민감정보가 찍히는 지점”부터 차단해 보세요

  • request.toString(), DTO 자동 toString()(Lombok @ToString)이 의외로 사고를 많이 냅니다. 비밀번호/주민번호/토큰 필드는 @ToString.Exclude 같은 방식으로 원천 차단하는 게 가장 안전합니다.
  • Spring Security를 쓰는 경우에도 인증 관련 객체를 통째로 로그로 찍지 말고, 필요한 식별자(예: userId)만 선별해서 남기는 편이 좋습니다.

💡 실무에서는: traceId 전략을 “팀 표준”으로 정해두면 검색 비용이 확 줄어요

  • 게이트웨이/로드밸런서가 X-Request-Id를 내려주면 애플리케이션은 그 값을 우선 사용하는 편이 좋습니다. 그래야 앞단(게이트웨이) 로그와 뒷단(애플리케이션) 로그가 같은 키로 연결됩니다.
  • 분산 추적을 도입할 계획이 있다면, UUID를 직접 만들기보다 OpenTelemetry 같은 표준과의 연동을 염두에 두고 키 이름(traceId)과 전파 헤더 정책을 정해두세요.

핵심 요약: 로그 레벨은 “운영 액션 기준”으로 나누고, 패턴에는 traceId/requestId를 꼭 포함하세요.
핵심 요약: MDC는 Filter에서 세팅하고 요청 종료 시 반드시 MDC.clear()로 정리해야 합니다.
핵심 요약: 민감정보는 “마스킹”보다 “처음부터 로그에 안 남기기”가 더 안전합니다.

다음 글: #07 Bean과 DI(의존성 주입) 핵심만