엠서클
경력근거: 여정, 가이드, 푸시 알림을 설계부터 배포
공고는 자격요건 첫 줄에 Flutter 로 서비스를 출시하고 운영해 본 경험을 두고, 주요업무에 여정과 가이드와 푸시 알림을 설계부터 배포를, 그 위에 대량 시계열 데이터의 렌더링과 기록 오버레이를 나란히 적었습니다. 그래서 산출물을 담당자가 폰에서 여는 Flutter 앱으로 두고, 발송 시각 위에 그 사용자의 열람 기록을 겹쳐 조용한 시간대를 추정하는 계산을 1주차 안에 넣었습니다. 웰다는 센서를 붙인 날부터 3일차 점검과 첫 리포트, 식사 기록으로 이어지는 순서가 정해진 제품이라, 알림이 순서를 건너뛰거나 그 사람이 반응하지 않는 시간에 도착하면 그 사용자의 여정이 그 자리에서 멈춥니다. 순서 없이 소식만 보내는 서비스의 알림 점검과는 다른 문제입니다. 다만 이 포트폴리오는 여정 규칙을 데이터 모델과 화면으로 세우는 과정을 보여줍니다. 연속혈당측정(CGM) 데이터의 해석이나 대사 건강의 임상 판단은 주장하지 않고, 모르는 깊이는 면접에서 모른다고 말하는 편이 낫습니다.
주요업무에 여정, 가이드, 푸시 알림을 설계부터 배포가 한 줄로 들어 있고 우대사항에는 푸시 알림을 제품 지표와 연결해 운영해 본 경험이 따로 적혀 있습니다. 알림을 보내는 데서 끝나지 않고 그 알림이 규칙대로 나갔는지까지 보는 자리입니다.
주요업무에 네이티브·웹 화면 간 라우팅과 브릿지 통신, 세션 동기화가 있고 우대사항에도 하이브리드 앱과 브릿지 설계 경험이 있습니다. 알림이 연 화면이 앱 쪽인지 웹 쪽인지 기록에 남겨야 다음 단계가 이어집니다.
자격요건이 AI 코딩 도구에 거부감이 없고 그 결과물을 검증할 수 있는 사람을 찾고, 개발 환경 도구 목록에도 ClaudeCode 와 Cursor 가 올라 있습니다. 모델이 쓴 문장을 규칙으로 되받아 검사하는 구성이 이 요구와 맞닿습니다.
웰다 앱이 보낸 여정 알림을 여정 규칙표와 맞춰, 규칙대로 나간 발송과 어긋난 발송을 사유별로 세우는 모바일 앱입니다. 운영 담당자가 출근길에 폰을 열어 밤사이 나간 알림부터 봅니다. 판정을 정하는 것은 세 가지입니다. 발송 시각을 시간창과 견주는 계산, 그 사용자가 앞 단계를 이미 받았는지 살피는 조회, 하루에 같은 단계가 몇 번 나갔는지 세는 집계입니다. 시간창은 규칙표 값을 그대로 쓰지 않습니다. 그 사용자가 알림을 연 적이 한 번도 없는 가장 긴 시간대를 발송 기록에서 추정해 조용 시간대로 잡고, 규칙표 시간창에서 그만큼을 덜어 낸 구간으로 판정합니다. 어긋난 발송이 왜 어긋났는지를 여정 회의에 그대로 올릴 한 문단으로 줄이는 자리에만 모델이 들어옵니다.
웰다 여정과 푸시 알림을 운영하는 담당자
알림을 끄는 사용자가 느는 이유는 알림이 많아서가 아니라 그 사람이 여태 한 번도 반응하지 않은 시간대에 도착하거나 앞 단계를 건너뛴 채 오기 때문입니다. 발송 기록을 여정 규칙표와 맞춰 두면 담당자는 열람률이 떨어진 뒤가 아니라 그날 안에 어긋난 발송부터 봅니다.
운영 담당자가 쓰는 앱 화면 하나가 돕니다. 하루치 발송 기록과 여정 규칙표를 폰에서 고르면 발송마다 판정이 붙고 사유별 묶음과 근거가 한 화면에 뜹니다. 시간창은 규칙표 값을 그대로 쓰지 않습니다. 그 사용자가 알림을 연 시각을 24시간 위에 늘어놓아 한 번도 열지 않은 가장 긴 구간을 조용 시간대로 잡고, 규칙표 시간창에서 그만큼을 덜어 낸 구간으로 판정합니다. 1주차는 웰다 서버에 붙지 않고 담당자가 내려받아 폰에 넣어 둔 파일 두 장을 읽는 것으로 시작합니다.
augment_prompt 로 늘린 발송 60건에 손으로 정답을 달아 판정 일치 수를 냅니다. 규칙을 짜며 쓴 26건은 채점에서 빼고 그 뒤에 만든 발송만 씁니다. 어긋난 건이 조용 시간대 추정에서 갈라졌는지 앞 단계 조회에서 갈라졌는지 나눠 리포트로 냅니다. 판정 API 를 컨테이너로 AWS 에 올리고 Firebase App Distribution 으로 앱 설치본을 팀에 돌립니다.
전날 발송 기록을 받아 오는 일정 작업을 붙여 사람이 고르지 않아도 매일 아침 판정이 새로 돌고, 어긋남이 늘어난 단계는 슬랙 채널로 보냅니다. 하루치 집계와 사용자별 조용 시간대는 Redis 에 담아 앱이 열릴 때마다 다시 세지 않습니다.
담당자가 하루치 발송 기록과 여정 규칙표를 폰에서 고르면 사유별 묶음이 서고, 카드를 열면 그 발송의 시각과 규칙표 시간창, 그 사용자에게서 추정한 조용 시간대가 나란히 놓입니다.
열람 여부가 비어 있는 발송, 앞 단계가 없는 첫 단계, 열람 기록이 두 건뿐이라 기준선을 못 세우는 사용자, 규칙표에 없는 이름으로 나간 발송을 섞은 입력 30종에서도 판정이 끝까지 돌고 응답 형식이 흐트러지지 않습니다.
발송 시각 칸이 비거나 열 이름이 어긋나거나 인코딩이 어긋난 파일 12종을 올려도 그 줄만 이슈로 내려가고 남은 발송은 판정을 그대로 받습니다.
augment_prompt 로 늘린 발송 60건에 손으로 정답을 달아 판정 일치 수를 냅니다. 규칙을 짜며 본 26건이 아니라 그 뒤에 만든 발송으로 채점해야 자가채점이 뜻을 가집니다. 단, 발송 기록과 정답을 모두 작성자가 만든 합성 기준이라 성능 검증이 아니라 데모 조건 동작입니다.
위 수치는 더미데이터 자가채점이며 실제 알림 해제율과 여정 이탈은 웰다에 쌓인 발송 기록과 사용자 반응이 있어야 측정됩니다. 이 포트폴리오는 그 검증을 바로 돌릴 수 있는 상태로 만듭니다.
판정 일치 수는 제가 만든 발송 기록에 제가 정답을 붙여 낸 숫자라 성능 증명이 아닙니다. 면접에서는 실제 발송 로그에서 기기 시각과 서버 시각이 어긋날 때 무엇부터 깨질지로 이야기하겠습니다.
발송 기록도 정답도 제가 만들었으니 판정이 맞아떨어지는 것은 놀랄 일이 아닙니다. 열람 여부가 비어 있는 발송, 앞 단계가 없는 첫 단계, 하루 한도를 두 번 넘긴 사용자, 열람 기록이 두 건뿐인 사용자, 규칙표에 없는 이름으로 나간 발송을 더미에 일부러 섞었습니다. 잘 맞는 발송만 모아 두면 자가채점이 뜻을 잃습니다.
조용 시간대는 그 사용자가 알림을 연 시각만으로 세운 추정입니다. 알림 권한이 꺼진 기기는 열람이 잡히지 않아 기준선이 낮 쪽으로 치우칠 수 있고, 들어온 지 며칠 안 된 사용자는 기록이 모자라 기준선이 서지 않습니다. 그런 사용자는 규칙표 시간창을 그대로 쓰고 카드에 기준선을 못 세웠다고 적습니다. 기준선을 며칠치로 세울지는 실제 로그를 보고 다시 잡을 값입니다.
발송 시각은 서버가 큐에 넣은 시각이지 사용자 화면에 뜬 시각이 아닙니다. 기기 방해 금지 설정이나 백그라운드 제한 때문에 늦게 도착하는 경우가 있어, 시간창 밖은 발송 설계를 고칠 자리를 알려줄 뿐 사용자가 실제로 새벽에 깼다는 말은 카드에 쓰지 않습니다.
앞 단계를 안 거침 판정은 이 표에 실린 발송만 봅니다. 이전 달 기록이나 문자로 나간 안내는 입력에 없으므로, 오래된 사용자의 앞 단계가 비어 보일 수 있습니다. 조회 기간을 넓히는 일은 도입 단계 과제로 따로 잡습니다.
이 포트폴리오는 여정 규칙을 데이터 모델과 규칙, 화면으로 세우는 과정을 보여줍니다. 연속혈당측정 데이터의 해석과 대사 건강의 임상 판단은 주장하지 않고, 판정은 시각과 순서, 건수로만 합니다.
실제로 도입하면 여정 규칙표가 운영 회의에서 자주 바뀝니다. 규칙이 바뀐 날 이전 발송을 옛 규칙으로 볼지 새 규칙으로 볼지는 사람이 정할 문제라, 규칙표에 적용 기간을 두는 일은 도입 단계 과제로 분리합니다.
① 인라인 샘플 · ② seed.json 다운로드 · ③ 증강 프롬프트. 세 층 모두 동일한 schema 에서 파생.
웰다 앱 사용자에게 나간 여정 알림 기록입니다. 누구에게 어느 단계가 언제 나갔는지와 열람 여부, 알림이 연 화면이 앱 쪽인지 웹 쪽인지를 담습니다. 열람 여부는 그 사용자가 언제 알림에 반응하는지를 알려주는 유일한 값이라 조용 시간대를 세우는 데 씁니다. 알림 권한이 꺼진 기기는 열람 여부를 잡을 수 없어 opened 를 비워 두었고, 열지 않은 발송은 landing_surface 도 비어 있습니다.
| send_id | user_id | journey_step | sent_at | opened | landing_surface | device_os |
|---|---|---|---|---|---|---|
| S-88201 | U-1092 | onboarding_start | 2026-09-06T10:12 | true | 앱 화면 | iOS |
| S-88205 | U-1092 | sensor_attach_d0 | 2026-09-06T18:30 | true | 앱 화면 | iOS |
| S-88214 | U-1092 | sensor_attach_d3 | 2026-09-09T03:12 | false | — | iOS |
| S-88222 | U-1092 | first_report | 2026-09-09T09:40 | true | 웹 화면 | iOS |
| S-88232 | U-1092 | glucose_spike_alert | 2026-09-09T13:40 | true | 앱 화면 | iOS |
아래 JSON Schema 에 정확히 맞는 웰다 여정 알림 발송 기록 60개를 JSON 배열로 만들어 주세요.
{"type":"object","properties":{"send_id":{"type":"string"},"user_id":{"type":"string"},"journey_step":{"type":"string"},"sent_at":{"type":"string"},"opened":{"type":["boolean","null"]},"landing_surface":{"type":["string","null"],"enum":["앱 화면","웹 화면",null]},"device_os":{"type":"string","enum":["iOS","Android"]}},"required":["send_id","user_id","journey_step","sent_at","opened","landing_surface","device_os"]}
조건: send_id 는 S- 로 시작하는 다섯 자리 번호로 서로 다르게 씁니다. user_id 는 U- 로 시작하는 네 자리 번호를 여덟 명 남짓 돌려 씁니다. journey_step 은 onboarding_start·sensor_attach_d0·sensor_attach_d3·first_report·meal_log_nudge·glucose_spike_alert·weekly_review·coach_message·kit_reorder·sleep_tip 를 씁니다. sent_at 은 2026-09-05 부터 2026-09-09 사이의 시각으로 흩뿌리되 대부분은 아침과 낮 시간대에 둡니다.
쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: opened 가 비어 있는 행 4건, 새벽 03시에서 05시 사이에 나간 행 3건, 같은 사용자에게 같은 단계가 하루에 네 번 나간 묶음 1개, 앞 단계 발송이 없는 채로 나간 first_report 행 2건, 규칙표에 없는 단계 이름으로 나간 행 2건, opened 가 true 인 발송이 두 건뿐이라 조용 시간대를 세울 수 없는 사용자 1명.
JSON 배열만 출력합니다.{"type":"object","properties":{"send_id":{"type":"string"},"user_id":{"type":"string"},"journey_step":{"type":"string"},"sent_at":{"type":"string"},"opened":{"type":["boolean","null"]},"landing_surface":{"type":["string","null"],"enum":["앱 화면","웹 화면",null]},"device_os":{"type":"string","enum":["iOS","Android"]}},"required":["send_id","user_id","journey_step","sent_at","opened","landing_surface","device_os"]}웰다 여정 단계마다 정해 둔 발송 규칙표입니다. 단계별로 보낼 수 있는 시간창과 앞서 나가야 하는 단계, 하루에 보낼 수 있는 최대 횟수를 담습니다. 첫 단계는 앞 단계가 없어 precondition_step 을 비워 두었습니다.
| rule_no | journey_step | step_label | window_start_hour | window_end_hour | precondition_step | max_per_day |
|---|---|---|---|---|---|---|
| JR-001 | onboarding_start | 시작 안내 | 9 | 21 | — | 1 |
| JR-002 | sensor_attach_d0 | 센서 부착 안내 | 8 | 20 | onboarding_start | 1 |
| JR-003 | sensor_attach_d3 | 센서 부착 3일차 점검 | 8 | 21 | sensor_attach_d0 | 1 |
| JR-004 | first_report | 첫 리포트 안내 | 9 | 21 | sensor_attach_d3 | 1 |
| JR-005 | meal_log_nudge | 식사 기록 독려 | 11 | 20 | first_report | 2 |
아래 JSON Schema 에 정확히 맞는 여정 발송 규칙 행 16개를 JSON 배열로 만들어 주세요.
{"type":"object","properties":{"rule_no":{"type":"string"},"journey_step":{"type":"string"},"step_label":{"type":"string"},"window_start_hour":{"type":"integer","minimum":0,"maximum":23},"window_end_hour":{"type":"integer","minimum":0,"maximum":23},"precondition_step":{"type":["string","null"]},"max_per_day":{"type":"integer","minimum":1,"maximum":5}},"required":["rule_no","journey_step","step_label","window_start_hour","window_end_hour","precondition_step","max_per_day"]}
조건: rule_no 는 JR- 로 시작하는 세 자리 번호로 서로 다르게 씁니다. journey_step 은 소문자와 밑줄로 쓰고 step_label 은 그 단계를 한국어 명사구로 적습니다. window_start_hour 는 7 부터 21 사이, window_end_hour 는 window_start_hour 보다 크게 둡니다. precondition_step 은 이 표 안의 다른 journey_step 값이나 null 로 씁니다.
쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: precondition_step 이 비어 있는 첫 단계 행 2건, window_end_hour 가 23 으로 하루 끝에 걸리는 행 1건, max_per_day 가 1 인 행 5건 이상, 시간창이 두 시간뿐인 좁은 행 2건.
JSON 배열만 출력합니다.{"type":"object","properties":{"rule_no":{"type":"string"},"journey_step":{"type":"string"},"step_label":{"type":"string"},"window_start_hour":{"type":"integer","minimum":0,"maximum":23},"window_end_hour":{"type":"integer","minimum":0,"maximum":23},"precondition_step":{"type":["string","null"]},"max_per_day":{"type":"integer","minimum":1,"maximum":5}},"required":["rule_no","journey_step","step_label","window_start_hour","window_end_hour","precondition_step","max_per_day"]}| Flutter | 담당자가 쓰는 앱 화면을 만듭니다. 사유별 묶음과 근거 펼침, 발송 시각 위에 조용 시간대를 겹쳐 그리는 띠가 모두 이 화면입니다. |
| TypeScript | 판정 규칙을 적는 언어입니다. 시각과 건수가 섞이지 않도록 계산 함수마다 단위를 타입으로 박습니다. |
| Node.js | 발송 기록과 규칙표를 받아 조용 시간대 추정과 판정을 돌리는 서버입니다. |
| PostgreSQL | 발송 기록과 여정 규칙을 담습니다. 사용자 번호와 날짜에 건 인덱스가 하루치 집계 시간을 좌우합니다. |
| MongoDB | 여정 단계마다 모양이 다른 알림 본문과 화면 파라미터를 문서 그대로 담습니다. |
| Redis | 사용자별 조용 시간대와 하루 발송 수를 담아 앱이 열릴 때마다 다시 세지 않게 합니다. |
| AWS | 2주차부터 판정 API 를 컨테이너로 올려 운영합니다. |
| CI/CD | GitHub Actions 로 판정 규칙 시험을 돌리고, 통과한 커밋만 Firebase App Distribution 으로 설치본을 냅니다. |
| WebView | 여정 가이드는 웹 화면이라 알림이 연 곳이 앱 화면인지 웹 화면인지 발송 기록에 함께 남깁니다. |
| React | WebView 로 여는 여정 가이드 쪽 화면을 만듭니다. 앱 화면과 같은 판정 결과를 받아 씁니다. |
| Next.js | 판정 API 와 WebView 화면을 한 저장소에 두고 함께 배포합니다. |
| Kotlin | 안드로이드에서 알림 권한이 꺼진 상태를 발송 기록에 같은 형식으로 남기도록 네이티브 쪽을 고칩니다. |
| Swift | iOS 에서 알림 권한과 백그라운드 수신 상태를 같은 형식으로 남기도록 네이티브 쪽을 고칩니다. |
| Claude | 어긋난 발송을 여정 회의에 올릴 한 문단으로 줄이는 기본 모델입니다. |
| Claude Code | 판정 규칙 시험과 더미데이터 증강 스크립트를 쓰는 데 씁니다. 생성한 코드는 시험을 돌려 확인한 뒤 넣습니다. |
✓ pass → 그대로 멈춰도 완결입니다. 더 키우려면 발송 26건에 정답을 달아 판정과 맞춰 보는 단계로 넘어갑니다.
✓ pass → 여기까지도 완결입니다. 더 키우려면 전날 기록을 저절로 받아 오고 어긋남이 는 단계를 알리는 데까지 확장합니다.
위 예측 UI 는 3주차까지 완주했을 때의 화면입니다. 1주차에 실제로 만드는 것은 아래 표시된 화면 하나입니다.
verdict사유 라벨 · send_id + 한 줄 요약(근거 수치 포함)화면·코드·면접에서 이 단어를 그대로 씁니다. 키는 출력 스키마의 enum 과 같습니다.
규칙대로규칙대로시간창 밖시간창 밖(큐 등록 시각 기준)앞 단계를 안 거침순서 어긋남하루 한도 초과한도 초과규칙에 없는 단계규칙 없는 단계발송 한 건이 여정 규칙을 지켰는지와 그 사유를 남기는 형식입니다. 그 사용자에게서 추정한 조용 시간대와 그것으로 좁힌 유효 시간창을 함께 실어, 판정이 어디서 갈렸는지 카드에서 바로 읽습니다. 다섯 판정 값은 규칙이 정하고 요약 문단만 모델이 씁니다.
{"type":"object","properties":{"send_id":{"type":"string"},"user_id":{"type":"string"},"journey_step":{"type":"string"},"verdict":{"type":"string","enum":["규칙대로","시간창 밖","앞 단계를 안 거침","하루 한도 초과","규칙에 없는 단계"]},"sent_hour":{"type":["integer","null"],"minimum":0,"maximum":23,"description":"push_sends.sent_at 문자열에서 시 단위만 떼어 낸 값입니다. 시간창 비교는 이 값으로 합니다"},"quiet_start_hour":{"type":["integer","null"],"minimum":0,"maximum":23,"description":"조용 시간대의 시작 시각입니다. 그 user_id 가 opened 가 true 인 발송을 낸 시각들을 24시간 원 위에 놓고, 이웃한 두 시각 사이에 열람이 하나도 없는 가장 긴 구간을 고른 뒤(길이가 같으면 이른 쪽) 앞뒤로 한 시간씩 좁혀 얻습니다. 열람한 발송이 세 건 미만이거나 좁힌 구간이 세 시간 미만이면 null 로 둡니다"},"quiet_end_hour":{"type":["integer","null"],"minimum":0,"maximum":23,"description":"조용 시간대의 끝 시각입니다. 자정을 넘어가면 quiet_end_hour 가 quiet_start_hour 보다 작습니다. quiet_start_hour 와 함께 차고 함께 빕니다"},"effective_start_hour":{"type":["integer","null"],"minimum":0,"maximum":23,"description":"유효 시간창의 시작 시각입니다. journey_rules.window_start_hour 부터 window_end_hour 까지의 시각 중 조용 시간대에 든 것을 덜어 내고, 남은 연속 구간 가운데 가장 긴 하나를 씁니다. 남는 시각이 없거나 조용 시간대가 null 이면 규칙표 시간창을 그대로 씁니다. 시간창 밖 판정은 이 값으로 합니다"},"effective_end_hour":{"type":["integer","null"],"minimum":0,"maximum":23,"description":"유효 시간창의 끝 시각입니다. effective_start_hour 와 같은 규칙으로 정합니다"},"daily_gap":{"type":["integer","null"],"description":"그날 그 사용자에게 같은 단계로 나간 발송 수에서 journey_rules.max_per_day 를 뺀 값입니다. 두 수치는 evidence 에 agg 가 count 인 send_id 값과 max_per_day 값으로 함께 싣습니다. 한도를 넘지 않았으면 null 로 둡니다"},"evidence":{"type":"array","items":{"type":"object","properties":{"dataset":{"type":"string"},"field":{"type":"string"},"agg":{"type":["string","null"],"enum":["count",null]},"value":{"type":["string","number","boolean","null"]},"quote":{"type":["string","null"]}},"required":["dataset","field","value"]}},"summary":{"type":"string"},"hedge":{"type":["string","null"]}},"required":["send_id","user_id","journey_step","verdict","sent_hour","quiet_start_hour","quiet_end_hour","effective_start_hour","effective_end_hour","daily_gap","evidence","summary","hedge"]}[
{
"send_id": "S-88240",
"user_id": "U-1092",
"journey_step": "weekly_review",
"verdict": "규칙대로",
"sent_hour": 10,
"quiet_start_hour": 20,
"quiet_end_hour": 7,
"effective_start_hour": 10,
"effective_end_hour": 19,
"daily_gap": null,
"evidence": [
{
"dataset": "push_sends",
"field": "sent_at",
"value": "2026-09-09T10:30",
"quote": "2026-09-09T10:30"
},
{
"dataset": "journey_rules",
"field": "rule_no",
"value": "JR-007",
"quote": "JR-007"
},
{
"dataset": "journey_rules",
"field": "window_start_hour",
"value": 10
},
{
"dataset": "journey_rules",
"field": "window_end_hour",
"value": 20
},
{
"dataset": "journey_rules",
"field": "precondition_step",
"value": "first_report"
},
{
"dataset": "push_sends",
"field": "send_id",
"agg": "count",
"value": 4
},
{
"dataset": "push_sends",
"field": "sent_at",
"value": "2026-09-06T18:30",
"quote": "2026-09-06T18:30"
},
{
"dataset": "push_sends",
"field": "opened",
"value": null
}
],
"summary": "U-1092 의 주간 리포트 안내가 10시 30분에 나갔습니다. 이 사용자가 알림을 연 네 건은 09시부터 18시 사이에 몰려 있어 조용 시간대는 20시부터 이튿날 07시까지로 섰고, 규칙표의 10시부터 20시에서 20시를 덜어 낸 10시부터 19시가 유효 시간창입니다. 앞 단계인 첫 리포트 안내도 같은 날 아침에 나갔습니다.",
"hedge": "이 발송은 알림 권한이 꺼져 있어 열람 여부가 비어 있습니다. 규칙을 지켰는지 정하는 데 열람 여부는 쓰지 않지만, 이런 발송이 늘면 조용 시간대를 세울 재료가 줄어듭니다."
},
{
"send_id": "S-88380",
"user_id": "U-4408",
"journey_step": "sleep_tip",
"verdict": "규칙대로",
"sent_hour": 21,
"quiet_start_hour": 23,
"quiet_end_hour": 6,
"effective_start_hour": 21,
"effective_end_hour": 22,
"daily_gap": null,
"evidence": [
{
"dataset": "push_sends",
"field": "sent_at",
"value": "2026-09-09T21:00",
"quote": "2026-09-09T21:00"
},
{
"dataset": "journey_rules",
"field": "rule_no",
"value": "JR-010",
"quote": "JR-010"
},
{
"dataset": "journey_rules",
"field": "window_start_hour",
"value": 21
},
{
"dataset": "journey_rules",
"field": "window_end_hour",
"value": 23
},
{
"dataset": "journey_rules",
"field": "precondition_step",
"value": "meal_log_nudge"
},
{
"dataset": "push_sends",
"field": "send_id",
"agg": "count",
"value": 6
},
{
"dataset": "push_sends",
"field": "sent_at",
"value": "2026-09-08T08:05",
"quote": "2026-09-08T08:05"
}
],
"summary": "U-4408 의 수면 전 안내가 21시 정각에 나갔습니다. 이 사용자는 08시부터 21시까지 알림을 열어 왔고 조용 시간대가 23시부터 이튿날 06시까지라, 규칙표의 21시부터 23시에서 23시를 덜어 낸 21시부터 22시가 유효 시간창입니다. 시작 시각과 끝 시각은 그 시각을 넣어 셉니다.",
"hedge": null
},
{
"send_id": "S-88395",
"user_id": "U-5510",
"journey_step": "onboarding_start",
"verdict": "규칙대로",
"sent_hour": 9,
"quiet_start_hour": null,
"quiet_end_hour": null,
"effective_start_hour": 9,
"effective_end_hour": 21,
"daily_gap": null,
"evidence": [
{
"dataset": "push_sends",
"field": "sent_at",
"value": "2026-09-09T09:00",
"quote": "2026-09-09T09:00"
},
{
"dataset": "journey_rules",
"field": "rule_no",
"value": "JR-001",
"quote": "JR-001"
},
{
"dataset": "journey_rules",
"field": "window_start_hour",
"value": 9
},
{
"dataset": "journey_rules",
"field": "window_end_hour",
"value": 21
},
{
"dataset": "journey_rules",
"field": "precondition_step",
"value": null
},
{
"dataset": "push_sends",
"field": "send_id",
"agg": "count",
"value": 1
}
],
"summary": "U-5510 의 시작 안내가 09시 정각에 나갔고 규칙표의 09시부터 21시 사이입니다. 이 사용자는 알림을 연 기록이 한 건뿐이라 조용 시간대를 세우지 않고 규칙표 시간창을 그대로 썼습니다. 시작 안내는 앞 단계가 없어 순서도 보지 않습니다.",
"hedge": "들어온 지 하루 된 사용자라 반응 시각이 모자랍니다. 기록이 세 건을 넘기기 전까지는 이 사용자에게 규칙표 시간창만 적용합니다."
},
{
"send_id": "S-88214",
"user_id": "U-1092",
"journey_step": "sensor_attach_d3",
"verdict": "시간창 밖",
"sent_hour": 3,
"quiet_start_hour": 20,
"quiet_end_hour": 7,
"effective_start_hour": 8,
"effective_end_hour": 19,
"daily_gap": null,
"evidence": [
{
"dataset": "push_sends",
"field": "sent_at",
"value": "2026-09-09T03:12",
"quote": "2026-09-09T03:12"
},
{
"dataset": "journey_rules",
"field": "rule_no",
"value": "JR-003",
"quote": "JR-003"
},
{
"dataset": "journey_rules",
"field": "window_start_hour",
"value": 8
},
{
"dataset": "journey_rules",
"field": "window_end_hour",
"value": 21
},
{
"dataset": "push_sends",
"field": "send_id",
"agg": "count",
"value": 4
},
{
"dataset": "push_sends",
"field": "sent_at",
"value": "2026-09-06T18:30",
"quote": "2026-09-06T18:30"
}
],
"summary": "U-1092 의 센서 부착 3일차 점검이 새벽 03시 12분에 나갔습니다. 규칙표가 정한 08시부터 21시를 벗어나고, 이 사용자의 조용 시간대인 20시부터 이튿날 07시 한가운데이기도 합니다. 두 기준 어느 쪽으로 봐도 보낼 시각이 아닙니다.",
"hedge": null
},
{
"send_id": "S-88370",
"user_id": "U-3310",
"journey_step": "glucose_spike_alert",
"verdict": "시간창 밖",
"sent_hour": 20,
"quiet_start_hour": 20,
"quiet_end_hour": 6,
"effective_start_hour": 7,
"effective_end_hour": 19,
"daily_gap": null,
"evidence": [
{
"dataset": "push_sends",
"field": "sent_at",
"value": "2026-09-09T20:50",
"quote": "2026-09-09T20:50"
},
{
"dataset": "journey_rules",
"field": "rule_no",
"value": "JR-006",
"quote": "JR-006"
},
{
"dataset": "journey_rules",
"field": "window_start_hour",
"value": 7
},
{
"dataset": "journey_rules",
"field": "window_end_hour",
"value": 22
},
{
"dataset": "push_sends",
"field": "send_id",
"agg": "count",
"value": 3
},
{
"dataset": "push_sends",
"field": "sent_at",
"value": "2026-09-08T18:05",
"quote": "2026-09-08T18:05"
},
{
"dataset": "push_sends",
"field": "opened",
"value": false
}
],
"summary": "U-3310 의 혈당 급상승 안내가 20시 50분에 나갔습니다. 규칙표는 07시부터 22시까지 열어 두었지만, 이 사용자가 알림을 연 세 건은 08시부터 18시 사이뿐이라 조용 시간대가 20시부터 이튿날 06시까지로 섰고 유효 시간창은 07시부터 19시로 좁혀집니다. 이 발송도 열리지 않았습니다.",
"hedge": "조용 시간대는 이 표에 실린 닷새치 열람 기록만으로 세운 추정입니다. 이 사용자가 저녁에 반응한 기록이 다른 주에 있으면 판정이 달라집니다."
},
{
"send_id": "S-88301",
"user_id": "U-2277",
"journey_step": "first_report",
"verdict": "앞 단계를 안 거침",
"sent_hour": 10,
"quiet_start_hour": 21,
"quiet_end_hour": 7,
"effective_start_hour": 9,
"effective_end_hour": 20,
"daily_gap": null,
"evidence": [
{
"dataset": "push_sends",
"field": "sent_at",
"value": "2026-09-09T10:05",
"quote": "2026-09-09T10:05"
},
{
"dataset": "journey_rules",
"field": "rule_no",
"value": "JR-004",
"quote": "JR-004"
},
{
"dataset": "journey_rules",
"field": "window_start_hour",
"value": 9
},
{
"dataset": "journey_rules",
"field": "window_end_hour",
"value": 21
},
{
"dataset": "journey_rules",
"field": "precondition_step",
"value": "sensor_attach_d3"
},
{
"dataset": "push_sends",
"field": "journey_step",
"value": "sensor_attach_d0"
}
],
"summary": "U-2277 에게 첫 리포트 안내가 나갔지만 앞 단계인 센서 부착 3일차 점검은 이 사용자에게 나간 적이 없습니다. 발송 시각 10시 05분은 유효 시간창인 09시부터 20시 안이라 시간은 문제가 아니고 순서가 문제입니다. 이 사용자가 받은 마지막 단계는 센서 부착 안내입니다.",
"hedge": null
},
{
"send_id": "S-88355",
"user_id": "U-2277",
"journey_step": "meal_log_nudge",
"verdict": "하루 한도 초과",
"sent_hour": 18,
"quiet_start_hour": 21,
"quiet_end_hour": 7,
"effective_start_hour": 11,
"effective_end_hour": 20,
"daily_gap": 2,
"evidence": [
{
"dataset": "push_sends",
"field": "sent_at",
"value": "2026-09-09T18:20",
"quote": "2026-09-09T18:20"
},
{
"dataset": "journey_rules",
"field": "rule_no",
"value": "JR-005",
"quote": "JR-005"
},
{
"dataset": "journey_rules",
"field": "window_start_hour",
"value": 11
},
{
"dataset": "journey_rules",
"field": "window_end_hour",
"value": 20
},
{
"dataset": "journey_rules",
"field": "max_per_day",
"value": 2
},
{
"dataset": "push_sends",
"field": "send_id",
"agg": "count",
"value": 4
}
],
"summary": "U-2277 에게 식사 기록 독려가 9월 9일 하루에 네 번 나갔고 규칙표의 하루 한도는 두 번입니다. 네 건 모두 유효 시간창인 11시부터 20시 안에 있어 시간이 아니라 횟수가 걸렸습니다. 세 번째와 네 번째 발송이 한도를 넘습니다.",
"hedge": null
},
{
"send_id": "S-88402",
"user_id": "U-5510",
"journey_step": "waist_measure",
"verdict": "규칙에 없는 단계",
"sent_hour": 16,
"quiet_start_hour": null,
"quiet_end_hour": null,
"effective_start_hour": null,
"effective_end_hour": null,
"daily_gap": null,
"evidence": [
{
"dataset": "push_sends",
"field": "journey_step",
"value": "waist_measure",
"quote": "waist_measure"
},
{
"dataset": "push_sends",
"field": "sent_at",
"value": "2026-09-09T16:20",
"quote": "2026-09-09T16:20"
}
],
"summary": "U-5510 에게 줄자 재측정 알림이 나갔지만 waist_measure 는 여정 규칙표에 없는 단계입니다. 시간창도 앞 단계도 견줄 기준이 없어 조용 시간대와 유효 시간창을 세우지 않습니다.",
"hedge": "규칙표에 올리지 않은 채 나간 발송입니다. 이 단계를 규칙표에 넣을지 발송을 접을지는 여정 담당자가 정합니다."
}
][자동 검증 · 코드가 판정합니다] 1. verdict 가 다섯 값(규칙대로·시간창 밖·앞 단계를 안 거침·하루 한도 초과·규칙에 없는 단계) 중 하나인지 확인합니다. 판정은 규칙이 정하므로 모델이 다른 값을 쓰면 그 응답을 버립니다. 2. send_id·user_id·journey_step·verdict·sent_hour·quiet_start_hour·quiet_end_hour·effective_start_hour·effective_end_hour·evidence·summary 열한 필드가 샘플에 모두 있는지 확인합니다. 3. sent_hour 와 조용 시간대·유효 시간창의 네 시각이 0 이상 23 이하인지, window_start_hour 와 window_end_hour 가 0 이상 23 이하인지, max_per_day 가 1 이상 5 이하인지 범위를 확인합니다. 4. sent_hour 를 push_sends.sent_at 의 시 단위로 다시 계산해 같은 값인지 봅니다. 5. 조용 시간대를 다시 계산해 quiet_start_hour·quiet_end_hour 와 맞는지 봅니다. 계산은 이렇습니다. 그 user_id 가 opened 가 true 인 발송을 받은 시각들을 24시간 원 위에 놓고, 이웃한 두 시각 사이에 열람이 하나도 없는 구간 중 가장 긴 것을 고른 뒤(길이가 같으면 시작이 이른 쪽) 앞뒤로 한 시간씩 좁힙니다. 열람한 발송이 세 건 미만이거나 좁힌 구간이 세 시간 미만이면 기준선을 세우지 않고 두 값을 비웁니다. 이 계산은 입력 두 표를 나란히 놓고 견주는 것으로는 나오지 않아, 1주차 안에서 규칙 엔진과 함께 시험을 따로 붙입니다(v9-8 이 요구하는 계산이 이 자리입니다). 6. 유효 시간창을 다시 계산해 effective_start_hour·effective_end_hour 와 맞는지 봅니다. journey_rules.window_start_hour 부터 window_end_hour 까지의 시각에서 조용 시간대에 든 것을 덜어 내고, 남은 연속 구간 가운데 가장 긴 하나를 씁니다. 남는 시각이 없거나 조용 시간대가 비면 규칙표 시간창을 그대로 씁니다. S-88395 가 기준선을 못 세워 규칙표 시간창을 그대로 쓴 경우이고, S-88380 은 조용 시간대가 23시를 덜어 내 21시부터 22시로 좁혀진 경우입니다. 7. daily_gap 을 그날 그 사용자에게 같은 단계로 나간 발송 수에서 journey_rules.max_per_day 를 뺀 값으로 다시 계산해 evidence 에 실린 두 수치와 맞는지 봅니다. 8. evidence 의 field 가 push_sends·journey_rules 에 실재하는 컬럼 이름인지 대조합니다. 없는 이름을 인용하면 요약이 지어낸 문장이라는 뜻이라 규칙이 만든 기본 문장으로 되돌립니다. 9. agg 가 없는 evidence 값이 그 컬럼에 수록된 행 값과 같은지 대조합니다. 여러 발송에서 센 수치에는 agg 를 함께 적습니다. 조용 시간대의 근거로는 그 사용자가 열람한 발송 수(agg 가 count 인 send_id)와 기준선의 경계를 정한 열람 발송의 sent_at 을 함께 싣습니다. 10. evidence 의 quote 가 수록 샘플의 원문 그대로인지 확인합니다. 11. summary 와 hedge 가 존댓말로 끝나는 한국어 문장인지 확인합니다. 12. push_sends.journey_step 이 journey_rules.journey_step 목록에 없으면 daily_gap 과 시간창 네 값은 모두 null 이고 앞 단계도 보지 않습니다. S-88402 가 조건이 성립한 경우이고, S-88240 은 규칙표에 있는 단계라 성립하지 않은 경우입니다. 13. journey_rules.precondition_step 이 비어 있으면 앞 단계 조회를 건너뜁니다. 시작 안내(JR-001)가 조건이 성립한 경우이고, 첫 리포트 안내(JR-004)는 앞 단계가 적혀 있어 성립하지 않은 경우입니다. 14. 판정은 규칙에 없는 단계, 시간창 밖, 앞 단계를 안 거침, 하루 한도 초과, 규칙대로 순으로 앞에서 걸리면 뒤를 보지 않습니다. 사용자가 알림을 끄게 만드는 힘이 큰 쪽을 앞에 둔 순서입니다. 15. 갈래마다 걸리는 조건은 아래와 같습니다. · 규칙에 없는 단계는 push_sends.journey_step 이 journey_rules.journey_step 목록에 없을 때입니다. · 시간창 밖은 sent_hour 가 effective_start_hour 보다 작거나 effective_end_hour 보다 클 때입니다. 두 시각은 그 시각을 넣어 셉니다. 규칙표 시간창을 벗어난 발송은 유효 시간창도 반드시 벗어나므로 이 갈래에 그대로 들어옵니다. · 앞 단계를 안 거침은 journey_rules.precondition_step 과 같은 journey_step 이 그 user_id 의 더 이른 push_sends 에 없을 때입니다. · 하루 한도 초과는 그날 그 사용자에게 같은 단계로 나간 발송 수가 journey_rules.max_per_day 를 넘을 때이고, 한도를 넘긴 순서의 발송부터 걸립니다. · 규칙대로는 위 네 갈래에 하나도 걸리지 않은 발송입니다. [평가 러너 · 2주차] 1. augment_prompt 로 늘린 발송 60건에 손으로 정답을 달아 판정 일치 수를 냅니다. 규칙을 짜며 본 인라인 26건은 채점 대상에서 뺍니다. 어긋난 건이 조용 시간대 추정에서 갈라졌는지 앞 단계 조회에서 갈라졌는지 나눠 리포트에 답니다. [수동 검증 · 사람이 봅니다] 1. 유효 시간창이 규칙표보다 좁아져 시간창 밖으로 뜬 발송을 표본 20건 따라가며, 그 사용자가 정말 그 시간대에 반응하지 않는지 봅니다. 기기 방해 금지 설정이 걸려 열람이 늦게 잡힌 경우가 섞입니다. 2. 여정 규칙표의 시간창과 하루 한도를 분기마다 운영과 다시 봅니다. 계절과 이벤트에 따라 알맞은 시간대가 달라집니다.