../배포 파이프라인·환경 분리/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/mcp의 McpAnomalyDetector는 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개
알림 발생 축. 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 Alerting
Grafana 내장 규칙 엔진. 다중 데이터소스(Prometheus/Loki) 쿼리 평가 → contact point(webhook) 발송
Discord Embed
Discord webhook의 구조화 메시지(제목·색상·필드) 포맷
Define Problem
AS-IS (실제 코드 근거)
NotificationChannel.kt:2 — enum {IN_APP, PUSH, EMAIL, SMS}. DISCORD 없음 → 알림 수신 채널 부재.
NotificationChannelGateway.kt:4 — val supportedChannel: NotificationChannel + fun send(notification): SendResult. 채널별 @Component 구현이 supportedChannel로 자기 채널을 선언(SmsChannelGateway.kt:27).
NotificationDomainService.kt#dispatchById — channelGateways.find { it.supportedChannel == notification.channel }로 게이트웨이를 찾아 발송, markSent/markFailed. DISCORD 게이트웨이만 추가하면 이 라우팅에 자동 편입됨.
NotificationDomainService.kt#send — QUEUED Notification 저장 + NotificationDispatchRequestedEvent 발행. enqueueOrSkip의 SUPPORTED_ENQUEUE_CHANNELS(:22)는 IN_APP/PUSH/EMAIL/SMS만 검사하나 send()는 채널 검사 없음 → DISCORD는 send() 경로로 발송 가능.
미채택 — 사용자향 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.alerting은 domain.notification을 import하지 않는다(① 교차 금지). 발송 트리거는 도메인 이벤트(AlertDeliveryReadyEvent)로 넘기고, presentation 레이어의 delivery worker가 notification의 SendNotificationUseCase를 호출한다(presentation→application 허용). 알림 대상 사용자는 ID(Long, 운영자 recipient)로만 참조.
supportedChannel=DISCORD, webhook URL(config), Embed 구성
NotificationChannelGateway 구현
ExternalRestClientFactory, DiscordProperties
AlertWebhookApiController
presentation.alerting
webhook/내부 raise 수신
시크릿 검증·Command 변환·UseCase 호출
POST /internal/alerts/grafana·POST /internal/alerts
RaiseAlertUseCase·ReceiveGrafanaAlertUseCase
AlertProcessingEventWorker
presentation.alerting
분석 비동기 실행
AFTER_COMMIT @Async
(이벤트 소비)
ProcessAlertUseCase
AlertDeliveryEventWorker
presentation.alerting
발송 트리거(교차)
AFTER_COMMIT @Async → notification
(이벤트 소비)
notification SendNotificationUseCase
AlertSelfCheckScheduler
presentation.alerting
1시간 heartbeat
@Scheduled(cron 0 0 * * * *)
—
SendSelfCheckUseCase
Grafana Alerting
인프라(⑤ Grafana)
규칙 평가·webhook 발신
alert rule·contact point
POST /internal/alerts/grafana
Prometheus/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 즉시 반환 (처리는 비동기)
기존 dispatchById가 markFailed. 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(쿨다운 획득)
RAISED
Alert 저장, 처리 이벤트 등록
(없음) × raise(쿨다운 미획득)
(미생성)
억제 — Alert 생성 안 함, null 반환
RAISED × process(텔레메트리 조회)
ENRICHED
attachTelemetry(snapshot)(빈 스냅샷 포함), 발송 이벤트 등록
ENRICHED × 발송 성공
DELIVERED
markDelivered
ENRICHED × 발송 실패
DELIVERY_FAILED
markDeliveryFailed
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
}
오버셀 감지를 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-06
LLM 원인분석·해결방법 제거 — process 단계를 텔레메트리 원지표 조회·첨부로 축소. IncidentAnalysis(Gateway/VO/Exception)·ClaudeClient·LlmProperties 삭제 반영, Alert.attachAnalysis→attachTelemetry, 상태 ANALYZED/FALLBACK→ENRICHED 단일화, ERD analysis/analysis_included→telemetry(V54), ADR-002 Superseded 처리