엠서클
경력근거: 웰다 헬스케어 서비스의 핵심 도메인(혈당, 여정, 로깅 등) 설계 및 개발
웰다는 연속혈당 센서와 앱 기록을 한 키트로 묶어 파는 서비스라 같은 사람의 끼니와 혈당이 한 계정 안에 시간축으로 나란히 쌓입니다. 공고도 혈당·여정·로깅을 핵심 도메인으로 지목하며 그 셋의 설계를 첫 업무로 둡니다. 센서 값과 식사 기록을 동시에 가진 회사라야 성립하는 판정이라 다른 회사 데이터로는 이 사슬이 만들어지지 않습니다. 다만 이 포트폴리오는 시계열 데이터 결합과 판정 규칙 설계를 보여 줍니다. 대사 의학이나 영양 처방의 전문성은 주장하지 않고, 판정은 이용자가 자기 기준과 견주는 자리까지만 다룹니다. 모르는 깊이는 면접에서 모른다고 말하는 편이 낫습니다.
주요업무 첫 줄이 웰다 헬스케어 서비스의 핵심 도메인 설계이고 괄호 안에 혈당·여정·로깅이 나란히 적혀 있습니다. 센서가 올리는 값과 이용자가 남기는 기록을 한 도메인으로 묶는 일이 이 자리의 첫 업무입니다.
사용자 행동 데이터를 기반으로 한 API 및 데이터 구조 설계가 주요업무에 따로 적혀 있고, 자격요건도 코드뿐 아니라 시스템 구조와 데이터 흐름까지 이해하려는 사람을 찾습니다. 식사 기록과 센서 값이 어떤 모양으로 쌓여야 판정이 서는지가 곧 설계 문제입니다.
이벤트 기반 아키텍처와 데이터 흐름 개선, AWS 기반 확장 가능한 인프라 설계와 운영이 주요업무에 있고 우대사항에도 메시징 시스템 경험이 들어 있습니다. 센서 값이 5분마다 들어오는 서비스라 이 요구가 장식이 아닙니다.
끼니를 기록하면 그 시각 뒤 두 시간의 혈당 곡선을 붙여, 정점이 자기 기준을 넘었는지 제때 공복선으로 돌아왔는지 한 끼 탄수 몫을 넘었는지 다섯 갈래로 나눠 앱에 띄웁니다. 정점 고르기와 복귀까지 걸린 분과 하루 탄수 합계는 계산 규칙이 내고, 모델은 그 결과를 이용자가 읽을 한 줄로 옮겨 적습니다.
웰다 키트로 연속혈당 센서를 붙이고 다이어트 중인 이용자 — 앱에 식사 사진과 혈당 그래프가 따로 쌓여 있어서, 어느 끼니가 자기 혈당을 튀겼는지는 두 화면을 눈으로 겹쳐 보고 짐작하는 수밖에 없다
같은 끼니를 몇 주씩 되풀이하는 이유는 의지가 약해서가 아니라 식사 기록과 혈당 곡선이 다른 화면에 있어 둘을 맞춰 볼 방법이 없기 때문입니다. 끼니마다 곡선을 붙여 판정을 달면 이용자가 다음 끼니를 고를 때 그 결과를 근거로 씁니다.
이용자가 앱에서 이번 주 끼니 목록을 열면 끼니마다 판정이 붙고, 눌러 들어가면 그 뒤 두 시간 곡선과 정점·복귀 시간·탄수 몫이 근거로 뜹니다. 센서 값은 내려받은 기록 파일로 시작하고, 센서에서 바로 받아 오는 연결은 2주차로 미룹니다.
센서 값이 들어올 때마다 그 끼니의 판정을 다시 계산하는 이벤트 흐름을 붙이고, 2주차부터 AWS 로 배포해 앱이 사내 주소로 붙습니다. 영양 코치가 손으로 판정한 40건과 맞춰 어디서 갈라지는지 리포트로 냅니다.
한 주 판정을 모아 기준 안에서 지나간 끼니를 조합별로 묶고, 정점이 튄 끼니에는 그 자리를 대신할 후보를 같이 띄웁니다.
이용자가 이번 주 끼니 목록을 열면 끼니마다 판정이 붙고, 눌러 들어가면 두 시간 곡선과 근거 수치가 그 자리에서 읽힙니다.
탄수가 결측인 끼니, 사진도 음식 기록도 없는 끼니, 센서를 교체해 값이 안 들어온 시간대를 섞은 기록 30종을 넣어도 앱이 죽지 않고 어느 끼니가 왜 비었는지 보입니다.
영양 코치가 손으로 판정한 끼니 40건과 맞춰 일치율을 냅니다. 단, 정답과 데이터를 모두 작성자가 만든 합성 기준이라 성능 검증이 아니라 데모 조건 동작입니다.
센서 값이 5분 간격으로 하루 288건씩 쌓인 한 주치를 열어도 끼니 목록이 1초 안에 그려지는지 재서 화면에 같이 띄웁니다.
위 수치는 더미데이터 자가채점이며 실제 판정 품질은 엠서클 웰다의 실제 센서 값과 이용자 기록이 있어야 측정됩니다. 이 포트폴리오는 그 검증을 바로 돌릴 수 있는 상태로 만듭니다.
일치율은 제가 만든 끼니 기록과 혈당 값에 제가 붙인 판정을 맞춰 본 자가채점이라 성능 증명이 아닙니다. 같은 음식에도 사람마다 곡선이 달라 실제 이용자 데이터에서는 수치가 달라지므로, 면접에서는 데모가 도는 모습과 실검증을 바로 돌릴 수 있는 상태까지를 보여 드립니다.
정답을 작성자가 만들었으니 잘 맞는 것이 당연합니다. 결측 탄수와 기록이 빈 끼니와 센서 교체로 값이 빈 시간대를 더미에 일부러 섞어 쉬운 문제만 푸는 함정을 피합니다.
혈당이 튀었다는 사실이 곧 그 음식 때문이라는 뜻은 아닙니다. 수면·운동·스트레스가 같은 곡선을 만들 수 있어 판정을 원인으로 읽지 않게 근거 수치를 항상 같이 보여 줍니다.
탄수 값은 사진에서 읽은 추정치라 실제 그램 수와 다를 수 있습니다. 실제 도입에서는 음식 데이터베이스와 이용자 확인을 거쳐야 하며, 그것은 도입 단계 과제로 분리합니다.
이 포트폴리오는 이용자가 자기 기준과 견주는 자리까지만 다룹니다. 당뇨 진단이나 처방으로 읽히지 않도록 화면에 의학적 판단이 아니라는 문구를 함께 둡니다.
① 인라인 샘플 · ② seed.json 다운로드 · ③ 증강 프롬프트. 세 층 모두 동일한 schema 에서 파생.
이용자가 앱에 남긴 끼니 기록입니다. 판정의 대상이며 탄수가 결측인 끼니와 사진도 음식 기록도 없는 끼니를 담았습니다. note 에는 그날의 기기 사정만 적고 판정에 해당하는 말은 담지 않습니다.
| meal_id | user_id | eaten_at | meal_slot | food_items | carb_g | photo_logged | note |
|---|---|---|---|---|---|---|---|
| MEAL-20260902-D | U-1001 | 2026-09-02T18:40:00 | 저녁 | ["제육덮밥","미역국"] | 44 | true | — |
| MEAL-20260903-L | U-1001 | 2026-09-03T12:30:00 | 점심 | ["김밥 한 줄","어묵국"] | 82 | true | — |
| MEAL-20260904-D | U-1001 | 2026-09-04T19:00:00 | 저녁 | ["닭가슴살 샐러드"] | 32 | true | — |
| MEAL-20260905-B | U-1001 | 2026-09-05T08:00:00 | 아침 | ["과일 스무디"] | 61 | true | — |
| MEAL-20260905-L | U-1001 | 2026-09-05T12:40:00 | 점심 | ["현미밥","된장국","나물"] | 40 | true | — |
아래 JSON Schema 에 맞는 끼니 기록을 더 만들어 주세요. {"type":"object","properties":{"meal_id":{"type":"string"},"user_id":{"type":"string"},"eaten_at":{"type":"string"},"meal_slot":{"type":"string","enum":["아침","점심","저녁","야식"]},"food_items":{"type":["array","null"],"items":{"type":"string"}},"carb_g":{"type":["number","null"],"minimum":0},"photo_logged":{"type":"boolean"},"note":{"type":["string","null"]}},"required":["meal_id","user_id","eaten_at","meal_slot","photo_logged"]} 쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: (1) carb_g 와 food_items 가 null 인 끼니, (2) photo_logged 가 거짓인 끼니, (3) 자정을 넘겨 먹은 야식, (4) 같은 날 끼니가 네 번 이상 몰린 하루. 생성한 행은 반드시 위 스키마를 통과해야 하며, 형식을 깨뜨리는 시도는 별도로 3건만 표시해 두세요.{"type":"object","properties":{"meal_id":{"type":"string"},"user_id":{"type":"string"},"eaten_at":{"type":"string"},"meal_slot":{"type":"string","enum":["아침","점심","저녁","야식"]},"food_items":{"type":["array","null"],"items":{"type":"string"}},"carb_g":{"type":["number","null"],"minimum":0},"photo_logged":{"type":"boolean"},"note":{"type":["string","null"]}},"required":["meal_id","user_id","eaten_at","meal_slot","photo_logged"]}연속혈당 센서가 올린 측정값입니다. 끼니 뒤 두 시간 곡선을 만드는 재료이며 센서를 교체한 날에는 값이 비어 있습니다.
| reading_id | user_id | measured_at | glucose_mgdl | sensor_slot | note |
|---|---|---|---|---|---|
| CGM-0902-01 | U-1001 | 2026-09-02T18:40:00 | 99 | SEN-0828 | — |
| CGM-0902-02 | U-1001 | 2026-09-02T19:30:00 | 151 | SEN-0828 | — |
| CGM-0902-03 | U-1001 | 2026-09-02T20:35:00 | 95 | SEN-0828 | — |
| CGM-0903-01 | U-1001 | 2026-09-03T12:30:00 | 102 | SEN-0828 | — |
| CGM-0903-02 | U-1001 | 2026-09-03T13:30:00 | 187 | SEN-0828 | — |
아래 JSON Schema 에 맞는 혈당 측정값을 더 만들어 주세요. {"type":"object","properties":{"reading_id":{"type":"string"},"user_id":{"type":"string"},"measured_at":{"type":"string"},"glucose_mgdl":{"type":"integer","minimum":40,"maximum":400},"sensor_slot":{"type":"string"},"note":{"type":["string","null"]}},"required":["reading_id","user_id","measured_at","glucose_mgdl","sensor_slot"]} 쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: (1) 센서 교체로 몇 시간 동안 값이 아예 없는 구간, (2) 정점 뒤 두 시간이 지나도 공복선으로 안 돌아오는 곡선, (3) 측정 간격이 5분에서 40분으로 벌어진 구간, (4) sensor_slot 이 바뀌는 날. 생성한 행은 반드시 위 스키마를 통과해야 하며, 형식을 깨뜨리는 시도는 별도로 3건만 표시해 두세요.{"type":"object","properties":{"reading_id":{"type":"string"},"user_id":{"type":"string"},"measured_at":{"type":"string"},"glucose_mgdl":{"type":"integer","minimum":40,"maximum":400},"sensor_slot":{"type":"string"},"note":{"type":["string","null"]}},"required":["reading_id","user_id","measured_at","glucose_mgdl","sensor_slot"]}이용자마다 잡아 둔 공복선과 정점 기준과 복귀 기준 분과 한 끼 탄수 몫입니다. 판정의 기준이 되는 표라 판정 결과를 담지 않습니다.
| user_id | fasting_avg_mgdl | peak_limit_mgdl | return_minutes | carb_per_meal_target | set_on | note |
|---|---|---|---|---|---|---|
| U-1001 | 95 | 140 | 120 | 45 | 2026-08-28 | 키트를 받고 첫 주에 잡은 기준 |
| U-1002 | 88 | 135 | 90 | 40 | 2026-08-20 | — |
| U-1003 | 104 | 160 | 150 | 55 | 2026-08-14 | 공복이 높게 잡힌 이용자 |
| U-1004 | 91 | 140 | 120 | 50 | 2026-08-31 | — |
| U-1005 | 97 | 145 | 110 | 60 | 2026-09-01 | — |
아래 JSON Schema 에 맞는 이용자 기준값을 더 만들어 주세요. {"type":"object","properties":{"user_id":{"type":"string"},"fasting_avg_mgdl":{"type":"integer","minimum":60,"maximum":140},"peak_limit_mgdl":{"type":"integer","minimum":110,"maximum":200},"return_minutes":{"type":"integer","minimum":30,"maximum":240},"carb_per_meal_target":{"type":"integer","minimum":20,"maximum":120},"set_on":{"type":"string"},"note":{"type":["string","null"]}},"required":["user_id","fasting_avg_mgdl","peak_limit_mgdl","return_minutes","carb_per_meal_target","set_on"]} 쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: (1) fasting_avg_mgdl 이 100 을 넘는 이용자, (2) return_minutes 를 60 분으로 빡빡하게 잡은 이용자, (3) carb_per_meal_target 이 70g 으로 넉넉한 이용자, (4) set_on 이 최근이라 기준을 막 잡은 이용자. 생성한 행은 반드시 위 스키마를 통과해야 하며, 형식을 깨뜨리는 시도는 별도로 3건만 표시해 두세요.{"type":"object","properties":{"user_id":{"type":"string"},"fasting_avg_mgdl":{"type":"integer","minimum":60,"maximum":140},"peak_limit_mgdl":{"type":"integer","minimum":110,"maximum":200},"return_minutes":{"type":"integer","minimum":30,"maximum":240},"carb_per_meal_target":{"type":"integer","minimum":20,"maximum":120},"set_on":{"type":"string"},"note":{"type":["string","null"]}},"required":["user_id","fasting_avg_mgdl","peak_limit_mgdl","return_minutes","carb_per_meal_target","set_on"]}| TypeScript | 판정 규칙과 API 를 같은 언어로 씁니다. 끼니와 센서 값이 지나는 타입을 한 곳에 두어 규칙과 앱 화면이 어긋나지 않게 합니다. |
| Node.js | 판정이 도는 서버 런타임입니다. 두 시간 구간을 잘라 정점과 복귀 시각을 계산합니다. |
| NestJS | 끼니 도메인과 혈당 도메인을 모듈로 갈라 담습니다. 판정 규칙을 한 모듈에 모아 두고 API 는 결과만 넘깁니다. |
| MongoDB | 5분 간격으로 들어오는 센서 값을 시간순으로 담습니다. 사용자와 측정 시각을 함께 잡은 색인이 두 시간 구간 조회를 받칩니다. |
| MariaDB | 끼니 기록과 이용자 기준값을 담습니다. 하루치 탄수 합계를 이쪽에서 냅니다. |
| Redis | 방금 계산한 끼니 판정을 잠깐 들고 있어 앱이 목록을 다시 열 때 곡선을 처음부터 훑지 않게 합니다. |
| 이벤트 기반 아키텍처 | 센서 값이 들어오면 그 시각을 덮는 끼니의 판정을 다시 계산하도록 이벤트를 흘립니다. |
| React Native | 이용자가 보는 앱 화면입니다. 끼니 목록과 곡선과 근거를 하단 탭 안에 둡니다. |
| AWS | 2주차부터 배포와 운영을 맡습니다. 센서 값 수집과 판정 서버를 나눠 올리고 지표를 CloudWatch 로 봅니다. |
| GitHub Actions | 판정 규칙 테스트를 올릴 때마다 돌려 계산이 바뀌지 않았는지 지킵니다. |
| LLM | 판정이 정해진 뒤 그 이유를 이용자가 읽을 한 줄로 옮겨 적습니다. 어느 갈래인지는 규칙이 정합니다. |
✓ pass → 그대로 멈춰도 완결입니다. 더 키우려면 센서 값이 들어올 때마다 판정을 다시 계산하는 단계로 넘어갑니다.
✓ pass → 여기까지도 완결입니다. 더 키우려면 한 주 판정을 모아 다음 주 끼니 후보를 고르는 단계로 확장합니다.
위 예측 UI 는 3주차까지 완주했을 때의 화면입니다. 1주차에 실제로 만드는 것은 아래 표시된 화면 하나입니다.
verdict사유 라벨 · meal_id + 한 줄 요약(근거 수치 포함)화면·코드·면접에서 이 단어를 그대로 씁니다. 키는 출력 스키마의 enum 과 같습니다.
기준 안에서 지나감기준 안정점이 기준을 넘김정점 초과제때 안 내려옴복귀 지연탄수 배분 초과탄수 초과짝지을 측정값이 없음측정값 없음끼니 하나에 대한 판정을 곡선 수치와 근거로 남기는 형식입니다. 판정은 계산 규칙이 정하고 요약 문장만 모델이 씁니다.
{"type": "object", "properties": {"verdict_id": {"type": "string"}, "meal_id": {"type": "string"}, "user_id": {"type": "string"}, "verdict": {"type": "string", "enum": ["기준 안에서 지나감", "정점이 기준을 넘김", "제때 안 내려옴", "탄수 배분 초과", "짝지을 측정값이 없음"]}, "peak_mgdl": {"type": ["integer", "null"], "minimum": 40, "maximum": 400, "description": "meal_log.eaten_at 뒤 두 시간 구간에 든 cgm_reading.glucose_mgdl 의 가장 높은 값입니다"}, "return_elapsed_min": {"type": ["integer", "null"], "minimum": 0, "description": "정점을 찍은 measured_at 에서 glucose_mgdl 이 user_baseline.fasting_avg_mgdl 이하로 돌아온 measured_at 까지의 분입니다"}, "day_carb_total_g": {"type": ["number", "null"], "minimum": 0, "description": "같은 날 같은 user_id 의 meal_log.carb_g 를 모두 더한 값입니다. 판정에는 쓰지 않고 그 끼니가 하루 몫에서 차지하는 자리를 함께 보여 주는 값입니다"}, "evidence": {"type": "array", "items": {"type": "object", "properties": {"dataset": {"type": "string"}, "field": {"type": "string"}, "agg": {"type": ["string", "null"]}, "value": {"type": ["string", "number", "boolean", "null"]}}, "required": ["dataset", "field", "value"]}}, "summary": {"type": "string"}, "hedge": {"type": ["string", "null"]}}, "required": ["verdict_id", "meal_id", "verdict", "evidence", "summary"]}[
{
"verdict_id": "VD-0904D",
"meal_id": "MEAL-20260904-D",
"user_id": "U-1001",
"verdict": "기준 안에서 지나감",
"peak_mgdl": 132,
"return_elapsed_min": 41,
"day_carb_total_g": 32,
"evidence": [
{
"dataset": "cgm_reading",
"field": "glucose_mgdl",
"agg": "max",
"value": 132
},
{
"dataset": "user_baseline",
"field": "peak_limit_mgdl",
"value": 140
},
{
"dataset": "cgm_reading",
"field": "measured_at",
"value": "2026-09-04T19:40:00"
},
{
"dataset": "cgm_reading",
"field": "measured_at",
"value": "2026-09-04T20:21:00"
},
{
"dataset": "user_baseline",
"field": "return_minutes",
"value": 120
},
{
"dataset": "meal_log",
"field": "carb_g",
"value": 32
}
],
"summary": "닭가슴살 샐러드는 정점이 132로 기준 140 아래였고 41분 만에 공복선으로 돌아왔습니다. 탄수도 32g이라 이 끼니는 그대로 이어 가도 됩니다.",
"hedge": null
},
{
"verdict_id": "VD-0907B",
"meal_id": "MEAL-20260907-B",
"user_id": "U-1001",
"verdict": "기준 안에서 지나감",
"peak_mgdl": 121,
"return_elapsed_min": 65,
"day_carb_total_g": 94,
"evidence": [
{
"dataset": "cgm_reading",
"field": "glucose_mgdl",
"agg": "max",
"value": 121
},
{
"dataset": "user_baseline",
"field": "peak_limit_mgdl",
"value": 140
},
{
"dataset": "cgm_reading",
"field": "measured_at",
"value": "2026-09-07T09:00:00"
},
{
"dataset": "cgm_reading",
"field": "measured_at",
"value": "2026-09-07T10:05:00"
},
{
"dataset": "meal_log",
"field": "carb_g",
"value": 36
},
{
"dataset": "user_baseline",
"field": "carb_per_meal_target",
"value": 45
}
],
"summary": "요거트와 견과로 시작한 아침은 정점이 121에 그쳤고 65분 만에 공복선으로 내려왔습니다. 탄수 36g도 한 끼 기준 45g 안입니다.",
"hedge": null
},
{
"verdict_id": "VD-0903L",
"meal_id": "MEAL-20260903-L",
"user_id": "U-1001",
"verdict": "정점이 기준을 넘김",
"peak_mgdl": 187,
"return_elapsed_min": 112,
"day_carb_total_g": 82,
"evidence": [
{
"dataset": "cgm_reading",
"field": "glucose_mgdl",
"agg": "max",
"value": 187
},
{
"dataset": "user_baseline",
"field": "peak_limit_mgdl",
"value": 140
},
{
"dataset": "meal_log",
"field": "carb_g",
"value": 82
},
{
"dataset": "cgm_reading",
"field": "measured_at",
"value": "2026-09-03T13:30:00"
},
{
"dataset": "cgm_reading",
"field": "measured_at",
"value": "2026-09-03T15:22:00"
}
],
"summary": "김밥 한 줄과 어묵국 뒤 정점이 187까지 올라 기준 140을 47 넘었습니다. 112분 만에 내려오기는 했지만 탄수 82g이 몰린 끼니라 같은 조합은 나눠 먹는 편이 낫습니다.",
"hedge": null
},
{
"verdict_id": "VD-0905B",
"meal_id": "MEAL-20260905-B",
"user_id": "U-1001",
"verdict": "제때 안 내려옴",
"peak_mgdl": 168,
"return_elapsed_min": 148,
"day_carb_total_g": 101,
"evidence": [
{
"dataset": "cgm_reading",
"field": "measured_at",
"value": "2026-09-05T09:00:00"
},
{
"dataset": "cgm_reading",
"field": "measured_at",
"value": "2026-09-05T11:28:00"
},
{
"dataset": "user_baseline",
"field": "return_minutes",
"value": 120
},
{
"dataset": "cgm_reading",
"field": "glucose_mgdl",
"agg": "max",
"value": 168
},
{
"dataset": "user_baseline",
"field": "fasting_avg_mgdl",
"value": 95
}
],
"summary": "과일 스무디 뒤 혈당이 148분 동안 공복선 95로 돌아오지 않아 기준 120분을 넘겼습니다. 정점보다 오래 떠 있는 쪽이 부담이 커서 이 끼니를 먼저 봅니다.",
"hedge": "갈아 마신 과일은 씹어 먹을 때와 곡선이 달라진다고 알려져 있지만, 이 판정은 그 원인까지 가리지는 않습니다."
},
{
"verdict_id": "VD-0907L",
"meal_id": "MEAL-20260907-L",
"user_id": "U-1001",
"verdict": "탄수 배분 초과",
"peak_mgdl": 138,
"return_elapsed_min": 50,
"day_carb_total_g": 94,
"evidence": [
{
"dataset": "meal_log",
"field": "carb_g",
"value": 58
},
{
"dataset": "user_baseline",
"field": "carb_per_meal_target",
"value": 45
},
{
"dataset": "meal_log",
"field": "carb_g",
"agg": "sum",
"value": 94
},
{
"dataset": "cgm_reading",
"field": "glucose_mgdl",
"agg": "max",
"value": 138
},
{
"dataset": "user_baseline",
"field": "peak_limit_mgdl",
"value": 140
}
],
"summary": "비빔밥과 계란국의 탄수 58g이 한 끼 기준 45g을 넘었습니다. 곡선은 정점 138로 기준 안에 들었지만 그날 탄수가 94g으로 아침에 몰려 저녁에 남는 몫이 얼마 없습니다.",
"hedge": "탄수 값은 사진에서 읽은 추정치라 실제 그램 수와 다를 수 있습니다."
},
{
"verdict_id": "VD-0908B",
"meal_id": "MEAL-20260908-B",
"user_id": "U-1001",
"verdict": "짝지을 측정값이 없음",
"peak_mgdl": null,
"return_elapsed_min": null,
"day_carb_total_g": null,
"evidence": [
{
"dataset": "meal_log",
"field": "food_items",
"value": null
},
{
"dataset": "meal_log",
"field": "carb_g",
"value": null
},
{
"dataset": "meal_log",
"field": "photo_logged",
"value": false
},
{
"dataset": "meal_log",
"field": "eaten_at",
"value": "2026-09-08T07:50:00"
}
],
"summary": "9월 8일 아침은 사진도 음식 기록도 없어 무엇을 먹었는지 알 수 없습니다. 곡선만으로는 어느 끼니의 몫인지 정해지지 않아 판정을 비우고 기록을 채워 달라고 띄웁니다.",
"hedge": "센서 값이 없는 것인지 끼니를 거른 것인지는 이용자가 확인해야 갈라집니다."
}
][자동 검증 · 코드가 판정합니다] 1. verdict 가 enum 다섯 값(기준 안에서 지나감·정점이 기준을 넘김·제때 안 내려옴·탄수 배분 초과·짝지을 측정값이 없음) 중 하나인지 확인합니다. 판정은 계산 규칙이 정하므로 모델이 다른 값을 쓰면 그 응답을 버립니다. 2. verdict_id·meal_id·verdict·evidence·summary 다섯 필드가 모두 채워졌는지 확인합니다. 3. peak_mgdl 이 40 이상 400 이하인지, return_elapsed_min 과 day_carb_total_g 가 0 이상인지 범위를 확인합니다. 4. peak_mgdl 을 meal_log.eaten_at 뒤 두 시간 구간의 cgm_reading.glucose_mgdl 가운데 가장 높은 값으로 다시 계산하고, user_baseline.peak_limit_mgdl 을 넘는지 확인합니다. 5. return_elapsed_min 을 정점을 찍은 measured_at 에서 glucose_mgdl 이 user_baseline.fasting_avg_mgdl 이하로 처음 돌아온 measured_at 을 빼 다시 계산하고, user_baseline.return_minutes 를 넘으면 제때 안 내려옴입니다. 복귀 시각은 정점 뒤 네 시간 안에서, 그 사이에 같은 이용자의 다음 끼니가 있으면 그 끼니 eaten_at 앞까지만 찾습니다. 그 안에 공복선 이하로 돌아온 값이 없으면 return_elapsed_min 을 비우고 제때 안 내려옴으로 둔 뒤 그 사실을 hedge 에 남깁니다. MEAL-20260905-B 는 정점 09:00 뒤 11:28 에 돌아와 148분이고, 이 값은 끼니 뒤 두 시간 구간 밖이지만 다음 끼니 12:40 앞이라 같은 곡선으로 봅니다. 6. day_carb_total_g 를 같은 날 같은 user_id 의 meal_log.carb_g 합계로 다시 계산해 값이 같은지 확인합니다. carb_g 가 비어 있는 끼니는 합계에서 빼고, 그날 더할 값이 하나도 없으면 비웁니다. 이 값은 판정에 쓰지 않고, 그 끼니가 하루 몫에서 어디쯤인지 화면에 함께 보여 주려고 싣습니다. 7. evidence 의 field 가 meal_log·cgm_reading·user_baseline 에 실재하는 컬럼 이름인지 대조합니다. 없는 이름을 인용하면 요약이 지어낸 문장이라는 뜻이라 규칙이 만든 기본 문장으로 되돌립니다. 8. agg 가 없는 evidence 값이 그 컬럼에 수록된 행 값과 같은지 대조합니다. 구간에서 고르거나 하루치를 더한 수치에는 agg 를 함께 적습니다. 9. summary 가 존댓말로 끝나는 한국어 문장인지 확인합니다. 10. meal_log.eaten_at 앞뒤 두 시간에 그 user_id 의 cgm_reading 이 하나도 없거나 photo_logged 가 거짓이고 food_items 도 비어 있으면 짝지을 측정값이 없음으로 두고 세 계산 값을 모두 비웁니다. MEAL-20260908-B 가 기록이 빈 경우이고, MEAL-20260904-D 가 사진과 곡선이 모두 있어 끝까지 계산된 경우입니다. 11. 판정 순서는 짝지을 측정값이 없음, 제때 안 내려옴, 정점이 기준을 넘김, 탄수 배분 초과, 기준 안에서 지나감 순으로 앞에서 걸리면 뒤를 보지 않습니다. 곡선이 오래 떠 있는 쪽이 정점 한 번보다 부담이 커서 앞에 둡니다. 12. 탄수 배분 초과는 하루 탄수를 끼니에 나눈 한 끼 몫을 그 끼니가 넘었다는 뜻입니다. meal_log.carb_g 가 user_baseline.carb_per_meal_target 보다 크면 이 판정이고, 같으면 걸리지 않습니다. meal_log.carb_g 가 비어 있으면 이 조건을 건너뛰고 곡선 조건만으로 판정합니다. [평가 러너 · 2주차] 1. 영양 코치가 손으로 판정한 끼니 40건과 맞춰 일치율을 냅니다. 어긋난 건은 곡선 조건에서 갈라졌는지 탄수 조건에서 갈라졌는지 나눠 리포트에 답니다. [수동 검증 · 사람이 봅니다] 1. 정점이 기준을 넘김으로 뜬 끼니를 이용자가 실제로 바꾸는지 표본 20건을 따라가 봅니다. 운동을 한 날은 같은 끼니라도 곡선이 달라집니다. 2. 공복선을 최근 7일 평균으로 잡을지 아침 첫 값으로 잡을지 이용자에게 물어 정합니다.