운영 중인 서비스에서 특정 사용자의 요청이 어떤 경로를 거쳐 처리되었는지 추적하려면 로그에 어떤 정보가 있어야 할까요?

개념 정의

SLF4J(Simple Logging Facade for Java)는 로깅 구현체를 추상화하는 파사드입니다. 코드에서는 SLF4J API만 사용하고, 런타임에 실제 로깅 프레임워크(Logback, Log4j2)가 동작합니다.

JAVA
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Service
public class OrderService {
    // SLF4J Logger 생성
    private static final Logger log =
        LoggerFactory.getLogger(OrderService.class);

    // 또는 Lombok으로 간단하게
    // @Slf4j 어노테이션 사용
}

파사드 패턴을 사용하는 이유는 구현체를 교체할 수 있기 때문입니다. Logback에서 Log4j2로 바꿔도 애플리케이션 코드를 수정할 필요가 없습니다.

Spring Boot 기본 로깅

Spring Boot는 Logback 을 기본 로깅 구현체로 사용합니다. spring-boot-starter에 이미 포함되어 있어 별도 의존성 추가가 필요 없습니다.

application.yml 설정

YAML
logging:
  level:
    root: INFO
    com.example.service: DEBUG     # 특정 패키지 레벨 변경
    org.hibernate.SQL: DEBUG       # 쿼리 확인
    org.springframework.web: WARN  # 불필요한 로그 줄이기

  pattern:
    console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
    file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n"

  file:
    name: logs/application.log     # 로그 파일 경로

  logback:
    rollingpolicy:
      max-file-size: 100MB         # 파일 최대 크기
      max-history: 30              # 보관 일수
      total-size-cap: 3GB          # 총 로그 크기 제한

logback-spring.xml — 세밀한 설정

application.yml로 부족할 때 logback-spring.xml을 사용합니다. Spring Boot의 기능(<springProfile>, <springProperty>)을 활용하려면 반드시 logback-spring.xml이어야 합니다.

XML
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- Spring Boot 기본 설정 포함 -->
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

    <!-- 변수 정의 -->
    <springProperty scope="context" name="APP_NAME"
                    source="spring.application.name" defaultValue="myapp"/>

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

이어서 추가 Appender를 정의합니다.

XML
    <!-- 파일 Appender (롤링) -->
    <appender name="FILE"
              class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/${APP_NAME}.log</file>
        <rollingPolicy
            class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>
                logs/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz
            </fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>
            <totalSizeCap>3GB</totalSizeCap>
        </rollingPolicy>
        <encoder>
            <pattern>
                %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger - %msg%n
            </pattern>
        </encoder>
    </appender>

이어서 프로파일별 설정을 분리하여 환경에 따라 다르게 동작하도록 구성합니다.

XML
    <!-- JSON 포맷 (ELK 연동) -->
    <appender name="JSON"
              class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/${APP_NAME}-json.log</file>
        <rollingPolicy
            class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>
                logs/${APP_NAME}-json.%d{yyyy-MM-dd}.log.gz
            </fileNamePattern>
            <maxHistory>7</maxHistory>
        </rollingPolicy>
        <encoder
            class="net.logstash.logback.encoder.LogstashEncoder">
            <customFields>{"app":"${APP_NAME}"}</customFields>
        </encoder>
    </appender>

이어서 프로파일별 설정을 분리합니다.

XML
    <!-- 프로필별 설정 -->
    <springProfile name="local">
        <root level="DEBUG">
            <appender-ref ref="CONSOLE"/>
        </root>
    </springProfile>

    <springProfile name="prod">
        <root level="INFO">
            <appender-ref ref="FILE"/>
            <appender-ref ref="JSON"/>
        </root>
    </springProfile>
</configuration>

로그 레벨 가이드

레벨용도예시
ERROR즉시 대응 필요한 오류DB 연결 실패, 결제 실패
WARN잠재적 문제, 당장은 동작응답 시간 초과, 재시도 성공
INFO주요 비즈니스 이벤트주문 생성, 사용자 로그인
DEBUG개발/디버깅 상세 정보쿼리 파라미터, 메서드 진입/종료
TRACE매우 상세한 실행 흐름루프 내부 값, 바이트 데이터
JAVA
@Service
@Slf4j
public class OrderService {

