Spring Boot 구조화 로깅 심화

Spring Boot 구조화 로깅이란?

Spring Boot 3.4에서 도입된 Structured Logging은 로그를 JSON 등 기계가 파싱 가능한 형식으로 출력하는 기능입니다. 기존에는 Logback의 JsonLayout이나 logstash-logback-encoder 같은 서드파티 라이브러리가 필요했지만, 이제 한 줄 설정으로 ECS(Elastic Common Schema), Logstash, GELF 형식의 JSON 로그를 출력할 수 있습니다.

한 줄 설정으로 JSON 로그 활성화

# application.yml — Spring Boot 3.4+

# ECS 형식 (Elasticsearch/Kibana 호환)
logging:
  structured:
    format:
      console: ecs
      file: ecs

# Logstash 형식
logging:
  structured:
    format:
      console: logstash

# 콘솔은 사람이 읽기 쉽게, 파일은 JSON으로
logging:
  structured:
    format:
      console: # 비워두면 기본 텍스트 포맷
      file: ecs
  file:
    name: /var/log/app/application.log
// 일반 로그 출력
@Slf4j
@Service
public class OrderService {

    public Order createOrder(CreateOrderRequest request) {
        log.info("주문 생성 시작: userId={}, amount={}",
            request.getUserId(), request.getAmount());

        Order order = orderRepository.save(toEntity(request));

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

// ECS 형식 출력 결과:
// {
//   "@timestamp": "2026-03-20T17:00:00.123Z",
//   "log.level": "INFO",
//   "process.pid": 12345,
//   "process.thread.name": "http-nio-8080-exec-1",
//   "service.name": "order-service",
//   "service.version": "1.2.0",
//   "service.environment": "production",
//   "log.logger": "com.example.OrderService",
//   "message": "주문 생성 시작: userId=42, amount=99000"
// }

ECS vs Logstash vs GELF: 형식 비교

형식 호환 시스템 특징
ecs Elasticsearch, Kibana, Filebeat Elastic Common Schema 표준, 필드명 계층 구조
logstash Logstash, ELK 스택 logstash-logback-encoder 호환 형식
gelf Graylog Graylog Extended Log Format

Fluent API: 구조화된 키-값 로깅

Spring Boot 3.4의 핵심 기능인 Fluent Logging API는 로그 메시지에 구조화된 키-값 쌍을 추가합니다. JSON 출력 시 별도 필드로 분리되어 검색과 필터링이 가능합니다:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static net.logstash.logback.argument.StructuredArguments.*;

@Slf4j
@Service
public class PaymentService {

    public PaymentResult processPayment(PaymentRequest request) {
        // SLF4J 2.0 Fluent API + 구조화 필드
        log.atInfo()
            .setMessage("결제 처리 시작")
            .addKeyValue("orderId", request.getOrderId())
            .addKeyValue("amount", request.getAmount())
            .addKeyValue("currency", request.getCurrency())
            .addKeyValue("paymentMethod", request.getMethod())
            .log();

        try {
            PaymentResult result = gateway.charge(request);

            log.atInfo()
                .setMessage("결제 성공")
                .addKeyValue("orderId", request.getOrderId())
                .addKeyValue("transactionId", result.getTransactionId())
                .addKeyValue("processingTimeMs", result.getDurationMs())
                .log();

            return result;

        } catch (PaymentDeclinedException e) {
            log.atWarn()
                .setMessage("결제 거절")
                .addKeyValue("orderId", request.getOrderId())
                .addKeyValue("declineReason", e.getReason())
                .addKeyValue("declineCode", e.getCode())
                .setCause(e)
                .log();
            throw e;
        }
    }
}

// JSON 출력 (ECS 형식):
// {
//   "@timestamp": "2026-03-20T17:00:01.456Z",
//   "log.level": "INFO",
//   "message": "결제 처리 시작",
//   "orderId": "ORD-2026-001234",
//   "amount": 99000,
//   "currency": "KRW",
//   "paymentMethod": "CARD",
//   "service.name": "payment-service"
// }

커스텀 필드: 서비스 메타데이터 자동 추가

# application.yml — 모든 로그에 공통 필드 추가
logging:
  structured:
    format:
      console: ecs
    ecs:
      service:
        name: order-service
        version: 1.2.0
        environment: production
        node-name: ${HOSTNAME:unknown}

spring:
  application:
    name: order-service  # service.name 기본값으로 사용됨
// 커스텀 StructuredLoggingJsonMembersCustomizer로 필드 추가
@Component
public class CustomJsonFieldsCustomizer
        implements StructuredLoggingJsonMembersCustomizer<ILoggingEvent> {

    @Override
    public void customize(JsonMembers<ILoggingEvent> members) {
        // 모든 로그에 deployment 정보 추가
        members.add("deploy.region", "ap-northeast-2");
        members.add("deploy.cluster", "prod-eks-01");

        // 동적 필드: 이벤트에서 추출
        members.add("trace.id", event ->
            MDC.get("traceId"));
        members.add("span.id", event ->
            MDC.get("spanId"));

        // 기존 필드 이름 변경
        members.rename("log.level", "severity");

        // 불필요한 필드 제거
        members.exclude("process.pid");
    }
}

MDC + 구조화 로깅: 요청 추적

MDC(Mapped Diagnostic Context)의 값은 구조화 로그에 자동으로 포함됩니다:

@Component
public class RequestTracingFilter extends OncePerRequestFilter {

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

        String traceId = Optional.ofNullable(request.getHeader("X-Trace-Id"))
            .orElse(UUID.randomUUID().toString());
        String requestId = UUID.randomUUID().toString();

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

        try {
            chain.doFilter(request, response);

            MDC.put("statusCode", String.valueOf(response.getStatus()));
            log.atInfo()
                .setMessage("요청 처리 완료")
                .addKeyValue("responseTimeMs", getElapsedMs())
                .log();
        } finally {
            MDC.clear();
        }
    }
}

// JSON 출력 — MDC 값이 자동 포함:
// {
//   "@timestamp": "2026-03-20T17:00:02.789Z",
//   "message": "요청 처리 완료",
//   "traceId": "abc-123-def",
//   "requestId": "req-456",
//   "method": "POST",
//   "uri": "/api/orders",
//   "clientIp": "10.0.0.5",
//   "statusCode": "201",
//   "responseTimeMs": 45
// }

Micrometer Observation + 구조화 로깅

Micrometer Observation API와 결합하면 분산 추적 ID가 자동으로 로그에 포함됩니다:

# application.yml
management:
  tracing:
    sampling:
      probability: 1.0
  observations:
    key-values:
      application: order-service

logging:
  structured:
    format:
      console: ecs
  pattern:
    correlation: "[${spring.application.name:},%X{traceId:-},%X{spanId:-}]"
@RestController
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;
    private final ObservationRegistry observationRegistry;

