1. 도입 배경
이상 거래 탐지 로직이 발동할 때 기존에는 이메일로만 알림을 보냈는데, 실제 운영팀이 즉각적으로 대응할 수 있도록 Slack Webhook 실시간 알림을 추가했습니다.

2. 설계
알림의 성격에 따라 메시지 포맷과 역할을 명확히 분리하기 위해 트리 구조로 서비스를 설계했습니다.
SlackNotificationService
├── sendAbnormalTradeBlocked(userId, reason) → 🚨 이상 거래 차단
├── sendAbnormalTradeWarning(email, reason) → ⚠️ 이상 거래 감지
└── sendServerError(exceptionType, message) → 💥 서버 오류 발생
[호출 위치 및 트리거 조건]
| 호출 위치 | 메서드 | 트리거 조건 |
| GlobalExceptionHandler | sendServerError | UpbitApiCallException, KafkaProducerErrorException, 미처리 Exception |
| AbnormalTradeDetector | sendAbnormalTradeBlocked | 1분 내 5건 초과 주문 |
| AbnormalTradeDetector | sendAbnormalTradeWarning | 잔고 80% 이상, 1천만원 이상, 심야 잔고 50% 이상 |
단순히 텍스트만 보내는 게 아니라 운영팀이 한눈에 심각도를 파악할 수 있도록 Slack 마크다운 포맷을 적극 활용해 메시지 템플릿을 분리했어요. *[🚨 이상 거래 차단]* 같은 굵은 글씨, > 인용구 등을 써서 실무 친화적인 로그 포맷팅을 고민했습니다.
3. 구현 - WebClient + subscribe
처음에 @Async + .block()을 사용했는데, 이건 비효율적이에요.
// 잘못된 방식 - @Async 스레드 안에서 block()으로 대기
@Async
public void sendAlert(String message) {
webClient.post()...block(); // 스레드 낭비
}
@Async는 별도 스레드를 생성하고, .block()은 그 스레드를 다시 대기 상태로 만들어요. 스레드 풀 낭비예요.
💡 해결: @Async를 제거하고 WebClient 본연의 논블로킹 방식인 .subscribe()를 사용했습니다.
public void sendAbnormalTradeBlocked(Long userId, String reason) {
String message = String.format(
"🚨 *[이상 거래 차단]*\n시간: %s\nUserID: %d\n사유: %s",
LocalDateTime.now(ZoneId.of("Asia/Seoul"))
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
userId, reason
);
sendToSlack(message);
}
private void sendToSlack(String message) {
webClient.post()
.uri(webhookUrl)
.bodyValue(Map.of("text", message))
.retrieve()
.bodyToMono(String.class)
.subscribe(
response -> {},
error -> log.warn("Slack 알림 전송 실패: {}", error.getMessage())
);
}
.subscribe()는 요청을 던지고 바로 반환해요. Slack 서버가 죽어있어도 log.warn으로 처리하고 메인 로직은 영향받지 않아요.
시간에 ZoneId.of("Asia/Seoul")을 명시한 이유가 있어요. 클라우드 서버(Ubuntu)의 기본 타임존은 UTC(영국 시간)이기 때문에 그냥 LocalDateTime.now()를 쓰면 한국 시간보다 9시간 느리게 알림이 와요. 코드 레벨에서 명시적으로 KST를 지정해서 방지했습니다.
4. 트러블슈팅 - GitHub Push Protection
application.yml에 Slack Webhook URL을 직접 넣고 push했더니 GitHub이 막았어요.
GH013: Repository rule violations found for refs/heads/main.
Push cannot contain secrets
GitHub이 민감 정보를 감지해서 퍼블릭 레포지토리 업로드를 차단한 거예요. 보안 사고로 직결될 수 있어서 GitHub이 자체적으로 막아준 것이었습니다.
💡 해결:
git reset --soft HEAD~1 # 마지막 커밋만 취소, 파일 변경사항은 유지
리셋 후 application.yml에서 실제 URL 값을 지우고 ${SLACK_WEBHOOK_URL}로 변경해 저장한 뒤, 다시 git add .와 git commit을 수행해 안전하게 푸시했습니다.
slack:
webhook:
url: ${SLACK_WEBHOOK_URL}
실제 값은 GitHub Secrets에 등록해 두었어요.
5. CI/CD 환경변수 구조 이해
이 과정에서 중요한 깨달음이 있었어요. Spring Boot 앱은 Docker 컨테이너 안에 있지 않고 리눅스 호스트에서 직접 실행되고 있어요.
Docker 컨테이너: MySQL, Redis, Kafka, Zookeeper
리눅스 호스트: Spring Boot (.jar) ← nohup java -jar로 실행
그래서 docker-compose.yml에 환경변수를 넣어봤자 Spring Boot는 읽지 못해요. GitHub Actions가 SSH로 접속해서 java -jar 실행 시 직접 환경변수를 주입해야 해요.
최종 해결 - deploy.yml 수정:
- name: Execute JAR on Oracle
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.ORACLE_HOST }}
username: ${{ secrets.ORACLE_USERNAME }}
key: ${{ secrets.ORACLE_SSH_KEY }}
script: |
sudo fuser -k -n tcp 8080 || true
cd ~/virtual-exchange/virtual-exchange
DB_PASSWORD="${{ secrets.DB_PASSWORD }}" \
JWT_SECRET="${{ secrets.JWT_SECRET }}" \
MAIL_USERNAME="${{ secrets.MAIL_USERNAME }}" \
MAIL_PASSWORD="${{ secrets.MAIL_PASSWORD }}" \
SLACK_WEBHOOK_URL="${{ secrets.SLACK_WEBHOOK_URL }}" \
nohup java -jar build/libs/virtual-exchange-0.0.1-SNAPSHOT.jar > app.log 2>&1 &
GitHub Secrets의 안전한 금고에 있던 값이 오라클 서버의 Spring Boot로 java -jar 실행 시점에 무사히 전달됩니다.
'개발 공부 > 프로젝트' 카테고리의 다른 글
| [Troubleshooting] Vercel + Oracle Cloud: 배포 환경에서 사라진 JWT 쿠키를 찾아서 (SameSite & Nginx) (0) | 2026.04.30 |
|---|---|
| Part 3. MongoDB 로그 시스템 구축 (Polyglot Persistence) (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 |