[킥온] AOP 로깅 도입...!

Minseo Lim·2025년 5월 10일

오늘은 참여형 축구 커뮤니티, 킥온의 로깅 시스템 구축기에 대해 소개해드리려 합니다 🚨
https://kick-on.kr/

📌 목적

API 호출 시 다음과 같은 정보를 자동으로 로깅합니다:
• 요청 URI, HTTP Method
• 사용자 식별 정보 (JWT 기반)
• PathVariable, Query Parameter, Body
• 요청 소요 시간
• 예외 발생 시 에러 메시지 포함

🧩 전체 흐름

클라이언트
   │
   ▼
[CachingRequestBodyFilter]     ← Body를 캐싱해서 여러 번 읽을 수 있도록 래핑
   │
   ▼
[ApiLoggingAspect]
   │   ├─ 요청 전 정보 추출
   │   ├─ 실제 메서드 실행 (joinPoint.proceed())
   │   ├─ 성공: 소요시간 포함 로그 기록
   │   └─ 예외: 예외 정보 포함 로그 기록
   ▼
[Controller 메서드 실행]
   ▼
응답 반환

🔍 ApiLoggingAspect 주요 기능 정리

📍 Pointcut 설정

@Pointcut("within(@org.springframework.web.bind.annotation.RestController *)")

→ 모든 @RestController 클래스 대상

    @Around("@annotation(org.springframework.scheduling.annotation.Scheduled)")
    public Object logScheduler(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("Scheduled task started: {}", joinPoint.getSignature());
        Object result = joinPoint.proceed();
        log.info("Scheduled task ended: {}", joinPoint.getSignature());
        return result;
    }

→ 스케줄러 어노테이션 가진 애들 대상 대상

📍 로깅 메시지 예시

성공시:

[POST] /api/user/login - UserController.login (152ms)
├─ UserID: 123
├─ QueryParams: redirectUrl=/home
└─ Body:
{
  "email": "test@example.com",
  "password": "****"
} ✅

에러시:

[GET] /api/users/12 - ERROR (UserNotFoundException) - 8ms
├─ UserID: 123
├─ PathVars: userId=12

📍 로그 분기

if (uri.startsWith("/migration")) {
    LoggerFactory.getLogger("MIGRATION_LOGGER")
} else {
    LoggerFactory.getLogger("API_LOGGER")
}
  • ApiLoggingAspect는 모든 @RestController 메서드를 감싸는 Around Advice로 동작하며, 요청에 대한 사용자 식별 정보, URI, 파라미터, 요청 바디, 소요 시간 등을 로그로 남겨줍니다. 또한, 예외 발생 시에도 상세한 오류 정보를 함께 로깅합니다.
  • 이때 CachingRequestBodyFilter를 통해 Body를 한 번 더 읽을 수 있게 처리하고, URI 조건 분기로 필요하지 않은 요청은 제외합니다.
  • 운영 중 API 성능이나 사용자 행위 추적, 장애 분석 등을 자동화하는 데 아주 유용한 방식입니다.

🔍 LogbackConfig 주요 기능 정리

xml을 기반으로 정리한 글들이 많았는데, 나는 Java 기반으로 관리하는게 통일성에 더 좋을 것 같아서 Java 파일로 config를 구성해보았다.

@Slf4j
@Configuration
public class LogbackConfig {

    private static final String LOG_DIR = "로그 파일 저장할 폴더 경로";

