002. Push Delivery Reporting (Client-Side)
Status: ✅ 구현 완료 작성일: 2026-03-20 완료일: 2026-03-21 검증 등급: A- (🟢 양호) 담당자: bok@weltcorp.com 우선순위: high 연관 Plan: agentz-studio Plan 120 (Push Delivery Monitoring Platform) 발견 계기: Prod push delivery rate 36.5%, stuck rate 16.1%. Platform에서 모니터링 인프라를 구축 중이며, client-side 보고가 필요.
배경
agentz-studio platform에서 Push Delivery Monitoring 인프라(Plan 120)를 구축하고 있다. Platform은 POST /v1/push-delivery/report API를 제공하여 client project가 push 상태를 보고할 수 있게 한다.
dha-sleep-api는 conversation.push_planned webhook을 수신하여 Bull로 push를 스케줄링하고 발송한다. 현재 push 발송 결과(성공/실패/취소)를 platform에 보고하지 않아, platform 측에서 push 전달 여부를 파악할 수 없는 블랙박스 구간이 존재한다.
현재 상태:
┌──────────────────┐ push_planned ┌──────────────────┐ Bull ┌────────┐
│ agentz-studio │ ─────────────→ │ dha-sleep-api │ ───────→ │ 사용자 │
│ (platform) │ │ (client) │ │ │
│ │ ← ❌ 보고 │ │ │ │
│ 모니터링 구축 중 │ 없음 │ 보고 구현 필요 │ │ │
└──────────────────┘ └──────────────────┘ └────────┘
Goals
- Push 상태 변경 시(SCHEDULED, COMPLETED, FAILED, CANCELLED) platform에 자동 보고
- 기존 push 처리 로직에 최소한의 변경으로 통합
- Best-effort 패턴: 보고 실패가 push 발송에 영향을 주지 않음
Non-Goals
- Platform-side 모니터링 인프라 구축 — agentz-studio Plan 120에서 진행
- Push retry 로직 변경 — 기존 Bull retry 설정 유지 (참고: 현재 코드에서 retry는 사실상 미작동 — 첫 실패 시 status→FAILED 후 retry에서 스킵됨. 별도 이슈)
- Push health endpoint 구현 — 별도 plan으로 분리
문제 분석
현재 Push 처리 흐름
conversation.push_planned webhook 수신
↓
PushSchedulerService.schedulePushFromPlan()
↓ daily limit, quiet hours, replace 체크
ScheduledPush 생성 (status: SCHEDULED)
↓
Bull delayed job 등록
↓ (delay = scheduledAt - now)
PushJobProcessor.handleSendPush()
↓ user activity 체크 → CANCELLED 가능
↓ status → TRIGGERED
NotificationChannelRouter.route()
↓
├─ 성공 → COMPLETED (sentAt 기록)
└─ 실패 → FAILED (첫 실패 시 즉시. retry는 status 체크에서 스킵됨)
보고 가능 시점
| 상태 | 시점 | 파일 | 코드 위치 |
|---|---|---|---|
| SCHEDULED | Push DB 저장 + Bull 등록 후 | push-scheduler.service.ts | saveScheduledPush() 끝 (line ~317) |
| COMPLETED | 발송 성공 + DB COMPLETED 업데이트 후 | push-job.processor.ts | handleSendPush() 성공 분기 (line ~113-137) |
| FAILED | 첫 번째 발송 실패 + DB FAILED 업데이트 후 | push-job.processor.ts | handleSendPush() 실패 분기 (line ~151-171) |
| CANCELLED | user activity 체크 후 자동 취소 | push-job.processor.ts | handleSendPush() 취소 분기 (line ~78-89) |
FAILED 보고 위치 근거: 기존 코드에서 첫 번째 실패 시 DB status가 FAILED로 변경(line 153)된 후 error throw(line 171)로 Bull retry가 트리거된다. 그러나 retry attempt 2에서는
push.status !== SCHEDULED체크(line 61)에 걸려 "already processed"로 return(성공)된다. 따라서handleFailedJob()의attemptsMade >= 3가드는 사실상 도달 불가능하며, FAILED 보고는handleSendPush()의 실패 분기에서만 수행해야 한다.
기존 인프라 재사용
| 인프라 | 위치 | 용도 |
|---|---|---|
agentz.apiUrl config | registerAs('agentz', ...) in agentz.config.ts → @nestjs/config ConfigService | Platform API 베이스 URL (이미 /v1 포함) |
agentz.apiKey config | 동일 | API Key 인증 (x-api-key 헤더) |
| Native fetch | agentz-proxy.service.ts:311 | HTTP 호출 패턴 |
| Best-effort 패턴 | AgentzProxyService.notifySessionEnd() (line 297-340) | 실패 시 non-fatal 처리 |
주의:
@core/config의ConfigService.get()은process.env[key]를 직접 조회하므로 dot-notation 불가. agentz namespace config를 읽으려면 반드시@nestjs/config의ConfigService를 사용해야 한다. (참고:agentz-proxy.service.ts도@nestjs/configimport 사용)
해결 방안
핵심 원칙
- Best-effort: 보고 실패는 push 처리에 영향 없음 (fire-and-forget)
- 최소 변경: 새 서비스 1개 + 기존 파일 3개 수정
- 기존 패턴 준수:
notifySessionEnd()패턴 따라 구현
데이터 플로우 (구현 후)
┌──────────────────┐ push_planned ┌──────────────────────────────────────┐
│ agentz-studio │ ─────────────→ │ dha-sleep-api │
│ (platform) │ │ │
│ │ │ PushSchedulerService │
│ │ │ └─ schedulePushFromPlan() │
│ │ │ └─ ① SCHEDULED 보고 │
│ │ │ │
│ POST /v1/push- │ │ PushJobProcessor.handleSendPush() │
│ delivery/report │ │ ├─ 성공 → ② COMPLETED 보고 │
│ │ ← ② COMPLETED │ ├─ 취소 → ③ CANCELLED 보고 │
│ │ ← ③ CANCELLED │ └─ 실패 → ④ FAILED 보고 │
│ │ ← ④ FAILED │ (첫 실패 시 즉시 보고) │
│ │ │ │
│ PushDelivery │ │ PushDeliveryReporterService (NEW) │
│ Report 테이블 │ │ └─ report() → fetch POST │
└──────────────────┘ └──────────────────────────────────────┘
설계 결정
Q1. 보고 서비스: 독립 서비스 vs AgentzProxyService 확장?
Context: AgentzProxyService는 chat streaming에 특화되어 있고 ChatModule 소속이다. Push delivery 보고는 chat과 무관한 독립적인 관심사다.
Decision: PushDeliveryReporterService 독립 서비스 생성
Consequences:
- ✅ 관심사 분리: push 도메인 안에서 완결
- ✅
ChatModuleimport 불필요 (push.module.ts에 ChatModule 이미 있지만 이 용도가 아님) - ✅ 테스트 시 독립적으로 mock 가능
- ⚠️
@nestjs/configConfigService 주입 필요 (agentz namespace는registerAs('agentz', ...)기반이므로,@core/config의 커스텀 ConfigService가 아닌 NestJS 원본 ConfigService 사용.agentz-proxy.service.ts와 동일한 패턴)
Alternatives Considered:
AgentzProxyService확장 — chat 도메인에 push 보고 책임이 섞임
Q2. 보고 시점: 모든 상태 vs 터미널 상태만?
Context: Platform sweep은 sessionId + userId + pushType natural key로 correlation한다. SCHEDULED 보고 시 client의 pushId를 platform에 등록하면 더 정확한 추적이 가능하지만, sweep의 lazy materialization이 이미 webhook payload에서 이를 처리한다.
Decision: 모든 상태(SCHEDULED, COMPLETED, FAILED, CANCELLED) 보고
Consequences:
- ✅ Platform에서 push의 전체 라이프사이클 추적 가능
- ✅ SCHEDULED 보고로 client pushId ↔ platform tracking 조기 매핑
- ✅ Overdue sweep에서 SCHEDULED만 보고된 push 감지 가능
- ⚠️ 보고 호출 4곳 (SCHEDULED 1 + COMPLETED 1 + CANCELLED 1 + FAILED 1)
Alternatives Considered:
- 터미널 상태만 — push lifecycle 시작 시점 추적 불가
Q3. FAILED 보고 시점: handleSendPush 실패 분기에서 즉시 보고
Context: 기존 코드의 retry 동작을 실제로 추적한 결과, Bull 3회 retry는 사실상 동작하지 않는다.
첫 번째 실패 시 DB status가 SCHEDULED→TRIGGERED→FAILED로 변경(line 94, 153)된 후 throw(line 171).
Retry attempt 2에서는 push.status !== SCHEDULED(line 61)에 걸려 성공(return)으로 처리된다.
따라서 handleFailedJob()의 attemptsMade >= 3 가드는 도달 불가능하다.
Decision: handleSendPush()의 실패 분기(line 151-171)에서 FAILED 즉시 보고
Consequences:
- ✅ 유일하게 FAILED 보고가 실행되는 지점
- ✅
push변수가 스코프에 있어createdFromSessionId,scheduledAt접근 가능 (추가 DB 조회 불필요) - ✅
routeResult.channel,routeResult.error등 실패 상세 정보 접근 가능 - ⚠️ Platform에서 upsert하므로 이전 SCHEDULED 보고가 FAILED로 갱신됨
기존 코드의 retry 동작 문제: 이 플랜의 범위 밖. 별도 이슈로 분리 권장.
Q4. CANCELLED 보고: push-scheduler의 replace 취소도 포함?
Context: PushSchedulerService.schedulePushFromPlan() line 119-132에서 ±30분 윈도우 내 기존 push를 CANCELLED로 변경한다. 이 "replace 취소"도 platform에 보고할지?
Decision: Replace 취소도 CANCELLED 보고. 모든 CANCELLED를 보고한다.
Context 변경 이유: 처음에는 "replace 취소는 보고 불필요"로 설계했으나, platform sweep의 correlation을
소스코드로 검증한 결과 문제가 발견되었다. Platform sweep은 sessionId + userId + pushType natural key로
PushDeliveryTracking과 PushDeliveryReport를 매칭한다. 대체된(old) push와 새(new) push는 다른 sessionId를
가지므로(각각 별도 대화에서 생성), 새 push의 보고가 old push의 tracking과 매칭되지 않는다.
old push를 CANCELLED로 보고하지 않으면 platform에서 OVERDUE(false positive)로 감지된다.
Consequences:
- ✅ Replace 취소 시 old push를 CANCELLED로 보고 → platform OVERDUE false positive 방지
- ✅ Platform에서 전체 push lifecycle 정확하게 추적
- ⚠️ Scheduler의 replace 취소 코드(line 119-132)에도 보고 hook 추가 필요 → 변경 파일 동일 (push-scheduler.service.ts)
Rabbit Holes
- ❌ Platform API 호출 재시도 — best-effort이므로 1회 호출 후 실패 시 무시
- ❌ 보고 큐잉 (Redis/Bull) — 복잡도 대비 이점 미미, 직접 fetch로 충분
- ❌ Batch 보고 — push 빈도가 낮아 (하루 최대 3건/유저) 개별 보고로 충분
- ❌ 양방향 상태 동기화 — client → platform 단방향 보고만
주의사항
pushType 형식: lowercase snake_case 사용
| 소스 | pushType 값 | 형식 |
|---|---|---|
webhook data.pushPlan.type (platform → client) | evening_checkin | lowercase snake_case |
job.data.type (Bull job에 저장) | evening_checkin | lowercase snake_case |
Prisma ScheduledPushType enum | EVENING_CHECKIN | SCREAMING_SNAKE_CASE |
| Platform DTO Swagger 예시 | EVENING_CHECKIN | SCREAMING_SNAKE_CASE (문서 오류) |
보고 시 job.data.type 값을 그대로 전송한다 (lowercase evening_checkin).
Platform sweep이 webhook payload의 data.pushPlan.type에서 PushDeliveryTracking을 생성하므로,
client 보고도 같은 원본 값을 써야 natural key correlation이 정확하게 동작한다.
ScheduledPushTypeenum (SCREAMING_SNAKE_CASE)은 Prisma DB 저장용이며, webhook 원본 값과 다르다. 보고 시 enum 값이 아닌plan.type/job.data.type원본 값을 사용해야 한다.
구현 계획
| 문서 | 내용 | 변경 파일 | 우선순위 |
|---|---|---|---|
| 01-reporter-service | Reporter Service + Hook Points | 1 NEW + 3 EDIT | P0 |
| 02-verification | 빌드/테스트/기능 검증 | - | - |
| 총계 | 1 NEW + 3 EDIT |
영향 범위
변경 대상 파일
apps/dha-sleep-api/src/app/push/
├── push-delivery-reporter.service.ts [NEW] Platform 보고 서비스
├── push-job.processor.ts [EDIT] COMPLETED/FAILED/CANCELLED 보고 추가
├── push-scheduler.service.ts [EDIT] SCHEDULED 보고 추가
└── push.module.ts [EDIT] Reporter 서비스 등록
변경하지 않는 것
| 파일/모듈 | 이유 |
|---|---|
push-queue.service.ts | Bull 스케줄링 로직 변경 없음 |
scheduled-push.entity.ts | 도메인 엔티티 변경 없음 |
unified-webhook.service.ts | Webhook 수신 로직 변경 없음 |
agentz-proxy.service.ts | Chat 전용 서비스, push 보고와 무관 |
notification-channel-router.service.ts | 채널 라우팅 변경 없음 |
| Prisma 스키마 | 새 테이블/필드 없음 |
컴플라이언스 체크
| 항목 | 해당 여부 | 대응 |
|---|---|---|
| PII 전송 | No | userId는 platform에 이미 존재하는 외부 사용자 ID |
| 외부 API 호출 | Yes | 기존 agentz-studio API 호출과 동일 경로, 동일 인증 |
| 데이터 저장 | No | Client 측 추가 저장 없음, platform에서 저장 |
예상 효과
| 시나리오 | Before | After |
|---|---|---|
| Push 발송 결과 추적 | Platform에서 불가능 (블랙박스) | COMPLETED/FAILED/CANCELLED 실시간 보고 |
| Push stuck 감지 | Sweep의 overdue 추정만 가능 | SCHEDULED 보고로 정확한 lifecycle 추적 |
| Delivery rate 계산 | 수동 DB 조회 필요 | Platform 대시보드 자동 집계 |
| 채널별 성공률 | 알 수 없음 | channel 필드로 FCM/Telegram/KakaoTalk 분석 |