지능형 장애 알림 TDD

Background

근거 PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/스포츠앱/지능형 장애 알림/PRD.md (검수 PASS).

선행 컨텍스트:

  • ../도메인 경계 재설계/TDD.md (①) — 컨텍스트 맵. notification·operator·weather를 “지원 도메인”으로 분류. 도메인 레이어 교차 참조 금지(ArchUnit 베이스라인 0건), 도메인 간 참조는 ID(Long)·이벤트만.
  • ../옵저버빌리티 스택 도입/TDD.md (⑤) — Prometheus(:9090 PromQL)·Loki(:3100)·Tempo(:3200) 데이터소스, Grafana(:3000), env 태그(management.metrics.tags.env=${APP_ENV:local}). 본 과제의 알림·원지표 조회 데이터소스이자 규칙 엔진(Grafana) 호스트.
  • ../배포 파이프라인·환경 분리/TDD.md (⑧) — APP_ENV dev/prod 리터럴 주입, 배포 실패를 source=deployment로 본 파이프라인에 흘려보냄(⑧ TDD §Observability·실패경로 명시).

sports-application backend는 NotificationChannel(IN_APP/PUSH/EMAIL/SMS)·NotificationChannelGateway(supportedChannel 기반 발송)·NotificationDomainService(AFTER_COMMIT dispatch)로 구성된 채널 확장형 알림 발송 구조를 이미 갖는다. Discord 연동·규칙 엔진 webhook 수신·텔레메트리 원지표 첨부·쿨다운은 전무하다. domain/mcpMcpAnomalyDetector는 MCP 토큰 오남용 탐지 용도로 목적이 달라 재사용하지 않는다(PRD 명시).

Overview

항목내용
무엇규칙 엔진(Grafana Alerting) webhook → 알림 수신 → 텔레메트리 원지표 조회(Prometheus/Loki/Tempo) → 원지표 스냅샷 첨부 → 기존 notification DISCORD 채널로 발송. source × severity 태그 모델, 신호 단위 쿨다운(Redis)
임계 위반 사실만으로는 초동 대응이 늦다. 텔레메트리 원지표(메트릭 요약·에러 로그·느린 trace)를 알림에 그대로 실어 초동 조사 시간을 줄인다. ③오버셀·⑧배포실패도 같은 파이프라인에 source 태그로 합류
어떻게알림 라이프사이클(신호·심각도·소스·쿨다운·원지표 첨부·이력)은 신규 alerting 지원 도메인이 소유. 발송은 PRD 지시대로 notification DISCORD 채널 + DiscordNotificationGatewayImpl을 재사용(독립 발송 파이프라인 미신설). 규칙 엔진은 Grafana Alerting(⑤ Grafana 재사용, Alertmanager 컨테이너 미추가)
도메인 영향신규 도메인 alerting 1개, notification 도메인에 DISCORD 채널 편입(엔진 변경 없음), 신규 테이블 alerts 1개, Redis 쿨다운 키 네임스페이스 1개

Terminology

용어정의
알람 신호(Alert Signal)쿨다운·dedup 판단 단위. API 엔드포인트 + source + severity 조합 (FR-7). 텔레메트리로 확인되는 “원인”이 아님
source알림 발생 축. latency(본 과제)·oversell(③)·deployment(⑧)·self_check
severity심각도 등급. info/warn/critical. source와 독립 태깅(FR-3)
쿨다운(cooldown)동일 신호 재알림 억제 창. 15분(FR-7). Redis SET NX PX로 상태 저장
lookback원지표 조회 구간. 10분(FR-4). 쿨다운(15분)과 별개
TelemetrySnapshot(원지표)텔레메트리 조회 산출물 — 메트릭 요약·에러 로그 샘플·느린 trace 샘플(타입화 data class). 알림 본문에 그대로 첨부
Grafana AlertingGrafana 내장 규칙 엔진. 다중 데이터소스(Prometheus/Loki) 쿼리 평가 → contact point(webhook) 발송
Discord EmbedDiscord webhook의 구조화 메시지(제목·색상·필드) 포맷

Define Problem

