내가 만든 선물추천 서비스는 비회원 기반이다. 사용자는 로그인 없이 추천을 요청하기 때문에 특정 유저의 행동 흐름을 추적하려면 별도의 식별자가 필요하다. 그래서 모든 요청에 대해 traceId를 발급하고 이 값을 로그에 기록해두면 추후 요청-응답의 흐름을 정밀하게 추적할 수 있다. 단순히 콘솔이나 파일로 로그를 남기면 grep이나 tail로 볼 수는 있지만 추적이 어렵고 성능이나 장애 분석 시에도 효율이 떨어진다. 특히 추천 실패나 외부 API 호출 오류 같은 비정상 케이스를 정형화된 로그로 저장해두고 SQL로 조회할 수 있는 구조가 필요하다고 판단했다. 그래서 처음엔 Logback의 Appender를 커스터마이징해 로그를 DB에 바로 저장하는 구조를 시도했다.
처음 생각은 간단했다. Spring Boot가 사용하는 Logback의 Appender를 상속해서 로그를 가공하고 DB에 저장하면 되지 않을까? 라는 생각으로 코드를 작성했다. 아래는 그 시도 코드다.
@Component
public class DbLogAppender extends AppenderBase<ILoggingEvent> {
@Autowired
private LogRepository logRepository;
@Override
protected void append(ILoggingEvent event) {
LogEntity log = LogEntity.builder()
.traceId(MDC.get("traceId"))
.logLevel(event.getLevel().toString())
.loggerName(event.getLoggerName())
.message(event.getFormattedMessage())
.threadName(event.getThreadName())
.createdAt(LocalDateTime.now())
.build();
logRepository.save(log);
}
}
그리고 ogback-spring.xml서 다음과 같이 등록했다.
<appender name="DB" class="com.example.giftrecommender.common.logging.appender.DbLogAppender"/>
<root level="INFO">
<appender-ref ref="DB"/>
</root>
이렇게 설정하고 앱을 실행하자마자 다음과 같은 에러가 무수히 반복되었다.
로그 DB 저장 실패 : Cannot invoke "LogRepository.save(Object)" because "this.logRepository" is null
처음엔 단순 오타인가 싶었지만 알고 보니 Logback의 Appender는 Spring Context보다 먼저 초기화되기 때문에 @Autowired가 동작하지 않는다. 즉, logRepository는 null 상태이고 어떤 로그가 발생하든 모두 NullPointerException이 발생하며 로그 저장에 실패했다. 또한 로그 저장 실패 -> 다시 로그 발생 -> 또 실패 -> 로그... 무한 루프도 가능하다는 걸 깨달았다. 이 방식은 기술적으로 위험한 구조였고 유지보수도 매우 까다로워질 수밖에 없었다. Appender를 사용한 직접 저장 방식은 중단하기로 결정했다.
traceId 기반 요청/응답 흐름 추적@Transactional, @Async 등 Spring 생태계 기능을 최대한 활용할 수 있는 구조모든 로그 이벤트를 발행하는 역할을 한다.
로그 수준, 로그 메시지, traceId 등 필드를 받아 ApplicationEventPublisher`를 통해 이벤트를 발송한다.
@Component
@RequiredArgsConstructor
public class LogEventService {
private final ApplicationEventPublisher publisher;
public void log(String logLevel, String loggerName, String message) {
publisher.publishEvent(
LogEvent.builder()
.traceId(MDC.get("traceId"))
.logLevel(logLevel)
.loggerName(loggerName)
.message(message)
.threadName(Thread.currentThread().getName())
.createdAt(LocalDateTime.now())
.build()
);
}
}
로그 이벤트를 수신하고 실제로 DB에 저장하는 컴포넌트이다.
@Async가 적용되어 있어 별도 쓰레드에서 로그를 저장하므로 성능에 영향이 적다.
@Component
@RequiredArgsConstructor
public class LogEventHandler {
private final LogRepository logRepository;
@Async
@EventListener
public void saveLog(LogEvent event) {
logRepository.save(LogEntity.builder()
.traceId(event.traceId())
.logLevel(event.logLevel())
.loggerName(event.loggerName())
.message(event.message())
.threadName(event.threadName())
.createdAt(event.createdAt())
.build());
}
}
모든 요청에 대해 traceId를 생성하고 요청 정보를 DB에 저장하는 역할이다.
JSON Body도 읽어서 함께 저장하며 ContentCachingRequestWrapper를 사용해 body 접근이 가능하도록 한다.
@Override
protected void doFilterInternal(...) {
...
try {
filterChain.doFilter(wrappedRequest, wrappedResponse);
saveRequestLogToDB(wrappedRequest, traceId);
} finally {
wrappedResponse.copyBodyToResponse();
MDC.clear();
}
}
private void saveRequestLogToDB(ContentCachingRequestWrapper request, String traceId) {
String body = new String(request.getContentAsByteArray(), StandardCharsets.UTF_8);
String logMessage = String.format("Request %s %s\nBody: %s",
request.getMethod(), request.getRequestURI(), body);
logEventService.log("INFO", "RequestLoggingFilter", logMessage);
}
컨트롤러 실행이 끝난 후 응답 내용을 가로채어 로그를 남기는 역할이다.
ContentCachingResponseWrapper를 통해 응답 바디를 읽고 traceId와 함께 저장한다.
@Override
public void afterCompletion(...) {
String responseBody = new String(wrapper.getContentAsByteArray(), StandardCharsets.UTF_8);
String logMessage = String.format("Response %s %s\nStatus: %d\nBody: %s",
request.getMethod(), request.getRequestURI(), response.getStatus(), responseBody);
log.info("Response [{}] Status: {}, Body: {}", traceId, response.getStatus(), responseBody);
logEventService.log("INFO", "ResponseLoggingInterceptor", logMessage);
MDC.clear();
}
이번 구조 전환을 통해 다음과 같은 결과를 얻을 수 있었다.
traceId와 함께 DB에 저장됨HTTP Method, URI, Body, 응답의 Status, Response Body 등 핵심 정보 모두 보존@Async)로 저장되어 성능 영향이 거의 없음처음엔 Logback Appender를 사용해서 로그를 DB에 바로 저장하는 것이 가장 정석적이고 간단할 것이라 생각했다. 하지만 Spring의 생명주기와 맞물리는 시점, Bean 주입 불가, 무한 루프 위험성 등을 실제로 겪으며 이 방식이 얼마나 위험한지 깨달았다. 오히려 Spring이 제공하는 Event 기반 구조는 훨씬 유연하고 안전했다. Filter와 Interceptor는 요청/응답 흐름을 정교하게 제어할 수 있었고 Service와 Handler 구조는 코드의 가독성과 유지보수성을 훨씬 높여줬다. 무엇보다도 운영 환경에서는 안정성, 추적 가능성, 성능 모두 중요하다. Appender는 단순히 로그를 남기는 것 이상으로 위험한 설계가 될 수 있다. 이번 구조 전환을 통해 Spring 친화적인 방식으로 로깅을 처리하는 법을 제대로 배운 것 같고 향후 대규모 트래픽 대응이나 장애 분석에도 확장 가능할 것으로 기대된다.