본문으로 건너뛰기

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

  1. Push 상태 변경 시(SCHEDULED, COMPLETED, FAILED, CANCELLED) platform에 자동 보고
  2. 기존 push 처리 로직에 최소한의 변경으로 통합
  3. Best-effort 패턴: 보고 실패가 push 발송에 영향을 주지 않음

Non-Goals

  1. Platform-side 모니터링 인프라 구축 — agentz-studio Plan 120에서 진행
  2. Push retry 로직 변경 — 기존 Bull retry 설정 유지 (참고: 현재 코드에서 retry는 사실상 미작동 — 첫 실패 시 status→FAILED 후 retry에서 스킵됨. 별도 이슈)
  3. 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 체크에서 스킵됨)

보고 가능 시점

상태시점파일코드 위치
SCHEDULEDPush DB 저장 + Bull 등록 후push-scheduler.service.tssaveScheduledPush() 끝 (line ~317)
COMPLETED발송 성공 + DB COMPLETED 업데이트 후push-job.processor.tshandleSendPush() 성공 분기 (line ~113-137)
FAILED첫 번째 발송 실패 + DB FAILED 업데이트 후push-job.processor.tshandleSendPush() 실패 분기 (line ~151-171)
CANCELLEDuser activity 체크 후 자동 취소push-job.processor.tshandleSendPush() 취소 분기 (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 configregisterAs('agentz', ...) in agentz.config.ts@nestjs/config ConfigServicePlatform API 베이스 URL (이미 /v1 포함)
agentz.apiKey config동일API Key 인증 (x-api-key 헤더)
Native fetchagentz-proxy.service.ts:311HTTP 호출 패턴
Best-effort 패턴AgentzProxyService.notifySessionEnd() (line 297-340)실패 시 non-fatal 처리

주의: @core/configConfigService.get()process.env[key]를 직접 조회하므로 dot-notation 불가. agentz namespace config를 읽으려면 반드시 @nestjs/configConfigService를 사용해야 한다. (참고: agentz-proxy.service.ts@nestjs/config import 사용)

해결 방안

핵심 원칙

  1. Best-effort: 보고 실패는 push 처리에 영향 없음 (fire-and-forget)
  2. 최소 변경: 새 서비스 1개 + 기존 파일 3개 수정
  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 도메인 안에서 완결
  • ChatModule import 불필요 (push.module.ts에 ChatModule 이미 있지만 이 용도가 아님)
  • ✅ 테스트 시 독립적으로 mock 가능
  • ⚠️ @nestjs/config ConfigService 주입 필요 (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_checkinlowercase snake_case
job.data.type (Bull job에 저장)evening_checkinlowercase snake_case
Prisma ScheduledPushType enumEVENING_CHECKINSCREAMING_SNAKE_CASE
Platform DTO Swagger 예시EVENING_CHECKINSCREAMING_SNAKE_CASE (문서 오류)

보고 시 job.data.type 값을 그대로 전송한다 (lowercase evening_checkin). Platform sweep이 webhook payload의 data.pushPlan.type에서 PushDeliveryTracking을 생성하므로, client 보고도 같은 원본 값을 써야 natural key correlation이 정확하게 동작한다.

ScheduledPushType enum (SCREAMING_SNAKE_CASE)은 Prisma DB 저장용이며, webhook 원본 값과 다르다. 보고 시 enum 값이 아닌 plan.type / job.data.type 원본 값을 사용해야 한다.

구현 계획

문서내용변경 파일우선순위
01-reporter-serviceReporter Service + Hook Points1 NEW + 3 EDITP0
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.tsBull 스케줄링 로직 변경 없음
scheduled-push.entity.ts도메인 엔티티 변경 없음
unified-webhook.service.tsWebhook 수신 로직 변경 없음
agentz-proxy.service.tsChat 전용 서비스, push 보고와 무관
notification-channel-router.service.ts채널 라우팅 변경 없음
Prisma 스키마새 테이블/필드 없음

컴플라이언스 체크

항목해당 여부대응
PII 전송NouserId는 platform에 이미 존재하는 외부 사용자 ID
외부 API 호출Yes기존 agentz-studio API 호출과 동일 경로, 동일 인증
데이터 저장NoClient 측 추가 저장 없음, platform에서 저장

예상 효과

시나리오BeforeAfter
Push 발송 결과 추적Platform에서 불가능 (블랙박스)COMPLETED/FAILED/CANCELLED 실시간 보고
Push stuck 감지Sweep의 overdue 추정만 가능SCHEDULED 보고로 정확한 lifecycle 추적
Delivery rate 계산수동 DB 조회 필요Platform 대시보드 자동 집계
채널별 성공률알 수 없음channel 필드로 FCM/Telegram/KakaoTalk 분석