AS-IS (실제 코드 근거)

  • NotificationChannel.kt:2 — enum {IN_APP, PUSH, EMAIL, SMS}. DISCORD 없음 → 알림 수신 채널 부재.
  • NotificationChannelGateway.kt:4val supportedChannel: NotificationChannel + fun send(notification): SendResult. 채널별 @Component 구현이 supportedChannel로 자기 채널을 선언(SmsChannelGateway.kt:27).
  • NotificationDomainService.kt#dispatchByIdchannelGateways.find { it.supportedChannel == notification.channel }로 게이트웨이를 찾아 발송, markSent/markFailed. DISCORD 게이트웨이만 추가하면 이 라우팅에 자동 편입됨.
  • NotificationDomainService.kt#send — QUEUED Notification 저장 + NotificationDispatchRequestedEvent 발행. enqueueOrSkipSUPPORTED_ENQUEUE_CHANNELS(:22)는 IN_APP/PUSH/EMAIL/SMS만 검사하나 send()는 채널 검사 없음 → DISCORD는 send() 경로로 발송 가능.
  • NotificationDispatchEventWorker.kt:22@Async @TransactionalEventListener(AFTER_COMMIT)DispatchNotificationUseCase. 발송은 트랜잭션 커밋 후 비동기.
  • Notification.kt:24userId: Long(nullable=false)·channel·templateId·payload(@Type(JsonStringType)status·eventId. 사용자향 모델 — 인프라 알림(무사용자)과 라이프사이클이 다름.
  • ExternalRestClientFactory.kt:16create(baseUrl), read timeout 5초 고정. 텔레메트리(Prometheus/Loki/Tempo) 조회는 저지연이라 공유 팩토리로 충분.
  • DomainEventPublisher.ktpublish/publishAll (common). 내부 이벤트는 SpringDomainEventPublisher(ApplicationEvent) 라우팅.
  • Redis: spring-boot-starter-data-redis 존재(build.gradle.kts:76). RedisDistributedLock·SeatLockStoreImplRedisTemplate 패턴 정착.
  • 규칙 엔진·Alertmanager·Grafana Alerting 규칙 전무.
  • SecurityConfig.kt:59-79 — 경로별 인가. webhook 수신 엔드포인트는 신규 permit + 공유 시크릿 검증 필요.
  • notifications.channel(V7:5) = VARCHAR(20) NOT NULL, CHECK 제약 없음 → DISCORD 저장에 DDL 변경 불필요.

TO-BE

  • alerting 도메인이 알람 신호·source/severity·쿨다운·텔레메트리 원지표 첨부·이력(alerts 테이블)을 소유.
  • Grafana Alerting 규칙이 P95 위반 시 webhook(POST /internal/alerts/grafana) 발신 → alerting이 수신·쿨다운 판정 후 비동기로 텔레메트리 원지표 조회·첨부 → notification DISCORD 채널로 발송.
  • ③오버셀·⑧배포실패는 내부 raise 엔드포인트(POST /internal/alerts) 또는 도메인 이벤트로 같은 파이프라인에 source 태그를 달고 합류.
  • 모든 알림에 source·severity·env 태그(누락 0). 텔레메트리 조회 실패 시 신호 정보만 담아 발송. 1시간 self-check heartbeat.

Architecture Benchmarking (의무)

제품/사례해결 방식참고할 패턴미참고 사유
Datadog Bits AI SRE (blog, product)알림 발생 시 에이전트가 metrics·logs·traces·메타데이터를 상관분석해 가설→검증 루프로 원인을 수분 내 도출, 대응 채널로 요약 통지① “알림 트리거 → 텔레메트리(메·로·트) 조회 → 원지표 첨부 통지”의 핵심 흐름을 채택. ② 3종 소스(Prometheus/Loki/Tempo)를 한 컨텍스트로 묶어 알림에 첨부LLM 상관분석·다단계 자율 가설-검증 루프는 비용·복잡도 과다로 미채택 → 본 과제는 10분 lookback 스냅샷 1회 조회 후 원지표를 그대로 알림 본문에 첨부하는 단순 파이프라인 채택(단순함 우선, NFR 60초)
PagerDuty AIOps (blog)이상 신호를 상관분석해 노이즈 억제, LLM 요약으로 온콜 초동 시간 단축알림 **억제(dedup)**를 신호 단위로 판단해 노이즈를 줄이는 패턴 → FR-7 신호 단위 쿨다운의 근거인시던트 그룹핑·에스컬레이션 정책 엔진은 개인 단일 운영자 규모에 과함 → 미참조
Grafana Alerting vs Alertmanager (alexandre-vazquez, grafana docs)Grafana 내장 Alerting은 대시보드와 동일 컴포넌트로 다중 데이터소스(Prometheus/Loki 등) 쿼리를 평가해 contact point(webhook)로 발송. Alertmanager는 PromQL 전용 단일 바이너리Loki 로그 기반 규칙까지 한 엔진에서, ⑤ Grafana 재사용으로 컨테이너 0 추가 → Grafana Alerting 채택(ADR-001)“대시보드와 알림이 같은 컴포넌트라 HA 결합”은 단일 호스트 개인 프로젝트에 무관 → 그 단점은 미고려

Possible Solutions

방안 비교 — 규칙 엔진 (ADR-001, PRD Open Q1 해소)

방안설명왜 채택 / 미채택
A. Grafana Alerting → webhook contact point⑤가 이미 띄운 Grafana에 alert rule(PromQL P95 + Loki 규칙 확장 가능) + webhook contact point 정의. 규칙 위반 시 POST /internal/alerts/grafana채택 — ① ⑤ Grafana 재사용 → 신규 컨테이너 0(단순함·8GB 제약). ② Prometheus뿐 아니라 Loki 로그 기반 규칙도 한 엔진에서. ③ env 라벨·대시보드와 통합 UI. ④ contact point webhook은 표준 기능
B. Prometheus Alertmanager → webhook receiverPrometheus alerting rule + 별도 Alertmanager 컨테이너 + routing config, webhook receiver미채택 — Alertmanager 컨테이너·config·라우팅을 추가 운영해야 하고 PromQL 전용(Loki 규칙 불가). 단일 호스트 개인 프로젝트에서 marginal benefit 대비 SPOF·운영 부담 증가. 상세: ADR-001
C. 앱 인프로세스 규칙 평가(스케줄러가 PromQL 직접 조회)@Scheduled가 Prometheus를 폴링해 임계 위반 자체 판정미채택 — 규칙 평가·억제·resolved 처리를 직접 구현 = 바퀴 재발명. Grafana Alerting이 이미 제공. 지금 규모에 과함

방안 비교 — 원인 조사 (ADR-002 참조, 폐기됨 — LLM 제거)

당초 ADR-002는 LLM(Claude API HTTP Gateway)으로 원인·해결책을 추정하는 방안을 채택했으나, LLM 원인분석을 제거하고 텔레메트리 원지표(Prometheus/Loki/Tempo 조회 결과)를 알림 본문에 그대로 첨부하는 방향으로 대체(2026-07-06)했다. TelemetryQueryGateway가 10분 lookback 스냅샷을 조회하고, Alert.attachTelemetry(snapshot)가 이를 본문에 실어 발송한다. LLM 상관분석·해결책 자동 생성은 비용·복잡도 이유로 미채택(PRD Non-Goals). 폐기 이력은 ADR-002 본문에 보존.

방안 비교 — 쿨다운/dedup 상태 저장 (ADR-003)

방안설명왜 채택 / 미채택
A. Redis SET NX PX(신호키, 15분 TTL)alerting:cooldown:{env}:{endpoint}:{source}:{severity} 키를 SET NX PX 900000. 성공=쿨다운 진입(발송 진행), 실패=쿨다운 중(억제)채택 — 원자적 dedup·TTL 자동만료·멀티인스턴스(⑦) 안전. Redis 기설치. 신호 단위 판정이 텔레메트리 조회 前(비용·레이턴시 절감, FR-7 의도)
B. DB 유니크 제약 + 시각 비교alerts(signal_key, window) 유니크, 조회로 판정미채택 — TTL 자동만료 없음(정리 배치 필요), 경합 시 DB 부하. 억제 판정에 DB 왕복
C. 인메모리 캐시(Caffeine)앱 로컬 캐시미채택 — ⑦ 다중 인스턴스에서 인스턴스별 상태 분리 → 신호가 인스턴스마다 중복 발송

방안 비교 — 알림 라이프사이클 소유 (ADR-004, 바운디드 컨텍스트)

방안설명왜 채택 / 미채택
A. 신규 alerting 지원 도메인 + notification DISCORD 채널 재사용(발송만)신호·심각도·소스·쿨다운·텔레메트리 원지표 첨부·이력은 alerting이 소유. 발송은 notification DISCORD 채널 + DiscordNotificationGatewayImpl 재사용. 교차는 이벤트채택 — 상세 §바운디드 컨텍스트 판단
B. notification 도메인에 전부 편입(신규 도메인 없음)Notification 엔티티에 source/severity/signal 필드 추가, 쿨다운·원지표 첨부·이력 로직을 notification에미채택 — 사용자향 Notification과 인프라 알림의 라이프사이클·데이터 소유가 뒤섞임. Notification에 알림 전용 nullable 필드 다수 추가 → Anemic 오염. ①의 “독립 데이터 소유·다른 변경 주기” 기준 위반

Detail Design

도메인 바운디드 컨텍스트 판단 (의무)

  • 결정: 신규 alerting 지원 도메인 분리 + notification DISCORD 채널 재사용(발송 계층만).
  • 근거 (분리 3기준 충족):
    • 독립 라이프사이클 — 알람 신호는 규칙 엔진 webhook·인프라 이벤트로 생성되고 쿨다운·원지표 첨부·발송·이력의 상태 전이를 가진다. 사용자 알림(Notification)의 QUEUED→SENT/READ와 전혀 다른 생애주기.
    • 독립 데이터 소유alerts 테이블(이력, FR-9), Redis 쿨다운 키를 alerting이 소유. 사용자 알림 데이터와 무관.
    • 다른 변경 주기 — 규칙 엔진·텔레메트리 조회 쿼리·심각도 정책은 운영/관측 요구로 바뀌고, 사용자 알림 템플릿·채널은 제품 요구로 바뀐다.
  • 미채택(방안 B: notification 편입) 사유Notification 엔티티에 인프라 알림 전용 필드(signal/source/severity/telemetry)를 추가하면 사용자 알림 행에는 전부 null인 Anemic 오염이 발생하고, 두 라이프사이클이 한 Aggregate에 섞여 ① 컨텍스트 맵의 소유 경계가 무너진다.
  • PRD “편입/독립 파이프라인 미신설”과의 정합 — PRD의 재사용 지시는 발송(Discord send) 계층에 대한 것이다(NotificationChannel.DISCORD + DiscordNotificationGatewayImpl + supportedChannel 패턴 그대로 사용, 별도 Discord 발송 경로 미신설). PRD가 명시한 “원인 조사는 발송 구조 앞단의 별도 단계”가 곧 alerting 도메인이다. 발송은 재사용, 앞단(신호·분석)은 신규 도메인 — PRD와 충돌하지 않는다.
  • 컨텍스트 경계·교차 규칙domain.alertingdomain.notification을 import하지 않는다(① 교차 금지). 발송 트리거는 도메인 이벤트(AlertDeliveryReadyEvent)로 넘기고, presentation 레이어의 delivery worker가 notification의 SendNotificationUseCase를 호출한다(presentation→application 허용). 알림 대상 사용자는 ID(Long, 운영자 recipient)로만 참조.

서버 토폴로지 설계 (의무)

과제 특성은 “저빈도 webhook 수신 + 텔레메트리 3종 조회·발송”이다. 후보와 선택:

서버 형태후보 검토채택 여부
API 서버(기존)webhook 수신(Controller)·self-check(@Scheduled)채택 — 요청-응답 진입점은 기존 API 서버로 충분
인프로세스 비동기 처리(@Async 스레드풀)텔레메트리 조회·발송을 webhook 스레드에서 분리채택 — webhook는 쿨다운 판정 후 즉시 200 ACK, 조회+발송은 AFTER_COMMIT @Async로 처리. Grafana webhook 타임아웃 회피. 기존 @EnableAsync(AsyncConfig.kt) 재사용
별도 워커 서버(프로세스 분리)알림 처리 전용 서버미채택 — 알림은 저빈도(쿨다운으로 신호당 15분 1건). 별도 프로세스·배포 단위는 지금 규모에 과함. 인프로세스 @Async로 충분
소켓/스케줄러 서버실시간 양방향·주기 전용미채택 — self-check @Scheduled는 기존 앱 인프로세스로 충분(McpAnomalyScheduler 선례)

시스템 역할 경계 (의무)

단위레이어역할소유/책임노출 인터페이스의존
Alertdomain.alerting (엔티티, Aggregate Root)알림 1건의 상태·원지표 스냅샷 캡슐화신호·source·severity·env·status·telemetry·시각attachTelemetry·markDelivered·markDeliveryFailed·질의 프로퍼티(없음, 순수)
AlertSignaldomain.alerting (VO)dedup 단위 값endpoint·source·severitycooldownKey(env)AlertSource·AlertSeverity
AlertSource/AlertSeveritydomain.alerting (enum)소스/심각도 분류전이·색상 매핑(severity→Discord color)discordColor()(없음)
TelemetrySnapshotdomain.alerting (VO, data class)텔레메트리 원지표 타입metricsSummary·logSamples·traceSamples(없음)
AlertDomainServicedomain.alerting (service)조회+검증+실행 오케스트레이션raise(쿨다운)·process(텔레메트리 조회+첨부)·selfCheck아래 시그니처3개 interface + DomainEventPublisher
TelemetryQueryGatewaydomain.alerting (interface)10분 텔레메트리 스냅샷 조회Prometheus/Loki/Tempo 질의 계약(부분 실패 흡수, 예외 미던짐)queryContext(signal, lookback)(구현은 infra)
AlertRepositorydomain.alerting (interface)알림 이력 영속화(FR-9)save·findByIdsave·findById(구현은 infra/MySQL)
AlertCooldownRepositorydomain.alerting (interface)신호 쿨다운 상태원자적 획득tryAcquire(signal, cooldown)(구현은 infra/Redis)
TelemetryQueryGatewayImplinfrastructure.alerting관측 3종 조회PromQL/LogQL/TraceQL RestClient, 부분실패 허용TelemetryQueryGateway 구현Prometheus/Loki/Tempo RestClient
AlertCooldownRepositoryImplinfrastructure.alertingRedis SET NX PX키·TTL(INFRA-01 계약)AlertCooldownRepository 구현RedisTemplate
DiscordNotificationGatewayImplinfrastructure.notificationDiscord Embed 발송supportedChannel=DISCORD, webhook URL(config), Embed 구성NotificationChannelGateway 구현ExternalRestClientFactory, DiscordProperties
AlertWebhookApiControllerpresentation.alertingwebhook/내부 raise 수신시크릿 검증·Command 변환·UseCase 호출POST /internal/alerts/grafana·POST /internal/alertsRaiseAlertUseCase·ReceiveGrafanaAlertUseCase
AlertProcessingEventWorkerpresentation.alerting분석 비동기 실행AFTER_COMMIT @Async(이벤트 소비)ProcessAlertUseCase
AlertDeliveryEventWorkerpresentation.alerting발송 트리거(교차)AFTER_COMMIT @Async → notification(이벤트 소비)notification SendNotificationUseCase
AlertSelfCheckSchedulerpresentation.alerting1시간 heartbeat@Scheduled(cron 0 0 * * * *)SendSelfCheckUseCase
Grafana Alerting인프라(⑤ Grafana)규칙 평가·webhook 발신alert rule·contact pointPOST /internal/alerts/grafanaPrometheus/Loki

인터페이스 시그니처 (구현자 간 해석 차이 제거)

// domain.alerting.gateway
interface TelemetryQueryGateway {
    fun queryContext(signal: AlertSignal, lookback: Duration): TelemetrySnapshot
    // 부분 실패 허용: 소스별 조회 실패 시 해당 섹션은 빈 값, 전체 예외 던지지 않음(실패 분기 이원화 불필요)
}

// domain.alerting.repository
interface AlertRepository {
    fun save(alert: Alert): Alert
    fun findById(alertId: Long): Alert?
}
interface AlertCooldownRepository {
    fun tryAcquire(signal: AlertSignal, cooldown: Duration): Boolean  // Redis SET NX PX
}

// domain.alerting.service — AlertDomainService
fun raise(command: RaiseAlertCommand): Alert?      // 쿨다운 미획득 시 null(억제). 획득 시 Alert(RAISED) 저장 + AlertProcessingRequestedEvent 등록
fun process(alertId: Long)                          // telemetry 조회 → attachTelemetry → AlertDeliveryReadyEvent 등록
fun selfCheck()                                     // SELF_CHECK/INFO heartbeat 발송 이벤트 등록(쿨다운·조회 미적용)

// domain.alerting VO
data class AlertSignal(val endpoint: String, val source: AlertSource, val severity: AlertSeverity) {
    fun cooldownKey(env: String): String = "alerting:cooldown:$env:$endpoint:${source.name}:${severity.name}"
}
data class TelemetrySnapshot(val metricsSummary: String, val logSamples: List<String>, val traceSamples: List<String>) {
    val isEmpty: Boolean get() = metricsSummary.isBlank() && logSamples.isEmpty() && traceSamples.isEmpty()
}

webhook / 내부 raise HTTP 계약:

POST /internal/alerts/grafana         # Grafana Alerting contact point (헤더 Authorization: Bearer <shared-secret>)
  # 주의: Grafana webhook contact point는 임의 커스텀 헤더(X-Alert-Token)를 발신할 수 없고
  #       Authorization: <scheme> <credentials>만 지원(INFRA-02 실측). → grafana 경로는 Bearer 토큰으로 검증.
  body(Grafana webhook 표준): { alerts: [ { labels: {alertname, endpoint, source, severity, env}, annotations: {...}, ... } ] }
POST /internal/alerts                 # ③⑧ 내부 raise (헤더 X-Alert-Token: <shared-secret>)
  body: { endpoint: String, source: "oversell|deployment|latency", severity: "info|warn|critical", env: String, contextHint?: String }
  → 둘 다 202 Accepted 즉시 반환 (처리는 비동기)

DTO 흐름

GrafanaWebhookRequest / RaiseAlertRequest (presentation)
  → RaiseAlertCommand (application)
    → AlertSignal + Alert (domain, attachTelemetry(TelemetrySnapshot))
      → AlertDeliveryReadyEvent (domain event, denormalized: title/body(원지표 렌더)/source/severity/env)
        → SendNotificationCommand (notification application, channel=DISCORD)
          → Notification(QUEUED) → DiscordNotificationGatewayImpl → Discord Embed

실패 경로·동시성·멱등 (의무)

실패 시나리오영향설계 대응감지
텔레메트리 전체 조회 실패(FR-8)원지표 누락TelemetryQueryGateway가 예외를 흡수하고 빈 TelemetrySnapshot 반환 → AlertDomainService.process가 빈 스냅샷을 첨부, 본문은 “원인: 원지표를 조회하지 못했습니다” + 신호(에러) 정보만 발송. 알림 자체는 진행알림 파이프라인 실패율 지표(⑤ 대시보드), 빈 스냅샷 비율
텔레메트리 소스 부분 실패컨텍스트 일부 공백TelemetryQueryGateway가 소스별 try/catch, 실패 섹션 빈 값. 조회된 데이터만 본문에 첨부로그 경고
Discord webhook 5xx/타임아웃발송 실패기존 dispatchByIdmarkFailed. Notification 상태 FAILED, 알림 유실(재시도는 Non-Goal 범위 — 로그·지표로 감지)Discord 전송 실패율 지표
Grafana webhook 중복 발신(repeat interval)중복 알림쿨다운 SET NX PX가 두 번째 신호를 억제(멱등). 동일 신호는 15분 1건쿨다운 hit 지표
동시 동일 신호(멀티 인스턴스 ⑦)이중 발송Redis SET NX가 원자적 — 한 인스턴스만 획득, 나머지 억제
webhook 인증 실패위조 알림grafana 경로 Authorization: Bearer·내부 raise X-Alert-Token 공유 시크릿 불일치 시 401. SecurityConfig permit + 컨트롤러/필터 검증. (Grafana 11.1.0 파일 provisioning은 발신 시 헤더 미첨부 — ⑤ 이미지 bump 또는 provisioning-API 부트스트랩으로 해소, 그전까지 compose 내부 네트워크 격리가 1차 경계)401 카운트
self-check 발송 실패파이프라인 장애 은폐self-check 끊김 자체가 알림 시스템 장애 신호(PRD Operations) — Grafana에서 “self_check 메시지 부재” 규칙으로 역감지 가능Discord 채널 heartbeat 부재
  • 동시성: 쓰기 경합 지점은 쿨다운 상태뿐 — Redis SET NX PX로 원자 처리(락 불필요). alerts 테이블은 append-only(신호마다 독립 행), 경합 없음.
  • 멱등: 알림 트리거 멱등 키 = 쿨다운 신호 키(FR-7). 규칙 엔진 재발신·내부 재호출은 15분 창에서 첫 건만 통과. 텔레메트리 조회·발송 단계는 이미 쿨다운을 통과한 단일 신호에 대해서만 실행되므로 중복 없음.

상태 전이 표 (Alert)

현재 상태 × 이벤트다음 상태거부/비고
(없음) × raise(쿨다운 획득)RAISEDAlert 저장, 처리 이벤트 등록
(없음) × raise(쿨다운 미획득)(미생성)억제 — Alert 생성 안 함, null 반환
RAISED × process(텔레메트리 조회)ENRICHEDattachTelemetry(snapshot)(빈 스냅샷 포함), 발송 이벤트 등록
ENRICHED × 발송 성공DELIVEREDmarkDelivered
ENRICHED × 발송 실패DELIVERY_FAILEDmarkDeliveryFailed
DELIVERED × 재발송 시도(거부)InvalidAlertStateException — 종료 상태
SELF_CHECK × selfCheck(직접 발송)쿨다운·조회 미적용, INFO heartbeat

Component Diagram (Mermaid flowchart LR)

flowchart LR
    subgraph Rule["규칙 엔진 (⑤ Grafana)"]
        GA["Grafana Alerting"]
    end
    subgraph Src["내부 소스 ③⑧"]
        OV["oversell / deployment"]
    end
    subgraph Pres["presentation.alerting"]
        WH["AlertWebhookApiController"]
        PW["ProcessingEventWorker"]
        DW["DeliveryEventWorker"]
    end
    subgraph App["application.alerting"]
        RU["Raise/Receive UseCase"]
        PU["ProcessAlertUseCase"]
    end
    subgraph Dom["domain.alerting"]
        DS["AlertDomainService"]
        TG["TelemetryQueryGateway"]
        CD["AlertCooldownRepository"]
    end
    subgraph Notif["notification (발송 재사용)"]
        SN["SendNotificationUseCase"]
        DG["DiscordNotificationGatewayImpl"]
    end
    GA --> WH
    OV --> WH
    WH --> RU
    RU --> DS
    DS --> CD
    PW --> PU
    PU --> DS
    DS --> TG
    DW --> SN
    SN --> DG

Sequence Diagram (Mermaid)

sequenceDiagram
    participant GA as Grafana Alerting
    participant WH as AlertWebhookApiController
    participant DS as AlertDomainService
    participant CD as Cooldown(Redis)
    participant PU as ProcessAlertUseCase
    participant TG as TelemetryQueryGateway
    participant SN as SendNotificationUseCase
    participant DG as DiscordGateway
    GA->>WH: POST /internal/alerts/grafana (X-Alert-Token)
    WH->>DS: raise(command)
    DS->>CD: tryAcquire(signal, 15m)
    CD-->>DS: acquired
    DS-->>WH: 202 Accepted (RAISED)
    Note over PU: AFTER_COMMIT @Async
    PU->>DS: process(alertId)
    DS->>TG: queryContext(signal, 10m)
    TG-->>DS: TelemetrySnapshot (부분/전체 실패 시 빈 값)
    Note over DS: attachTelemetry(snapshot) → ENRICHED
    Note over SN: AFTER_COMMIT @Async
    SN->>DG: send(Notification[DISCORD])
    DG-->>SN: SendResult

ERD

erDiagram
    ALERTS {
        bigint id PK
        varchar signal_key "endpoint+source+severity"
        varchar endpoint
        varchar source "latency|oversell|deployment|self_check"
        varchar severity "info|warn|critical"
        varchar env "local|dev|prod"
        varchar status "RAISED|ENRICHED|DELIVERED|DELIVERY_FAILED"
        text telemetry "JsonStringType TelemetrySnapshot"
        datetime raised_at
        datetime delivered_at
        bigint version
    }
  • notifications 테이블은 변경 없음 — channel VARCHAR(20)DISCORD 저장(CHECK 제약 없음, DDL 불필요). 상세 컬럼·인덱스는 DB-01(senior-dba)에 위임.

Testing Plan

레벨대상범위
domainAlert 엔티티상태 전이(RAISED→ENRICHED→DELIVERED), 종료 상태 재발송 거부, attachTelemetry, severity→color
domainAlertSignalcooldownKey(env) 조합 정확성
domainAlertDomainServiceraise(쿨다운 획득/미획득), process(텔레메트리 조회·첨부, 빈 스냅샷 처리), selfCheck — MockK로 gateway·repo 모킹
applicationUseCase 4종Command 변환·DomainService 위임(단위, DomainService 모킹)
infrastructureAlertCooldownRepositoryImplTestContainers Redis — 첫 획득 true, 15분 내 재획득 false, TTL 만료 후 true
infrastructureAlertRepositoryImplTestContainers MySQL — 저장·조회, telemetry JSON 왕복
infrastructureTelemetryQueryGatewayImplmock 서버 — 3종 조회 병합, 소스 부분 실패 시 빈 섹션, 전체 실패 시 빈 스냅샷(예외 미던짐)
infrastructureDiscordNotificationGatewayImplmock webhook — Embed 필드(env/source/severity) 포함, 5xx→SendResult(success=false)
presentationAlertWebhookApiController정상 webhook 202, 시크릿 불일치 401, 내부 raise 202
presentationworker/scheduler이벤트 소비→UseCase 호출, self-check cron 발송
scenario(E2E)전체 흐름latency webhook → 쿨다운 → 텔레메트리 조회·첨부 → DISCORD 발송(mock) 도달. 동일 신호 재발신 억제. oversell/deployment source 태깅

핵심 실패 경로 시나리오:

  • 텔레메트리 조회 전체 실패(mock 서버 다운) → 빈 스냅샷 첨부, 신호 정보만 담아 발송, 파이프라인 미중단.
  • 텔레메트리 원지표 렌더/빈 스냅샷 처리 — 조회된 섹션만 본문에 표시, 전부 비면 “원인: 원지표를 조회하지 못했습니다”.
  • 동일 신호 15분 내 2회 → 1건만 발송(쿨다운 억제).
  • webhook X-Alert-Token 누락/오류 → 401, Alert 미생성.

Release Scenario — 무중단 배포 (의무)

신규 도메인·테이블·채널 추가는 additive(expand)라 기존 동작에 영향 없다. 리스크는 webhook 엔드포인트 노출이다.

  • 피처 플래그: alerting.enabled(기본 false)로 전체 파이프라인 게이팅. OFF면 webhook 컨트롤러가 202만 반환하고 처리 스킵(또는 빈으로 미등록). 검증 완료 후 ON.
  • 배포 순서(코드 먼저, expand-contract):
    1. DB-01 V42 create alerts(nullable 위주, 하위호환) → 앱 배포(alerting.enabled=false) → 부팅 성공 확인.
    2. DISCORD 채널·DiscordNotificationGatewayImpl 포함 배포 — 기존 채널 발송 무영향(라우팅은 supportedChannel 매칭, 신규 게이트웨이만 추가).
    3. Redis 쿨다운 키(런타임 자동 생성, 마이그레이션 불필요).
    4. alerting.enabled=true + Grafana Alerting 규칙/contact point 활성 → 실제 알림 흐름 개시.
  • 단계별 전환 조건:
    • 1→2: 앱 부팅 + /actuator/health UP + alerts 테이블 존재.
    • 2→3: 임의 DISCORD 발송(mock webhook) 성공.
    • 3→4: 쿨다운 획득/억제 동작 확인 → 규칙 엔진 연결.
  • 롤백(단계별):
    • 알림 오작동/노이즈: alerting.enabled=false(플래그 OFF, 즉시) 또는 Grafana contact point 비활성.
    • 텔레메트리 조회 문제: 조회 실패는 빈 스냅샷으로 흡수돼 발송은 유지(신호 정보만) — 별도 롤백 불필요.
    • 테이블 문제: V42 역방향 DDL(DROP TABLE alerts) — 참조 코드는 alerting 도메인에 격리돼 있어 안전.
    • 채널 문제: DiscordNotificationGatewayImpl 빈 제거(revert) — 기존 채널 무영향.

마이그레이션 번호 배정 — ② V38~V40, ③ V41, ⑥ V42가 현재 배정이다. “먼저 머지되는 쪽이 번호를 점유하고, 나중 쪽은 origin/dev 기준 최신 번호로 재배정”한다 — 머지 순서에 따라 ⑥의 V42는 조정될 수 있다.

Observability (조건부 — 신규 외부 연동·상태 전이)

관측 지표원천알람/대시보드
텔레메트리 조회 실패율TelemetryQueryGatewayImpl 카운터(Micrometer)⑤ 대시보드, 10% 초과 시 self-알람
Discord 전송 실패율DiscordNotificationGatewayImpl·Notification FAILED 비율⑤ 대시보드
빈 스냅샷(원지표 미첨부) 비율alerts 집계Success Metric(원지표 첨부율 90% 이상)
쿨다운 억제 건수Redis hit 카운터노이즈 관측
self-check heartbeat1시간 주기 DISCORD 발송부재 시 알림 시스템 장애(PRD Operations)
source·severity·env 태그 누락Alert 저장 시 필수 검증누락 0건(Success Metric)

형제 과제 접점 (③⑤⑧)

과제접점경계
⑤ 옵저버빌리티Prometheus/Loki/Tempo를 원지표 조회 데이터소스로, Grafana를 규칙 엔진으로 사용⑤=데이터소스·Grafana 제공. 본 과제=alert rule/contact point·webhook 수신·원지표 조회·발송. ⑤ 완료가 선행
③ 마케팅 이벤트 고부하(오버셀)오버셀 감지를 source=oversell, severity=critical로 본 파이프라인에 합류본 과제=수신 계약(POST /internal/alerts 또는 OversellDetectedEvent 소비) 제공. ③=오버셀 감지·이벤트 발행(producer는 ③ 범위)
⑧ 배포 파이프라인배포 실패를 source=deployment, severity=critical로 합류본 과제=POST /internal/alerts 계약 제공. ⑧ CI가 실패 시 curl 호출(⑧ TDD §실패경로 명시). producer는 ⑧ 범위
  • ③⑧의 producer(감지·호출) 코드는 각 PRD 범위 밖 — 본 TDD는 수신 계약만 확정한다.

Open Questions

  • (PRD Open Q1 해소) 규칙 엔진 = Grafana Alerting(ADR-001). Alertmanager 미도입.
  • (원 PRD Open Q2) LLM 원인분석은 제거됨(2026-07-06). process 단계는 텔레메트리 원지표 조회·첨부로 대체 — ADR-002는 Superseded, TelemetryQueryGateway만 유지.
  • (PRD Open Q3) 알림 대상 API 범위 — 1차는 핵심 트랜잭션 API(결제·예약·티케팅) 한정으로 Grafana 규칙 작성(INFRA-02). 전체 확대는 노이즈 관측 후 결정.
  • 운영자 recipient userId — alerting.discord.recipient-user-id config로 주입(DISCORD는 per-user 연락처가 아닌 고정 webhook URL 사용). 사용자 in-app 피드 노출 여부는 채널 필터로 후속 조정 가능.

Document History

날짜변경 내용
2026-07-03최초 작성 — 신규 alerting 도메인 분리 + notification DISCORD 채널 발송 재사용, 규칙 엔진=Grafana Alerting, LLM=Claude HTTP Gateway, Redis 쿨다운, ③⑤⑧ 접점·무중단 배포·ADR 4건
2026-07-06LLM 원인분석·해결방법 제거 — process 단계를 텔레메트리 원지표 조회·첨부로 축소. IncidentAnalysis(Gateway/VO/Exception)·ClaudeClient·LlmProperties 삭제 반영, Alert.attachAnalysis→attachTelemetry, 상태 ANALYZED/FALLBACK→ENRICHED 단일화, ERD analysis/analysis_included→telemetry(V54), ADR-002 Superseded 처리