    public Order createOrder(OrderRequest request) {
        log.info("주문 생성 시작: customerId={}, items={}",
            request.getCustomerId(), request.getItems().size());

        try {
            Order order = processOrder(request);
            log.info("주문 생성 완료: orderId={}", order.getId());

이어서 결과를 반환하는 마무리 로직입니다.

JAVA
            return order;
        } catch (InsufficientStockException e) {
            log.warn("재고 부족으로 주문 실패: productId={}",
                e.getProductId());
            throw e;
        } catch (Exception e) {
            log.error("주문 생성 중 예상치 못한 오류: customerId={}",
                request.getCustomerId(), e);  // 예외 객체를 마지막 인자로
            throw e;
        }
    }
}

로깅 안티패턴

JAVA
// 나쁜 예: 문자열 연결 (로그 레벨이 꺼져 있어도 연산 발생)
log.debug("사용자 정보: " + user.toString());

// 좋은 예: 파라미터 바인딩 (로그 레벨이 꺼져 있으면 연산 안 함)
log.debug("사용자 정보: {}", user);

// 나쁜 예: 예외 메시지만 기록
log.error("오류 발생: " + e.getMessage());

// 좋은 예: 스택 트레이스 포함
log.error("오류 발생: {}", e.getMessage(), e);

MDC — 요청 추적

MDC(Mapped Diagnostic Context)는 ThreadLocal 기반으로 스레드별 컨텍스트 정보를 저장합니다. 요청 ID를 MDC에 넣으면 해당 요청과 관련된 모든 로그를 추적할 수 있습니다.

Filter로 MDC 설정

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

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        try {
            // 요청 ID 생성 (또는 헤더에서 추출)
            String traceId = Optional.ofNullable(
                    request.getHeader("X-Trace-Id"))
                .orElse(UUID.randomUUID().toString().substring(0, 8));

이어서 필터 체인을 통해 요청을 다음 단계로 전달하는 부분입니다.

JAVA
            MDC.put("traceId", traceId);
            MDC.put("clientIp", request.getRemoteAddr());
            MDC.put("method", request.getMethod());
            MDC.put("uri", request.getRequestURI());

            // 응답 헤더에도 포함
            response.setHeader("X-Trace-Id", traceId);

            filterChain.doFilter(request, response);
        } finally {
            MDC.clear();  // 반드시 정리 (스레드 재사용 시 오염 방지)
        }
    }
}

로그 패턴에서 MDC 값 출력

XML
<!-- %X{키이름}으로 MDC 값 출력 -->
<pattern>
    %d{HH:mm:ss.SSS} [%X{traceId}] [%X{clientIp}] %-5level %logger{36} - %msg%n
</pattern>

출력 예:

PLAINTEXT
10:23:45.123 [a1b2c3d4] [192.168.1.100] INFO  OrderService - 주문 생성 시작: customerId=42
10:23:45.456 [a1b2c3d4] [192.168.1.100] INFO  PaymentService - 결제 처리: orderId=123
10:23:45.789 [a1b2c3d4] [192.168.1.100] INFO  OrderService - 주문 생성 완료: orderId=123

a1b2c3d4로 검색하면 하나의 요청과 관련된 모든 로그를 찾을 수 있습니다.

@Async와 MDC

@Async로 실행되는 비동기 작업은 별도 스레드에서 실행되므로 MDC가 전파되지 않습니다. TaskDecorator로 해결합니다.

JAVA
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);  // 새 스레드에 MDC 설정
                }
                runnable.run();
            } finally {
                MDC.clear();
            }
        };
    }
}

이어서 스레드 풀 설정과 실행기(Executor)를 구성합니다.

JAVA
@Bean
public TaskExecutor asyncExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setTaskDecorator(new MdcTaskDecorator());  // 데코레이터 등록
    executor.setCorePoolSize(5);
    executor.initialize();
    return executor;
}

비동기 로깅 (AsyncAppender)

