스프링부트 실무 가이드 7편: 로깅 전략과 실무

스프링부트 실무 가이드 7편: 로깅 전략과 실무


시리즈 네비게이션

이전현재다음
6편: 모니터링7편: 로깅-

서론

6편에서 옵저버빌리티의 3가지 축(Metrics, Logs, Traces) 중 Metrics를 다뤘다. 이번 편에서는 Logs 를 집중적으로 다룬다.

로그는 단순히 System.out.println()의 대체가 아니다. 잘 설계된 로그 시스템은 장애 원인 파악 시간을 몇 시간에서 몇 분으로 단축 시킨다.

7편에서 다루는 내용:

  • 스프링 로그 기술 스택 비교 (SLF4J, Logback, Log4j2)
  • 로그 레벨 가이드
  • Logback 설정 실무
  • 구조화된 로그 (JSON 포맷)
  • MDC를 활용한 요청 추적
  • 중앙 집중식 로그 관리 (ELK, Loki)
  • 실무 유의사항

목차


1. 로그가 왜 중요한가?

1.1 로그 없이 장애 대응하기

장애 발생 시나리오:
"결제 API에서 간헐적으로 500 에러 발생"

로그 없이:
├── "어떤 요청에서 에러가 났지?"
├── "어떤 파라미터로 호출했지?"
├── "DB 쿼리가 문제인가? 외부 API인가?"
├── "재현이 안 되는데..."
└── 원인 파악 불가

로그 있으면:
├── ERROR PaymentService - Payment failed for orderId=12345
├── ERROR External PG API timeout after 5000ms
├── DEBUG Request: POST /api/payments, userId=678, amount=50000
└── 5분 만에 원인 파악: PG사 API 지연

1.2 좋은 로그 vs 나쁜 로그

// ❌ 나쁜 로그
logger.info("에러 발생")
logger.error("실패함")
logger.debug(user.toString())  // 민감정보 노출 위험

// ✓ 좋은 로그
logger.info("주문 생성 완료: orderId={}, userId={}, amount={}", orderId, userId, amount)
logger.error("결제 처리 실패: orderId={}, errorCode={}, message={}", orderId, e.code, e.message, e)
logger.debug("상품 조회 요청: productId={}", productId)

좋은 로그의 특징:

  • 누가: userId, sessionId
  • 무엇을: 어떤 작업
  • 어떻게: 파라미터, 결과값
  • : 에러 원인 (예외 포함)

2. 기술 스택 선정 가이드

2.1 스프링 로깅 아키텍처

┌─────────────────────────────────────────────────────────┐
│                   Application Code                       │
│                                                         │
│              logger.info("Hello World")                 │
│                         │                               │
│                         ▼                               │
│              ┌─────────────────┐                        │
│              │     SLF4J       │  ← 로깅 추상화 (Facade)│
│              │  (Simple Logging│                        │
│              │   Facade 4 Java)│                        │
│              └────────┬────────┘                        │
│                       │                                 │
│         ┌─────────────┼─────────────┐                  │
│         ▼             ▼             ▼                  │
│   ┌──────────┐  ┌──────────┐  ┌──────────┐            │
│   │ Logback  │  │  Log4j2  │  │ JUL      │            │
│   │ (기본)    │  │          │  │(java.util│            │
│   │          │  │          │  │ .logging)│            │
│   └──────────┘  └──────────┘  └──────────┘            │
│                                                         │
│                    실제 로깅 구현체                      │
└─────────────────────────────────────────────────────────┘

SLF4J 는 로깅의 인터페이스(추상화)이고, Logback/Log4j2 는 실제 구현체다.

2.2 기술 스택 비교

구분LogbackLog4j2
Spring Boot 기본O (기본 내장)X (별도 설정 필요)
성능좋음더 좋음 (Async Logger)
설정 파일logback-spring.xmllog4j2-spring.xml
개발사SLF4J 개발자 (Ceki)Apache
JSON 로깅추가 라이브러리 필요기본 지원
비동기 로깅AsyncAppender기본 지원 (LMAX Disruptor)

2.3 선택 가이드

┌─────────────────────────────────────────────────────────┐
│                    기술 스택 선택                        │
│                                                         │
│  Q1. 특별한 요구사항이 있는가?                          │
│      │                                                  │
│      ├── 초고성능 필요 (수십만 TPS) → Log4j2            │
│      ├── 기본 설정으로 충분 → Logback (기본)            │
│      └── JSON 로깅 필수 → 둘 다 가능                    │
│                                                         │
│  권장: 대부분의 경우 Logback (Spring Boot 기본)          │
│       - 별도 설정 없이 바로 사용                        │
│       - Spring Profile 연동 기본 지원                   │
│       - 충분한 성능                                     │
└─────────────────────────────────────────────────────────┘

TPS (Transactions Per Second)란? 시스템이 1초 동안 처리할 수 있는 트랜잭션(요청) 수를 의미한다. 서버 성능을 측정하는 핵심 지표로, 값이 높을수록 더 많은 요청을 처리할 수 있다.

  • 소규모 서비스: 수백~수천 TPS
  • 중규모 서비스: 수천~수만 TPS
  • 대규모 서비스: 수십만 TPS 이상

2.4 중앙 집중식 로그 관리 비교

구분ELK StackLoki + Grafana
구성Elasticsearch + Logstash + KibanaLoki + Promtail + Grafana
인덱싱전문 검색 (Full-text)라벨 기반 (메타데이터만)
리소스높음 (ES가 무거움)낮음
검색 속도빠름 (인덱스 활용)라벨 검색 빠름, 본문 검색 느림
복잡도높음낮음

2.5 전문 검색 vs 라벨 기반 인덱싱

전문 검색 인덱싱 (ELK - Elasticsearch)

로그 원본:
{"timestamp":"2024-01-15T10:30:45Z","level":"ERROR","message":"Payment failed for user 12345","traceId":"abc123"}

Elasticsearch 인덱싱:
┌─────────────────────────────────────────────────────────┐
│  역인덱스 (Inverted Index) 생성                         │
│                                                         │
│  "payment" → [doc1, doc15, doc203, ...]                │
│  "failed"  → [doc1, doc42, doc89, ...]                 │
│  "user"    → [doc1, doc2, doc3, ...]                   │
│  "12345"   → [doc1, doc156, ...]                       │
│  "error"   → [doc1, doc5, doc10, ...]                  │
│                                                         │
│  모든 단어가 인덱싱됨 → 어떤 단어로도 검색 가능         │
└─────────────────────────────────────────────────────────┘

검색 예시:
- "Payment failed" 포함된 모든 로그 → 즉시 반환
- "NullPointerException" 포함된 로그 → 즉시 반환
- message에 "timeout" AND level="ERROR" → 즉시 반환

라벨 기반 인덱싱 (Loki)

로그 원본:
{"timestamp":"2024-01-15T10:30:45Z","level":"ERROR","message":"Payment failed for user 12345","traceId":"abc123"}

