개발 공부/프로젝트

Part 2. Slack 알림 연동

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

1. 도입 배경

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

구현된 모습


2. 설계

알림의 성격에 따라 메시지 포맷과 역할을 명확히 분리하기 위해 트리 구조로 서비스를 설계했습니다.

Plaintext
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()을 사용했는데, 이건 비효율적이에요.

Java
// 잘못된 방식 - @Async 스레드 안에서 block()으로 대기
@Async
public void sendAlert(String message) {
    webClient.post()...block(); // 스레드 낭비
}

@Async는 별도 스레드를 생성하고, .block()은 그 스레드를 다시 대기 상태로 만들어요. 스레드 풀 낭비예요.

 

💡 해결: @Async를 제거하고 WebClient 본연의 논블로킹 방식인 .subscribe()를 사용했습니다.

Java
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이 막았어요.

Plaintext
GH013: Repository rule violations found for refs/heads/main.
Push cannot contain secrets

GitHub이 민감 정보를 감지해서 퍼블릭 레포지토리 업로드를 차단한 거예요. 보안 사고로 직결될 수 있어서 GitHub이 자체적으로 막아준 것이었습니다.

 

💡 해결:

Bash
git reset --soft HEAD~1  # 마지막 커밋만 취소, 파일 변경사항은 유지

리셋 후 application.yml에서 실제 URL 값을 지우고 ${SLACK_WEBHOOK_URL}로 변경해 저장한 뒤, 다시 git add .와 git commit을 수행해 안전하게 푸시했습니다.

YAML
slack:
  webhook:
    url: ${SLACK_WEBHOOK_URL}

실제 값은 GitHub Secrets에 등록해 두었어요.


5. CI/CD 환경변수 구조 이해

이 과정에서 중요한 깨달음이 있었어요. Spring Boot 앱은 Docker 컨테이너 안에 있지 않고 리눅스 호스트에서 직접 실행되고 있어요.

Plaintext
Docker 컨테이너: MySQL, Redis, Kafka, Zookeeper
리눅스 호스트:   Spring Boot (.jar) ← nohup java -jar로 실행

그래서 docker-compose.yml에 환경변수를 넣어봤자 Spring Boot는 읽지 못해요. GitHub Actions가 SSH로 접속해서 java -jar 실행 시 직접 환경변수를 주입해야 해요.

최종 해결 - deploy.yml 수정:

YAML
- 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 실행 시점에 무사히 전달됩니다.