1편에서 설계를 마쳤으니 이번엔 실제 구현 과정을 기록합니다.
1. Sliding Window Rate Limiting
단시간 과다 주문 탐지에서 처음에는 단순 카운팅 방식을 고려했습니다.
Key: "order:count:userId"
Value: 주문 횟수
TTL: 60초
근데 이 방식에는 문제가 있어요. 바로 고정 윈도우(Fixed Window) 문제입니다.
0초~60초: 4건 주문 → 통과
61초: 카운트 초기화
61초~70초: 4건 더 주문 → 통과
실제로는 55초~65초 사이에 8건이 몰렸는데 차단이 안 돼요. 그래서 Redis Sorted Set으로 Sliding Window를 구현했습니다.
public void checkOverTrade(Long userId) {
String key = ORDER_COUNT_PREFIX + userId;
long now = System.currentTimeMillis();
long windowStart = now - 60_000; // 60초 전
// 1. 60초 이전 데이터 삭제
redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart);
// 2. 현재 윈도우 내 주문 수 조회
Long count = redisTemplate.opsForZSet().size(key);
// 3. 5건 이상이면 차단
if (count != null && count >= MAX_ORDER_PER_MINUTE) {
throw new AbnormalTradeException("단시간 과다 주문으로 차단");
}
// 4. 현재 주문 timestamp 추가
redisTemplate.opsForZSet().add(key, String.valueOf(now), now);
// 5. 키 TTL 갱신 (60초)
redisTemplate.expire(key, 60, TimeUnit.SECONDS);
}
각 주문의 timestamp를 Score로 저장해서 항상 "지금 기준 60초 전~지금" 범위만 정확하게 볼 수 있어요.
removeRangeByScore가 필요한 이유도 있어요. TTL은 키 전체가 한 번에 삭제되는 거고, removeRangeByScore는 키 안의 오래된 데이터만 삭제하는 거예요. 이 두 개를 같이 써야 진짜 Sliding Window가 됩니다.
2. 이메일 알림 구현
고액 거래 감지 시 이메일 알림을 발송하도록 구현했습니다.
spring:
mail:
host: smtp.gmail.com
port: 587
username: ${MAIL_USERNAME}
password: ${MAIL_PASSWORD}
properties:
mail:
smtp:
auth: true
starttls:
enable: true
Gmail SMTP를 사용했고 앱 비밀번호를 발급받아 환경변수로 안전하게 관리했습니다.
@Async
public void sendAbnormalTradeAlert(String to, String tradeInfo) {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(to);
message.setSubject("[가상 거래소] 이상 거래 감지 알림");
message.setText("고액 거래가 감지되었습니다.\n\n거래 정보: " + tradeInfo +
"\n\n본인이 요청한 거래가 아니라면 즉시 비밀번호를 변경해주세요.");
mailSender.send(message);
}
@Async를 붙인 이유가 중요합니다. 이메일 발송이 동기로 처리되면 SMTP 서버 응답까지 기다리느라 주문 처리가 블로킹돼요. 비동기로 처리해서 메인 주문 흐름에 영향을 주지 않도록 했습니다.
이때 Async가 작동하게 하기 위해 아무 Configuration에 @EnableAsync 를 붙여줘야 합니다.
3. AbnormalTradeDetector 설계
처음엔 각 검증 메서드 안에서 user, account, stock을 각각 DB에서 조회했어요. 세 메서드를 다 호출하면 같은 데이터를 3번씩 조회하는 문제가 있었죠.
그래서 checkAbnormalTrade에서 한 번만 조회하고 파라미터로 넘기는 구조로 개선했습니다.
public void checkAbnormalTrade(OrderMessageDto dto) {
Long userId = dto.getUserId();
String email = userRepository.findById(userId)...getEmail();
Account account = accountRepository.findByUserId(userId)...;
long totalPrice = (long)(stock.getCurrentPrice() * dto.getQuantity());
checkOverTrade(userId); // 매수/매도 모두
checkLargeTrade(totalPrice, email); // 매수/매도 모두
if ("BUY".equalsIgnoreCase(dto.getOrderType())) {
checkHeavyTrade(account, totalPrice, email); // 매수만
checkNightTrade(account, totalPrice, email); // 매수만
}
}
매수/매도 구분이 필요한 이유도 있어요. 잔고 80% 이상, 심야 거래는 매수일 때만 의미가 있어요. 매도는 이미 보유한 주식을 파는 거라 잔고 기준 이상 거래 판단이 맞지 않거든요.
이후 실제 성공 화면들



'개발 공부 > 프로젝트' 카테고리의 다른 글
| Part 1. GCP에서 Oracle Cloud로 이전하기 (0) | 2026.04.28 |
|---|---|
| 이상 거래 탐지 트러블슈팅 - StackOverflowError부터 @Transactional 롤백까지 (0) | 2026.04.24 |
| 이상 거래 탐지 시스템 설계 - 금융권은 어떻게 이상 거래를 막는가? (클로드 코드) (0) | 2026.04.24 |
| 클로드 코드로 구현하면서 겪은 트러블슈팅 (0) | 2026.04.22 |
| JWT에서 Access Token + Refresh Token + Rotation 방식으로 전환하기 (클로드 코드) (0) | 2026.04.22 |