    @PostConstruct
    public void setupLogger() {
        // 디렉토리 없으면 생성
        File logDir = new File(LOG_DIR);
        if (!logDir.exists()) {
            if (!logDir.mkdirs()) {
                log.error("Failed to create log directory: {}", LOG_DIR);
            } else {
                log.info("Log directory created: {}", LOG_DIR);
            }
        }

        LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();

        // 로그 포맷 정의
        PatternLayoutEncoder encoder = new PatternLayoutEncoder();
        encoder.setContext(context);
        encoder.setPattern("%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n");
        encoder.start();

        // Console Appender 추가
        ConsoleAppender<ILoggingEvent> consoleAppender = buildConsoleAppender(context, encoder);

        // API 로그 Appender
        RollingFileAppender<ILoggingEvent> apiAppender = buildRollingAppender(context,
                "API", LOG_DIR + "/api.log", LOG_DIR + "/api-%d{yyyy-MM-dd}.log", encoder);
        Logger apiLogger = context.getLogger("API_LOGGER");
        apiLogger.setAdditive(false);
        apiLogger.setLevel(Level.INFO);
        apiLogger.addAppender(apiAppender);
        apiLogger.addAppender(consoleAppender);

        // Migration 로그 Appender
        RollingFileAppender<ILoggingEvent> migrationAppender = buildRollingAppender(context,
                "MIGRATION", LOG_DIR + "/migration.log", LOG_DIR + "/migration-%d{yyyy-MM-dd}.log", encoder);
        Logger migrationLogger = context.getLogger("MIGRATION_LOGGER");
        migrationLogger.setAdditive(false);
        migrationLogger.setLevel(Level.INFO);
        migrationLogger.addAppender(migrationAppender);
        migrationLogger.addAppender(consoleAppender);

        // ERROR 로그 Appender (ERROR만 기록)
        RollingFileAppender<ILoggingEvent> errorAppender = buildRollingAppender(context,
                "ERROR", LOG_DIR + "/error.log", LOG_DIR + "/error-%d{yyyy-MM-dd}.log", encoder);
        Logger errorLogger = context.getLogger("ERROR_LOGGER");
        errorLogger.setAdditive(false);  // rootLogger에 영향 없도록 설정
        errorLogger.setLevel(Level.ERROR);  // ERROR만 기록
        errorLogger.addAppender(errorAppender);
        errorLogger.addAppender(consoleAppender);
    }

    private ConsoleAppender<ILoggingEvent> buildConsoleAppender(LoggerContext context, PatternLayoutEncoder encoder) {
        ConsoleAppender<ILoggingEvent> appender = new ConsoleAppender<>();
        appender.setContext(context);
        appender.setName("CONSOLE");
        appender.setEncoder(encoder);
        appender.start();
        return appender;
    }

    private RollingFileAppender<ILoggingEvent> buildRollingAppender(LoggerContext context, String name,
                                                                    String logFile, String fileNamePattern,
                                                                    PatternLayoutEncoder encoder) {
        RollingFileAppender<ILoggingEvent> appender = new RollingFileAppender<>();
        appender.setContext(context);
        appender.setName(name + "_APPENDER");
        appender.setFile(logFile);

        TimeBasedRollingPolicy<ILoggingEvent> policy = new TimeBasedRollingPolicy<>();
        policy.setContext(context);
        policy.setParent(appender);
        policy.setFileNamePattern(fileNamePattern);
        policy.setMaxHistory(30);
        policy.start();

        appender.setRollingPolicy(policy);
        appender.setEncoder(encoder);

        if (name.equals("ERROR")) {
            ThresholdFilter errorFilter = new ThresholdFilter();
            errorFilter.setLevel("ERROR");
            errorFilter.start();
            appender.addFilter(errorFilter);
        }

        appender.start();
        return appender;
    }
}

♻️ RollingFileAppender 설정: 로그 순환(로테이션) 시스템

운영 환경에서는 로그가 하루만 지나도 수십 MB 이상 쌓이는 경우가 많습니다. 이를 방치하면 디스크 공간이 부족해지거나, 로그 분석이 어려워질 수 있습니다.
이를 해결하기 위해 RollingFileAppender를 사용하여 일 단위 로그 파일 분리 및 자동 보존 정책을 설정했습니다.

policy.setFileNamePattern("/var/log/kickon-server/api-%d{yyyy-MM-dd}.log");
policy.setMaxHistory(30);
설정 항목 설명
fileNamePattern 날짜 형식으로 로그 파일이 분리됩니다. 예: api-2025-05-10.log
maxHistory 최대 보관할 로그 파일 수입니다. 위 예시에서는 최근 30일간의 로그만 유지하고 자동 삭제됩니다.

📦 로그 파일 구조 예시

/var/log/kickon-server/
├─ api.log              → 오늘 날짜의 실시간 로그 (쓰기 중)
├─ api-2025-05-09.log   → 어제자 로그 (읽기 전용, 보존 중)
├─ api-2025-05-08.log   → 2일 전 로그 (보존 중)
...

😄 마무리

아직은 파일시스템에 바로 로그 파일을 저장하는 구조인데, EC2 인스턴스에 장애가 생길경우 파일에 접근하지 못하는 문제가 있기 때문에 cloudwatch 기반 로깅 시스템을 구축중에 있다 :) 완료하면 또 오겠음

profile
프로덕트 엔지니어 임민서입니다.

2개의 댓글

comment-user-thumbnail
2025년 5월 20일

KIA 즤기네요. UI는 어떻게 짜셨나요?

1개의 답글