    @PostMapping("/api/orders")
    public ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest req) {
        return Observation.createNotStarted("order.create", observationRegistry)
            .lowCardinalityKeyValue("order.type", req.getType())
            .highCardinalityKeyValue("user.id", req.getUserId())
            .observe(() -> {
                // 이 블록 내 모든 로그에 traceId, spanId 자동 포함
                Order order = orderService.createOrder(req);
                return ResponseEntity.status(201).body(order);
            });
    }
}

// 출력: traceId와 spanId가 자동 주입
// {
//   "@timestamp": "2026-03-20T17:00:03.012Z",
//   "message": "주문 생성 완료",
//   "trace.id": "6f3b2c1a8e9d4f5b",
//   "span.id": "a1b2c3d4e5f6",
//   "orderId": "ORD-001234",
//   "order.type": "standard"
// }

로그 민감 정보 마스킹

@Component
public class SensitiveDataMaskingCustomizer
        implements StructuredLoggingJsonMembersCustomizer<ILoggingEvent> {

    private static final Pattern EMAIL_PATTERN =
        Pattern.compile("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}");
    private static final Pattern CARD_PATTERN =
        Pattern.compile("\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b");

    @Override
    public void customize(JsonMembers<ILoggingEvent> members) {
        // 메시지 내 민감 정보 마스킹
        members.add("message", event -> {
            String msg = event.getFormattedMessage();
            msg = EMAIL_PATTERN.matcher(msg)
                .replaceAll(m -> maskEmail(m.group()));
            msg = CARD_PATTERN.matcher(msg)
                .replaceAll("****-****-****-$4");
            return msg;
        });
    }

    private String maskEmail(String email) {
        int at = email.indexOf('@');
        if (at <= 2) return "***" + email.substring(at);
        return email.substring(0, 2) + "***" + email.substring(at);
    }
}

// 입력: "결제 완료: user@example.com, 카드 1234-5678-9012-3456"
// 출력: "결제 완료: us***@example.com, 카드 ****-****-****-3456"

프로파일별 로깅 전략

# application.yml — 기본 (개발)
logging:
  structured:
    format:
      console:  # 빈 값 → 텍스트 포맷 (읽기 쉬움)

---
# application-prod.yml — 프로덕션
logging:
  structured:
    format:
      console: ecs
      file: ecs
  file:
    name: /var/log/app/application.log
  level:
    root: WARN
    com.example: INFO

---
# application-staging.yml — 스테이징
logging:
  structured:
    format:
      console: logstash  # Logstash로 직접 전송 시
  level:
    root: INFO
    com.example: DEBUG

테스트에서 구조화 로그 검증

@SpringBootTest
class OrderServiceLoggingTest {

    @Autowired
    private OrderService orderService;

    @RegisterExtension
    static OutputCaptureExtension output = new OutputCaptureExtension();

    @Test
    void shouldLogStructuredOrderCreation(CapturedOutput capture) {
        // given
        var request = new CreateOrderRequest("user-42", BigDecimal.valueOf(99000));

        // when
        orderService.createOrder(request);

        // then — 구조화 로그 필드 검증
        String log = capture.getAll();
        assertThat(log).contains(""orderId":");
        assertThat(log).contains(""message":"주문 생성 완료"");

        // JSON 파싱으로 정확한 필드 검증
        ObjectMapper mapper = new ObjectMapper();
        String jsonLine = Arrays.stream(log.split("n"))
            .filter(l -> l.contains("주문 생성 완료"))
            .findFirst()
            .orElseThrow();
        JsonNode node = mapper.readTree(jsonLine);

        assertThat(node.get("orderId").asText()).isNotEmpty();
        assertThat(node.get("log.level").asText()).isEqualTo("INFO");
    }
}

핵심 정리

기능 설정
JSON 로그 활성화 logging.structured.format.console: ecs
구조화 키-값 추가 log.atInfo().addKeyValue("k","v").log()
커스텀 필드 추가 StructuredLoggingJsonMembersCustomizer
분산 추적 연동 Micrometer Observation → traceId/spanId 자동 포함
민감정보 마스킹 Customizer에서 message 필드 변환

Spring Boot 3.4 Structured Logging은 서드파티 없이 한 줄 설정으로 프로덕션급 JSON 로깅을 제공합니다. ELK, Grafana Loki 등 로그 수집 시스템과 즉시 호환되며, Fluent API로 검색 가능한 구조화 필드를 추가할 수 있습니다. Spring Logback MDC 구조화 로깅Spring Observation API 심화도 함께 참고하세요.

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