로그를 DB에 저장하려다 Appender로 실패하고 Spring 구조로 바꾼 이유

송현진·2025년 6월 15일

Architecture

목록 보기
11/18

내가 만든 선물추천 서비스는 비회원 기반이다. 사용자는 로그인 없이 추천을 요청하기 때문에 특정 유저의 행동 흐름을 추적하려면 별도의 식별자가 필요하다. 그래서 모든 요청에 대해 traceId를 발급하고 이 값을 로그에 기록해두면 추후 요청-응답의 흐름을 정밀하게 추적할 수 있다. 단순히 콘솔이나 파일로 로그를 남기면 grep이나 tail로 볼 수는 있지만 추적이 어렵고 성능이나 장애 분석 시에도 효율이 떨어진다. 특히 추천 실패나 외부 API 호출 오류 같은 비정상 케이스를 정형화된 로그로 저장해두고 SQL로 조회할 수 있는 구조가 필요하다고 판단했다. 그래서 처음엔 Logback의 Appender를 커스터마이징해 로그를 DB에 바로 저장하는 구조를 시도했다.

1차 시도: 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를 사용한 직접 저장 방식은 중단하기로 결정했다.

새로운 구조 LogEventService

설계 목표

  • traceId 기반 요청/응답 흐름 추적
  • 로그는 DB에 저장되며 정형화된 구조로 쿼리 가능
  • 모든 요청/응답 로그는 자동으로 저장되어야 함
  • 성능 영향을 줄이기 위해 비동기로 저장
  • Spring Bean, @Transactional, @Async 등 Spring 생태계 기능을 최대한 활용할 수 있는 구조

LogEventService

모든 로그 이벤트를 발행하는 역할을 한다.
로그 수준, 로그 메시지, 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()
        );
    }
}

LogEventHandler

로그 이벤트를 수신하고 실제로 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());
    }
}

RequestLoggingFilter

모든 요청에 대해 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);
}

ResponseLoggingInterceptor

컨트롤러 실행이 끝난 후 응답 내용을 가로채어 로그를 남기는 역할이다.
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)로 저장되어 성능 영향이 거의 없음
  • 추후 특정 사용자의 추천 실패 이력이나 API 호출 문제를 SQL로 바로 조회 가능
  • Kibana 없이도 웹 기반 대시보드나 모니터링 시스템에 쉽게 연동할 수 있는 구조가 마련됨

📝 느낀점

처음엔 Logback Appender를 사용해서 로그를 DB에 바로 저장하는 것이 가장 정석적이고 간단할 것이라 생각했다. 하지만 Spring의 생명주기와 맞물리는 시점, Bean 주입 불가, 무한 루프 위험성 등을 실제로 겪으며 이 방식이 얼마나 위험한지 깨달았다. 오히려 Spring이 제공하는 Event 기반 구조는 훨씬 유연하고 안전했다. Filter와 Interceptor는 요청/응답 흐름을 정교하게 제어할 수 있었고 Service와 Handler 구조는 코드의 가독성과 유지보수성을 훨씬 높여줬다. 무엇보다도 운영 환경에서는 안정성, 추적 가능성, 성능 모두 중요하다. Appender는 단순히 로그를 남기는 것 이상으로 위험한 설계가 될 수 있다. 이번 구조 전환을 통해 Spring 친화적인 방식으로 로깅을 처리하는 법을 제대로 배운 것 같고 향후 대규모 트래픽 대응이나 장애 분석에도 확장 가능할 것으로 기대된다.

profile
개발자가 되고 싶은 취준생

0개의 댓글