Loki 인덱싱:
┌─────────────────────────────────────────────────────────┐
│  라벨(메타데이터)만 인덱싱                              │
│                                                         │
│  {app="payment-service", level="ERROR", env="prod"}    │
│       ↓                                                 │
│  이 라벨 조합에 해당하는 로그 청크 위치만 저장          │
│                                                         │
│  로그 본문("Payment failed for user 12345")은           │
│  압축만 하고 인덱싱하지 않음                            │
└─────────────────────────────────────────────────────────┘

검색 예시:
- {app="payment-service", level="ERROR"} → 빠름 (인덱스 사용)
- {app="payment-service"} |= "Payment failed" → 느림 (전체 스캔)
- "NullPointerException" 포함된 로그 → 느림 (전체 스캔)

검색 성능 비교

검색 유형ELKLoki
라벨/필드 검색 (level=ERROR)빠름빠름
특정 ID 검색 (traceId=abc123)빠름빠름 (traceId가 라벨인 경우)
본문 키워드 검색 ("NullPointer")빠름느림 (전체 스캔)
정규식 검색 (message =~ "timeout.*")빠름느림
복잡한 집계 (에러율 통계)빠름제한적

실무 시나리오별 적합도

시나리오 1: "traceId=abc123인 요청의 모든 로그"
├── ELK: 빠름 ✓
└── Loki: 빠름 ✓ (traceId를 라벨로 설정한 경우)

시나리오 2: "오늘 발생한 NullPointerException 모두 찾기"
├── ELK: 빠름 ✓ (역인덱스로 즉시 검색)
└── Loki: 느림 ✗ (오늘 로그 전체 스캔 필요)

시나리오 3: "최근 1시간 에러 로그 중 'timeout' 포함된 것"
├── ELK: 빠름 ✓
└── Loki: 보통 (1시간치만 스캔)

시나리오 4: "지난 30일간 API별 에러율 통계"
├── ELK: 빠름 ✓ (집계 쿼리 강력)
└── Loki: 느림/불가 ✗

결론:

ELK 선택:
- 로그 본문 검색이 빈번함
- "어떤 에러가 발생했는지 모르지만 찾아야 함"
- 복잡한 통계/분석 필요

Loki 선택:
- 대부분 traceId, userId 등 특정 ID로 검색
- "이 요청의 로그를 보여줘" 수준의 검색
- 비용 최적화 우선

2.6 비용 비교 가이드

왜 비용 차이가 나는가?

ELK Stack:
- Elasticsearch가 모든 로그 내용을 인덱싱
- 인덱스 저장에 원본 데이터의 1.5~2배 스토리지 필요
- 인덱싱/검색을 위한 높은 CPU/메모리 요구
- 최소 권장: 16GB RAM, 4 Core (단일 노드)

Loki:
- 로그 본문은 인덱싱하지 않고 압축 저장만
- 라벨(메타데이터)만 인덱싱
- 스토리지 사용량 ELK 대비 10~20% 수준
- 최소 권장: 4GB RAM, 2 Core

규모별 비용 비교 (AWS 기준 월 예상 비용)

로그 볼륨ELK (Self-hosted)Loki (Self-hosted)비고
10GB/일$150~250$50~80소규모, Loki 권장
50GB/일$400~600$100~150중규모, Loki 권장
200GB/일$1,500~2,500$300~500대규모, 요구사항에 따라 선택
1TB/일$5,000+$1,000~2,000복잡한 검색 필요시 ELK

참고: 관리형 서비스(AWS OpenSearch, Grafana Cloud) 사용 시 비용이 2~5배 증가하지만 운영 부담이 줄어든다.

선택 가이드

Loki를 선택해야 할 때:
├── 일일 로그 볼륨 100GB 이하
├── "traceId=xxx인 로그 보여줘" 수준의 검색이면 충분
├── 이미 Grafana를 사용 중 (모니터링 통합)
├── 비용 최적화가 중요
└── 운영 인력이 적음

ELK를 선택해야 할 때:
├── 로그 본문 전문 검색 필수 ("NullPointerException" 포함된 모든 로그)
├── 복잡한 집계/분석 필요 (에러 패턴 분석, 통계)
├── 일일 로그 볼륨 수백 GB 이상 + 빠른 검색 필요
├── 보안/컴플라이언스 요구사항 (감사 로그 분석)
└── 전담 운영 인력 있음

실무 팁: 하이브리드 접근

비용과 기능 모두 잡기:
┌─────────────────────────────────────────────────────────┐
│  모든 로그 → Loki (저비용 장기 보관)                    │
│      │                                                  │
│      └── ERROR 로그만 → ELK (상세 분석)                 │
│                                                         │
│  효과:                                                  │
│  - 전체 비용 70% 절감                                   │
│  - 에러 분석 시에만 ELK 사용                            │
└─────────────────────────────────────────────────────────┘

3. 로그 레벨 가이드

3.1 로그 레벨 정의

┌─────────────────────────────────────────────────────────┐
│  레벨      │  용도                     │  운영 환경    │
├─────────────────────────────────────────────────────────┤
│  ERROR     │  즉시 대응 필요한 에러     │  O           │
│  WARN      │  주의 필요, 잠재적 문제    │  O           │
│  INFO      │  주요 비즈니스 이벤트      │  O           │
│  DEBUG     │  상세 디버깅 정보          │  X (개발만)   │
│  TRACE     │  매우 상세한 정보          │  X           │
└─────────────────────────────────────────────────────────┘

3.2 레벨별 사용 예시

@Service
class OrderService(
    private val orderRepository: OrderRepository,
    private val paymentClient: PaymentClient
) {
    private val logger = LoggerFactory.getLogger(javaClass)

    fun createOrder(request: CreateOrderRequest): Order {
        // DEBUG: 메서드 진입, 상세 파라미터
        logger.debug("주문 생성 시작: request={}", request)

        // INFO: 주요 비즈니스 이벤트
        logger.info("주문 생성 요청: userId={}, productId={}, quantity={}",
            request.userId, request.productId, request.quantity)

        try {
            val order = Order.create(request)
            orderRepository.save(order)

            // INFO: 성공 결과
            logger.info("주문 생성 완료: orderId={}, userId={}", order.id, request.userId)
            return order

        } catch (e: InsufficientStockException) {
            // WARN: 예상 가능한 비즈니스 예외
            logger.warn("재고 부족으로 주문 실패: productId={}, requested={}, available={}",
                request.productId, request.quantity, e.availableStock)
            throw e

        } catch (e: Exception) {
            // ERROR: 예상치 못한 시스템 에러
            logger.error("주문 생성 중 오류 발생: userId={}, productId={}",
                request.userId, request.productId, e)
            throw e
        }
    }
}

3.3 로그 레벨 체크, 해야 하나?

위 예시에서 logger.isDebugEnabled() 같은 레벨 체크가 없다. 이유가 있다.

SLF4J 파라미터화 로깅의 동작 방식

// 이렇게 작성하면
logger.debug("주문 생성 시작: request={}", request)

// SLF4J 내부에서 이렇게 처리됨
if (logger.isDebugEnabled()) {
    String message = "주문 생성 시작: request=" + request.toString()
    // 실제 로그 출력
}

플레이스홀더({})를 사용하면 로그 레벨이 비활성화된 경우 문자열 연결 자체가 발생하지 않는다.

