Spring Boot 구조화 로그 설계: JSON 포맷과 MDC로 운영 로그 체계 잡기
운영 중인 백엔드에서 장애가 터지면 제일 먼저 보게 되는 게 로그예요. 근데 서비스가 여러 인스턴스와 비동기 작업으로 쪼개질수록 그냥 문자열 로그만 가지고는 요청 하나가 어디를 거쳐서 어디서 실패했는지 따라가기가 꽤 힘들어집니다. 이럴 때 구조화 로그랑 요청 식별자(traceId)를 같이 쓰면, 사람이 눈으로 읽던 로그를 검색하고 집계할 수 있는 데이터로 바꿀 수 있어요.
Spring Boot 3.4부터는 구조화 로그 라이브러리를 따로 추가하지 않아도 ECS, Logstash, GELF 같은 형식으로 구조화 로그를 바로 출력할 수 있습니다. 그래서 신규 프로젝트라면 일단 Spring Boot 기본 기능으로 충분한지 먼저 보고, 사내 표준 스키마가 있거나 커스터마이징이 복잡할 때만 별도 인코더를 알아보는 쪽이 낫다고 봅니다.

구조화 로그가 필요한 이유: JSON보다 중요한 건 검색 가능한 컨텍스트
구조화 로그의 핵심은 로그를 JSON으로 찍는 것 자체가 아니에요. timestamp, level, service, traceId, requestId 같은 값을 정해진 필드로 남겨서, 로그 수집 시스템이 각각을 따로따로 인식하게 만드는 게 목적입니다.
예를 들어 아래 같은 로그 두 줄이 있다고 해볼게요. 문자열 로그에서 traceId=8f31을 찾으려면 수집 시스템이 로그 문장을 파싱해야 할 수도 있는데, 구조화 로그는 traceId 필드를 바로 조건으로 걸어서 검색하면 됩니다. 여러 서버에서 동시에 쌓인 로그 중에 특정 요청 흐름만 뽑아야 할 때 이 차이가 크게 느껴져요.
{"timestamp":"2026-10-15T00:10:21Z","level":"INFO","service":"order-api","traceId":"8f31","message":"order created"}
{"timestamp":"2026-10-15T00:10:22Z","level":"ERROR","service":"payment-api","traceId":"8f31","message":"payment failed"}
다만 JSON으로 바꾼다고 장애 추적이 저절로 쉬워지진 않아요. 서비스마다 필드 이름이 다르거나, 어떤 요청엔 traceId가 있고 어떤 요청엔 없으면 중앙 로그 시스템에서 또 해석 작업이 필요해지거든요. 그래서 도입할 때는 포맷보다 공통 필드의 정의, 그리고 생성·전파·삭제 규칙을 먼저 정해두는 게 중요합니다.
Spring Boot 3.4 네이티브 구조화 로그와 Logstash Encoder 비교
Spring Boot 3.4에서는 logging.structured.format.console=ecs 같은 설정 한 줄로 콘솔 출력에 구조화 로그 형식을 적용할 수 있어요. ECS 말고도 Logstash, GELF 형식을 지원하고, 구조화 JSON에 필드를 추가하거나 빼고 이름을 바꾸는 설정도 제공합니다.
그러니까 예전처럼 JSON 로그를 만들겠다고 꼭 logstash-logback-encoder를 넣고 logback-spring.xml부터 쓸 필요는 없어졌어요. Spring Boot가 제공하는 ECS 형식으로 충분한 프로젝트라면 의존성이랑 설정 파일을 안 늘리는 게 유지보수하기도 편합니다.
| 구분 | Spring Boot 네이티브 구조화 로그 | Logstash Encoder |
|---|---|---|
| 설정 시작점 | application.properties 또는 YAML | 의존성 및 Logback 설정 |
| 기본 형식 | ECS, Logstash, GELF 등 | Logstash 중심의 세밀한 구성 |
| 필드 커스터마이징 | 추가·제외·이름 변경 및 커스터마이저 지원 | 매우 세밀한 인코더 구성 가능 |
| 적합한 경우 | 표준화된 JSON 로그가 필요한 신규 서비스 | 기존 Logback 설정이나 사내 로깅 표준을 그대로 유지해야 하는 환경 |
여기서 판단 기준은 "어느 쪽이 더 좋냐"가 아니에요. 지금 쓰는 로그 수집 시스템이 요구하는 출력 형식을 Spring Boot 기본 기능으로 맞출 수 있느냐가 핵심입니다. 기존 시스템이 특정 필드명이나 인코더 동작, 공통 Logback 설정에 기대고 있다면 굳이 네이티브 방식으로 갈아탈 이유가 없고요. 반대로 신규 서비스에서 표준적인 JSON 로그만 있으면 된다면 기본 기능부터 쓰는 게 불필요한 의존성을 줄이는 방법이에요.
가장 단순한 구조화 로그 설정
logging.structured.format.console=ecs
운영 환경에서 파일 로그가 아니라 컨테이너 표준 출력(stdout)을 수집하는 구조라면, 콘솔 JSON부터 적용하는 게 제일 간단합니다. 그다음에 실제 수집 시스템에서 필요한 필드가 뭔지 확인되면 logging.structured.json.add, exclude, rename 같은 기능으로 조정하면 돼요.
MDC로 traceId 붙일 때 꼭 확인할 것
구조화 로그를 적용하고 나면 제일 먼저 넣어볼 만한 컨텍스트가 요청을 구분하는 ID예요. Spring MVC 같은 Servlet 기반 애플리케이션이라면, 요청이 들어올 때 traceId를 만들어서 MDC에 넣고 요청 처리가 끝나면 지우는 식으로 구현할 수 있습니다.
MDC는 스레드 단위 컨텍스트라는 점이 중요해요. 일반적인 웹 요청은 같은 스레드에서 처리되는 동안 값을 쓸 수 있지만, 작업을 다른 스레드로 넘기는 순간부터는 자동으로 이어질 거라고 생각하면 안 됩니다. 그리고 서버가 스레드 풀을 쓰면 작업이 끝난 스레드가 다음 요청에 재사용될 수 있어서, MDC 정리를 빼먹으면 안 돼요.
import jakarta.servlet.*;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.UUID;
@Component
public class LoggingFilter implements Filter {
private static final String TRACE_ID = "traceId";
@Override
public void doFilter(
ServletRequest request,
ServletResponse response,
FilterChain chain
) throws IOException, ServletException {
String traceId = UUID.randomUUID().toString();
try {
MDC.put(TRACE_ID, traceId);
chain.doFilter(request, response);
} finally {
MDC.remove(TRACE_ID);
}
}
}
이 코드에서 중요한 건 UUID를 만드는 부분이 아니라 finally에서 MDC.remove()를 호출하는 부분이에요. 정상 응답일 때뿐 아니라 예외가 터졌을 때도 정리해줘야, 다음 요청에 이전 요청 값이 남지 않습니다.
traceId는 직접 만들기 전에 기존 값이 있는지부터 확인하자
이미 API Gateway나 로드밸런서, 다른 추적 시스템에서 요청 식별자를 넘겨주고 있다면, 애플리케이션이 무조건 새 ID를 만들어버리는 순간 추적이 끊길 수 있어요. 그래서 외부에서 받은 식별자를 그대로 믿을지, 검증하고 쓸지, 아니면 애플리케이션에서 새로 발급할지를 먼저 정해야 합니다.
그리고 인증 정보나 사용자 입력값을 traceId로 그대로 쓰는 건 안 됩니다. 요청 추적용 ID는 아무 의미 없는 값으로 두고, 사용자 식별이 필요하면 별도 필드와 접근 권한 정책을 두는 쪽이 안전해요.
비동기 작업에서는 MDC가 자동으로 따라가지 않는다
아래 같은 코드에서는 요청을 처리하던 스레드와 실제 작업을 실행하는 스레드가 달라질 수 있어요.
executor.execute(() -> {
log.info("async task started");
});
MDC가 스레드 로컬 기반이라서 이런 비동기 경계에서는 traceId가 사라질 수 있습니다. 그렇다고 MDC 값을 전역 변수처럼 쓰거나, 모든 작업마다 직접 복사해주는 방식은 나중에 관리하기가 힘들어요.
Spring Framework 6.1 이상이라면 ContextPropagatingTaskDecorator로 실행 컨텍스트를 작업 스레드까지 전파하는 방법을 생각해볼 수 있어요. 다만 컨텍스트를 캡처하고 복원하는 것도 비용이 들기 때문에, 아주 작은 작업을 엄청 많이 돌리는 환경이라면 성능에 영향이 없는지 같이 확인해야 합니다.
import org.springframework.context.annotation.Bean;
import org.springframework.core.task.TaskDecorator;
import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
@Bean
TaskDecorator taskDecorator() {
return new ContextPropagatingTaskDecorator();
}
반대로 WebFlux처럼 Reactor 기반으로 비동기 처리를 하는 애플리케이션에서는 Servlet식 ThreadLocal 방식만으로 요청 컨텍스트 전체를 관리하려고 하면 문제가 생길 수 있어요. 이런 환경에서는 Reactor Context와 관측·컨텍스트 전파 방식을 애플리케이션 실행 모델에 맞게 따로 설계해야 합니다.
로그에 뭘 넣을까: 많이 남기는 것보다 잘 설계하는 게 중요하다
구조화 로그를 도입하면 원하는 필드를 쉽게 붙일 수 있다 보니 userId, requestId, traceId, API 경로, 요청 파라미터까지 전부 넣고 싶어지기 쉬워요. 근데 로그 필드는 많다고 좋은 게 아닙니다.
| 필드 | 활용 목적 | 주의점 |
|---|---|---|
| traceId | 요청 흐름 추적 | 서비스 간 전파 규칙 필요 |
| requestId | 개별 요청 식별 | traceId와 역할이 겹치지 않도록 정의 |
| service | 발생 서비스 구분 | 서비스별 명칭을 일관되게 유지 |
| userId | 사용자 단위 장애 분석 | 개인정보 및 접근권한 정책 확인 |
| request body | 요청 내용 확인 | 비밀번호·토큰·개인정보 노출 위험이 큼 |
특히 요청 본문 전체를 MDC에 넣는 건 피하세요. 로그는 개발할 때 잠깐 찍어보는 디버깅 출력과 달라서, 중앙 시스템에 오래 보존되고 여러 운영자가 볼 수도 있거든요. 비밀번호, 인증 토큰, 세션 값, 주민등록번호 같은 민감 정보는 애초에 로그 대상에서 빼버리는 설계가 제일 먼저예요.
구조화 로그의 비용: JSON 전환보다 로그량 관리가 더 중요하다
JSON 로그는 필드명이 계속 반복돼서, 같은 정보를 담은 단순 텍스트보다 저장량이 커질 수 있어요. 그래도 실제 운영 비용이 포맷 하나로 정해지는 건 아닙니다. 로그 발생량, 로그 레벨, 수집 에이전트, 압축 여부, 보존 기간, 검색 빈도, 저장 시스템에 따라 다 달라지거든요.
그래서 "JSON은 텍스트보다 몇 퍼센트 더 크다" 같은 수치 하나를 모든 환경에 갖다 붙이기보다는, 실제 서비스의 로그량을 기준으로 직접 재보는 게 정확합니다. 구조화 로그를 적용하기 전후로 시간당 로그 용량이랑 수집 에이전트의 CPU·메모리 사용량을 비교해보면 비용 영향을 판단하기 쉬워요.
모든 로그를 같은 보존 기간으로 저장할 필요도 없습니다. 운영 장애 분석에 쓰는 ERROR 로그와 오래 보관해야 하는 감사성 로그는 목적 자체가 다르니까, 로그 레벨, 저장 기간, 접근 권한을 나눠서 가져가는 게 효율적이에요.
로컬에서는 사람이 읽고, 운영에서는 시스템이 검색하게 하기
JSON 로그가 모든 환경에서 더 편한 건 아니에요. 개발자가 터미널에서 예외 메시지를 빠르게 확인할 땐 사람이 읽기 좋은 일반 텍스트 로그가 오히려 효율적일 수 있거든요. 반면 중앙 로그 수집 시스템에서는 필드별 검색과 집계가 중요해서 구조화 로그의 장점이 커집니다.
그래서 프로파일별로 출력 형식을 나누는 방법이 현실적이에요. 예를 들면 로컬 개발 환경에서는 기본 콘솔 로그를 쓰고, 스테이징과 프로덕션에서만 ECS 같은 구조화 로그를 켜는 식이죠. 대신 개발이든 운영이든 필드와 추적 ID가 의미하는 바는 똑같이 유지되도록 공통 규칙은 지켜야 합니다.
Spring Boot 3.4면 Logstash Encoder는 필요 없나요?
꼭 필요한 건 아니에요. ECS, Logstash, GELF 같은 Spring Boot 기본 구조화 로그 형식과 기본적인 JSON 커스터마이징으로 요구사항이 충족된다면 별도 인코더 없이도 운영할 수 있습니다. 반대로 기존 Logback 구성이나 사내 표준 인코더에 많이 의존하고 있다면 기존 방식을 유지하는 게 더 안전할 수 있어요.
MDC에 traceId를 넣었는데 비동기 작업에서는 왜 사라지나요?
MDC가 기본적으로 스레드 단위 컨텍스트이기 때문이에요. 다른 스레드에서 도는 작업에 원래 스레드의 MDC가 알아서 복사될 거라고 생각하면 안 됩니다. Spring Framework의 컨텍스트 전파 기능이나 적절한 TaskDecorator 같은 걸 써서 비동기 경계에서 컨텍스트를 직접 넘겨줘야 해요.
MDC에서 traceId를 꼭 remove해야 하나요?
요청 처리 단위로 MDC에 값을 넣었다면 정리해주는 게 안전합니다. 특히 스레드 풀을 재사용하는 서버에서는 이전 요청 값이 다음 요청 로그에 남는 문제를 막아야 하니까요. 가장 단순하고 확실한 방법은 try-finally에서 값을 넣고, 마지막에 MDC.remove()를 호출하는 거예요.
요청 정보를 전부 로그에 남기면 장애 분석에 더 유리하지 않나요?
그렇지 않아요. 로그가 많아질수록 저장·수집 비용이 늘고, 정작 장애 원인을 찾는 데 필요한 정보가 묻힐 수 있습니다. 특히 요청 본문에는 인증 토큰이나 개인정보가 들어 있을 가능성이 있으니, 전부 기록하기보다 장애 분석에 필요한 최소한의 필드만 골라서 남기고 민감 정보는 처음부터 기록하지 않는 게 좋아요.
핵심 요약
- Spring Boot 3.4 이상이면
logging.structured.format.console=ecs같은 기본 설정만으로 구조화 로그를 시작할 수 있다. - 구조화 로그의 가치는 JSON 자체가 아니라, 로그 필드를 일정하게 정의해서 검색·집계할 수 있게 만드는 데 있다.
- MDC는 요청 단위 traceId를 로그에 연결하기 좋지만, 스레드 풀이 재사용되니 요청이 끝나면 꼭 컨텍스트를 정리해야 한다.
- 다른 스레드에서 실행되는 비동기 작업에는 MDC가 자동으로 전파되지 않아서 컨텍스트 전파 전략이 따로 필요하다.
- 로그에는 필요한 정보만 남기고, 비밀번호·인증 토큰·개인정보 같은 민감 정보는 처음부터 기록 대상에서 빼는 게 안전하다.
- JSON 전환 비용은 고정된 비율로 판단하지 말고, 실제 로그량과 수집·보존 정책을 기준으로 직접 측정해야 한다.
실무 체크리스트
- □ 도입 대상: 중앙 로그 수집 시스템에서 필드 검색이나 요청 단위 추적이 필요한 서비스인지 확인
- □ 포맷 선택: ECS 같은 Spring Boot 기본 형식으로 현재 수집 시스템 요구사항을 맞출 수 있는지 먼저 검토
- □ 추적 ID: traceId와 requestId의 역할을 구분하고, 서비스 간 전파 규칙을 정의
- □ MDC 정리: 요청 종료나 예외 상황에서도 MDC가 남지 않도록 finally 또는 그에 준하는 정리 구조 적용
- □ 비동기 전파: @Async, Executor, ThreadPoolTaskExecutor 등의 경계에서 traceId가 유지되는지 테스트
- □ 보안: 비밀번호, 토큰, 세션 값, 개인정보가 로그에 안 찍히는지 실제 출력 결과로 확인
- □ 비용: 로그 발생량, 수집 에이전트 자원 사용량, 저장 용량, 보존 기간을 같이 검토
- □ 환경 분리: 로컬은 가독성, 운영은 검색성을 우선하도록 프로파일별 로그 포맷 검토
구조화 로그를 넣는다고 운영 장애가 알아서 해결되는 건 아니에요. 실제로 효과를 내는 건 일관된 필드 정의, 요청 추적 ID 전파, MDC 정리, 비동기 컨텍스트 전파, 민감 정보 차단입니다. 신규 Spring Boot 서비스라면 기본 ECS 로그와 traceId 정도로 먼저 시작해보고, 실제 장애를 분석하다가 부족했던 필드만 하나씩 추가하는 편이 관리하기 편해요. 반대로 기존 로그 수집 체계가 이미 안정적으로 돌아가고 있다면, 최신 기술이라는 이유만으로 로깅 구조 전체를 갈아엎기보다 지금 시스템의 검색성과 유지보수 비용을 기준으로 바꿀지 말지 판단하는 게 맞다고 봅니다.
Spring Boot 시작 시간 줄이기, Lazy Initialization을 프로덕션에 그대로 쓰면 안 되는 이유
대규모 Spring Boot 애플리케이션에서 시작 시간이 길어지는 걸 두고 "스프링이 원래 무거워서 그래"라고 하기엔 좀 애매해요. 빈 생성, 자동 설정 조건 평가, 컴포넌트 스캔, DB 연결 초기화, 애플리
byteandbit.tistory.com
'백엔드 개발 > Spring' 카테고리의 다른 글
| 스프링 부트에서 조건부 Bean이 예상대로 등록되지 않을 때 원인 찾는 법 (0) | 2026.10.24 |
|---|---|
| Spring Boot 시작 시간 줄이기, Lazy Initialization을 프로덕션에 그대로 쓰면 안 되는 이유 (0) | 2026.10.11 |
| Spring HTTP 클라이언트 선택 기준, 동기식과 논블로킹은 무엇이 다를까? (0) | 2026.09.29 |
| Spring AI 자바 백엔드 적용: 벤더 종속성을 줄이는 인터페이스 설계와 비용·운영 트레이드오프 분석 (0) | 2026.09.28 |
| Spring Boot 4 멀티 모듈 도입 시 순환 의존성과 공통 모듈 비대화를 해결하는 방법은? (0) | 2026.08.28 |