오늘은 참여형 축구 커뮤니티, 킥온의 로깅 시스템 구축기에 대해 소개해드리려 합니다 🚨
https://kick-on.kr/
API 호출 시 다음과 같은 정보를 자동으로 로깅합니다:
• 요청 URI, HTTP Method
• 사용자 식별 정보 (JWT 기반)
• PathVariable, Query Parameter, Body
• 요청 소요 시간
• 예외 발생 시 에러 메시지 포함
클라이언트
│
▼
[CachingRequestBodyFilter] ← Body를 캐싱해서 여러 번 읽을 수 있도록 래핑
│
▼
[ApiLoggingAspect]
│ ├─ 요청 전 정보 추출
│ ├─ 실제 메서드 실행 (joinPoint.proceed())
│ ├─ 성공: 소요시간 포함 로그 기록
│ └─ 예외: 예외 정보 포함 로그 기록
▼
[Controller 메서드 실행]
▼
응답 반환
@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")
}
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;
}
}
운영 환경에서는 로그가 하루만 지나도 수십 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 기반 로깅 시스템을 구축중에 있다 :) 완료하면 또 오겠음
KIA 즤기네요. UI는 어떻게 짜셨나요?