레벨 체크가 필요 없는 경우 (대부분)

// ✓ 플레이스홀더 사용 - 레벨 체크 불필요
logger.debug("userId={}, orderId={}", userId, orderId)
logger.debug("request={}", request)  // toString()도 레벨 활성화 시에만 호출

// 이유: DEBUG가 OFF면 파라미터 평가 자체를 하지 않음

레벨 체크가 필요한 경우 (비용이 큰 연산)

// ❌ 비용이 큰 연산은 레벨 체크 필요
logger.debug("결과: {}", expensiveCalculation())  // 항상 호출됨!

// ✓ 레벨 체크로 불필요한 연산 방지
if (logger.isDebugEnabled) {
    logger.debug("결과: {}", expensiveCalculation())
}

// ✓ Kotlin에서는 람다로 해결 (kotlin-logging 라이브러리)
// implementation("io.github.microutils:kotlin-logging:3.0.5")
logger.debug { "결과: ${expensiveCalculation()}" }  // DEBUG가 OFF면 람다 실행 안 함

정리: 언제 레벨 체크를 하는가?

상황레벨 체크이유
단순 변수 출력불필요플레이스홀더가 처리
객체 toString()불필요레벨 활성화 시에만 호출
메서드 호출필요메서드는 항상 실행됨
복잡한 연산필요연산 비용 발생
컬렉션 순회필요순회 비용 발생
// 실무 예시
class OrderService {
    fun processOrder(order: Order) {
        // ✓ 단순 변수 - 체크 불필요
        logger.debug("주문 처리 시작: orderId={}", order.id)

        // ✓ 객체 - 체크 불필요 (toString은 레벨 활성화 시에만)
        logger.debug("주문 상세: {}", order)

        // ⚠️ 메서드 호출 - 체크 필요
        if (logger.isDebugEnabled) {
            logger.debug("재고 현황: {}", inventoryService.getStockSummary())
        }

        // ⚠️ 컬렉션 변환 - 체크 필요
        if (logger.isDebugEnabled) {
            val itemNames = order.items.map { it.name }.joinToString()
            logger.debug("주문 상품: {}", itemNames)
        }
    }
}

3.4 로그 레벨 선택 기준

ERROR 사용:
- 즉시 대응이 필요한 상황
- 서비스 기능이 동작하지 않음
- 예: DB 연결 실패, 필수 외부 API 오류

WARN 사용:
- 지금은 괜찮지만 주의가 필요
- 예상 가능한 비즈니스 예외
- 예: 재고 부족, 잘못된 입력, 재시도 발생

INFO 사용:
- 운영에 필요한 정보
- 주요 비즈니스 이벤트
- 예: 주문 생성, 결제 완료, 사용자 가입

DEBUG 사용:
- 개발/디버깅 시에만 필요
- 메서드 진입/종료, 상세 파라미터
- 운영 환경에서는 OFF

TRACE 사용:
- 매우 상세한 추적
- 루프 내부, 변수 값 변화
- 거의 사용하지 않음

3.5 환경별 로그 레벨 설정

# application.yml - 공통 설정
logging:
  level:
    root: INFO
    com.example.marketplace: INFO

---
# application-local.yml - 로컬 개발
spring:
  config:
    activate:
      on-profile: local
logging:
  level:
    com.example.marketplace: DEBUG
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql: TRACE

---
# application-prod.yml - 운영
spring:
  config:
    activate:
      on-profile: prod
logging:
  level:
    root: WARN
    com.example.marketplace: INFO

4. Logback 설정

4.1 기본 설정 파일

Spring Boot에서는 logback-spring.xml을 사용하면 Spring Profile 연동이 가능하다.

