1. 도입 배경
핵심 비즈니스 데이터(주문, 체결)와 비정형 데이터(에러 로그, 사용자 활동)의 성격이 완전히 다릅니다.
- MySQL: 트랜잭션이 중요한 비즈니스 데이터
- MongoDB: 스키마가 유동적인 로그 데이터
모든 로그를 MySQL에 넣으면 불필요한 부하가 생깁니다. 데이터 특성에 맞게 저장소를 분리하는 Polyglot Persistence(폴리글랏 퍼시스턴스) 아키텍처를 도입했습니다.
2. 컬렉션 구조
📄 error_logs - ErrorLog
@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
@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 인덱스가 만들어지지 않습니다.
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단계: 진짜 증발을 확인하기 위해 볼륨을 주석 처리했는데, 이런 에러가 발생했어요.
ERROR: Service "mongo" uses an undefined volume "mongo-data"
서비스 선언부(services)의 볼륨은 지웠지만, 파일 맨 아래 선언부(volumes)를 맞게 지우지 않아 발생한 문법 오류였어요. 사용하는 곳과 선언하는 곳, 양쪽을 모두 같이 수정해야 합니다.
이 실험을 통해 컨테이너의 휘발성과 Named Volume 마운트의 중요성을 뼈저리게 체감했습니다.
5. ErrorLogService - @Async 비동기 저장
@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를 붙여주어야 합니다. 이게 없으면 비동기로 동작하지 않고 메인 스레드를 그대로 잡아먹습니다.
@EnableAsync
@EnableCaching
@Configuration
public class CacheConfig { ... }
6. AOP 기반 ActivityLog - @LogActivity
비즈니스 로직에 로깅 코드가 직접 들어가면 관심사가 섞여 지저분해집니다. 이를 AOP를 활용해 완전히 분리했습니다.
@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. 전체 흐름 요약
마지막으로 구현된 알림 및 로깅 시스템의 전체적인 흐름 트리입니다.
예외 발생 → GlobalExceptionHandler
├── Slack 알림 (일부 예외만, WebClient subscribe)
└── MongoDB error_logs 저장 (@Async)
API 호출 → @LogActivity 어노테이션
└── ActivityLogAspect (AOP, @AfterReturning)
└── MongoDB activity_logs 저장 (@Async)
이상 거래 감지 → AbnormalTradeDetector
├── Slack 알림 (WebClient subscribe)
└── 이메일 알림 (@Async)
'개발 공부 > 프로젝트' 카테고리의 다른 글
| [Troubleshooting] Vercel + Oracle Cloud: 배포 환경에서 사라진 JWT 쿠키를 찾아서 (SameSite & Nginx) (0) | 2026.04.30 |
|---|---|
| Part 2. Slack 알림 연동 (0) | 2026.04.30 |
| Part 1. GCP에서 Oracle Cloud로 이전하기 (0) | 2026.04.28 |
| 이상 거래 탐지 트러블슈팅 - StackOverflowError부터 @Transactional 롤백까지 (0) | 2026.04.24 |
| 이상 거래 탐지 구현 - Redis Sliding Window와 이메일 알림 (0) | 2026.04.24 |