EU Engagement (독일향) — 이메일·Push 넛지와 발송 원장
적용 범위:
dta-wide-api(독일향 wide/wed, 독일어). 한국향 카카오톡 알림톡 시스템은 사용자 참여도 향상 시스템 개요를 참조한다 — 두 시스템은 채널·정책·인프라가 전부 다르다. 권위 출처: Confluence[WIP] 푸시, 이메일 알림(DTAWIUNIVE / pageId 1603534849). 코드와 본 문서가 어긋나면 Confluence가 기준이다. 출처 플랜: 230(이메일 정책 리셋), 232(push 넛지 + 발송 원장 + 수동 API), 233(이메일 원장 기록 시점 — SendGrid 결과 후)
1. 정책 요약
DiGA/GDPR 범위에서 앱 사용 개입성(engagement) 알림을 전면 중단하고, "최근 3일 수면일기 미작성" 사용자에 대한 기술지원 안내 1종만 남겼다. 이 프레임이 핵심이다 — 문구가 "기술적 문제가 있으신가요?"인 이유는 사용 독려가 아니라 기술지원 안내여야 규제 정리가 유지되기 때문이다.
같은 조건을 이메일과 app push 두 채널로 보내되, 발송 간격·횟수·상태 저장소는 다르다.
| 이메일 (plan 230) | App Push (plan 232) | |
|---|---|---|
| 트리거 | 15:00 Europe/Berlin | 15:00 Europe/Berlin (동일 슬롯) |
| 판정 창 | 당일 포함 최근 3일 전부 미작성 | 동일 |
| dayIndex 범위 | 3 ~ 90 | 3 ~ 90 |
| 간격 | 3일 | 2일 |
| 최대 횟수 | 3회 후 영구 중단 | 무제한 (dayIndex 상한·정지로만 종료) |
| 대상 사용자 | 계정 활성 · 사이클 진행 중(정지 아님) · 이메일 주소 보유 | 계정 활성 · 사이클 진행 중(정지 아님) · 앱 알림 허용 · FCM 토큰 보유 |
| 상태 저장 | 게이트는 Redis (nudge:sleep-diary-tech:{userCycleId}), 발송 결과는 DB 원장에도 기록 (channel=EMAIL, SendGrid 결과 후 SENT/FAILED) | DB 원장 (private.engagement_notification_logs) |
| 전달 경로 | cross-event → user-engagement chokepoint → SendGrid | SendPushNotificationDirectCommand → FCM (직접) |
이메일 게이트는 Redis 에 둔다 (2026-09-17 결정). Redis 는
count·마지막 발송일만 덮어쓰기로 남겨 과거 발송 이력을 원장으로 옮길 수 없다. 그래서 지금부터 원장에 실제 결과를 쌓고, 진행 중 사이클이 한 바퀴 도는 약 104일(치료 90일 + 여유) 뒤 폴백·시드 없이 게이트를 원장으로 옮긴다(plan 233). 그때까지 총 횟수·간격 판정은 Redis 가 그대로 담당하므로 발송에는 영향이 없다.알려진 차이: Redis 는 cross-event 발행 시점에 갱신되므로 SendGrid 가 실패해도 한 회·3일을 소진한다. 원장에는
FAILED로 남아 이관 시점에 규모를 실측할 수 있다.이메일 원장 기록 위치: sleep 핸들러는 cross-event metadata 에
triggerType/sentDate/sentAt(사용자 시계)을 실어 보내고, user-engagement(eu)UserEngagementTriggeredHandler가 SendGrid 결과를 본 뒤SENT또는FAILED(+failure_reason) 로 기록한다. 수신자 없음·템플릿 매핑 없음 같은 발송 전 skip 은 digest 에만 남고 원장에는 기록하지 않는다.
비활성 이메일 8종
plan 230 이전의 engagement 이메일(가입 환영·미접속·설문 리마인드·RTIB 안내 등)은 발송 중단 상태다. user-engagement-cross-event-receiver.ts의 @OnEvent 라인을 주석 처리해 차단했고, eu-engagement.map.ts/registry의 엔트리는 롤백·감사 용이성을 위해 보존한다(이벤트가 발행되지 않으므로 무해). 보안/인증성 메일(2FA, magic access code, email verification)은 정책 대상이 아니며 그대로 발송된다.
2. 판정 규칙
2-0. 판정 순서와 사유 코드
15:00 발화 시 사용자마다 아래 순서로 판정하고, 처음 걸리는 단계에서 멈춘다. 사유 코드는 로그와 타임머신 결과(reason)에 그대로 나온다.
공통 (두 채널 동일)
| # | 조건 | 걸리면 |
|---|---|---|
| 0 | 계정 status = ACTIVE, 탈퇴 아님, 프로필 타임존 Europe/Berlin | fan-out 대상에서 제외 (사유 없음) |
| 1 | 사용자 상태 조회 가능 (진행 중 사이클 있음) | no_user_state |
| 2 | 사이클 정지 아님 | suspended |
| 3 | 사용기간 dayIndex 3 ~ 90 (정지 기간은 dayIndex 계산에서 제외) | out_of_day_range |
| 4 | 당일 포함 최근 3일 수면일기 전부 없음 (임시 저장 제외) | has_recent_entry |
App Push (공통 통과 후)
| # | 조건 | 걸리면 | 원장 |
|---|---|---|---|
| P1 | 모바일 설정 행 있음 | fcm_token_missing | 기록 안 함 |
| P2 | appNotificationEnabled = true | notifications_disabled | 기록 안 함 |
| P3 | 오늘 시도한 행 없음 (SENT·FAILED 무관) | already_processed_today | — |
| P4 | 마지막 SENT 로부터 2일 이상 | interval_not_reached | — |
| P5 | FCM 발송 → dedup 충돌이면 | already_processed_today | 기록 안 함 (다른 인스턴스가 보냄) |
| — | FCM 발송 결과 | 발송 | SENT / FAILED |
간격은 SENT 기준이다. 토큰이 없거나 무효라 FAILED 가 난 사용자는 다음날 다시 대상이 된다 — 그 사이 알림을 허용하거나 토큰이 등록되면 바로 받는다.
이메일 (공통 통과 후)
| # | 조건 | 걸리면 |
|---|---|---|
| E1 | Redis count < 3 | max_count_reached (영구 중단) |
| E2 | Redis 마지막 발송일로부터 3일 이상 | interval_not_reached |
| E3 | 오늘 lock 획득 (SET NX) | already_processed_today |
| E4 | 이메일 주소 있음 | no_email |
| — | cross-event 발행 → Redis count+1, 마지막 발송일=오늘 | 발행 |
| — | user-engagement(eu) 가 SendGrid 호출 | 원장 SENT / FAILED |
이메일은 앱 알림 허용 여부를 보지 않는다 — 앱 알림 설정은 push 채널에만 적용된다. 따라서 알림을 끈 사용자도 조건이 맞으면 이메일은 받는다.
2-1. "최근 3일"은 당일을 포함한다
목요일 15:00 판정이면 화·수·목을 본다. Confluence "최근 3일 동안 수면일기 기록이 없는 사용자"의 자연스러운 해석이다.
| 15:00 판정일 | 검사 dayIndex | 판정 |
|---|---|---|
| 수 (월·화·수 미작성) | 월·화·수 | 발송 (첫 발송) |
| 목 (목 미작성) | 화·수·목 | 조건 충족이어도 간격 미달 → 미발송 |
| 금 (금 미작성) | 수·목·금 | 발송 |
| 토·일 (금 15:00 이후 작성) | 창에 금 포함 | 미발송 — 당일 작성 존중 |
첫 발송은 3일째 미작성한 날 15:00이다. dayIndex 하한이 3인 이유는 3일치 창을 채우려면 최소 3일차여야 하기 때문이고, 상한 90은 TREATMENT_DURATION_DAYS와 같다.
판정 로직은 두 채널이 한 곳을 공유한다 — libs/feature/sleep/src/lib/application/services/sleep-diary-missing-window.ts. 창이 갈라지면 같은 사용자가 이메일만 받거나 push만 받는 일이 생기므로 분기시키지 않는다.
2-2. 발송 전 opt-out 확인 (필수)
FirebasePushService는 appNotificationEnabled를 확인하지 않는다 — fcmToken 존재·유효성만 본다. 따라서 호출측이 반드시 걸러야 한다.
| 상태 | 처리 | 원장 |
|---|---|---|
| 모바일 설정 행 없음 | fcm_token_missing | 기록 안 함 |
appNotificationEnabled === false | notifications_disabled | 기록 안 함 |
2-3. 시계 — 타임머신 사용자 주의
날짜 판정은 사용자 시계(userState.currentUserTime)에서 뽑는다. 타임머신 사용자면 가상시간이다.
dayIndex·판정 창이 가상시간 기준이므로 게이트만 실시간을 쓰면 시계가 어긋난다 — 가상시간을 2일 옮겨도 실제 날짜가 그대로라 "오늘 이미 시도함"에 걸려 막히고, 간격도 가상일이 아니라 실제일로 계산된다.
3. 발송 원장 private.engagement_notification_logs
채널 공용 원장이다. 자동 넛지의 간격 게이트이자 운영 감사 기록으로 쓴다. 이메일 넛지도 SendGrid 결과 후 channel=EMAIL, template_key=LACK_OF_SLEEPDIARY_ENTRY, status=SENT|FAILED 로 기록한다 — 게이트는 아직 Redis 다(plan 233).
private 스키마에 둔다 — user_id와 custom 발송의 제목/본문 스냅샷이 들어가는 개인정보 테이블이라 sleep_logs·user_mobile_settings와 같은 위치가 맞다. 신규 PG 스키마를 만들지 않는다.
3-1. 주요 컬럼
| 컬럼 | 설명 |
|---|---|
user_cycle_id | 게이트 조회 단위. MANUAL은 userState 조회 실패 시 NULL 허용 |
trigger_type | LACK_OF_SLEEPDIARY_ENTRY | MANUAL |
channel | PUSH | EMAIL |
template_key | LACK_OF_SLEEPDIARY_ENTRY_PUSH | BoardMessageType | EU 이메일 키 | CUSTOM |
title / body | CUSTOM만 채운다 (아래 기록 규칙) |
sent_date | 대상 사용자 시계 기준 로컬 날짜 (@db.Date) |
status | SENT | FAILED |
metadata | dayIndex, requestedBy, deliveredCount 등 |
3-2. 기록 규칙
- 템플릿 발송:
template_key만 남기고title/body는 NULL. 문구는 템플릿이 권위 출처이므로 스냅샷이 오히려 혼선을 만든다. - 직접 입력(custom) 발송:
template_key = 'CUSTOM'+title/body스냅샷. 재현 불가능한 일회성 문구라 남겨야 한다. - opt-out으로 미발송: 기록하지 않는다.
- 원장 기록은 발송 이후다. 기록 실패가 발송을 되돌리지 않는다 — 다만 기록이 빠지면 다음 실행에서 간격 게이트를 통과해 재발송될 수 있으므로 error 로그를 남긴다.
3-3. 하루 1행 제한 — partial unique index
CREATE UNIQUE INDEX "engagement_notification_logs_daily_uniq"
ON "private"."engagement_notification_logs" ("user_cycle_id", "trigger_type", "channel", "sent_date")
WHERE "trigger_type" <> 'MANUAL' AND "user_cycle_id" IS NOT NULL;
자동 넛지만 하루 1행으로 제한한다. MANUAL은 운영자가 하루에 여러 번 보낼 수 있어야 하므로 제외한다.
⚠️ Prisma 스키마로 표현할 수 없어
migration.sql에 수기로 작성했다. 이후prisma migrate dev --create-only가 이 인덱스에 대해DROP INDEXdrift를 제안할 수 있다 — 무시하고 제거할 것.
중복 방어는 이중이다: push의 deduplicationKey(engagement_sleepdiary_push_{userCycleId}_{date})가 Redis에서 1차로 막고, partial unique가 DB에서 최종 방어한다. dedup 충돌은 다른 인스턴스가 이미 보냈다는 뜻이므로 원장에 기록하지 않는다.
4. 문구와 다국어
문구는 각 언어 이메일 템플릿의 제목에서 가져온 승인 문구를 출발점으로 한다. push는 잠금화면 길이를 고려해 제목 한 문장만 차용하고, 제목은 앱 이름(SleepQ)으로 고정한다. 독일어 push는 매체 특성에 맞춰 du-Form(deinem)을 쓴다 — 이메일은 Sie-Form(Ihrem)을 유지하므로 같은 사용자가 이메일과 push에서 존칭이 다른 문구를 받는 것은 의도된 결정이다(PR #318).
| 언어 | push 본문 |
|---|---|
de-DE | Gibt es technische Probleme mit deinem Schlaftagebuch? |
ko-KR | 수면일기를 작성하는데 기술적인 문제가 있으세요? |
en-US | 없음 → de-DE 폴백 |
en-US가 비어 있는 것은 의도적이다 — 영어 이메일 템플릿이 존재하지 않아 승인된 영어 문구가 없다. 영어 템플릿이 생기면 그 제목을 상수에 추가한다.
이메일 템플릿의 문구 내용이 바뀌면 push 상수도 함께 검토한다. 존칭(Sie/du)은 채널별로 다르게 두되, 안내하는 내용(기술지원 프레임·질문 취지)은 두 채널이 같아야 한다.
언어는 요청 파라미터로 받지 않는다. 대상 사용자의 language로 서버가 해석한다.
5. 수동 Push 발송 API
운영 중 "이 사용자에게 지금 이 내용으로 push" 요청을 처리하는 프로덕션 엔드포인트다.
POST /v1/engagement/push
Authorization: Bearer {access token}
| 항목 | 규칙 |
|---|---|
| 인증 | JwtAuthGuard만. SA 토큰 호출이 1차 용도이나 가드는 토큰 유효성만 본다 |
| 모드 | templateType XOR (title + body). 섞어 보내면 400 |
| 언어 | 요청에 받지 않음 — 대상 사용자 language로 해석 |
| topboard | 갱신하지 않는다. BoardMessage를 저장하지 않고 DeliverMessageCommand도 쓰지 않는다 |
| 원장 | trigger_type = MANUAL. partial unique 대상이 아니라 하루 여러 건 허용 |
| 템플릿 렌더 실패 | 400 (원장 기록 없음) |
⚠️ 인증 범위
유효한 access token을 가진 일반 사용자도 호출할 수 있다. 즉 환자가 타인에게 push를 보낼 수 있다. 의식적 결정이며 완화책은 다음과 같다.
- 원장
metadata.requestedBy에 호출자 기록 [ENGAGEMENT-PUSH] requestedBy=로그
근본 해결(PermissionsGuard + SA roles)은 백로그에 있다.
6. 트리거 배선
Cloud Scheduler 15:00 Europe/Berlin (기존 잡 재사용 — 신규 cron 없음)
→ POST /v1/webhooks/scheduler
→ SCHEDULED_TIME_REACHED cross event
→ sleep receiver process1500EuropeBerlinTimeReached tasks[]
├─ SendLackOfSleepdiaryEntryNudgeNotificationsCommand (이메일)
└─ SendLackOfSleepdiaryEntryPushNudgeCommand (push)
두 커맨드 모두 같은 대상 선정 규칙(nudge-target-users.ts)을 쓰고, 어느 경로든 같은 사용자 루프로 처리한다 — 단일 사용자 전용 메서드를 따로 두지 않는다.
payload userId | 조건 | 대상 |
|---|---|---|
| 없음 (운영 cron) | — | GetActiveUsersByFilterQuery(timezoneId) 타임존 활성 사용자 전체 fan-out |
| 있음 (타임머신 이동) | dev 환경 && 그 사용자 타임머신 활성 | 그 사용자 1명 — 판정 사유(reason)를 결과에 그대로 올린다 |
| 있음 | 위 조건 미충족 | skip (timemachine_target_outside_dev / timemachine_not_active) |
userId가 실리는 발행 경로는 타임머신 시간 설정뿐이다: POST /v1/time-machine/users/:userId/time { newTime: "15:00" } → SetUserVirtualTimeCommand → SCHEDULED_TIME_REACHED(scheduledTime=newTime, userId). 이 이벤트의 currentTime은 가상시간이므로, 조건이 맞지 않을 때 전체 fan-out으로 흘려보내면 실제와 다른 시각으로 모든 사용자를 판정하게 된다 — 그래서 skip 한다. 운영 cron은 수신부 String(undefined) 때문에 문자열 'undefined'를 보내며, 이는 "userId 없음"으로 정규화된다.
fan-out 필터는 profile.timezoneId 정확 일치다.
day-index 이동(
POST .../time/day-index)은SCHEDULED_TIME_REACHED를 발행하지 않는다. QA에서 15:00 슬롯을 발화시키려면 시각 설정 API로15:00을 지정한다.
7. 알려진 한계
구현 시점(2026-09-16)에 확인된 미해결 항목이다. 상세는 레포의 docs/development/backlogs/232-sleepdiary-push-nudge-engagement-ledger.md.
| # | 한계 | 영향 |
|---|---|---|
| 1 | GetUserStateQuery가 프로필 언어를 반영하지 않는다 — userState.language가 항상 de-DE | 다국어화가 코드상으로만 동작. 실환경에서는 전원 독일어 |
| 2 | 타임머신 day-index 이동은 15:00 슬롯을 발화시키지 않는다 (시각 설정 newTime만 발화) | QA는 시각 설정 API로 15:00을 지정해 검증한다. §6 참조 |
| 3 | push tap으로 화면 이동 불가 — FirebasePushService가 deepLink를 FCM payload에 싣지 않고, SleepQDE의 didReceive도 라우팅하지 않음 | 서버·앱 양쪽 작업 필요. 그때까지 API는 deepLink를 받지 않는다 |
| 4 | 15:00 슬롯 fan-out이 두 벌(이메일·push) 순차 루프 | 현 규모 무해. 사용자 급증 시 슬롯 지연 |
| 5 | fcmToken 미등록·무효 사용자는 매일 FAILED 행 누적 (2일 간격은 SENT 기준) | 의도된 동작 — 알림 허용·토큰 등록 즉시 다음날 대상. 운영 조회로 비율 확인 |
| 6 | 이메일은 SendGrid 실패도 Redis 에서 한 회·3일을 소진 | 게이트 원장 이관(plan 233, 약 104일 뒤)까지 유지. 원장 FAILED 로 규모 확인 |
| 7 | 이메일 발송 전 skip(템플릿 매핑 없음 등)은 원장에 남지 않음 | 설정 오류라 digest 알림으로 확인 |
8. 운영 조회
발송 이력 — 채널·결과별 최근 이력:
SELECT sent_date, channel, status, count(*)
FROM private.engagement_notification_logs
WHERE trigger_type = 'LACK_OF_SLEEPDIARY_ENTRY'
AND sent_date >= current_date - 14
GROUP BY 1, 2, 3
ORDER BY 1 DESC, 2, 3;
특정 사용자 이메일 게이트 상태 — Redis:
GET nudge:sleep-diary-tech:{userCycleId} # {"count":N,"lastSentDate":"YYYY-MM-DD"}
원장의 EMAIL 행은 2026-09 plan 233 배포 이후분만 있다. 그 전 발송 횟수는 Redis count 로 본다.
발송 전 대상 예측 — §2-0 공통 조건 0~4 와 채널별 조건을 SQL·Redis 로 재현하면 당일 발송 목록을 미리 뽑을 수 있다. dayIndex 는 (판정일 - 사이클 시작일 [Europe/Berlin]) + 1 로 근사한다(정지 이력·타임머신 사용자는 개별 확인).
9. 관련 코드
| 영역 | 경로 |
|---|---|
| 판정 창 (공유) | libs/feature/sleep/src/lib/application/services/sleep-diary-missing-window.ts |
| 이메일 넛지 | libs/feature/sleep/.../send-lack-of-sleepdiary-entry-nudge-notifications.handler.ts |
| push 넛지 | libs/feature/sleep/.../send-lack-of-sleepdiary-entry-push-nudge.handler.ts |
| 원장 스키마 | libs/core/database/prisma/schema/user_engagement.prisma (PG 스키마는 private) |
| 원장 리포지토리·핸들러 | libs/feature/user-engagement/core/src/lib/{infrastructure/persistence,application} |
| 계약·문구 상수 | libs/shared/contracts-user-engagement/src/lib/{interfaces,commands,queries,constants} |
| 수동 API | apps/dta-wide-api/src/app/engagement/ |
| EU 이메일 템플릿 | libs/feature/user-engagement/eu/src/lib/infrastructure/email/eu-engagement-template.registry.ts |
| 15:00 배선 | libs/feature/sleep/src/lib/application/events/handlers/sleep-cross-event-receiver.ts |