<!-- src/main/resources/logback-spring.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="30 seconds">

    <!-- 변수 정의 -->
    <property name="LOG_PATH" value="${LOG_PATH:-./logs}"/>
    <property name="LOG_FILE" value="${LOG_FILE:-application}"/>

    <!-- 콘솔 출력 패턴 -->
    <property name="CONSOLE_PATTERN"
              value="%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n"/>

    <!-- 파일 출력 패턴 -->
    <property name="FILE_PATTERN"
              value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n"/>

    <!-- 콘솔 Appender -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- 파일 Appender (Rolling) -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_PATH}/${LOG_FILE}.log</file>
        <encoder>
            <pattern>${FILE_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!-- 일별 롤링 -->
            <fileNamePattern>${LOG_PATH}/${LOG_FILE}.%d{yyyy-MM-dd}.log</fileNamePattern>
            <!-- 30일 보관 -->
            <maxHistory>30</maxHistory>
            <!-- 최대 총 용량 -->
            <totalSizeCap>10GB</totalSizeCap>
        </rollingPolicy>
    </appender>

    <!-- 에러 전용 파일 -->
    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_PATH}/${LOG_FILE}-error.log</file>
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>ERROR</level>
        </filter>
        <encoder>
            <pattern>${FILE_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>${LOG_PATH}/${LOG_FILE}-error.%d{yyyy-MM-dd}.log</fileNamePattern>
            <maxHistory>90</maxHistory>
        </rollingPolicy>
    </appender>

    <!-- Profile별 설정 -->
    <springProfile name="local">
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
        </root>
        <logger name="com.example.marketplace" level="DEBUG"/>
        <logger name="org.hibernate.SQL" level="DEBUG"/>
    </springProfile>

    <springProfile name="dev">
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
            <appender-ref ref="FILE"/>
        </root>
        <logger name="com.example.marketplace" level="DEBUG"/>
    </springProfile>

    <springProfile name="prod">
        <root level="WARN">
            <appender-ref ref="FILE"/>
            <appender-ref ref="ERROR_FILE"/>
        </root>
        <logger name="com.example.marketplace" level="INFO"/>
    </springProfile>

</configuration>

4.2 비동기 로깅 설정

로그 쓰기가 애플리케이션 성능에 영향을 주지 않도록 비동기로 처리한다.

<!-- 비동기 Appender -->
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
    <!-- 큐 크기 (기본값: 256) -->
    <queueSize>1024</queueSize>
    <!-- 큐가 80% 차면 WARN 이하 로그 버림 -->
    <discardingThreshold>20</discardingThreshold>
    <!-- 큐가 가득 차면 블로킹하지 않고 버림 -->
    <neverBlock>true</neverBlock>
    <!-- 실제 Appender -->
    <appender-ref ref="FILE"/>
</appender>

<springProfile name="prod">
    <root level="WARN">
        <appender-ref ref="ASYNC_FILE"/>
        <appender-ref ref="ERROR_FILE"/>
    </root>
</springProfile>

4.3 로그 로테이션 전략

<!-- 크기 + 시간 기반 롤링 -->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
    <!-- 일별 + 인덱스 -->
    <fileNamePattern>${LOG_PATH}/${LOG_FILE}.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
    <!-- 파일당 최대 크기 -->
    <maxFileSize>100MB</maxFileSize>
    <!-- 보관 기간 -->
    <maxHistory>30</maxHistory>
    <!-- 전체 최대 용량 -->
    <totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>

5. 구조화된 로그 (JSON)

5.1 왜 JSON 로그인가?

일반 로그:
2024-01-15 10:30:45.123 INFO [http-nio-8080-exec-1] OrderService - 주문 생성 완료: orderId=12345, userId=678

JSON 로그:
{
  "timestamp": "2024-01-15T10:30:45.123Z",
  "level": "INFO",
  "thread": "http-nio-8080-exec-1",
  "logger": "OrderService",
  "message": "주문 생성 완료",
  "orderId": "12345",
  "userId": "678",
  "traceId": "abc123",
  "spanId": "def456"
}

JSON 로그의 장점:

  • 로그 수집 시스템에서 파싱 용이
  • 필드별 검색/필터링 가능
  • 구조화된 데이터로 분석 용이

5.2 Logstash Encoder 설정

// build.gradle.kts
dependencies {
    implementation("net.logstash.logback:logstash-logback-encoder:7.4")
}
<!-- logback-spring.xml -->
<appender name="JSON_CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <!-- 타임스탬프 형식 -->
        <timestampPattern>yyyy-MM-dd'T'HH:mm:ss.SSS'Z'</timestampPattern>
        <!-- 커스텀 필드 추가 -->
        <customFields>{"application":"marketplace-api","environment":"${SPRING_PROFILES_ACTIVE:-local}"}</customFields>
        <!-- 예외 스택트레이스 포함 -->
        <throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
            <maxDepthPerThrowable>30</maxDepthPerThrowable>
            <maxLength>2048</maxLength>
            <shortenedClassNameLength>20</shortenedClassNameLength>
            <rootCauseFirst>true</rootCauseFirst>
        </throwableConverter>
    </encoder>
</appender>

<appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>${LOG_PATH}/${LOG_FILE}.json</file>
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <timestampPattern>yyyy-MM-dd'T'HH:mm:ss.SSS'Z'</timestampPattern>
    </encoder>
    <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
        <fileNamePattern>${LOG_PATH}/${LOG_FILE}.%d{yyyy-MM-dd}.json</fileNamePattern>
        <maxHistory>30</maxHistory>
    </rollingPolicy>
</appender>

5.3 구조화된 로그 출력

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

@Service
class OrderService {
    private val logger = LoggerFactory.getLogger(javaClass)

    fun createOrder(request: CreateOrderRequest): Order {
        // 구조화된 인자 사용
        logger.info("주문 생성 완료",
            kv("orderId", order.id),
            kv("userId", request.userId),
            kv("amount", request.amount),
            kv("productCount", request.items.size)
        )

        // 출력:
        // {
        //   "message": "주문 생성 완료",
        //   "orderId": "12345",
        //   "userId": "678",
        //   "amount": 50000,
        //   "productCount": 3
        // }
    }
}

6. MDC를 활용한 요청 추적

6.1 MDC란?

MDC (Mapped Diagnostic Context):
스레드 로컬에 저장되는 컨텍스트 정보

┌─────────────────────────────────────────────────────────┐
│  요청 A (Thread-1)                                      │
│  MDC: {traceId: "abc123", userId: "100"}               │
│  └── OrderService.createOrder()                        │
│       └── PaymentService.process()                     │
│            └── NotificationService.send()              │
│  모든 로그에 traceId, userId 자동 포함                  │
│                                                         │
│  요청 B (Thread-2)                                      │
│  MDC: {traceId: "def456", userId: "200"}               │
│  └── 별도의 컨텍스트로 격리됨                           │
└─────────────────────────────────────────────────────────┘

6.2 MDC Filter 구현

// MdcLoggingFilter.kt
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)  // 가장 먼저 실행되어야 함
class MdcLoggingFilter : OncePerRequestFilter() {

    companion object {
        const val TRACE_ID = "traceId"
        const val SPAN_ID = "spanId"
        const val USER_ID = "userId"
        const val REQUEST_URI = "requestUri"
        const val REQUEST_METHOD = "requestMethod"
        const val CLIENT_IP = "clientIp"
    }

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        try {
            // TraceId 생성 (또는 헤더에서 추출)
            val traceId = request.getHeader("X-Trace-Id")
                ?: UUID.randomUUID().toString().replace("-", "").take(16)
            val spanId = UUID.randomUUID().toString().replace("-", "").take(8)

            // MDC에 컨텍스트 설정
            MDC.put(TRACE_ID, traceId)
            MDC.put(SPAN_ID, spanId)
            MDC.put(REQUEST_URI, request.requestURI)
            MDC.put(REQUEST_METHOD, request.method)
            MDC.put(CLIENT_IP, getClientIp(request))

            // 응답 헤더에 TraceId 포함 (프론트엔드에서 확인용)
            response.setHeader("X-Trace-Id", traceId)

            filterChain.doFilter(request, response)
        } finally {
            // 스레드 재사용을 위해 반드시 clear
            MDC.clear()
        }
    }

    private fun getClientIp(request: HttpServletRequest): String {
        val xForwardedFor = request.getHeader("X-Forwarded-For")
        return if (xForwardedFor.isNullOrEmpty()) {
            request.remoteAddr
        } else {
            xForwardedFor.split(",")[0].trim()
        }
    }
}

@Order(Ordered.HIGHEST_PRECEDENCE)를 사용하는 이유:

필터 실행 순서:
┌─────────────────────────────────────────────────────────┐
│  1. MdcLoggingFilter (HIGHEST_PRECEDENCE = 가장 먼저)   │
│     └── traceId 생성, MDC 설정                          │
│                                                         │
│  2. Spring Security Filters                             │
│     └── 인증/인가 처리                                  │
│                                                         │
│  3. 기타 필터들                                         │
│                                                         │
│  4. Controller                                          │
└─────────────────────────────────────────────────────────┘

이유:
- 모든 로그에 traceId가 포함되려면 가장 먼저 실행되어야 함
- Security 필터에서 발생하는 로그에도 traceId 포함
- 인증 실패 로그에도 요청 추적 가능

X-Trace-Id, X-Forwarded-For 헤더 표준:

헤더표준 여부정의
X-Forwarded-For사실상 표준RFC 7239에서 Forwarded 헤더로 표준화됨. 하지만 X-Forwarded-For가 더 널리 사용됨
X-Trace-Id비표준 (사실상 표준)W3C Trace Context에서 traceparent 헤더로 표준화됨. 하지만 X-Trace-Id, X-Request-Id 등이 관례적으로 사용됨
X-Forwarded-For:
- 프록시/로드밸런서가 원본 클라이언트 IP를 전달할 때 사용
- 형식: X-Forwarded-For: client, proxy1, proxy2
- RFC 7239 Forwarded 헤더가 공식 표준이지만, X-Forwarded-For가 더 보편적