파일 I/O가 요청 처리를 지연시키지 않도록 비동기 Appender를 사용합니다.

XML
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
    <queueSize>1024</queueSize>
    <discardingThreshold>0</discardingThreshold>  <!-- 버리지 않음 -->
    <neverBlock>true</neverBlock>  <!-- 큐 가득 차도 블로킹하지 않음 -->
    <appender-ref ref="FILE"/>
</appender>

주의: neverBlock=true로 설정하면 큐가 가득 찼을 때 로그가 유실될 수 있습니다. 로그 유실이 허용되지 않는 환경에서는 false로 설정하세요.

런타임 로그 레벨 변경

Spring Boot Actuator를 사용하면 재시작 없이 로그 레벨을 변경할 수 있습니다.

YAML
management:
  endpoints:
    web:
      exposure:
        include: loggers
BASH
# 현재 로그 레벨 확인
curl http://localhost:8080/actuator/loggers/com.example.service

# 로그 레벨 변경
curl -X POST http://localhost:8080/actuator/loggers/com.example.service \
  -H 'Content-Type: application/json' \
  -d '{"configuredLevel": "DEBUG"}'

운영 환경에서 특정 패키지의 로그를 일시적으로 DEBUG로 올려 문제를 진단한 뒤, 다시 INFO로 돌리는 패턴을 자주 사용합니다.

구조화된 로깅 (JSON)

ELK(Elasticsearch + Logstash + Kibana) 스택과 연동할 때는 JSON 포맷이 필수입니다.

JAVA
// build.gradle
implementation 'net.logstash.logback:logstash-logback-encoder:8.0'
JAVA
// 구조화된 로그 이벤트
log.info("주문 생성", kv("orderId", order.getId()),
    kv("customerId", request.getCustomerId()),
    kv("amount", order.getTotalAmount()));

JSON 출력:

JSON
{
  "@timestamp": "2026-03-19T10:23:45.123+09:00",
  "level": "INFO",
  "logger_name": "com.example.OrderService",
  "message": "주문 생성",
  "traceId": "a1b2c3d4",
  "orderId": 123,
  "customerId": 42,
  "amount": 59000
}

주의할 점

1. MDC.clear()를 빠뜨리면 다른 요청의 로그에 이전 요청 정보가 출력된다

Tomcat은 스레드 풀을 재사용하므로, 요청 처리 후 MDC.clear()를 호출하지 않으면 이전 요청의 traceId, userId 등이 다음 요청의 로그에 그대로 남습니다. 특히 예외 발생 시 finally 블록에서 MDC.clear()를 빠뜨리는 실수가 잦으며, 이는 로그 추적을 완전히 엉망으로 만듭니다.

2. 문자열 연결로 로그를 찍으면 로그 레벨이 꺼져도 성능이 낭비된다

log.debug("사용자: " + user.toString())은 DEBUG 레벨이 꺼져 있어도 문자열 연결과 toString() 호출이 실행됩니다. 대용량 객체의 toString()이나 컬렉션 변환이 포함되면 불필요한 CPU와 GC 비용이 발생합니다. 반드시 log.debug("사용자: {}", user) 형태의 파라미터 바인딩을 사용해야 합니다.

3. @Async 스레드에서 MDC가 전파되지 않아 로그 추적이 끊긴다

@Async로 실행되는 비동기 작업은 별도 스레드에서 실행되므로, 원래 요청의 MDC 정보가 전파되지 않습니다. 비동기 작업의 로그에 traceId가 없어 분산 추적이 불가능해집니다. TaskDecorator를 구현하여 MDC 컨텍스트를 비동기 스레드에 복사해야 합니다.

정리

항목설명
SLF4J / Logback파사드(API)와 구현체. 코드에서는 항상 SLF4J 사용
logback-spring.xml<springProfile>로 환경별 로그 설정 분리
MDCThreadLocal 기반 요청 ID 전파. MDC.clear() 필수
@Async + MDCTaskDecorator로 비동기 스레드에 MDC 복사
파라미터 바인딩log.debug("{}", user) — 문자열 연결 금지
런타임 레벨 변경Actuator /loggers 엔드포인트
댓글 로딩 중...