목표와 범위
- 목표: 운영 상에서 발생한 예외를 "채널명"으로 실시간 알림이 오게 하고 싶었습니다.
- 범위: 비즈니스 로직 상에서 선언한 커스텀 예외를 타겟으로 잡았습니다.
- 스크린샷 ↓

파일 구조
⏺ src/main/java/com/eventitta/common/notification/
├── config/
│ └── SlackProperties.java
├── domain/
│ ├── AlertLevel.java
│ ├── SlackAttachment.java
│ ├── SlackField.java
│ └── SlackMessage.java
├── exception/
│ ├── AlertErrorCode.java
│ └── SlackNotificationException.java
├── resolver/
│ └── AlertLevelResolver.java
└── service/
├── RateLimiter.java
├── SimpleRateLimiter.java
├── SlackMessageBuilder.java
└── SlackNotificationService.java
역할 정리
- SlackProperties:
- notification package 에서 사용하는 슬랙 관련 패키지 환경 정보 세팅하는 역할
- AlertLevel
- 예외의 수준을 설정하여 심각성을 분별하기 위한 enum 클래스
- 다음과 같이 설정
| 의미 | 대응 | |
| CRITICAL | 서비스 중단/데이터 위험 | 즉시 대응 |
| HIGH | 심각한 장애/성능 저하 | 빠른 시간(업무시간 내) |
| MEDIUM | 기능 이슈/재현성 낮음 | 관찰 |
| INFO | 스케줄링 완료 | 무대응, 필요 시 모아서 확인 |
- SlackMessage, SlackAttachment, SlackField
- Slack 메시지 구조를 모델링
- Slack API 호출을 하기 위해 정의
- https://api.slack.com/legacy/outmoded-messaging
Legacy: An outmoded introduction to messages
In which we describe the bare basics of message composition before the advent of Block Kit.
api.slack.com
- SlackNotificationException
- Slack 알림 커스텀 예외
- AlertLevelResolver
- 예외 타입에 따른 알림 레벨 결정
- RateLimiter(Interface)
- 알림 속도 제한 인터페이스
- SimpleRateLimiter
- 같은 예외가 짧은 시간에 발생할 때 Slack 채널이 도배되는 것을 방지하기 위해 구현
- 중요한 예외가 묻히지 않도록 함
- SlackMessageBuilder
- Slack 웹훅에 보낼 메시지 생성하는 빌더
- 알림 레벨 / 오류 정보 / 요청을 받아 Slack 메시지 구성한 후 반환
- SlackNotificationService
- Slack 알림을 수행
구현 흐름
예외 발생 → 전역 핸들러에서 이벤트 작성 → 중복/샘플링 체크 → Slack Webhook POST → 개발자 Slack 알림 확인