X-Trace-Id (X-Request-Id):
- 분산 시스템에서 요청 추적용 ID
- W3C Trace Context (https://www.w3.org/TR/trace-context/)가 공식 표준
  - traceparent: 00-{trace-id}-{span-id}-{flags}
- 하지만 단순한 X-Trace-Id가 더 많이 사용됨
- OpenTelemetry, Jaeger, Zipkin 등에서 표준 헤더 지원

6.3 인증 후 사용자 ID 추가

사용자 ID를 MDC에 추가하려면 인증 필터 이후에 실행되어야 한다. Spring Security의 필터 체인에 명시적으로 등록한다.

// MdcUserFilter.kt
class MdcUserFilter : OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        try {
            // SecurityContext에서 사용자 정보 추출
            SecurityContextHolder.getContext().authentication?.let { auth ->
                if (auth.isAuthenticated && auth.principal is UserDetails) {
                    val user = auth.principal as UserDetails
                    MDC.put("userId", user.username)
                }
            }
            filterChain.doFilter(request, response)
        } finally {
            MDC.remove("userId")
        }
    }
}

SecurityConfig에 필터 등록:

// SecurityConfig.kt
@Configuration
@EnableWebSecurity
class SecurityConfig {

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        http
            .authorizeHttpRequests { auth ->
                auth.anyRequest().authenticated()
            }
            .oauth2Login { }
            // SecurityContextPersistenceFilter 이후에 MdcUserFilter 추가
            .addFilterAfter(MdcUserFilter(), SecurityContextPersistenceFilter::class.java)

        return http.build()
    }
}
필터 실행 순서:
┌─────────────────────────────────────────────────────────┐
│  MdcLoggingFilter (HIGHEST_PRECEDENCE)                  │
│  └── traceId 설정                                       │
│                                                         │
│  ... Spring Security 필터들 ...                         │
│                                                         │
│  SecurityContextPersistenceFilter                       │
│  └── SecurityContext 로드                               │
│                                                         │
│  MdcUserFilter (addFilterAfter로 등록)                  │
│  └── userId 설정 (인증 정보 사용 가능)                  │
│                                                         │
│  ... 나머지 필터들 ...                                  │
│                                                         │
│  Controller                                             │
└─────────────────────────────────────────────────────────┘

6.4 Logback 패턴에 MDC 적용

<!-- 일반 로그 패턴 -->
<property name="CONSOLE_PATTERN"
          value="%d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] [%X{traceId}] %cyan(%logger{36}) - %msg%n"/>

<!-- JSON 로그에서는 자동으로 MDC 필드 포함 -->
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
    <includeMdcKeyName>traceId</includeMdcKeyName>
    <includeMdcKeyName>spanId</includeMdcKeyName>
    <includeMdcKeyName>userId</includeMdcKeyName>
    <includeMdcKeyName>requestUri</includeMdcKeyName>
</encoder>

6.5 비동기 작업에서 MDC 전파

MDC는 스레드 로컬(ThreadLocal) 기반이므로 비동기 작업 시 자동으로 전파되지 않는다. 별도 설정이 필요하다.

MDC와 로깅 프레임워크:

프레임워크MDC 지원비고
SLF4JOMDC API 제공 (org.slf4j.MDC)
LogbackOSLF4J MDC 그대로 사용
Log4j2OThreadContext 사용 (SLF4J MDC와 연동됨)

결론: MDC 전파 코드는 SLF4J API를 사용하므로 Logback, Log4j2 모두에서 동일하게 동작 한다.

TaskDecorator로 MDC 전파:

// AsyncConfig.kt
@Configuration
@EnableAsync
class AsyncConfig : AsyncConfigurer {

    override fun getAsyncExecutor(): Executor {
        val executor = ThreadPoolTaskExecutor()
        executor.corePoolSize = 10
        executor.maxPoolSize = 50
        executor.setTaskDecorator(MdcTaskDecorator())  // MDC 전파 설정
        executor.initialize()
        return executor
    }
}

class MdcTaskDecorator : TaskDecorator {
    override fun decorate(runnable: Runnable): Runnable {
        // 현재 스레드(호출자)의 MDC 복사
        val contextMap = MDC.getCopyOfContextMap()
        return Runnable {
            try {
                // 새 스레드(비동기 작업)에 MDC 설정
                contextMap?.let { MDC.setContextMap(it) }
                runnable.run()
            } finally {
                MDC.clear()
            }
        }
    }
}

실제 사용 예시:

@Service
class OrderService(
    private val notificationService: NotificationService
) {
    private val logger = LoggerFactory.getLogger(javaClass)

    fun createOrder(request: CreateOrderRequest): Order {
        // MDC에 traceId가 설정된 상태
        logger.info("주문 생성: orderId={}", order.id)  // traceId 포함

        // 비동기 호출 - MdcTaskDecorator가 MDC를 전파
        notificationService.sendAsync(order)

        return order
    }
}

@Service
class NotificationService {
    private val logger = LoggerFactory.getLogger(javaClass)

    @Async  // 별도 스레드에서 실행
    fun sendAsync(order: Order) {
        // MdcTaskDecorator 덕분에 동일한 traceId 유지
        logger.info("알림 발송: orderId={}", order.id)  // 동일한 traceId 포함!
    }
}
로그 출력:
[traceId=abc123] 주문 생성: orderId=100        (http-nio-8080-exec-1)
[traceId=abc123] 알림 발송: orderId=100        (async-executor-1)
                 ↑ 동일한 traceId로 연결됨

CompletableFuture에서 MDC 전파:

CompletableFuture는 @Async와 달리 TaskDecorator가 자동 적용되지 않는다. 두 가지 방식이 있다.

방식 1: 매번 수동 처리 (Service 코드)

// OrderService.kt - 사용하는 곳에서 직접 처리
@Service
class OrderService(
    private val asyncExecutor: Executor  // Configuration에서 주입
) {
    fun processAsync(): CompletableFuture<Result> {
        // 현재 스레드의 MDC 복사
        val contextMap = MDC.getCopyOfContextMap()

        return CompletableFuture.supplyAsync({
            try {
                contextMap?.let { MDC.setContextMap(it) }
                doProcess()
            } finally {
                MDC.clear()
            }
        }, asyncExecutor)
    }
}

단점: 매번 boilerplate 코드 작성 필요

방식 2: MDC 전파 Executor를 Configuration에 등록 (권장)

// AsyncConfig.kt - Configuration에 등록
@Configuration
class AsyncConfig {

    /**
     * MDC를 자동 전파하는 Executor
     * CompletableFuture.supplyAsync(..., mdcExecutor) 로 사용
     */
    @Bean
    fun mdcExecutor(): Executor {
        val executor = ThreadPoolTaskExecutor()
        executor.corePoolSize = 10
        executor.maxPoolSize = 50
        executor.setThreadNamePrefix("mdc-async-")
        executor.setTaskDecorator(MdcTaskDecorator())  // MDC 전파
        executor.initialize()
        return executor
    }
}

// MdcTaskDecorator.kt (위에서 정의한 것과 동일)
class MdcTaskDecorator : TaskDecorator {
    override fun decorate(runnable: Runnable): Runnable {
        val contextMap = MDC.getCopyOfContextMap()
        return Runnable {
            try {
                contextMap?.let { MDC.setContextMap(it) }
                runnable.run()
            } finally {
                MDC.clear()
            }
        }
    }
}
// OrderService.kt - 깔끔하게 사용
@Service
class OrderService(
    @Qualifier("mdcExecutor") private val mdcExecutor: Executor
) {
    fun processAsync(): CompletableFuture<Result> {
        // MDC 전파가 자동으로 됨!
        return CompletableFuture.supplyAsync({
            doProcess()  // 별도 처리 불필요
        }, mdcExecutor)
    }
}

