개발 공부/프로젝트

Part 3. MongoDB 로그 시스템 구축 (Polyglot Persistence)

baby-t 2026. 4. 30. 20:47

1. 도입 배경

핵심 비즈니스 데이터(주문, 체결)와 비정형 데이터(에러 로그, 사용자 활동)의 성격이 완전히 다릅니다.

  • MySQL: 트랜잭션이 중요한 비즈니스 데이터
  • MongoDB: 스키마가 유동적인 로그 데이터

모든 로그를 MySQL에 넣으면 불필요한 부하가 생깁니다. 데이터 특성에 맞게 저장소를 분리하는 Polyglot Persistence(폴리글랏 퍼시스턴스) 아키텍처를 도입했습니다.

2. 컬렉션 구조

📄 error_logs - ErrorLog

Java
@Document(collection = "error_logs")
public class ErrorLog {
    @Id 
    private String id;
    
    @Indexed(expireAfter = "30d")
    private LocalDateTime timestamp;
    
    private String exceptionType;
    private String message;
    private String endpoint;
    private String stackTrace; // 최대 20줄
}

📄 activity_logs - ActivityLog

Java
@Document(collection = "activity_logs")
public class ActivityLog {
    @Id 
    private String id;
    
    private Long userId;
    private ActivityType activityType;
    private String description;
    private String ip;
    
    @Indexed(expireAfter = "30d")
    private LocalDateTime timestamp;
}

3. TTL 인덱스 설정

로그가 무한정 쌓이면 디스크 용량이 터집니다. @Indexed(expireAfter = "30d") 옵션을 주어 30일 후 자동 삭제되도록 설정했습니다.

🚨 주의 1: @Indexed(expireAfterSeconds = 2592000) 방식은 deprecated 되었습니다. 문자열로 기간을 명시하는 최신 문법("30d")을 써야 합니다. 🚨 주의 2: application.yml에 반드시 아래 설정이 있어야 실제로 인덱스가 생성됩니다. 이 옵션이 없으면 코드를 짜도 TTL 인덱스가 만들어지지 않습니다.

YAML
spring:
  data:
    mongodb:
      uri: mongodb://localhost:27017/virtual_exchange_log
      auto-index-creation: true  # ⭐️ 이게 없으면 TTL 인덱스가 생성 안 됨

4. 트러블슈팅 - 컨테이너 휘발성 검증 및 볼륨 문법 에러

MongoDB 도입 후 데이터 영속성을 확인하기 위해 docker-compose down으로 파괴 실험을 진행했습니다.

  • 1단계: 처음엔 데이터를 삭제하려 했으나 멀쩡히 살아있었어요. 알고 보니 docker-compose.yml에 이미 mongo-data:/data/db 볼륨 설정이 완벽하게 되어 있어 도커가 데이터를 지켜준 것이었어요.
  • 2단계: 진짜 증발을 확인하기 위해 볼륨을 주석 처리했는데, 이런 에러가 발생했어요.
Plaintext
ERROR: Service "mongo" uses an undefined volume "mongo-data"

서비스 선언부(services)의 볼륨은 지웠지만, 파일 맨 아래 선언부(volumes)를 맞게 지우지 않아 발생한 문법 오류였어요. 사용하는 곳과 선언하는 곳, 양쪽을 모두 같이 수정해야 합니다.

이 실험을 통해 컨테이너의 휘발성과 Named Volume 마운트의 중요성을 뼈저리게 체감했습니다.

5. ErrorLogService - @Async 비동기 저장

Java
@Service 
@RequiredArgsConstructor
public class ErrorLogService {
    
    private final ErrorLogRepository errorLogRepository;
    
    @Async
    public void saveErrorLog(Exception ex, HttpServletRequest request) {
        String stackTrace = Arrays.stream(ex.getStackTrace())
                .limit(20) // 전체 저장 시 100줄 넘어 MongoDB 용량 낭비
                .map(StackTraceElement::toString)
                .collect(Collectors.joining("\n"));
                
        ErrorLog errorLog = ErrorLog.builder()
                .timestamp(LocalDateTime.now(ZoneId.of("Asia/Seoul"))) // UTC 아닌 서울 시간
                .exceptionType(ex.getClass().getSimpleName())
                .message(ex.getMessage())
                .endpoint(request.getMethod() + " " + request.getRequestURI())
                .stackTrace(stackTrace)
                .build();
                
        errorLogRepository.save(errorLog);
    }
}
  • 스택 트레이스를 20줄로 제한한 이유: 핵심 원인은 보통 앞부분 20줄 이내에 다 있습니다. 전체를 저장하면 용량 낭비가 심합니다.
  • 🚨 주의: @Async가 실제로 작동하려면 Spring Boot 설정 클래스에 반드시 @EnableAsync를 붙여주어야 합니다. 이게 없으면 비동기로 동작하지 않고 메인 스레드를 그대로 잡아먹습니다.
Java
@EnableAsync
@EnableCaching
@Configuration
public class CacheConfig { ... }

6. AOP 기반 ActivityLog - @LogActivity

비즈니스 로직에 로깅 코드가 직접 들어가면 관심사가 섞여 지저분해집니다. 이를 AOP를 활용해 완전히 분리했습니다.

Java
@Aspect 
@Component 
@RequiredArgsConstructor
public class ActivityLogAspect {
    
    private final ActivityLogService activityLogService;
    
    @AfterReturning("@annotation(logActivity)")
    public void logActivity(LogActivity logActivity) {
        Long userId = extractUserId();
        String ip = extractIp();
        
        activityLogService.saveActivityLog(
            userId, logActivity.activityType(),
            logActivity.description(), ip
        );
    }
    
    private String extractIp() {
        try {
            HttpServletRequest request = ((ServletRequestAttributes)
                    RequestContextHolder.currentRequestAttributes()).getRequest();
            String forwarded = request.getHeader("X-Forwarded-For");
            
            // Nginx 뒤에서는 X-Forwarded-For 헤더로 실제 IP가 전달됨
            // getRemoteAddr()는 항상 127.0.0.1 반환
            return (forwarded != null && !forwarded.isBlank())
                    ? forwarded.split(",")[0].trim()
                    : request.getRemoteAddr();
        } catch (Exception e) {
            return "unknown";
        }
    }
}
  • @AfterReturning을 사용한 이유: 메서드가 성공적으로 완료됐을 때만 활동 로그를 남기기 위해서입니다. 예외 발생 시에는 error_logs에 따로 저장되니까요.
  • 설계적 이점: ActivityLogAspect 안에서 직접 DB Repository에 접근하지 않고, ActivityLogService라는 별도의 빈을 주입받아 호출했습니다. 계층을 분리한 덕분에 순환 참조를 막고, 스프링 AOP의 고질적 문제인 내부 호출 시 @Async 미작동(Proxy 우회) 문제를 원천적으로 방지할 수 있었습니다.

7. 전체 흐름 요약

마지막으로 구현된 알림 및 로깅 시스템의 전체적인 흐름 트리입니다.

Plaintext
예외 발생 → GlobalExceptionHandler
              ├── Slack 알림 (일부 예외만, WebClient subscribe)
              └── MongoDB error_logs 저장 (@Async)

API 호출 → @LogActivity 어노테이션
              └── ActivityLogAspect (AOP, @AfterReturning)
                    └── MongoDB activity_logs 저장 (@Async)

이상 거래 감지 → AbnormalTradeDetector
                  ├── Slack 알림 (WebClient subscribe)
                  └── 이메일 알림 (@Async)