실제 코드
GlobalExceptionHandler
package: `/common/exception/GlobalExceptionHandler`
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private final HttpServletRequest request;
private final SlackNotificationService slackNotificationService;
private final AlertLevelResolver alertLevelResolver;
@ExceptionHandler(CustomException.class)
public ResponseEntity<ApiErrorResponse> handleCustom(CustomException ex) {
ResponseEntity<ApiErrorResponse> response = toResponse(ex.getErrorCode());
sendSlackNotification(ex, ex.getErrorCode().name(), ex.getErrorCode().defaultMessage());
return response;
}
/* 생략: 예외 처리 메서드 */
private void sendSlackNotification(Exception exception, String errorCode, String message) {
AlertLevel level = alertLevelResolver.resolveLevel(exception);
String userInfo = alertLevelResolver.extractUserInfo();
slackNotificationService.sendAlert(
level,
errorCode,
message,
request.getRequestURI(),
userInfo,
exception
);
}
}
추가된 의존성 역할 정리
- AlertLevelResolver
- “예외 입력을 받아서 최종적으로 사용할 값을 정해주는 역할”
- SlackNotificationService
- "예외 발송을 실질적으로 처리"
- HttpServletRequest
- "예외가 발생한 URL을 알아내기 위해 필요"
AlertLevelResolver
package: `/common/notification/resolver/AlertLevelResolver`
@Component
public class AlertLevelResolver {
public AlertLevel resolveLevel(Exception exception) {
if (isCriticalException(exception)) {
return AlertLevel.CRITICAL;
}
if (isHighLevelException(exception)) {
return AlertLevel.HIGH;
}
if (isMediumLevelException(exception)) {
return AlertLevel.MEDIUM;
}
return AlertLevel.INFO;
}
/* 생략: 검증 메서드 */
public String extractUserInfo() {
return SecurityUtil.getCurrentUserInfo();
}
}
메서드 정리
- resolveLevel
- 예외의 수준을 판별하여 반환하는 메서드
- extractUserInfo
- getCurrentUserInfo는 AccessToken으로부터 사용자 정보를 가지고 와서 반환하는 메서드
- 어떤 유저의 활동으로 예외가 발생했는지 파악 가능
SimpleRateLimiter
package: `/common/notification/service/SimpleRateLimiter`
- Slack 알림에서 동일한 에러가 반복해서 발생했을 때, 알림 스팸을 방지하기 위한 제한을 두는 클래스입니다.
- 일정 시간 내 알림 전송 횟수를 제한하도록 설정하였습니다.
@Component
public class SimpleRateLimiter implements RateLimiter {
private static final String KEY_SEPARATOR = ":";
private final ConcurrentHashMap<String, AlertRecord> alerts = new ConcurrentHashMap<>();
private final Clock clock;
public SimpleRateLimiter() {
this(Clock.systemDefaultZone());
}
public SimpleRateLimiter(Clock clock) {
this.clock = clock;
}
@Override
public boolean shouldSendAlert(String errorCode, AlertLevel level) {
String key = createKey(errorCode, level);
long now = clock.millis();
cleanupExpiredRecords(now);
AlertRecord record = alerts.get(key);
if (record == null) {
alerts.put(key, new AlertRecord(now));
return true;
}
int maxAlerts = getMaxAlertsPerPeriod(level);
return record.incrementAndGet() <= maxAlerts;
}
@Override
public void reset() {
alerts.clear();
}
private String createKey(String errorCode, AlertLevel level) {
return errorCode + KEY_SEPARATOR + level;
}
private void cleanupExpiredRecords(long now) {
long windowMillis = TimeUnit.MINUTES.toMillis(AlertConstants.RATE_LIMIT_WINDOW_MINUTES);
alerts.entrySet().removeIf(entry ->
now - entry.getValue().getTimestamp() > windowMillis);
}
private int getMaxAlertsPerPeriod(AlertLevel level) {
return switch (level) {
case CRITICAL -> AlertConstants.CRITICAL_ALERT_LIMIT;
case HIGH -> AlertConstants.HIGH_ALERT_LIMIT;
case MEDIUM -> AlertConstants.MEDIUM_ALERT_LIMIT;
case INFO -> AlertConstants.INFO_ALERT_LIMIT;
};
}
private static class AlertRecord {
private final long timestamp;
private final AtomicInteger count;
AlertRecord(long timestamp) {
this.timestamp = timestamp;
this.count = new AtomicInteger(1);
}
long getTimestamp() {
return timestamp;
}
int incrementAndGet() {
return count.incrementAndGet();
}
}
}
코드 설명
private static final String KEY_SEPARATOR = ":";
private String createKey(String errorCode, AlertLevel level) {
return errorCode + KEY_SEPARATOR + level;
}
- 에러코드 + 레벨 조합으로 고유 키 생성
- 예시) "NOT_FOUND_POST_ID:MEDIUM", "ACCESS_TOKEN_INVALID:HIGH"
private final ConcurrentHashMap<String, AlertRecord> alerts = new ConcurrentHashMap<>();
- ConcurrentHashMap 을 사용해서 위의 createKey로 고유 키를 생성해 키 값으로 사용합니다.
- 메모리 기반으로 작동하기 때문에 애플리케이션 재시작 시 초기화됩니다.
- ConcurrentHashMap은 동시성을 보장합니다.
private static class AlertRecord {
private final long timestamp;
private final AtomicInteger count;
AlertRecord(long timestamp) {
this.timestamp = timestamp;
this.count = new AtomicInteger(1);
}
}
- timestamp: 첫 알림 발생 시간을 기록합니다.
- count: 현재 설정된 시간 안의 알림 횟수를 카운팅 합니다.
- AtomicInteger는 동시성을 보장합니다.
private void cleanupExpiredRecords(long now) {
long windowMillis = TimeUnit.MINUTES.toMillis(AlertConstants.RATE_LIMIT_WINDOW_MINUTES);
alerts.entrySet().removeIf(entry ->
now - entry.getValue().getTimestamp() > windowMillis);
}
- 5분 고정으로 alerts(ConcurrentHashMap)의 보관 시간을 설정하고 5분이 넘어가게 되면 삭제합니다.
@Override
public boolean shouldSendAlert(String errorCode, AlertLevel level) {
String key = createKey(errorCode, level);
long now = clock.millis();
cleanupExpiredRecords(now);
AlertRecord record = alerts.get(key);
if (record == null) {
alerts.put(key, new AlertRecord(now));
return true;
}
int maxAlerts = getMaxAlertsPerPeriod(level);
return record.incrementAndGet() <= maxAlerts;
}
- key값을 생성
- 현재 시간을 체크(여기서 만료된 값이면 삭제)
- AlertRecord 객체를 가지고 와서 null이 아닌 경우 alerts(ConcurrentHashMap)에 추가
- 제한 횟수를 확인해서 횟수가 넘은 경우 false를 반환(알림 보내지 않음)
- 예시)
- CRITICAL: 10회 / 5분
- HIGH: 5회 / 5분
- MEDIUM: 2회 / 5분
- INFO: 1회 / 5분
SlackMessageBuilder
package: `/common/notification/service/SlackMessageBuilder`
@Component
public class SlackMessageBuilder {
private static final int MAX_STACK_TRACE_LINES = 3;
private static final String ALERT_TITLE_FORMAT = "%s - %s [%s]";
private static final String LINE_SEPARATOR = "\\n";
public SlackMessage buildMessage(AlertLevel level, String errorCode, String message,
String requestUri, String userInfo, Throwable exception,
String environment, String channel, String username) {
String color = getColorForLevel(level);
String title = String.format(ALERT_TITLE_FORMAT, errorCode, level, environment);
List<SlackField> fields = buildFields(requestUri, userInfo, exception, level);
SlackAttachment attachment = SlackAttachment.builder()
.color(color)
.title(title)
.text(message)
.fields(fields)
.ts(System.currentTimeMillis() / 1000)
.build();
return SlackMessage.builder()
.channel(channel)
.username(username)
.text(String.format(AlertConstants.ALERT_TEXT_FORMAT, AlertConstants.ALERT_EMOJI, level))
.attachments(List.of(attachment))
.build();
}
private List<SlackField> buildFields(String requestUri, String userInfo,
Throwable exception, AlertLevel level) {
List<SlackField> fields = new ArrayList<>();
String REQUEST_URL = "Request URI";
String USER = "User";
String EXCEPTION = "Exception";
if (StringUtils.hasText(requestUri)) {
fields.add(SlackField.builder()
.title(REQUEST_URL)
.value(requestUri)
.build());
}
if (StringUtils.hasText(userInfo)) {
fields.add(SlackField.builder()
.title(USER)
.value(userInfo)
.build());
}
if (exception != null && level == AlertLevel.CRITICAL) {
fields.add(SlackField.builder()
.title(EXCEPTION)
.value(getShortStackTrace(exception))
.isShort(false)
.build());
}
return fields;
}
private String getColorForLevel(AlertLevel level) {
return switch (level) {
case CRITICAL -> AlertConstants.CRITICAL_COLOR;
case HIGH -> AlertConstants.HIGH_COLOR;
case MEDIUM -> AlertConstants.MEDIUM_COLOR;
case INFO -> AlertConstants.INFO_COLOR;
};
}
private String getShortStackTrace(Throwable exception) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
exception.printStackTrace(pw);
String stackTrace = sw.toString();
return Arrays.stream(stackTrace.split(LINE_SEPARATOR))
.limit(MAX_STACK_TRACE_LINES)
.collect(Collectors.joining(LINE_SEPARATOR));
}
}
코드 설명
// 상수 정의
private static final int MAX_STACK_TRACE_LINES = 3;
private static final String ALERT_TITLE_FORMAT = "%s - %s [%s]";
private static final String LINE_SEPARATOR = "\\n";
- MAX_STACK_TRACE_LINES: 스택트레이스 출력 최대 라인 수
- ALTER_TITLE_FORMAT: 알림 제목 포맷 (에러코드 - 레벨[환경])
- LINE_SEPARATOR: 출바꿈 구분자
public SlackMessage buildMessage(AlertLevel level, String errorCode, String message,
String requestUri, String userInfo, Throwable exception,
String environment, String channel, String username)
- Slack 알림 메시지의 최종 조합 및 반환
- 완성된 SlackMessage 객체 생성하는 메서드
- 반환값
SlackMessage {
channel: "#alerts"
username: "eventitta-bot"
text: "⚠️ MEDIUM Alert"
attachments: [SlackAttachment]
}
- 사용예시
SlackMessage message = builder.buildMessage(
AlertLevel.HIGH,
"ACCESS_TOKEN_INVALID",
"잘못된 액세스 토큰입니다.",
"/api/v1/posts/123",
"user@example.com (ID: 1)",
null,
"production",
"#alerts",
"eventitta-bot"
);
private List<SlackField> buildFields(String requestUri, String userInfo,
Throwable exception, AlertLevel level)
- buildFields() - 상세 정보 필드 구성
- Slack 메시지의 상세 필드들을 동적으로 구성하는 메서드
- 입력 값에 따라 필요한 필드만 선택적으로 추가
private String getColorForLevel(AlertLevel level)
- AlertLevel 에 따라 Slack 첨부파일의 색상을 결정
- 시각적으로 중요도를 구분할 수 있도록 색상 코드를 제공
private String getShortStackTrace(Throwable exception)
- 예외의 스택트레이스를 축약하여 가독성을 향상 시키기 위한 메서드
SlackNotificationService
package: `/common/notification/service/SlackNotificationService`
- 애플리케이션에서 발생하는 예외나 중요한 이벤트를 Slack 알림으로 전송하는 서비스
- 비동기 처리 등을 통해 알림을 구현
클래스 구조
@Service
@Slf4j
public class SlackNotificationService {
private final SlackProperties slackProperties; // 설정 정보
private final RateLimiter rateLimiter; // 알림 제한
private final RestClient slackRestClient; // HTTP 클라이언트
private final Environment environment; // 환경 정보
private final SlackMessageBuilder messageBuilder; // 메시지 구성
}
의존성 흐름
SlackNotificationService → SlackProperties (설정 확인)
→ RateLimiter (전송 허용 확인)
→ SlackMessageBuilder (메시지 구성)
→ RestClient (Slack API 호출)
메서드 분석
@Async
public void sendAlert(AlertLevel level, String errorCode, String message,
String requestUri, String userInfo, Throwable exception)
- 알림 전송 메서드
- 비동기로 Slack 알림을 전송
private SlackMessage createSlackMessage(AlertLevel level, String errorCode, String message,
String requestUri, String userInfo, Throwable exception)
- SlackMessageBuilder에게 메시지 구성을 위임
- 환경 정보와 추가적인 정보를 제공
private void sendToSlack(SlackMessage message)
- Slack Webhook API로 실제 HTTP POST 요청 전송
- RestClient를 사용하여 통신
- 예시
POST https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX
Content-Type: application/json
{
"channel": "#alerts",
"username": "eventitta-bot",
"text": "⚠️ HIGH Alert",
"attachments": [
{
"color": "#FF8C00",
"title": "ACCESS_TOKEN_INVALID - HIGH [production]",
"text": "잘못된 액세스 토큰입니다.",
"fields": [
{"title": "Request URI", "value": "/api/v1/posts/123", "short": true},
{"title": "User", "value": "user@example.com (ID: 1)", "short": true}
],
"ts": 1703123456
}
]
}
private String getActiveProfile()
- 현재 활성 Spring Profile을 조회하는 메서드
- 알림 메시지에 prod, test, local, docker 등 포함
Application.yml
# application.yml
notification:
slack:
enabled: true
webhook-url: https://hooks.slack.com/services/...
channel: "#alerts"
username: "eventitta-bot"
timeout-seconds: 5
- 운영 환경에서만 enabled: true로 설정하고 나머지 환경에서는 false로 설정하여 알림 비활성화 가능
Slack Webhook 연동 참고 사이트
https://develop-writing.tistory.com/142
Spring으로 Slack 알림 보내기
목차 1. 알림의 중요성 2. Spring으로 Slack 알림 보내기 알림의 중요성 서비스 지표가 문제가 있거나 시스템에 문제가 있는 경우 팀 내부에서 빠르게 인지하는 게 필요합니다. 서비스 지표에 문제가
twothoupermonth.com
RateLimiter 관련 참고 사이트
https://0xkishan.medium.com/designing-and-implementing-an-api-rate-limiter-in-java-af34c17145f2
Designing and Implementing an API Rate Limiter in Java
In this article, we’ll go over the basics of what an API rate limiter is. We’ll answer fundamental questions such as why we need a rate…
0xkishan.medium.com
'사이드 프로젝트 > 이벤트 있다' 카테고리의 다른 글
| 처리율 제한(Rate Limiter) 의 성능 병목점 확인 (0) | 2025.09.02 |
|---|---|
| SimpleRateLimiter의 분산 환경 한계점 (0) | 2025.08.31 |
| 게이미피케이션 시스템 개선 2부 - 이벤트 아키텍처로 문제 해결하기 (3) | 2025.08.12 |
| 두 개의 외부 API 통합하기: 서로 다른 특성을 가진 데이터를 하나의 시스템으로 (0) | 2025.08.12 |
| @EventListener는 비동기일까? 직접 실험해봤습니다 (4) | 2025.07.26 |