정리:

방식위치장점단점
수동 처리Service 코드명시적boilerplate 많음
mdcExecutor BeanConfiguration깔끔, 재사용Bean 주입 필요

6.6 EDA 환경에서 traceId 전파 (Kafka/RabbitMQ)

앞서 다룬 @Async같은 JVM 내 에서 스레드만 다른 경우다. 하지만 EDA(Event-Driven Architecture) 환경에서는 메시지가 네트워크를 통해 다른 서비스로 전달되므로 MDC 전파 방식이 다르다.

@Async vs Kafka/RabbitMQ 차이:

@Async (같은 JVM):
┌─────────────────────────────────────────────────────────┐
│  Order Service (JVM)                                    │
│                                                         │
│  Thread-1 ──TaskDecorator──▶ Thread-2                  │
│  MDC 복사로 해결                                        │
└─────────────────────────────────────────────────────────┘

Kafka/RabbitMQ (다른 JVM):
┌─────────────────────────────────────────────────────────┐
│  Order Service        Kafka         Payment Service    │
│  (JVM 1)              Broker        (JVM 2)            │
│                                                         │
│  MDC: {traceId}  ─────X─────▶  MDC: {} (없음!)        │
│                                                         │
│  MDC는 ThreadLocal이라 네트워크 전송 불가              │
│  → 메시지 헤더에 traceId를 담아서 전달해야 함          │
└─────────────────────────────────────────────────────────┘

Kafka Producer - traceId를 헤더에 포함:

@Component
class OrderEventProducer(
    private val kafkaTemplate: KafkaTemplate<String, OrderEvent>
) {
    private val logger = LoggerFactory.getLogger(javaClass)

    fun sendOrderCreatedEvent(order: Order) {
        val event = OrderCreatedEvent(order.id, order.userId, order.amount)

        // MDC에서 traceId 추출하여 헤더에 추가
        val traceId = MDC.get("traceId") ?: UUID.randomUUID().toString()

        val record = ProducerRecord<String, OrderEvent>(
            "order-events",      // topic
            null,                // partition
            order.id.toString(), // key
            event                // value
        ).apply {
            headers().add("traceId", traceId.toByteArray())
            headers().add("spanId", UUID.randomUUID().toString().take(8).toByteArray())
        }

        kafkaTemplate.send(record)
        logger.info("이벤트 발행: orderId={}, traceId={}", order.id, traceId)
    }
}

Kafka Consumer - 헤더에서 traceId 추출하여 MDC 설정:

@Component
class PaymentEventConsumer {
    private val logger = LoggerFactory.getLogger(javaClass)

    @KafkaListener(topics = ["order-events"], groupId = "payment-service")
    fun handleOrderCreated(
        event: OrderCreatedEvent,
        @Header("traceId", required = false) traceIdBytes: ByteArray?
    ) {
        // 헤더에서 traceId 추출하여 MDC 설정
        val traceId = traceIdBytes?.let { String(it) } ?: UUID.randomUUID().toString()

        try {
            MDC.put("traceId", traceId)
            MDC.put("eventType", "OrderCreated")

            logger.info("이벤트 수신: orderId={}", event.orderId)  // traceId 포함됨

            // 결제 처리 로직
            processPayment(event)

            logger.info("결제 처리 완료: orderId={}", event.orderId)

        } finally {
            MDC.clear()
        }
    }
}

공통 로직을 Interceptor로 분리:

// Producer Interceptor - 발행 시 자동으로 traceId 추가
@Component
class TracingProducerInterceptor : ProducerInterceptor<String, Any> {

    override fun onSend(record: ProducerRecord<String, Any>): ProducerRecord<String, Any> {
        val traceId = MDC.get("traceId") ?: UUID.randomUUID().toString()
        record.headers().add("traceId", traceId.toByteArray())
        return record
    }

    override fun onAcknowledgement(metadata: RecordMetadata?, exception: Exception?) {}
    override fun close() {}
    override fun configure(configs: MutableMap<String, *>?) {}
}

// Consumer Interceptor - 소비 시 자동으로 MDC 설정
@Component
class TracingConsumerInterceptor : ConsumerInterceptor<String, Any> {

    override fun onConsume(records: ConsumerRecords<String, Any>): ConsumerRecords<String, Any> {
        // 각 레코드 처리 전에 MDC 설정은 @KafkaListener에서 처리
        return records
    }

    override fun onCommit(offsets: MutableMap<TopicPartition, OffsetAndMetadata>?) {}
    override fun close() {}
    override fun configure(configs: MutableMap<String, *>?) {}
}

KafkaConfig에 Interceptor 등록:

@Configuration
class KafkaConfig {

    @Bean
    fun producerFactory(): ProducerFactory<String, Any> {
        val config = mapOf(
            ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to "localhost:9092",
            ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
            ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JsonSerializer::class.java,
            ProducerConfig.INTERCEPTOR_CLASSES_CONFIG to listOf(
                TracingProducerInterceptor::class.java.name
            )
        )
        return DefaultKafkaProducerFactory(config)
    }
}

로그 출력 예시 (서비스 간 traceId 연결):

# Order Service (Producer)
[traceId=abc123] 주문 생성: orderId=100
[traceId=abc123] 이벤트 발행: orderId=100

# Payment Service (Consumer) - 동일한 traceId!
[traceId=abc123] 이벤트 수신: orderId=100
[traceId=abc123] 결제 처리 완료: orderId=100

# 중앙 로그 시스템에서 traceId=abc123으로 검색하면
# 두 서비스의 로그가 모두 조회됨

RabbitMQ에서도 동일한 방식:

// RabbitMQ Producer
@Component
class OrderEventPublisher(
    private val rabbitTemplate: RabbitTemplate
) {
    fun publish(event: OrderCreatedEvent) {
        val traceId = MDC.get("traceId") ?: UUID.randomUUID().toString()

        rabbitTemplate.convertAndSend("order-exchange", "order.created", event) { message ->
            message.messageProperties.setHeader("traceId", traceId)
            message
        }
    }
}

// RabbitMQ Consumer
@Component
class PaymentEventListener {

    @RabbitListener(queues = ["payment-queue"])
    fun handle(event: OrderCreatedEvent, @Header("traceId") traceId: String?) {
        try {
            MDC.put("traceId", traceId ?: UUID.randomUUID().toString())
            // 처리 로직
        } finally {
            MDC.clear()
        }
    }
}

정리: MDC 전파 방식 비교

상황전파 방식구현
@Async (같은 JVM)TaskDecoratorMDC.getCopyOfContextMap()
CompletableFuture수동 복사MDC.setContextMap()
Kafka메시지 헤더ProducerRecord.headers()
RabbitMQ메시지 헤더MessageProperties.setHeader()
HTTP 호출 (다른 서비스)HTTP 헤더X-Trace-Id 헤더

실무 팁: 분산 추적이 복잡해지면 OpenTelemetry, Spring Cloud Sleuth 같은 라이브러리 사용을 고려한다. 자동으로 traceId를 전파하고 Jaeger, Zipkin 같은 도구와 연동된다.


