본문으로 건너뛰기

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/Berlin15:00 Europe/Berlin (동일 슬롯)
판정 창당일 포함 최근 3일 전부 미작성동일
dayIndex 범위3 ~ 903 ~ 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 → SendGridSendPushNotificationDirectCommand → 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/Berlinfan-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기록 안 함
P2appNotificationEnabled = truenotifications_disabled기록 안 함
P3오늘 시도한 행 없음 (SENT·FAILED 무관)already_processed_today—
P4마지막 SENT 로부터 2일 이상interval_not_reached—
P5FCM 발송 → dedup 충돌이면already_processed_today기록 안 함 (다른 인스턴스가 보냄)
—FCM 발송 결과발송SENT / FAILED

간격은 SENT 기준이다. 토큰이 없거나 무효라 FAILED 가 난 사용자는 다음날 다시 대상이 된다 — 그 사이 알림을 허용하거나 토큰이 등록되면 바로 받는다.

이메일 (공통 통과 후)

#조건걸리면
E1Redis count < 3max_count_reached (영구 중단)
E2Redis 마지막 발송일로부터 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 === falsenotifications_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_typeLACK_OF_SLEEPDIARY_ENTRY | MANUAL
channelPUSH | EMAIL
template_keyLACK_OF_SLEEPDIARY_ENTRY_PUSH | BoardMessageType | EU 이메일 키 | CUSTOM
title / bodyCUSTOM만 채운다 (아래 기록 규칙)
sent_date대상 사용자 시계 기준 로컬 날짜 (@db.Date)
statusSENT | FAILED
metadatadayIndex, 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 INDEX drift를 제안할 수 있다 — 무시하고 제거할 것.

중복 방어는 이중이다: 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-DEGibt 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.

#한계영향
1GetUserStateQuery가 프로필 언어를 반영하지 않는다 — userState.language가 항상 de-DE다국어화가 코드상으로만 동작. 실환경에서는 전원 독일어
2타임머신 day-index 이동은 15:00 슬롯을 발화시키지 않는다 (시각 설정 newTime만 발화)QA는 시각 설정 API로 15:00을 지정해 검증한다. §6 참조
3push tap으로 화면 이동 불가 — FirebasePushService가 deepLink를 FCM payload에 싣지 않고, SleepQDE의 didReceive도 라우팅하지 않음서버·앱 양쪽 작업 필요. 그때까지 API는 deepLink를 받지 않는다
415:00 슬롯 fan-out이 두 벌(이메일·push) 순차 루프현 규모 무해. 사용자 급증 시 슬롯 지연
5fcmToken 미등록·무효 사용자는 매일 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}
수동 APIapps/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