운영 중인 일부 서비스에서 웹훅을 통해 애플리케이션 이벤트 알림을 실시간으로 받고 있습니다. 현재는 Slack(이하 슬랙)을 통해 알림을 전송하고 있으나, 이를 Discord(이하 디스코드)로 이전하자는 의견이 제기되었습니다. 대부분의 온라인 활동(회의, 스터디 등)이 디스코드에서 이루어지고 있는 반면, 슬랙은 개발 팀에서만 사용되고 있었기 때문입니다. 또한, 슬랙의 무료 요금제는 메시지를 최대 90일까지만 보관할 수 있어 과거 기록을 확인하기 어려운 점도 중요한 이유 중 하나였습니다.
디스코드로의 이전은 기록 관리와 편의성 측면에서 장기적으로 더 나은 선택이라 판단하여 웹훅 시스템의 마이그레이션을 결정하게 되었습니다. 마이그레이션 과정에서는 애플리케이션의 비즈니스 로직과의 결합을 최소화하면서도 확장성이 뛰어난 구조를 설계하는 데 중점을 두었습니다. 이를 위해 이벤트 발행-구독(Event Publisher-Subscriber) 패턴을 활용하여 문제를 해결하였으며, 이 경험이 Spring Boot 기반 웹훅 시스템을 구현하려는 분들께 도움이 되기를 바랍니다.
*웹훅(Webhook)
서버에 특정 이벤트가 발생했을 때 사전에 설정된 URL로 HTTP 요청을 보내 알림을 전달하는 방식.
참고: 웹훅(Webhook) | 토스페이먼츠 개발자센터
*이벤트 발행-구독(Event Publisher-Subscriber) 패턴
발행자(Publisher)가 이벤트를 생성하면 구독자(Subscriber)가 이를 수신하여 처리하는 방식.
시스템 내에서 발생하는 이벤트를 효율적으로 관리하고, 컴포넌트 간의 독립성과 상호작용을 강화함.
참고: Event-Driven Architecture and Pub/Sub Pattern Explained | AltexSoft
0. 프로젝트 환경
- Spring Boot 3.3.6
- Gradle 8.11.1
1. 기존 웹훅 시스템의 구조
1-1. 동작 흐름
기존의 시스템은 슬랙에만 특화된 웹훅 시스템으로 구성되어 있었으며, 아래와 같은 방식으로 동작했습니다.
- 슬랙 웹훅 서비스
- 각 알림 유형에 따라 사전 정의된 메시지 템플릿을 사용합니다.
- 전달받은 데이터를 해당 템플릿에 조합하여 HTTP 요청을 생성하고 전송하는 메소드를 제공합니다.
- 비즈니스 로직과의 결합
- 알림이 필요한 비즈니스 로직에서 슬랙 웹훅 서비스를 직접 호출합니다.
- 슬랙 서비스와의 강한 의존성이 존재하여, 알림 로직 변경 시 비즈니스 로직도 영향을 받습니다.
- 메시지 전송 방식
- 슬랙 웹훅 서비스는 메시지 전송에 필요한 데이터를 매개변수로 받아 처리합니다.
- 전달받은 데이터를 기반으로 사전에 설정된 슬랙 웹훅 URL로 HTTP POST 요청을 통해 알림을 전송합니다.
1-2. 기존 구조의 문제점
이러한 구조는 다음과 같은 문제를 가집니다.
- 플랫폼 종속성
- 슬랙에 특화된 구조로 인해, 다른 플랫폼으로 확장하려면 기존 로직을 수정하거나 새로운 웹훅 서비스를 별도로 개발해야 합니다.
- 높은 결합도
- 비즈니스 로직과 슬랙 웹훅 서비스가 강하게 결합되어 있어, 알림 방식이나 플랫폼 변경 시 비즈니스 로직에 불필요한 변경이 발생합니다.
- 비즈니스 로직 내에서 웹훅 URL과 호출 방식을 관리해야 하므로 유지보수성이 낮아집니다.
- 확장성 제한
- 다수의 플랫폼을 지원하거나 알림 전달 방식을 확장하려면 기존 구조를 대폭 수정해야 하는 비효율성이 존재합니다.
- 플랫폼별로 새로운 로직을 추가할수록 코드 복잡도가 증가합니다.
2. 개선된 웹훅 시스템의 구조
2-1. 주요 구성 요소
기존 시스템의 문제점을 해결하기 위해 이벤트 발행-구독 패턴을 적용하여, 확장 가능하고 유연한 웹훅 시스템으로 재설계했습니다. 새롭게 설계된 시스템은 비즈니스 로직에서 알림 이벤트를 발행하는 역할만 수행하며, 다른 로직과 최소한의 결합을 유지합니다. 또한, Listener가 알림 이벤트를 구독하고 알림 유형에 따라 지정된 플랫폼별 Sender에 메시지를 전달하기 때문에, 기존 코드를 수정하지 않고도 쉽게 확장할 수 있습니다. 주요 구성 요소는 다음과 같습니다.
- Notification Event 기반의 비즈니스 로직 분리
- 알림은 `NotificationEvent` 객체를 통해 비동기로 처리됩니다.
- 비즈니스 로직과 웹훅 로직 간의 직접적인 결합을 제거하여, 새로운 플랫폼 추가 시 비즈니스 로직을 수정하지 않아도 됩니다.
- NotificationListener
- `NotificationEvent`를 구독하고, 알림 설정 및 플랫폼 매핑 정보를 확인하고, 적절한 `NotificationSender`를 호출합니다.
- 특정 알림 유형에 대한 설정(`enabled` 여부)을 확인한 뒤, 활성화된 알림만 처리합니다.
- 알림 유형(`AlertType`)과 플랫폼별 매핑 정보는 `application.yml` 설정 파일에서 관리됩니다. 이는 코드베이스를 수정하지 않고 재배포만으로 매핑 정보를 변경할 수 있도록 하기 위함입니다.
- 플랫폼별 NotificationSender
- 플랫폼별로 구현된 `NotificationSender` 인터페이스를 통해 Slakc과 Discord에 맞춤화된 알림 메시지를 전송합니다.
- 새로운 플랫폼 추가 시 기존 코드를 수정하지 않고, 새로운 Sender를 추가하여 확장 가능합니다.
- NotificationSetting
- 알림 유형(`AlertType`)별 설정(`enabled` 여부)을 데이터베이스에서 관리하며, 기본 설정이 없는 경우 자동으로 생성됩니다.
- 새로운 알림 유형이 추가될 때 알림 설정 데이터가 누락될 가능성을 방지하기 위해, 기본적으로 활성화된 상태의 알림 데이터를 자동으로 생성합니다. 이는 설정 데이터 누락 시에도 시스템 에러가 발생하지 않도록 보장합니다.
- NotificationConfigProperties
- 플랫폼별 웹훅 URL과 알림 유형-플랫폼 매핑 정보를 설정 파일(`application.yml`)에서 관리합니다.
- 기본 매핑 및 카테고리별 매핑을 제공하여 설정의 유연성과 확장성을 강화합니다.
- 코드 수정 없이 설정 파일을 변경하여 매핑 구성을 업데이트할 수 있습니다.
2-2. 동작 흐름
- NotificationEvent 생성
- 알림이 필요한 비즈니스 로직에서 `NotificationEvent`를 발행합니다.
- NotificationListener에서 이벤트 처리
- `NotificationListener`는 발행된 이벤트를 구독하여 알림 설정(`enabled` 여부)을 확인합니다.
- 알림이 활성화되어 있으면, 알림 유형(`AlertType`)에 해당하는 플랫폼 매핑 정보를 조회합니다.
- NotificationSender 호출
- 매핑 정보에 따라 적절한 플랫폼의 `NotificationSender`를 호출합니다.
- 플랫폼에 맞는 메시지 형식을 생성하여 웹훅 URL로 전송합니다.
- 알림 전송 결과 처리
- 플랫폼에서 알림 전송 성공 여부를 반환하며, 실패 시 로그에 기록합니다.
2-3. 개선된 구조의 장점
이벤트 기반으로 동작하는 개선된 구조는 다음과 같은 장점을 가집니다.
- 플랫폼 독립성
- 새로운 플랫폼을 추가할 경우, 기존 이벤트 및 알림 로직을 수정하지 않아도 됩니다.
- 단순히 새로운 플랫폼 유형에 대한 `NotificationSender`를 구현하기만 하면 확장이 가능하므로, 다양한 플랫폼에 손쉽게 대응할 수 있습니다.
- 유지보수성 강화
- 비즈니스 로직과 알림 로직이 완전히 분리되어, 알림 로직 변경이 비즈니스 로직에 영향을 미치지 않습니다.
- 로직 간 결합도가 낮아, 코드 변경 범위를 최소화하여 시스템 안정성을 유지할 수 있습니다.
- 유연한 설정 관리
- 알림 유형과 플랫폼 매핑 정보를 설정 파일(`application.yml`)에서 관리함으로써 코드 수정 없이도 설정을 변경할 수 있습니다.
- 재배포 없이 설정을 업데이트할 수 있어 운영 환경에서의 유연성과 편의성이 크게 향상됩니다.
- 확장성 및 누락 방지
- 알림 유형에 대한 설정이 데이터베이스에 없을 경우, 자동으로 활성화된 기본 설정을 생성하여 누락으로 인한 에러를 방지합니다.
- 이를 통해 새로운 알림 유형이 추가되어도 시스템이 정상적으로 동작할 수 있도록 보장합니다.
2-4. 이벤트 기반 시스템을 선택한 이유
시스템 설계에 정답은 없으며, 기능을 구현하는 방법은 매우 다양합니다. 웹훅 시스템은 기존처럼 직접 호출 방식으로 구현하거나, 스케줄러, 메시지 큐 등의 방법을 사용할 수도 있습니다. 그럼에도 불구하고 이벤트 기반 시스템을 선택한 이유에 대해 살펴보겠습니다.
2-4-1. 직접 호출 방식
비즈니스 로직에서 알림 로직을 직접 호출하여 처리하는 방식입니다. 예를 들어, 특정 로직 내에서 슬랙 웹훅 URL을 호출하거나, 알림 메시지를 구성하는 로직을 삽입하는 방식으로 구현됩니다.
장점
- 구현이 간단하고 초기 개발 속도가 빠릅니다.
- 추가적인 인프라나 외부 도구 없이 구현이 가능합니다.
단점
- 비즈니스 로직과 알림 로직이 강하게 결합되어, 알림 로직 변경 시 비즈니스 로직에도 영향을 미칩니다.
- 플랫폼이 추가될 경우 기존 로직을 수정하거나 복잡한 조건문을 추가해야 하므로 유지보수가 어렵습니다.
- 코드가 플랫폼 종속적이 되어, 새로운 플랫폼 추가나 알림 방식 변경 시 수정 범위가 늘어납니다.
2-4-2. 스케줄러 기반 방식
알림 데이터를 주기적으로 조회(Polling)하여 알림을 처리하는 방식입니다. 스케줄러가 특정 간격으로 알림 데이터베이스나 상태 변화를 확인하여 처리합니다.
장점
- Spring의 `@Scheduled` 애너테이션을 사용하여 쉽게 구현 가능합니다.
- 실패한 알림은 다음 주기에 다시 조회하여 처리할 수 있습니다.
- 정해진 주기로 작업을 처리하므로 부하와 작업 시간이 예측 가능합니다.
단점
- 알림이 스케줄러 주기에 따라 처기되기에 즉각적인 알림을 받을 수 없습니다.
- 이벤트가 없는 상태에서도 주기적으로 데이터를 조회하므로 불필요한 리소스가 사용될 수 있습니다.
- 트래픽이 급증할 경우 스케줄러에 병목 현상이 발생할 수 있습니다.
2-4-3. 이벤트 기반 방식
비즈니스 로직에서 발생한 이벤트를 발행(Publish)하고, 이를 구독(Subscribe)하여 알림을 처리하는 방식입니다. Spring의 `ApplicationEventPublisher`와 `@EventListener`를 활용하여 구현하거나, 메시지 브로커(Kafka, RabbitMQ 등)와 결합하여 분산 환경에서도 사용할 수 있습니다.
장점
- 비즈니스 로직과 알림 로직이 완전히 분리되어, 코드 수정 없이 알림 로직의 변경 및 확장이 가능합니다.
- 알림 이벤트 발생 즉시 처리되어 실시간성이 보장됩니다.
- 메시지 큐를 통해 부하를 분산하여 대규모 트래픽 처리에도 유연하게 대응할 수 있습니다.
- 작업이 비동기로 처리되어 시스템의 응답성과 처리 효율이 향상됩니다.
단점
- 메시지 브로커를 사용하는 경우 초기 설정 및 운영 관리에 대한 비용과 리소스가 추가로 요구됩니다.
이벤트 기반 방식은 낮은 결합도, 실시간 처리 능력, 확장성이 뛰어나 웹훅 시스템과 가장 적합하다고 판단됩니다. 모놀리식(단일) 환경에서는 Spring의 `ApplicationEventPublisher`와 `@EventListener`를 활용하여 효율적으로 구현할 수 있으며, 추후 분산 환경으로 전환할 경우 메시지 브로커와 결합하여 손쉽게 확장 가능합니다. 이러한 방식은 비즈니스 로직과 알림 로직을 독립적으로 관리하면서 유지보수성과 운영 효율성을 극대화할 수 있습니다.
3. 다중 플랫폼을 지원하는 웹훅 시스템 구현하기
3-1. 패키지 구조
본 글에서는 운영 중인 서비스의 패키지 구조를 바탕으로, 헥사고날 아키텍처를 기반으로 설계된 코드를 설명합니다. 구현 코드는 포트 앤 어댑터 패턴을 따르는 구조를 사용하고 있으며, 이 설명에서 사용하는 패키지 구조와 설계 방식이 독자의 프로젝트와 다를 수 있습니다. 그러나 이는 단순히 구조적 차이에 불과하며, 구현 로직 자체는 프로젝트의 아키텍처와 무관하게 이해하고 활용할 수 있습니다. 구체적인 구현 방식이나 구조에 대해 더 깊이 이해하고자 하신다면 아래 링크를 참고하시길 권장합니다.
*헥사고날 아키텍처(Hexagonal Architecture)
애플리케이션의 핵심 비즈니스 로직을 외부 세계(사용자 인터페이스, 데이터베이스, 외부 API 등)로부터 완전히 분리하는 것을 목표로 하는 설계 방식.
애플리케이션을 내부의 도메인 계층과 이를 둘러싼 어댑터 계층으로 구분하며, 두 계층 간의 의존성은 포트(Port)와 어댑터(Adapter)를 통해 관리됨. 이를 통해 외부 시스템 변경이 내부 로직에 영향을 미치지 않도록 설계함으로써 테스트와 유지보수성을 크게 향상시킬 수 있음.
참고: Hexagonal Architecture with Java and Spring
*포트 앤 어댑터 패턴(Port And Adapter Pattern)
헥사고날 아키텍처의 근간을 이루는 설계 패턴으로, 내부 비즈니스 로직(도메인)과 외부 시스템(사용자 인터페이스, 데이터 베이스 등)을 느슨하게 결합하기 위해 사용됨.
참고: Ports & Adapters Architecture
*포트(Port)
도메인 로직과 외부 시스템 간의 인터페이스를 정의하며, 내부와 외부의 연결점을 추상화함. 이를 통해 도메인 계층은 외부의 구체적인 구현에 의존하지 않고, 필요한 기능을 포트를 통해 제공받음.
*어댑터(Adapter)
포트를 구현하여 실제 외부 시스템과의 연결을 처리하는 클래스. 외부 시스템의 구체적인 요구 사항을 포트가 정의한 인터페이스에 맞추어 조정함으로써, 도메인 로직과 외부 시스템 간의 원활한 통신을 가능하게 함.
구현 코드의 패키지 구조
본 글은 이벤트 발행-구독 패턴을 활용한 설계와 유연한 플랫폼 확장에 초점을 맞춰 작성되었습니다. 패키지 구조에 포함된 클래스 중 이벤트 생성부터 플랫폼에 알림을 전송하는 핵심 구현에 필요한 부분만 다루며, 알림 설정 On/Off와 같은 관리 기능은 제외합니다. 구체적인 구현이나 더 많은 코드를 확인하고자 하신다면 아래 저장소를 참고하시기 바랍니다.
notificationSetting
├── adapter
│ ├── in
│ │ └── web
│ │ ├── NotificationSettingRetrieveController.java
│ │ - 알림 설정 조회를 담당하는 웹 컨트롤러
│ │ └── NotificationSettingToggleController.java
│ │ - 알림 설정 토글(활성화/비활성화)을 담당하는 웹 컨트롤러
│ └── out
│ ├── persistence
│ │ ├── NotificationSettingPersistenceAdapter.java
│ │ - 알림 설정의 영속성을 관리하는 어댑터
│ │ └── NotificationSettingRepository.java
│ │ - 알림 설정 데이터 접근을 위한 리포지토리 인터페이스
│ └── webhook
│ ├── AbstractWebhookClient.java
│ │ - 웹훅 클라이언트의 기본 기능을 제공하는 추상 클래스
│ ├── DiscordNotificationSender.java
│ │ - Discord로 알림을 전송하는 구현체
│ ├── DiscordWebhookClient.java
│ │ - Discord 웹훅 클라이언트 구현체
│ ├── SlackNotificationSender.java
│ │ - Slack으로 알림을 전송하는 구현체
│ └── SlackWebhookClient.java
│ - Slack 웹훅 클라이언트 구현체
├── application
│ ├── dto
│ │ ├── mapper
│ │ │ └── NotificationSettingDtoMapper.java
│ │ │ - 도메인 모델과 DTO 간의 매핑을 담당
│ │ ├── notification
│ │ │ ├── BoardNotificationInfo.java
│ │ │ │ - 게시판 관련 알림 정보 DTO
│ │ │ ├── BookLoanRecordNotificationInfo.java
│ │ │ │ - 도서 대출 기록 관련 알림 정보 DTO
│ │ │ └── MembershipFeeNotificationInfo.java
│ │ │ - 회원비 관련 알림 정보 DTO
│ │ ├── request
│ │ │ └── NotificationSettingToggleRequestDto.java
│ │ │ - 알림 설정 토글 요청 시 사용하는 DTO
│ │ └── response
│ │ └── NotificationSettingResponseDto.java
│ │ - 알림 설정 응답 시 사용하는 DTO
│ ├── event
│ │ ├── ApplicationStartupListener.java
│ │ │ - 애플리케이션 시작 시 이벤트를 발행하는 리스너
│ │ ├── NotificationEvent.java
│ │ │ - 알림 관련 이벤트 클래스
│ │ └── NotificationListener.java
│ │ - 알림 이벤트를 처리하는 리스너
│ ├── exception
│ │ └── AlertTypeNotFoundException.java
│ │ - 특정 알림 유형을 찾을 수 없을 때 발생하는 예외 클래스
│ ├── port
│ │ ├── in
│ │ │ ├── ManageNotificationSettingUseCase.java
│ │ │ │ - 알림 설정 관리를 위한 유스케이스 인터페이스
│ │ │ └── RetrieveNotificationSettingUseCase.java
│ │ │ - 알림 설정 조회를 위한 유스케이스 인터페이스
│ │ └── out
│ │ ├── NotificationSender.java
│ │ │ - 알림 전송을 위한 포트 인터페이스
│ │ ├── RetrieveNotificationSettingPort.java
│ │ │ - 알림 설정 조회를 위한 포트 인터페이스
│ │ ├── UpdateNotificationSettingPort.java
│ │ │ - 알림 설정 업데이트를 위한 포트 인터페이스
│ │ └── WebhookClient.java
│ │ - 웹훅 클라이언트를 위한 포트 인터페이스
│ └── service
│ ├── ManageNotificationSettingService.java
│ │ - 알림 설정 관리 로직을 구현하는 서비스
│ ├── RetrieveNotificationSettingService.java
│ │ - 알림 설정 조회 로직을 구현하는 서비스
│ └── WebhookCommonService.java
│ - 공통 웹훅 관련 로직을 처리하는 서비스
├── config
│ ├── NotificationConfig.java
│ │ - 알림 기능과 관련된 설정을 구성하는 클래스
│ └── NotificationConfigProperties.java
│ - 외부 설정 파일에서 읽어오는 알림 관련 프로퍼티를 정의하는 클래스
└── domain
├── AlertCategory.java
│ - 알림의 카테고리를 정의하는 도메인 클래스
├── AlertType.java
│ - 알림 유형을 정의하는 도메인 클래스
├── AlertTypeConverter.java
│ - 알림 유형 변환을 담당하는 클래스
├── AlertTypeResolver.java
│ - 알림 유형을 결정하는 로직을 포함하는 클래스
├── ExecutivesAlertType.java
│ - 임원 관련 알림 유형을 정의하는 클래스
├── GeneralAlertType.java
│ - 일반 알림 유형을 정의하는 클래스
├── NotificationSetting.java
│ - 알림 설정을 나타내는 도메인 엔티티
├── PlatformType.java
│ - 플랫폼 유형을 정의하는 클래스
└── SecurityAlertType.java
- 보안 관련 알림 유형을 정의하는 클래스
3-2. 도메인 모델 및 알림 유형 정의
`domain` 패키지는 다음과 같은 클래스를 포함합니다.
notificationSetting
└── domain
├── AlertCategory.java
│ - 알림의 카테고리를 정의하는 도메인 클래스
├── AlertType.java
│ - 알림 유형을 정의하는 도메인 클래스
├── AlertTypeConverter.java
│ - 알림 유형 변환을 담당하는 클래스
├── AlertTypeResolver.java
│ - 알림 유형을 결정하는 로직을 포함하는 클래스
├── ExecutivesAlertType.java
│ - 임원 관련 알림 유형을 정의하는 클래스
├── GeneralAlertType.java
│ - 일반 알림 유형을 정의하는 클래스
├── NotificationSetting.java
│ - 알림 설정을 나타내는 도메인 엔티티
├── PlatformType.java
│ - 플랫폼 유형을 정의하는 클래스
└── SecurityAlertType.java
- 보안 관련 알림 유형을 정의하는 클래스
NotificationSetting
알림 설정을 관리하는 도메인 클래스입니다. 알림 유형(`AlertType`)과 활성화 여부(`enabled`)를 관리하며, 알림 설정 데이터를 데이터베이스에 저장합니다.
@Getter
@Setter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Entity
public class NotificationSetting {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Convert(converter = AlertTypeConverter.class)
private AlertType alertType;
private boolean enabled;
public static NotificationSetting createDefault(AlertType alertType) {
return NotificationSetting.builder()
.alertType(alertType)
.enabled(true)
.build();
}
public void updateEnabled(boolean enabled) {
this.enabled = enabled;
}
}
- `alertType`: 알림의 유형으로, 알림의 종류를 구분하는 데 사용됩니다.
- `enabled`: 알림의 활성화 여부를 나타냅니다.
- `createDefault()`: 기본적으로 활성화된 상태의 알림 설정 객체를 생성합니다.
- `updateEnabled()`: 알림의 활성화 상태를 업데이트합니다.
AlertTypeConverter
알림 유형 데이터를 애플리케이션과 데이터베이스 간에 변환하는 클래스입니다. 데이터베이스에는 `AlertType`의 제목만 저장하며, 애플리케이션 내에서 해당 제목을 기반으로 `AlertType` 객체를 생성합니다.
@Converter(autoApply = true)
public class AlertTypeConverter implements AttributeConverter<AlertType, String> {
private static final Map<String, AlertType> CACHE = new HashMap<>();
static {
for (GeneralAlertType type : GeneralAlertType.values()) {
CACHE.put(type.getTitle(), type);
}
for (SecurityAlertType type : SecurityAlertType.values()) {
CACHE.put(type.getTitle(), type);
}
for (ExecutivesAlertType type : ExecutivesAlertType.values()) {
CACHE.put(type.getTitle(), type);
}
}
@Override
public String convertToDatabaseColumn(AlertType alertType) {
if (alertType == null) {
return null;
}
return alertType.getTitle();
}
@Override
public AlertType convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isEmpty()) {
return null;
}
AlertType alertType = CACHE.get(dbData);
if (alertType == null) {
throw new AlertTypeNotFoundException(dbData);
}
return alertType;
}
}
public class AlertTypeNotFoundException extends RuntimeException {
public AlertTypeNotFoundException(String alertTypeName) {
super("Unknown alert type: " + alertTypeName);
}
}
- `CACHE`
- 프로젝트 내 모든 `AlertType` 구현체가 `CACHE`에 등록됩니다.
- 자주 호출되는 변환 작업의 성능을 향상시키기 위해 사용됩니다.
- `convertToDatabaseColumn(AlertType alertType)`: `AlertTypte` 객체를 데이터베이스에 저장할 문자열 값(`title`)으로 변환합니다.
- `convertToEntityAttribute(String dbData)`: 데이터베이스의 문자열 값을 기반으로 `AlertType` 객체를 생성합니다.
AlertType
알림 유형을 정의하는 인터페이스입니다.
public interface AlertType {
String getTitle();
String getDefaultMessage();
AlertCategory getCategory();
}
- 모든 알림 유형은 `AlertType` 인터페이스를 구현하며, 일관된 구조를 유지하도록 강제합니다.
- `getTitle()`: 알림의 제목을 반환합니다.
- `getDefaultMessage()`: 알림의 기본 메시지를 반환합니다.
- `getCategory`: 알림의 카테고리를 반환합니다.
AlertCategory
알림 유형의 카테고리를 정의하는 열거형입니다.
public enum AlertCategory {
GENERAL,
SECURITY,
EXECUTIVES
}
- 카테고리는 알림의 목적에 따라 구분됩니다.
- `GENERAL`: 일반 알림(관리자 로그인, 서버 시작 등)
- `SECURITY`: 보안 관련 알림(인가되지 않은 접근, 권한 상승 등)
- `EXECUTIVES`: 임원 관련 알림(새 지원서 도착, 회비 신청 등)
SecurityAlertType
보안 관련 알림 유형을 정의하는 열거형 클래스입니다.
@Getter
@AllArgsConstructor
public enum SecurityAlertType implements AlertType {
ABNORMAL_ACCESS("비정상적인 접근", "Unexpected access pattern detected.", AlertCategory.SECURITY),
REPEATED_LOGIN_FAILURES("지속된 로그인 실패", "Multiple consecutive failed login attempts.", AlertCategory.SECURITY),
...
MEMBER_ROLE_CHANGED("멤버 권한 변경", "Member role has been changed.", AlertCategory.SECURITY);
private final String title;
private final String defaultMessage;
private final AlertCategory category;
}
- `title`: 알림 제목
- `defaultMessage`: 기본 알림 메시지
- `category`: 알림 카테고리
PlatformType
웹훅 전송 플랫폼을 정의하는 열거형 클래스입니다.
@Getter
@RequiredArgsConstructor
public enum PlatformType {
SLACK("slack"),
DISCORD("discord");
private final String name;
}
- `name`: 설정 파일(`application.yml`)에서 플랫폼을 구분하는 값입니다.
3-3. 알림 유형별 플랫폼 매핑 정보 정의
`config` 패키지는 다음과 같은 클래스를 포함합니다.
notificationSetting
└── config
├── NotificationConfig.java
│ - 알림 기능과 관련된 설정을 구성하는 클래스
└── NotificationConfigProperties.java
- 외부 설정 파일에서 읽어오는 알림 관련 프로퍼티를 정의하는 클래스
application.yml
다양한 플랫폼과 알림 카테고리의 매핑 정보를 설정합니다.
notification:
common:
web-url: "${WEB_URL}" # 기본 웹 URL
api-url: "${API_URL}" # API 문서 URL
color: "#FF968A" # 알림 메시지에서 사용되는 색상
platforms:
slack:
webhooks:
core-team: "${SLACK_CORE_TEAM_WEBHOOK_URL}" # 코어팀에 대한 Slack 웹훅 URL
executives: "${SLACK_EXECUTIVES_WEBHOOK_URL}" # 임원진에 대한 Slack 웹훅 URL
discord:
webhooks:
release: "${DISCORD_RELEASE_WEBHOOK_URL}" # 릴리즈 관련 Discord 웹훅 URL
notifications: "${DISCORD_NOTIFICATIONS_WEBHOOK_URL}" # 일반 알림 Discord 웹훅 URL
executives: "${DISCORD_EXECUTIVES_WEBHOOK_URL}" # 임원진 Discord 웹훅 URL
category-mappings:
GENERAL:
- platform: slack
webhook: core-team
- platform: discord
webhook: notifications
SECURITY:
- platform: slack
webhook: core-team
- platform: discord
webhook: notifications
EXECUTIVES:
- platform: slack
webhook: executives
- platform: discord
webhook: executives
default-mappings:
- platform: slack
webhook: core-team
- platform: discord
webhook: notifications
- `common`
- `web-url`, `api-url`: 알림 메시지의 버튼에 사용되는 URL입니다.
- `color`: 알림 메시지에서 사용되는 블록 색상입니다.
- `platforms`
- 알림을 전송할 플랫폼(슬랙, 디스코드)의 이름과 각 플랫폼의 웹훅 URL을 정의합니다.
- `webhooks`: 각 플랫폼의 채널별 웹훅 URL을 매핑합니다.
- `category-mappings`
- 알림 카테고리(`GENERAL`, `SECURITY`, `EXECUTIVES`)별 메시지를 전송할 플랫폼과 웹훅 URL을 매핑합니다.
- 하나의 카테고리에 대해 다수의 플랫폼과 웹훅을 지정할 수 있습니다.
- `default-mappings`
- 매핑되지 않은 알림 카테고리에 대해 메시지를 전송할 기본 플랫폼과 웹훅을 설정합니다.
`common` 프로퍼티를 활용한 슬랙 알림 메시지 예시
- Web 버튼: `web-url`에 등록된 URL로 이동합니다.
- API Docs 버튼: `api-url`에 등록된 URL로 이동합니다.
- 메시지 색상: `color`에 지정된 색상(노란색)으로 메시지 블록을 나타냅니다.
NotificationConfigProperties
`application.yml`의 설정 정보를 애플리케이션에서 사용할 수 있도록 객체로 매핑합니다.
@Configuration
@ConfigurationProperties(prefix = "notification")
@Getter
@Setter
public class NotificationConfigProperties {
private CommonProperties common;
private Map<String, PlatformConfig> platforms;
private Map<String, List<PlatformMapping>> categoryMappings;
private List<PlatformMapping> defaultMappings;
@Getter
@Setter
public static class CommonProperties {
private String webUrl;
private String apiUrl;
private String color;
public int getColorAsInt() {
return Integer.parseInt(color.replaceFirst("^#", ""), 16);
}
}
@Getter
@Setter
public static class PlatformConfig {
private Map<String, String> webhooks;
}
@Getter
@Setter
public static class PlatformMapping {
private String platform;
private String webhook;
}
}
- `common`
- `webUrl`, `apiUrl`, `color`: 알림 메시지의 공통 설정 정보입니다.
- `getColorAsInt()`: 색상을 16진수 문자열에서 정수로 변환합니다.
- `platforms`: 각 플랫폼 이름과 웹훅 URL 정보를 저장합니다.
- `categoryMappings`: 알림 카테고리별 메시지를 전송할 플랫폼과 웹훅 URL을 매핑합니다.
- `defaultMappings`: 매핑되지 않은 알림 카테고리에 대해 사용할 기본 설정을 정의합니다.
NotificationConfig
설정 파일(`application.yml`)의 정보를 빈으로 등록하여 애플리케이션 전역에서 사용할 수 있도록 합니다.
@Configuration
public class NotificationConfig {
@Bean
public NotificationConfigProperties notificationConfigProperties() {
return new NotificationConfigProperties();
}
}
- `@Configuration`: 스프링 컨텍스트에 설정 클래스를 등록하여 애플리케이션 전반에서 사용할 수 있도록 합니다.
- `notificationConfigProperties()`: `application.yml`에 정의된 설정 값을 객체로 매핑한 `NotificationConfigProperties` 빈을 생성합니다.
3-4. 알림 이벤트 모델 및 리스너 구현
`application.event` 패키지는 다음과 같은 클래스를 포함합니다.
notificationSetting
└── application
└── event
├── ApplicationStartupListener.java
│ - 애플리케이션 시작 시 이벤트를 발행하는 리스너
├── NotificationEvent.java
│ - 알림 관련 이벤트 클래스
└── NotificationListener.java
- 알림 이벤트를 처리하는 리스너
NotificationEvent
알림 이벤트를 정의하는 모델 클래스입니다. 알림과 관련된 데이터를 캡슐화하여 이벤트 발행자와 리스너 간 데이터를 전달합니다.
@Getter
public class NotificationEvent extends ApplicationEvent {
private final AlertType alertType;
private final HttpServletRequest request;
private final Object additionalData;
public NotificationEvent(Object source, AlertType alertType, HttpServletRequest request, Object additionalData) {
super(source);
this.alertType = alertType;
this.request = request;
this.additionalData = additionalData;
}
}
- `ApplicationEvent`: 스프링의 이벤트 시스템에서 기본적으로 제공되는 클래스입니다. 이벤트 발행 및 처리에 필요한 기본 기능을 제공합니다.
- `alertType`: 알림의 유형으로, 알림의 종류를 구분하는 데 사용됩니다.
- `request`: 이벤트를 발생시킨 HTTP 요청 정보입니다.
- `additionalData`: 알림과 함께 전달되는 추가 데이터입니다.
NotificationListener
`NotificationEvent`를 구독하여 알림 로직을 처리하는 리스너 클래스입니다.
@Component
@Slf4j
public class NotificationListener {
private final ManageNotificationSettingUseCase manageNotificationSettingUseCase;
private final Map<String, NotificationSender> notificationSenders;
private final NotificationConfigProperties notificationConfigProperties;
public NotificationListener(
ManageNotificationSettingUseCase manageNotificationSettingUseCase,
List<NotificationSender> notificationSenderList,
NotificationConfigProperties notificationConfigProperties) {
this.manageNotificationSettingUseCase = manageNotificationSettingUseCase;
this.notificationConfigProperties = notificationConfigProperties;
this.notificationSenders = notificationSenderList.stream()
.collect(Collectors.toMap(NotificationSender::getPlatformName, Function.identity()));
}
@EventListener
public void handleNotificationEvent(NotificationEvent event) {
AlertType alertType = event.getAlertType();
NotificationSetting setting = manageNotificationSettingUseCase.getOrCreateDefaultSetting(alertType);
if (!setting.isEnabled()) {
return;
}
List<PlatformMapping> mappings = getMappingsForAlertType(alertType);
if (mappings.isEmpty()) {
return;
}
mappings.forEach(mapping -> getWebhookUrl(mapping)
.ifPresent(webhookUrl -> sendNotification(mapping.getPlatform(), event, webhookUrl)));
}
private List<PlatformMapping> getMappingsForAlertType(AlertType alertType) {
String categoryName = alertType.getCategory().name();
Map<String, List<PlatformMapping>> categoryMappings = notificationConfigProperties.getCategoryMappings();
return Optional.ofNullable(categoryMappings.get(categoryName))
.filter(list -> !list.isEmpty())
.orElseGet(notificationConfigProperties::getDefaultMappings);
}
private Optional<String> getWebhookUrl(PlatformMapping mapping) {
String platform = mapping.getPlatform();
String webhookKey = mapping.getWebhook();
Map<String, PlatformConfig> platforms = notificationConfigProperties.getPlatforms();
return Optional.ofNullable(platforms.get(platform))
.map(platformConfig -> platformConfig.getWebhooks().get(webhookKey))
.map(url -> {
log.debug("Found webhook URL for platform '{}', key '{}': {}", platform, webhookKey, url);
return url;
})
.or(() -> {
log.warn("No webhook URL found for platform '{}', key '{}'", platform, webhookKey);
return Optional.empty();
});
}
private void sendNotification(String platform, NotificationEvent event, String webhookUrl) {
NotificationSender sender = notificationSenders.get(platform);
if (sender == null) {
log.warn("No NotificationSender found for platform: {}", platform);
return;
}
try {
sender.sendNotification(event, webhookUrl);
log.debug("Notification sent via platform: {}", platform);
} catch (Exception e) {
log.error("Failed to send notification via platform: {}", platform, e);
}
}
}
- `manageNotificationSettingUseCase`: 알림 설정 데이터를 조회하고 관리하는 유스케이스입니다.
- `notificationSenders`: 플랫폼별로 구현된 `NotificationSender` 객체를 매핑하여 관리합니다.
- `notificationConfigProperties`: `application.yml`에서 알림 설정 정보를 불러오는 클래스입니다.
- `handleNotificationEvent(NotificationEvent event)`
- 알림 이벤트를 처리합니다.
- 발생한 이벤트의 알림 유형이 활성화되어 있는지 확인합니다.
- 알림 유형에 따라 등록된 플랫폼과 웹훅 URL 매핑 정보를 가져옵니다.
- 매핑된 플랫폼의 웹훅 URL에 알림을 전송합니다.
- `getMappingsForAlertType(AlertType alertType)`
- 알림 유형(`AlertType`)의 카테고리에 해당하는 매핑 정보를 반환합니다.
- 매핑 정보가 없는 경우, 기본 매핑(`defaultMappings`)을 반환합니다.
- `getWebhookUrl(PlatformMapping mapping)`
- 플랫폼과 매핑 키(웹훅 이름)를 기반으로 웹훅 URL을 조회합니다.
- URL이 존재하지 않는 경우, 로그에 경고 메시지를 기록합니다.
- `sendNotification(String platform, NotificationEvent event, String webhookUrl)`
- 플랫폼별로 구현된 `NotificationSender`를 통해 알림을 전송합니다.
- 알림 전송 중 예외가 발생한 경우 로그로 기록합니다.
NotificationListener의 동작 흐름
- 이벤트 수신: `@EventListener`를 통해 `NotifiacationEvent`를 구독합니다.
- 알림 설정 확인: 발생한 이벤트의 알림 유형이 비활성화된 경우 작업을 중단합니다.
- 매핑 정보 조회: 알림 유형의 카테고리에 해당하는 매핑 정보를 가져옵니다.
- 웹훅 URL 확인: 매핑 정보에서 플랫폼별 웹훅 URL을 조회합니다.
- 알림 전송: 조회된 웹훅 URL을 사용하여 해당 플랫폼으로 알림을 전송합니다.
3-5. 알림 발신자 구현
`adapter`, `application` 패키지는 다음과 같은 클래스를 포함합니다.
notificationSetting
├── adapter
│ └── out
│ └── webhook
│ ├── AbstractWebhookClient.java
│ │ - 웹훅 클라이언트의 기본 기능을 제공하는 추상 클래스
│ ├── DiscordNotificationSender.java
│ │ - Discord로 알림을 전송하는 구현체
│ ├── DiscordWebhookClient.java
│ │ - Discord 웹훅 클라이언트 구현체
│ ├── SlackNotificationSender.java
│ │ - Slack으로 알림을 전송하는 구현체
│ └── SlackWebhookClient.java
│ - Slack 웹훅 클라이언트 구현체
└── application
└── port
└── out
├── NotificationSender.java
│ - 알림 전송을 위한 포트 인터페이스
├── RetrieveNotificationSettingPort.java
│ - 알림 설정 조회를 위한 포트 인터페이스
├── UpdateNotificationSettingPort.java
│ - 알림 설정 업데이트를 위한 포트 인터페이스
└── WebhookClient.java
- 웹훅 클라이언트를 위한 포트 인터페이스
NotificationSender
플랫폼별 알림 전송을 위한 공통 인터페이스입니다.
public interface NotificationSender {
String getPlatformName();
void sendNotification(NotificationEvent event, String webhookUrl);
}
- `getPlatformName()`: 알림을 전송할 플랫폼의 이름을 반환합니다.
- `sendNotification()`: 이벤트와 웹훅 URL을 기반으로 알림을 전송합니다.
SlackNotificationSender
슬랙으로 알림을 전송하는 `NotificationSender`의 구현체입니다.
@Component
@RequiredArgsConstructor
public class SlackNotificationSender implements NotificationSender {
private final SlackWebhookClient slackWebhookClient;
@Override
public String getPlatformName() {
return PlatformType.SLACK.getName();
}
@Override
public void sendNotification(NotificationEvent event, String webhookUrl) {
slackWebhookClient.sendMessage(webhookUrl, event.getAlertType(), event.getRequest(),
event.getAdditionalData());
}
}
- `getPlatformName()`: 슬랙 플랫폼을 나타내는 이름(`slack`)을 반환합니다.
- `sendNotification()`: `SlackWebhookClient`를 통해 알림 메시지를 전송합니다.
WebhookClient
웹훅 메시지를 전송하기 위한 인터페이스입니다.
public interface WebhookClient {
CompletableFuture<Boolean> sendMessage(String webhookUrl, AlertType alertType, HttpServletRequest request,
Object additionalData);
}
- `sendMessage()`: 비동기로 메시지를 전송하고 성공 여부를 반환합니다.
AbstractWebhookClient
`WebhookClient` 인터페이스의 추상 클래스입니다. 플랫폼별 클라이언트에서 공통적인 동작을 정의하고나 재정의할 수 있도록 설계되었습니다.
public abstract class AbstractWebhookClient implements WebhookClient {
@Override
public abstract CompletableFuture<Boolean> sendMessage(String webhookUrl, AlertType alertType,
HttpServletRequest request,
Object additionalData);
}
SlackWebhookClient
슬랙 플랫폼의 웹훅 메시지를 전송하는 클라이언트입니다.
@Component
@Slf4j
public class SlackWebhookClient extends AbstractWebhookClient {
private final Slack slack;
private final NotificationConfigProperties.CommonProperties commonProperties;
private final Environment environment;
private final WebhookCommonService webhookCommonService;
public SlackWebhookClient(
NotificationConfigProperties notificationConfigProperties,
Environment environment,
WebhookCommonService webhookCommonService
) {
this.slack = Slack.getInstance();
this.commonProperties = notificationConfigProperties.getCommon();
this.environment = environment;
this.webhookCommonService = webhookCommonService;
}
public CompletableFuture<Boolean> sendMessage(String webhookUrl, AlertType alertType,
HttpServletRequest request, Object additionalData) {
List<LayoutBlock> blocks = createBlocks(alertType, request, additionalData);
return CompletableFuture.supplyAsync(() -> {
Payload payload = Payload.builder()
.blocks(Collections.singletonList(blocks.getFirst()))
.attachments(Collections.singletonList(
Attachment.builder()
.color(commonProperties.getColor())
.blocks(blocks.subList(1, blocks.size()))
.build()
))
.build();
try {
WebhookResponse response = slack.send(webhookUrl, payload);
if (response.getCode() == HttpStatus.OK.value()) {
return true;
} else {
log.error("Slack notification failed: {}", response.getMessage());
return false;
}
} catch (IOException e) {
log.error("Failed to send Slack message: {}", e.getMessage(), e);
return false;
}
});
}
public List<LayoutBlock> createBlocks(AlertType alertType, HttpServletRequest request, Object additionalData) {
switch (alertType) {
...
case GeneralAlertType generalAlertType -> {
return createGeneralAlertBlocks(generalAlertType, request, additionalData);
}
case null, default -> {
log.error("Unknown alert type: {}", alertType);
return Collections.emptyList();
}
}
}
private List<LayoutBlock> createGeneralAlertBlocks(GeneralAlertType alertType, HttpServletRequest request,
Object additionalData) {
switch (alertType) {
...
case SERVER_START:
return createServerStartBlocks();
default:
log.error("Unknown general alert type: {}", alertType);
}
return Collections.emptyList();
}
private List<LayoutBlock> createServerStartBlocks() {
String osInfo = webhookCommonService.getOperatingSystemInfo();
String jdkVersion = webhookCommonService.getJavaRuntimeVersion();
double cpuUsage = webhookCommonService.getCpuUsage();
String memoryUsage = webhookCommonService.getMemoryUsage();
return Arrays.asList(
section(s -> s.text(markdownText(":battery: *Server Started*"))),
section(s -> s.fields(Arrays.asList(
markdownText("*Environment:* \n" + environment.getProperty("spring.profiles.active")),
markdownText("*OS:* \n" + osInfo),
markdownText("*JDK Version:* \n" + jdkVersion),
markdownText("*CPU Usage:* \n" + String.format("%.2f%%", cpuUsage)),
markdownText("*Memory Usage:* \n" + memoryUsage)
))),
actions(a -> a.elements(asElements(
button(b -> b.text(plainText(pt -> pt.emoji(true).text("Web")))
.url(commonProperties.getWebUrl())
.value("click_web")),
button(b -> b.text(plainText(pt -> pt.emoji(true).text("API Docs")))
.url(commonProperties.getApiUrl())
.value("click_apiDocs"))
)))
);
}
- `sendMessage()`
- 슬랙 메시지를 생성하여 지정된 웹훅 URL로 전송합니다.
- `ComletableFuture`를 사용해 메시지를 비동기로 전송합니다.
- `createBlocks()`
- 알림 유형에 따라 메시지의 레이아웃 블록을 생성합니다.
- Switch 문을 통해 다양한 알림 유형(`AlertType`)을 처리합니다.
- `createServerStartBlocks()`
- 서버 시작 시 전송되는 메시지의 블록을 생성합니다.
3-6. 알림 테스트
스프링 애플리케이션 시작 이벤트를 감지하여 알림 이벤트를 발생시키고, 웹훅 메시지가 정상적으로 전송되는지 확인합니다. 본 글에서 구현 코드를 다루지 않은 디스코드 발신자 또한 서버 시작 알림 발신자로 함께 등록하여, 동일 알림 유형에 여러 플랫폼이 등록된 경우에도 정상적으로 작동하는지 확인합니다.
GeneralAlertType
서버 시작 알림을 나타내는 일반 알림 유형입니다.
@Getter
@AllArgsConstructor
public enum GeneralAlertType implements AlertType {
SERVER_START("서버 시작", "Server has been started.", AlertCategory.GENERAL);
private final String title;
private final String defaultMessage;
private final AlertCategory category;
}
application.yml
알림 설정 파일에서 `GENERAL` 카테고리의 알림 유형에 대해 슬랙과 디스코드 플랫폼을 매핑합니다.
notification:
common:
web-url: "${WEB_URL}"
api-url: "${API_URL}"
color: "#FF968A"
platforms:
slack:
webhooks:
core-team: "${SLACK_CORE_TEAM_WEBHOOK_URL}"
discord:
webhooks:
notifications: "${DISCORD_NOTIFICATIONS_WEBHOOK_URL}"
category-mappings:
GENERAL:
- platform: slack
webhook: core-team
- platform: discord
webhook: notifications
...
ApplicationStartupListener
애플리케이션 시작 이벤트(`ContextRefreshedEvent`)를 감지하여 알림 이벤트를 발행합니다.
@Component
public class ApplicationStartupListener {
private final ApplicationEventPublisher eventPublisher;
public ApplicationStartupListener(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
@EventListener(ContextRefreshedEvent.class)
public void onApplicationEvent(ContextRefreshedEvent event) {
eventPublisher.publishEvent(
new NotificationEvent(this, GeneralAlertType.SERVER_START, null, null));
}
}
- `onApplicationEvent(ContextRefreshedEvent event)`: 스프링 애플리케이션이 초기화되면 `GeneralAlertType.SERVER_START` 알림 이벤트를 발행합니다.
테스트 결과
1) 슬랙
2) 디스코드
동일 알림 유형에 여러 플랫폼이 매핑된 경우, 각 플랫폼에 알림이 정상적으로 전송되었음을 확인할 수 있습니다.
4. 마치며
슬랙에서 디스코드로 웹훅 플랫폼을 이전하면서 기존의 슬랙 관련 로직을 제거하고 디스코드 로직을 새로 작성해도 문제는 없었습니다. 그러나 과거에도 플랫폼을 변경한 경험이 있었기에 앞으로도 웹훅 메시지를 전달받을 플랫폼이 변경될 가능성을 배제할 수 없었습니다. 이를 방지하기 위해 단순히 마이그레이션에 그치지 않고 최대한 유연하고 확장성 있는 설계를 도입하기로 했습니다. 그 결과, 이번 재설계를 통해 기존 로직을 수정하지 않고도 플랫폼을 확장하거나 알림 설정을 변경할 수 있는 시스템을 구현할 수 있었습니다.
재설계 과정에서는 필요한 기능 목록을 작성하고, 이에 적합한 디자인 패턴을 탐색하여 적용했습니다. 이 과정은 이전에 경험하지 못했던 설계 작업을 직접 해볼 수 있는 값진 기회였으며, 이를 통해 큰 성장을 이룰 수 있었습니다. 특히 디자인 패턴의 중요성을 다시 한번 깊이 깨닫게 되었고, 앞으로도 꾸준히 공부하며 역량을 키워야겠다는 다짐을 하게 되었습니다. 이번 시스템 설계 경험이 비슷한 고민을 하고 계신 분들께 조금이나마 도움이 되길 바라며, 긴 글을 읽어주신 모든 분께 감사드립니다. 감사합니다.
'Spring' 카테고리의 다른 글
CDS와 AOT, Layered JAR로 Spring Boot 시작 시간, 메모리 최적화하기 (3) | 2024.12.23 |
---|---|
N-gram과 유사도 측정으로 검색 정확도 높이기 (7) | 2024.11.21 |