7. 중앙 집중식 로그 관리

7.1 왜 중앙 집중식 로그가 필요한가?

분산 환경의 문제:
┌────────────────────────────────────────────────────────┐
│  서버 A (/var/log/app.log)                             │
│  서버 B (/var/log/app.log)                             │
│  서버 C (/var/log/app.log)                             │
│                                                        │
│  문제: "에러가 어느 서버에서 발생했지?"                 │
│        "전체 요청 흐름을 어떻게 추적하지?"              │
└────────────────────────────────────────────────────────┘

중앙 집중식:
┌────────────────────────────────────────────────────────┐
│  서버 A ─┐                                             │
│  서버 B ─┼──▶ 로그 수집기 ──▶ 중앙 저장소 ──▶ UI     │
│  서버 C ─┘                                             │
│                                                        │
│  해결: traceId로 전체 서버의 로그를 한 번에 검색       │
└────────────────────────────────────────────────────────┘

7.2 ELK Stack 구성

# docker-compose.yml
version: '3.8'
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ports:
      - "9200:9200"
    volumes:
      - elasticsearch-data:/usr/share/elasticsearch/data

  logstash:
    image: docker.elastic.co/logstash/logstash:8.11.0
    volumes:
      - ./logstash/pipeline:/usr/share/logstash/pipeline
    ports:
      - "5044:5044"
    depends_on:
      - elasticsearch

  kibana:
    image: docker.elastic.co/kibana/kibana:8.11.0
    ports:
      - "5601:5601"
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    depends_on:
      - elasticsearch

  filebeat:
    image: docker.elastic.co/beats/filebeat:8.11.0
    volumes:
      - ./filebeat/filebeat.yml:/usr/share/filebeat/filebeat.yml
      - ./logs:/var/log/app
    depends_on:
      - logstash

volumes:
  elasticsearch-data:
# filebeat/filebeat.yml
filebeat.inputs:
  - type: log
    enabled: true
    paths:
      - /var/log/app/*.json
    json.keys_under_root: true
    json.add_error_key: true

output.logstash:
  hosts: ["logstash:5044"]

7.3 Loki + Grafana 구성 (경량 대안)

# docker-compose.yml
version: '3.8'
services:
  loki:
    image: grafana/loki:2.9.0
    ports:
      - "3100:3100"
    volumes:
      - ./loki/config.yml:/etc/loki/config.yml
    command: -config.file=/etc/loki/config.yml

  promtail:
    image: grafana/promtail:2.9.0
    volumes:
      - ./promtail/config.yml:/etc/promtail/config.yml
      - ./logs:/var/log/app
    command: -config.file=/etc/promtail/config.yml

  grafana:
    image: grafana/grafana:10.0.0
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - ./grafana/provisioning:/etc/grafana/provisioning
# promtail/config.yml
server:
  http_listen_port: 9080

positions:
  filename: /tmp/positions.yaml

clients:
  - url: http://loki:3100/loki/api/v1/push

scrape_configs:
  - job_name: spring-app
    static_configs:
      - targets:
          - localhost
        labels:
          job: marketplace-api
          __path__: /var/log/app/*.log
    pipeline_stages:
      - json:
          expressions:
            level: level
            traceId: traceId
            message: message
      - labels:
          level:
          traceId:

7.4 Logback에서 직접 전송 (Loki)

// build.gradle.kts
dependencies {
    implementation("com.github.loki4j:loki-logback-appender:1.4.2")
}
<!-- logback-spring.xml -->
<appender name="LOKI" class="com.github.loki4j.logback.Loki4jAppender">
    <http>
        <url>http://loki:3100/loki/api/v1/push</url>
    </http>
    <format>
        <label>
            <pattern>application=marketplace-api,host=${HOSTNAME},level=%level</pattern>
        </label>
        <message>
            <pattern>{"timestamp":"%d{yyyy-MM-dd'T'HH:mm:ss.SSS'Z'}","level":"%level","logger":"%logger","traceId":"%X{traceId}","message":"%message"}</pattern>
        </message>
    </format>
</appender>

8. 실무 유의사항

8.1 민감정보 마스킹

// ❌ 위험: 민감정보 직접 로깅
logger.info("사용자 로그인: email={}, password={}", email, password)
logger.info("결제 정보: cardNumber={}", cardNumber)

// ✓ 마스킹 적용
logger.info("사용자 로그인: email={}", maskEmail(email))
logger.info("결제 정보: cardNumber={}", maskCardNumber(cardNumber))

// 마스킹 유틸리티
object LogMaskingUtils {
    fun maskEmail(email: String): String {
        val parts = email.split("@")
        if (parts.size != 2) return "***"
        val local = parts[0]
        val masked = if (local.length > 2) {
            "${local.take(2)}***"
        } else {
            "***"
        }
        return "$masked@${parts[1]}"
    }

    fun maskCardNumber(cardNumber: String): String {
        if (cardNumber.length < 4) return "****"
        return "****-****-****-${cardNumber.takeLast(4)}"
    }

    fun maskPhoneNumber(phone: String): String {
        if (phone.length < 4) return "****"
        return "${phone.take(3)}-****-${phone.takeLast(4)}"
    }
}

8.2 Logback 패턴에서 마스킹

<!-- 정규식으로 패턴 마스킹 -->
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
    <jsonGeneratorDecorator class="net.logstash.logback.mask.MaskingJsonGeneratorDecorator">
        <!-- 특정 필드 마스킹 -->
        <valueMask>
            <value>password</value>
            <mask>****</mask>
        </valueMask>
        <!-- 정규식 마스킹 -->
        <valueMask>
            <value>\d{4}-\d{4}-\d{4}-\d{4}</value>
            <mask>****-****-****-****</mask>
        </valueMask>
    </jsonGeneratorDecorator>
</encoder>

8.3 로깅 성능 고려사항

// ❌ 비효율적: 로그 레벨과 관계없이 문자열 연결 발생
logger.debug("사용자 정보: " + user.toString())

// ❌ 비효율적: toString() 항상 호출
logger.debug("사용자 정보: {}", user.toString())

// ✓ 효율적: 플레이스홀더 사용 (DEBUG가 비활성화면 연산 안 함)
logger.debug("사용자 정보: userId={}, name={}", user.id, user.name)

// ✓ 더 효율적: 로그 레벨 체크
if (logger.isDebugEnabled) {
    logger.debug("복잡한 계산 결과: {}", expensiveCalculation())
}

// ✓ Kotlin에서 lazy 로깅 (라이브러리 사용)
// implementation("io.github.microutils:kotlin-logging:3.0.5")
import mu.KotlinLogging
private val logger = KotlinLogging.logger {}

logger.debug { "복잡한 계산 결과: ${expensiveCalculation()}" }
// DEBUG가 비활성화면 람다 자체가 실행되지 않음

8.4 예외 로깅 주의사항

// ❌ 잘못된 예외 로깅
try {
    process()
} catch (e: Exception) {
    logger.error("에러 발생: " + e.message)  // 스택트레이스 없음
    logger.error("에러 발생: {}", e)          // e.toString()만 출력
}

// ✓ 올바른 예외 로깅 (스택트레이스 포함)
try {
    process()
} catch (e: Exception) {
    logger.error("에러 발생: orderId={}", orderId, e)  // e가 마지막 인자면 스택트레이스 출력
}

// ✓ 비즈니스 예외는 WARN + 간단한 메시지
try {
    process()
} catch (e: BusinessException) {
    logger.warn("비즈니스 예외: code={}, message={}", e.code, e.message)
    // 스택트레이스 불필요
}

8.5 운영 환경 로그 설정 체크리스트

✓ 로그 레벨
  - 루트: WARN 이상
  - 애플리케이션: INFO
  - 프레임워크 (hibernate, spring): WARN

✓ 로그 로테이션
  - 일별 또는 크기 기반
  - 보관 기간 설정 (30일 권장)
  - 최대 용량 제한

✓ 비동기 로깅
  - 고성능 필요 시 AsyncAppender 사용
  - neverBlock=true로 블로킹 방지

✓ 민감정보
  - 비밀번호, 카드번호, 주민번호 마스킹
  - 개인정보 최소 로깅

✓ 구조화된 로그
  - JSON 포맷 (로그 수집 용이)
  - MDC로 traceId 포함

✓ 에러 로그 분리
  - 에러 전용 파일
  - 알림 연동 고려

9. 면접 대비 Q&A

Q1. SLF4J와 Logback의 관계는?

SLF4J = 로깅 추상화 (인터페이스)
Logback = 로깅 구현체

비유:
JDBC = 데이터베이스 추상화
MySQL Driver = 구현체

장점:
- 코드에서는 SLF4J만 사용
- 구현체 변경 시 코드 수정 불필요
- 예: Logback → Log4j2 변경 가능

Q2. 로그 레벨 선택 기준은?

ERROR: 즉시 대응 필요, 서비스 기능 장애
WARN:  주의 필요, 예상 가능한 예외
INFO:  운영에 필요한 비즈니스 이벤트
DEBUG: 개발/디버깅 용도 (운영 OFF)
TRACE: 매우 상세한 추적 (거의 사용 안 함)

운영 환경 권장 설정:

logging:
  level:
    root: WARN                        # 기본값 (프레임워크, 라이브러리 포함)
    com.example.myapp: INFO           # 내가 만든 패키지
    org.springframework: WARN         # Spring 프레임워크
    org.hibernate: WARN               # Hibernate
    org.apache.kafka: WARN            # Kafka
설명:
- root: WARN → 모든 로그의 기본 레벨 (Spring, 라이브러리 등)
- com.example.myapp: INFO → 내가 작성한 코드의 로그 레벨
  (실제 패키지명으로 변경: com.company.projectname 등)

Q3. MDC는 무엇이고 왜 사용하나?

MDC (Mapped Diagnostic Context):
- 스레드 로컬에 저장되는 컨텍스트 정보
- 요청별로 traceId, userId 등을 저장
- 모든 로그에 자동으로 포함

사용 이유:
- 분산 환경에서 요청 추적
- 로그 검색/필터링 용이
- 문제 발생 시 관련 로그만 추출

주의점:
- 스레드 재사용 시 반드시 clear()
- 비동기 작업 시 전파 필요 (TaskDecorator)

Q4. ELK와 Loki의 차이점은?

ELK Stack (Elasticsearch + Logstash + Kibana):
- 전문 검색 (Full-text indexing)
- 복잡한 쿼리, 분석 가능
- 리소스 많이 필요
- 비용 높음

Loki + Grafana:
- 라벨 기반 인덱싱 (메타데이터만)
- 라벨 검색 빠름, 본문 검색 느림
- 리소스 적게 필요
- Grafana와 자연스러운 통합
- 비용 효율적

선택 기준:
- 복잡한 로그 분석 → ELK
- 비용 효율, 간단한 검색 → Loki

Q5. 비동기 로깅의 장단점은?

장점:
- 로그 쓰기가 애플리케이션 성능에 영향 안 줌
- 처리량 향상

단점:
- 로그 유실 가능성 (큐 overflow 시)
- 즉시성 떨어짐 (버퍼링)
- 애플리케이션 비정상 종료 시 유실

설정 팁:
- neverBlock=true: 블로킹 대신 버림
- discardingThreshold: 큐가 차면 낮은 레벨 버림
- 에러 로그는 동기로 처리 권장

Q6. 구조화된 로그(JSON)의 장점은?

장점:
- 로그 수집기에서 파싱 용이
- 필드별 검색/필터링 가능
- 자동화된 분석 가능
- 일관된 포맷

예시:
{"timestamp":"2024-01-15T10:30:45Z","level":"INFO","traceId":"abc123","orderId":"12345"}

검색 예:
- orderId=12345인 모든 로그
- level=ERROR인 최근 1시간 로그
- 특정 traceId의 전체 요청 흐름

10. 정리

핵심 개념 정리

개념설명
SLF4J로깅 추상화 (인터페이스)
LogbackSpring Boot 기본 로깅 구현체
MDC스레드 로컬 컨텍스트 (요청 추적)
JSON 로깅구조화된 로그 (파싱 용이)
비동기 로깅성능 최적화 (AsyncAppender)
ELK/Loki중앙 집중식 로그 관리

실무 체크리스트

로그 레벨:
✓ ERROR: 즉시 대응 필요한 장애
✓ WARN: 예상 가능한 예외, 주의 필요
✓ INFO: 주요 비즈니스 이벤트
✓ DEBUG: 개발 환경에서만 사용

로그 내용:
✓ 누가: userId, sessionId
✓ 무엇을: 작업 내용
✓ 어떻게: 파라미터, 결과
✓ 왜: 에러 원인 (예외 포함)

주의사항:
✓ 민감정보 마스킹
✓ 예외 스택트레이스 포함
✓ MDC로 요청 추적
✓ 비동기 로깅 고려
✓ 로그 로테이션 설정

시리즈를 마치며

지금까지 7편에 걸쳐 대용량 트래픽 처리를 위한 스프링부트 실무 기법들을 다뤘다.

주제핵심 기술
1편동시성 제어Atomic UPDATE, 분산 락, 멱등성 키
2편캐싱 전략Redis, Caffeine, Cache-Aside
3편이벤트 드리븐Kafka, Outbox 패턴, 중복 처리
4편Resilience 패턴Circuit Breaker, Rate Limiter, Bulkhead
5편DB 최적화인덱스, 커서 페이지네이션, Read Replica
6편모니터링Prometheus, Grafana, 커스텀 메트릭
7편로깅SLF4J, Logback, MDC, ELK/Loki

이 시리즈에서 다룬 내용들은 실제 대규모 서비스에서 공통적으로 사용되는 패턴들이다. 각 기술이 어떤 문제를 해결하는지 이해하고, 직접 코드를 작성해보면서 체감해보길 권한다.

핵심은 왜 필요한지 를 이해하는 것이다. 분산 락이 왜 필요한지, MDC가 어떤 상황에서 유용한지, 구조화된 로그가 왜 중요한지를 설명할 수 있다면 면접에서도 좋은 결과가 있을 것이다.

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.