Structured Logging — 스프링 부트의 구조화된 로그 출력
로그를 남겼는데, 정작 장애가 터졌을 때 원하는 로그를 못 찾은 적 있으신가요?
운영 환경에서 grep으로 텍스트 로그를 뒤져본 경험이 있다면, 이 고통이 익숙할 겁니다. 로그 메시지 포맷이 제각각이고, 타임스탬프 형식도 다르고, 여러 줄에 걸친 스택트레이스는 파싱이 깨지고. Structured Logging은 이 문제를 로그를 처음부터 기계가 읽기 좋은 형태로 출력 해서 해결합니다.
개념 정의
Structured Logging(구조화된 로깅)은 로그를 사람이 읽는 텍스트 한 줄이 아니라, JSON 같은 키-값 쌍 형태 로 출력하는 방식입니다. 로그 수집 도구(ELK, Grafana Loki 등)가 별도 파싱 없이 바로 필드 기반으로 검색하고 분석할 수 있게 해줍니다.
텍스트 로그 vs 구조화 로그
먼저 둘의 차이를 직관적으로 비교해 보겠습니다.
기존 텍스트 로그:
2026-03-28 14:23:15.123 INFO 12345 --- [nio-8080-exec-1] c.e.order.OrderService : 주문 생성 완료 orderId=ORD-001 userId=USR-42
** 구조화 로그 (JSON):**
{
"@timestamp": "2026-03-28T14:23:15.123Z",
"log.level": "INFO",
"process.pid": 12345,
"process.thread.name": "nio-8080-exec-1",
"log.logger": "com.example.order.OrderService",
"message": "주문 생성 완료",
"orderId": "ORD-001",
"userId": "USR-42",
"service.name": "order-service"
}
텍스트 로그에서 orderId를 검색하려면 정규식을 짜야 합니다. 구조화 로그에서는 orderId == "ORD-001"로 바로 검색됩니다.
텍스트 로그는 사람이 읽기 편하고, 구조화 로그는 기계가 읽기 편합니다. 운영 환경에서는 기계가 읽기 편한 쪽이 훨씬 중요합니다. 로그가 수만 건씩 쌓이면 사람의 눈으로는 한계가 있으니까요.
Spring Boot 3.4+의 내장 지원
Spring Boot 3.4부터 ** 별도 라이브러리 없이** 구조화된 로그 출력을 지원합니다. application.yml에 한 줄만 추가하면 됩니다.
지원 포맷
| 포맷 | 설정값 | 대상 시스템 |
|---|---|---|
| ECS (Elastic Common Schema) | ecs | Elasticsearch, Kibana |
| GELF (Graylog Extended Log Format) | gelf | Graylog |
| Logstash JSON | logstash | Logstash → Elasticsearch |
기본 설정
# application.yml
logging:
structured:
format:
console: ecs # 콘솔 출력을 ECS 포맷으로
file: logstash # 파일 출력을 Logstash 포맷으로
이게 전부입니다. 재시작하면 콘솔에 JSON이 출력됩니다.
콘솔 vs 파일 — 언제 나눠서 쓸까
# 개발 환경: 콘솔은 사람이 읽을 텍스트, 파일은 JSON
logging:
structured:
format:
file: ecs # 파일만 구조화
file:
name: /var/log/app/application.log
# 운영 환경: 콘솔도 JSON (stdout → 로그 수집기)
logging:
structured:
format:
console: ecs # 컨테이너 환경에서는 stdout을 수집
컨테이너(Docker, Kubernetes) 환경에서는 보통 stdout으로 JSON 을 출력하고, Fluentd나 Filebeat 같은 수집기가 이걸 가져갑니다. 그래서
console설정만 쓰는 경우가 많습니다.
포맷별 출력 예시
같은 log.info("주문 생성 완료") 코드가 포맷에 따라 어떻게 달라지는지 살펴보겠습니다.
ECS (Elastic Common Schema)
{
"@timestamp": "2026-03-28T14:23:15.123Z",
"log.level": "INFO",
"process.pid": 12345,
"process.thread.name": "http-nio-8080-exec-1",
"service.name": "order-service",
"log.logger": "com.example.order.OrderService",
"message": "주문 생성 완료",
"ecs.version": "8.11"
}
Logstash
{
"@timestamp": "2026-03-28T14:23:15.123+00:00",
"@version": "1",
"message": "주문 생성 완료",
"logger_name": "com.example.order.OrderService",
"thread_name": "http-nio-8080-exec-1",
"level": "INFO",
"level_value": 20000
}
GELF
{
"version": "1.1",
"host": "order-service-pod-abc",
"short_message": "주문 생성 완료",
"timestamp": 1774973000.123,
"level": 6,
"_logger": "com.example.order.OrderService",
"_thread": "http-nio-8080-exec-1"
}
MDC로 커스텀 필드 추가하기
로그에 요청 ID, 사용자 ID, 트레이스 ID 같은 컨텍스트 정보를 넣고 싶다면 MDC(Mapped Diagnostic Context)를 사용합니다. MDC에 넣은 값은 구조화 로그에 자동으로 포함됩니다.
필터로 요청마다 MDC 설정
@Component
public class MdcFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
try {
// 요청마다 고유 ID 생성
MDC.put("requestId", UUID.randomUUID().toString().substring(0, 8));
// 인증된 사용자 정보가 있으면 추가
HttpServletRequest httpRequest = (HttpServletRequest) request;
String userId = httpRequest.getHeader("X-User-Id");
if (userId != null) {
MDC.put("userId", userId);
}
chain.doFilter(request, response);
} finally {
// 반드시 정리 — 스레드 풀에서 재사용될 때 오염 방지
MDC.clear();
}
}
}
출력 결과
{
"@timestamp": "2026-03-28T14:23:15.123Z",
"log.level": "INFO",
"message": "주문 생성 완료",
"requestId": "a1b2c3d4",
"userId": "USR-42",
"service.name": "order-service"
}
MDC는
ThreadLocal기반이라,@Async나 리액티브 환경에서는 자동 전파가 안 됩니다. Spring Boot 3.x에서는 Micrometer의ObservationRegistry를 통해 컨텍스트를 전파하는 방식을 권장합니다.
Micrometer Tracing과 연동
Spring Boot 3.x + Micrometer Tracing을 사용하면 **traceId, spanId가 MDC에 자동으로 들어갑니다 **.
# application.yml
management:
tracing:
sampling:
probability: 1.0 # 개발 시 모든 요청 추적 (운영은 0.1 등으로 조절)
{
"@timestamp": "2026-03-28T14:23:15.123Z",
"log.level": "INFO",
"message": "주문 생성 완료",
"traceId": "6a3f1b2c8d4e5f001a2b3c4d5e6f7890",
"spanId": "1a2b3c4d5e6f7890",
"requestId": "a1b2c3d4",
"service.name": "order-service"
}
이렇게 하면 ** 하나의 요청이 여러 서비스를 거쳐도** traceId로 전체 흐름을 추적할 수 있습니다.
서비스 이름과 환경 정보 설정
구조화 로그에 서비스 이름이 들어가야 여러 서비스의 로그를 한 곳에서 구분할 수 있습니다.
# application.yml
spring:
application:
name: order-service # 로그의 service.name 필드에 반영
logging:
structured:
format:
console: ecs
ecs:
service:
name: ${spring.application.name} # 서비스명
version: ${app.version:1.0.0} # 버전
environment: ${spring.profiles.active} # 환경 (dev, prod 등)
커스텀 구조화 포맷 만들기
ECS, GELF, Logstash 외에 ** 자체 포맷 **이 필요하다면 StructuredLogFormatter를 구현합니다.
@Component
public class CustomJsonFormatter implements StructuredLogFormatter<ILoggingEvent> {
@Override
public String format(ILoggingEvent event) {
// 필요한 필드만 골라서 JSON 구성
Map<String, Object> log = new LinkedHashMap<>();
log.put("time", event.getInstant().toString());
log.put("level", event.getLevel().toString());
log.put("logger", event.getLoggerName());
log.put("msg", event.getFormattedMessage());
log.put("thread", event.getThreadName());
// MDC 필드 전부 포함
if (event.getMDCPropertyMap() != null) {
log.putAll(event.getMDCPropertyMap());
}
// 예외가 있으면 스택트레이스 포함
if (event.getThrowableProxy() != null) {
log.put("error", event.getThrowableProxy().getMessage());
}
try {
return new ObjectMapper().writeValueAsString(log);
} catch (JsonProcessingException e) {
return "{\"error\":\"로그 포매팅 실패\"}";
}
}
}
StructuredLogFormatter 빈을 등록하면 Boot가 자동으로 감지해서 사용합니다.
ELK 스택 연동
구조화 로그를 ELK(Elasticsearch + Logstash + Kibana)로 보내는 일반적인 구성입니다.
┌─────────────┐ stdout (JSON) ┌───────────┐ ┌─────────────────┐ ┌─────────┐
│ Spring Boot │ ──────────────────▶ │ Filebeat │ ──▶ │ Elasticsearch │ ──▶ │ Kibana │
│ (ECS) │ │ │ │ │ │ │
└─────────────┘ └───────────┘ └─────────────────┘ └─────────┘
Filebeat 설정 예시
# filebeat.yml
filebeat.inputs:
- type: container
paths:
- /var/lib/docker/containers/*/*.log
# ECS 포맷이면 별도 파서가 필요 없음
json.keys_under_root: true
json.add_error_key: true
output.elasticsearch:
hosts: ["http://elasticsearch:9200"]
index: "app-logs-%{+yyyy.MM.dd}"
ECS 포맷을 사용하면 Elasticsearch의 필드 매핑이 자동으로 맞아서 **인덱스 템플릿을 따로 만들 필요가 거의 없습니다 **.
Grafana Loki 연동
Loki를 사용한다면 Promtail이나 Grafana Alloy로 수집합니다.
# promtail 설정 (간략)
scrape_configs:
- job_name: spring-app
static_configs:
- targets: [localhost]
labels:
job: order-service
__path__: /var/log/app/*.log
pipeline_stages:
- json:
expressions:
level: log.level
service: service.name
trace: traceId
- labels:
level:
service:
민감 정보 필터링
구조화 로그에 비밀번호, 토큰 같은 민감 정보가 들어가지 않도록 주의해야 합니다.
@Component
public class SanitizingFilter implements StructuredLogFormatter<ILoggingEvent> {
// 마스킹할 필드 목록
private static final Set<String> SENSITIVE_KEYS =
Set.of("password", "token", "secret", "authorization");
private final StructuredLogFormatter<ILoggingEvent> delegate;
public SanitizingFilter(StructuredLogFormatter<ILoggingEvent> delegate) {
this.delegate = delegate;
}
@Override
public String format(ILoggingEvent event) {
// MDC에서 민감 정보 마스킹 후 위임
Map<String, String> mdc = new HashMap<>(event.getMDCPropertyMap());
mdc.entrySet().forEach(entry -> {
if (SENSITIVE_KEYS.contains(entry.getKey().toLowerCase())) {
entry.setValue("***MASKED***");
}
});
return delegate.format(event);
}
}
구조화 로그는 모든 MDC 필드를 자동으로 포함하기 때문에, 텍스트 로그보다 민감 정보가 노출될 위험이 더 큽니다. 로그에 뭐가 찍히는지 반드시 확인하세요.
실전 적용 — 프로필별 설정
개발과 운영 환경에서 다르게 설정하는 패턴입니다.
# application.yml (공통)
spring:
application:
name: order-service
logging:
level:
root: INFO
com.example: DEBUG
---
# application-dev.yml (개발)
logging:
structured:
format:
file: ecs # 파일만 JSON, 콘솔은 읽기 쉬운 텍스트
file:
name: ./logs/app.log
---
# application-prod.yml (운영)
logging:
structured:
format:
console: ecs # 컨테이너 stdout으로 JSON 출력
level:
root: WARN
com.example: INFO
마이그레이션 체크리스트
기존 텍스트 로그에서 구조화 로그로 전환할 때 확인할 것들입니다.
- ** 로그 수집기 설정 변경 **: Filebeat, Fluentd 등의 파서를 JSON 모드로 전환
- **Kibana 대시보드 업데이트 **: 기존 grok 패턴 기반 파싱을 필드 기반으로 변경
- ** 로그 용량 확인 **: JSON은 텍스트보다 용량이 큽니다 (보통 2~3배). 로테이션 정책 재검토
- **MDC 정리 **: 불필요하거나 민감한 MDC 필드가 자동으로 노출되지 않는지 확인
- ** 멀티라인 로그 **: 스택트레이스가 한 줄 JSON에 포함되므로, 기존 멀티라인 파서는 불필요
정리
| 항목 | 텍스트 로그 | 구조화 로그 |
|---|---|---|
| 사람 가독성 | 높음 | 낮음 (JSON) |
| 기계 파싱 | 정규식 필요 | 필드 기반 즉시 검색 |
| 설정 난이도 | 기본값 | application.yml 한 줄 |
| 로그 용량 | 작음 | 2~3배 증가 |
| ELK 연동 | grok 패턴 필요 | 바로 사용 가능 |
| MDC 필드 | 패턴에 명시해야 표시 | 자동 포함 |
| 스택트레이스 | 여러 줄 → 파싱 깨짐 | 한 줄 JSON에 포함 |
구조화 로깅은 "로그를 잘 남기는 것"이 아니라 "로그를 잘 찾는 것"에 초점을 맞춘 기술입니다. Spring Boot 3.4부터는 설정 한 줄이면 시작할 수 있으니, 새 프로젝트에서는 처음부터 적용하는 걸 추천합니다.