📦 이 문서는 v1 이전 자료(deprecated)입니다 — 최신 정보는 문서 허브를 보세요
🏗 DETAILED ARCHITECTURE · v2

밀프레드 편식도감
스키마 · UX · 알고리즘

14개 테이블 ER 다이어그램 + 37개 유즈케이스 + 18개 핵심 알고리즘 상세 명세

📊 ER Diagram 👥 37 Use Cases ⚙️ 18 Algorithms

14개 테이블 + ER 다이어그램

1-1. ER 다이어그램

4개 도메인 14 테이블의 관계. 1:N (사용자→아이→기록), N:M (식단↔식재료, 레시피↔식재료).

erDiagram users ||--o{ children : "보호자" children ||--o{ growth_stats : "주1회 키몸무게" children ||--o{ meal_logs : "끼니별 기록" children ||--o{ daycare_evals : "식단표 평가" children ||--o{ recommendations : "추천 캐시" meal_logs ||--o{ meal_log_ingredients : "끼니↔재료" ingredients ||--o{ meal_log_ingredients : "사용된 재료" recipes ||--o{ recipe_ingredients : "레시피↔재료" ingredients ||--o{ recipe_ingredients : "구성 재료" ingredients }o--|| kdri_rni : "영양소 매핑" daycare_evals ||--|| ocr_cache : "이미지 캐시" users { UUID id PK TEXT email UK ENUM parent_type "mom·dad·other" TIMESTAMPTZ created_at } children { UUID id PK UUID user_id FK TEXT nickname DATE birth_date ENUM sex "M·F" JSONB allergens } meal_logs { UUID id PK UUID child_id FK DATE eaten_at ENUM meal_type "아침·점심·저녁·간식" INT approx_hour ENUM texture_level ENUM autonomy ENUM reaction } ingredients { UUID id PK TEXT name UK TEXT category JSONB nutri_per_100g ENUM status "verified·estimated·pending" } recipes { UUID id PK TEXT name TEXT base_type TEXT texture_level JSONB nutri_total TEXT_arr nutrient_highlights } kdri_rni { TEXT age_band PK TEXT nutrient_code PK DECIMAL rni DECIMAL ul }

1-2. 도메인별 14 테이블 상세 스키마

👤 도메인 A — 사용자/아이/성장

usersDOMAIN A
컬럼타입제약설명
idUUIDPK사용자 고유 ID (uuid_generate_v4)
emailTEXTUK, NOT NULL이메일 (OAuth provider에서 받음)
parent_typeENUMDEFAULT 'mom''mom' | 'dad' | 'other' — 인사말 어조용
oauth_providerENUMNOT NULL'kakao' | 'google' | 'apple'
push_tokenTEXTFCM/APNS 토큰 (알림용)
created_atTIMESTAMPTZDEFAULT now()가입 시각
deleted_atTIMESTAMPTZsoft delete (30일 보존 후 hard delete)
childrenDOMAIN A
컬럼타입제약설명
idUUIDPK아이 고유 ID
user_idUUIDFK→users.id, NOT NULL보호자 FK
nicknameTEXTNOT NULL별명 (실명 X — 프라이버시)
birth_dateDATENOT NULL생년월일 (월·년만 사용)
sexENUMNOT NULL'M' | 'F' — KDC 성장도표 percentile 계산용
allergensJSONBDEFAULT '[]'['난류','우유','땅콩'] — 17 대 알레르겐
care_typeENUM'어린이집' | '유치원' | '가정' | '없음'
created_atTIMESTAMPTZDEFAULT now()
growth_statsDOMAIN A
컬럼타입제약설명
child_idUUIDFK→children.id아이 FK
recorded_atDATEPK(child_id,date)측정 일자
height_cmDECIMAL(5,1)NOT NULL키 cm
weight_kgDECIMAL(4,1)NOT NULL몸무게 kg
bmiDECIMAL(4,1)GENERATEDweight_kg / (height_cm/100)² 자동 계산
pct_heightINTCHECK 0-100KDC 성장도표 키 백분위
pct_weightINTCHECK 0-100KDC 몸무게 백분위
pct_bmiINTCHECK 0-100KDC BMI 백분위 (5/85/95)

📝 도메인 B — 식단 기록

meal_logsDOMAIN B
컬럼타입제약설명
idUUIDPK
child_idUUIDFK→children.idINDEX(child_id, eaten_at DESC)
eaten_atDATENOT NULL먹은 날짜 (소급 입력 가능)
meal_typeENUMNOT NULL'아침' | '점심' | '저녁' | '간식'
approx_hourINTCHECK 0-23대략 몇 시 (정확한 시간 X)
duration_minINT먹은 시간 분 (10/15/20/30+)
placeENUM'집' | '어린이집' | '외식' | '이동'
photo_urlTEXTSupabase Storage URL (90일 TTL)
photo_hashTEXTINDEXsha256 — OCR 캐시 키
texture_levelENUM'미음·페이스트·매시·다진·큐브·스틱·통째'
autonomyENUM'떠먹여줌·반자율·스스로'
reactionENUMNOT NULL'거부·남김·잘먹음·또달라'
noteTEXT특이사항 메모
created_atTIMESTAMPTZDEFAULT now()기록 시각 (소급 입력 추적)
meal_log_ingredientsDOMAIN B · 다대다
컬럼타입제약설명
meal_log_idUUIDFK, PK끼니 FK
ingredient_idUUIDFK, PK식재료 FK
sourceENUMNOT NULL'ai_detected' | 'manual' | 'autocomplete'
confidenceDECIMAL(3,2)AI 인식 신뢰도 0.00-1.00
estimated_gINT추정 분량 (LLM 또는 카테고리 평균)
daycare_evalsDOMAIN B · 24h TTL
컬럼타입제약설명
idUUIDPK
child_idUUIDFK
evaluated_atTIMESTAMPTZDEFAULT now()
source_image_urlTEXTSupabase Storage (24h TTL — pg_cron 자동 삭제)
image_hashTEXTFK→ocr_cacheOCR 캐시 키
expires_atTIMESTAMPTZDEFAULT now()+24h자동 삭제 트리거
ocr_extractedJSONB{days:[{date,menus,allergens}], footer}
scoresJSONB{diversity:88, texture:55, ...}
total_scoreINTCHECK 0-100
gradeENUM'S·A·B·C·D'

🗂 도메인 C — 마스터 데이터

ingredientsDOMAIN C · ~1,000건
컬럼타입제약설명
idUUIDPK
nameTEXTUK정규화 이름 (예: '시금치')
raw_aliasesTEXT[]['시금치, 잎, 생것', '시금치_생것'] 변형들
categoryTEXTINDEX16 카테고리 (잎채소·뿌리·생선...)
emojiTEXTUI 표시용
chosungTEXTINDEX초성 (자동완성용 — 'ㅅㄱㅊ')
nutri_per_100gJSONB{iron_mg:2.7, calcium_mg:81, ...} 36 영양
allergensTEXT[]매핑된 알레르겐
exposure_targetINTDEFAULT 30권장 노출 횟수 (Solid Starts)
statusENUMDEFAULT 'estimated''verified' | 'ai_estimated' | 'pending_review'
sourceTEXT'농진청·KDRI·LLM_estimated'
recipesDOMAIN C · 4,432건 enrich
컬럼타입제약설명
idUUIDPK
nameTEXTNOT NULL메뉴명
base_typeTEXTINDEX15분류 (죽·계란찜·부침개·국·볶음...)
texture_levelTEXTINDEX7단계
stepsTEXT[]조리 순서 (배열)
tipTEXT조리 팁
age_groupTEXTINDEX'6-11m·1-2y·3-5y·6-11y'
allergensTEXT[]자동 추출
cook_minutesINT조리 시간
difficultyENUM'easy·medium·hard'
nutri_totalJSONB계산된 영양 합계 36종
nutrient_highlightsTEXT[]GIN INDEX30%+ RNI 기여 영양소 (Phase 4 매칭)
concealmentJSONB{시금치:'high', 가지:'mid'} 가림 정도
sourceTEXT'영아기·유아기·아동기·월별식단'
recipe_ingredientsDOMAIN C · 다대다
컬럼타입제약설명
recipe_idUUIDFK, PK
ingredient_idUUIDFK, PK
amount_gDECIMAL(5,1)분량 (g)
formENUM'즙·다진·매시·큐브·스틱·통째·페이스트·가루'
is_mainBOOLEANDEFAULT false주재료 vs 조미료
kdri_rniDOMAIN C · KDRI 2025
컬럼타입제약설명
age_bandTEXTPK(band,code)'0-5m·6-11m·1-2y·3-5y·6-11y...'
nutrient_codeTEXTPK(band,code)'iron·calcium·choline·vit_d...'
rniDECIMAL권장섭취량 (있는 영양소만)
aiDECIMAL충분섭취량 (RNI 없을 때)
ulDECIMAL상한섭취량
unitTEXT'mg·μg·g·kcal'
priorityINT표시 우선순위 (높을수록 빨강 임계 우선)

🗄 도메인 D — 캐시

recommendationsDOMAIN D · 24h TTL
컬럼타입제약설명
child_idUUIDINDEX
typeENUMINDEX'3day_plan·single_recipe·ingredient_followup'
profile_hashTEXTINDEXsha256(프로파일 요약) — 캐시 hit 판정
resultJSONBLLM 응답 그대로
generated_atTIMESTAMPTZDEFAULT now()
expires_atTIMESTAMPTZDEFAULT now()+24h
ocr_cacheDOMAIN D · 영구
컬럼타입제약설명
image_hashTEXTPKsha256(이미지 바이트)
extractedJSONBVision API 결과
confidenceDECIMAL전체 OCR 신뢰도
model_versionTEXT'claude-sonnet-4-7' — 모델 교체 시 invalidate
created_atTIMESTAMPTZDEFAULT now()

1-3. 인덱스 + RLS (Row Level Security) 정책

-- 핵심 인덱스
CREATE INDEX idx_meal_logs_child_date ON meal_logs (child_id, eaten_at DESC);
CREATE INDEX idx_recipes_highlights ON recipes USING GIN (nutrient_highlights);
CREATE INDEX idx_ingredients_chosung ON ingredients (chosung text_pattern_ops);
CREATE INDEX idx_reco_lookup ON recommendations (child_id, type, profile_hash);

-- RLS: 사용자는 자기 데이터만
ALTER TABLE children ENABLE ROW LEVEL SECURITY;
CREATE POLICY "own children" ON children
  FOR ALL USING (user_id = auth.uid());

CREATE POLICY "own meal_logs" ON meal_logs
  FOR ALL USING (child_id IN (SELECT id FROM children WHERE user_id = auth.uid()));

-- 24h TTL 자동 삭제 (pg_cron)
SELECT cron.schedule('expire-daycare-evals', '0 * * * *',
  'DELETE FROM daycare_evals WHERE expires_at < NOW()');

37개 유즈케이스 — 고객은 무엇을 할 수 있나?

2-1. Actor 다이어그램

flowchart LR P((👩 부모
주 사용자)) K((👶 아이
대상)) N((🧑‍🔬 영양사
운영)) P -->|온보딩 1-4| OB[온보딩] P -->|기록 5-13| REC[식단 기록] P -->|평가 14-22| EVAL[평가 보기] P -->|레시피 23-28| RCP[레시피] P -->|도감 29-32| DEX[도감] P -->|공유 33| SHR[공유] N -->|운영 34| ADM[운영] classDef actor fill:#FFE8D1,stroke:#E89244,color:#1F2D3D classDef cat fill:#E3F2FD,stroke:#3498DB,color:#1F2D3D class P,K,N actor class OB,REC,EVAL,RCP,DEX,SHR,ADM cat

2-2. 카테고리 A — 온보딩 (UC-01 ~ UC-04)

01

회원가입 / 로그인

👩 부모

고객은 카카오·구글·애플 OAuth로 빠르게 가입할 수 있다.

전제스마트폰 + 해당 SNS 계정 보유
흐름: 진입 → SNS 선택 → OAuth 인증 → 첫 진입 시 UC-02로 자동 이동 / 재방문 시 홈으로
예외OAuth 실패 시 재시도 / 이메일 권한 거부 시 푸시 알림 제한
02

아이 프로필 등록

👩 부모

고객은 별명·생년월일·성별·알레르겐·돌봄 유형(어린이집/유치원/가정)을 한 화면에서 등록할 수 있다.

흐름: 별명 입력 → 생년월일 휠 → 성별 toggle → 알레르겐 17 chip 다중선택 → 돌봄 chip 선택 → POST /api/children → KDC 성장도표 기반 적정 키·몸무게 범위 초기 계산
출력child_id 발급 + 홈 화면 이동
03

키·몸무게 입력 (주 1회)

👩 부모

고객은 주 1회 키·몸무게를 기록하고 BMI percentile을 즉시 확인할 수 있다.

흐름: 기록 탭 → 📏 카드 → 키(cm) + 몸무게(kg) 입력 → 저장 → BMI = w/(h/100)² 자동 계산 → KDC percentile lookup → 결과 표시 (정상/저체중/과체중)
알림주 1회 미입력 시 리마인더 푸시
04

스트릭 🔥 + 데일리 미션

👩 부모

고객은 상단 바의 🔥 N일 뱃지와 홈 상단 데일리 미션 카드("오늘 한 컷 기록하기 · 30초만 · 12일 연속 🔥")로 매일 식단 기록 동기를 받는다.

흐름: 식단 1건+ 저장 시 streak 증가 → top-bar에 🔥 12일 표시 + daily-mission 카드 갱신 → 클릭 시 기록 탭 자동 이동
알림당일 미기록 시 저녁 8시 푸시 ("오늘 기록 끊기지 마세요") 진화 검토날짜 소급 입력 UX와 충돌하지 않게 — "이번 달 25/28일 기록" 같은 부담 적은 지표로 전환 고려

2-3. 카테고리 B — 식단 기록 (UC-05 ~ UC-13)

05

날짜 선택 + 소급 입력

👩 부모

고객은 오늘/어제/그저께 chip 또는 캘린더로 임의 날짜의 기록을 입력·수정할 수 있다.

흐름: 기록 탭 진입 → 날짜 chip (빠진 날 빨간 점) → 선택 → 해당 날짜로 입력 폼 로드 → 빈 칸 알림 배너 ("어제 점심·저녁, 오늘 아침 3끼 비어있어요")
06

식사 종류 선택 (간식 포함)

👩 부모

고객은 아침·점심·저녁·간식 중 한 종류를 선택해 기록할 수 있다.

chip 4개 라디오 → 선택 → meal_type 값 저장
07

사진 업로드 + AI 식재료 자동 인식

👩 부모 + 🤖 AI

고객은 음식 사진 1장만 올리면 AI가 식재료를 자동 인식해 해시태그로 미리 채워준다.

흐름: 📸 박스 탭 → 카메라/갤러리 → 업로드 → Supabase Storage 저장 → POST /api/ocr-food → Claude Vision → 식재료 리스트 추출 → ingredient_pool 매칭 → 해시태그 chip 자동 추가 (✨ AI 마크) → 부모 수정 가능
정확도≥80% (사용자 수정으로 학습)
08

해시태그 식재료 입력 + 한글 자동완성

👩 부모

고객은 식재료를 텍스트로 입력하면 첫글자·초성으로 자동완성 받을 수 있다.

흐름: 태그 박스 클릭 → "ㅂㄹㅋㄹ" 입력 → INGREDIENTS 35 + INGREDIENT_POOL 147 통합 검색 → 매칭 결과 dropdown → ↑↓ 이동, Enter 선택 → chip 추가
신규풀에 없는 단어 입력 시 ✨신규 뱃지 + 운영 큐에 적재
09

식감 단계 입력

👩 부모

고객은 죽·다진·핑거푸드·테이블푸드 중 하나를 선택해 식감 단계를 기록할 수 있다.

용도: HabEat 권고와 비교한 식감 단계 진행 평가
10

자율성 입력

👩 부모

고객은 떠먹여줌·도와줌·스스로 중 하나로 자율성을 기록할 수 있다.

용도: Satter Division of Responsibility 8축 평가
11

식사 반응 입력

👩 부모

고객은 거부·남김·잘먹음·또달라 중 하나로 아이 반응을 기록할 수 있다.

용도: 식재료별 accept_rate 자동 집계 → 4원칙 추천에 반영
12

메모 + 퀵노트

👩 부모

고객은 자유 메모 또는 quick chip ("김 가루로 덮으니 먹음")으로 특이사항을 기록할 수 있다.

13

월간 PDF 리포트 다운로드

👩 부모

고객은 한 달 식습관 PDF를 다운로드받아 소아과·어린이집에 공유할 수 있다.

흐름: 기록 탭 → 📄 월간 리포트 → PDF 생성 (서버사이드 puppeteer) → 다운로드

2-4. 카테고리 C — 평가 (UC-14 ~ UC-22b)

14

영양 점수 카드 (등급·스케일·진척)

👩 부모

고객은 홈에서 영양 점수를 4단계 시각화로 확인한다: ① 60점 큰 숫자 + B 보통 뱃지 + 지난주 대비 변화(+8), ② 5등급 가로 스케일 바 (D→C→B→A→S, 현재 위치 화살표), ③ 이번 주 먹은 식재료 진척도 (18/30종, 다음 등급까지 N종), ④ 🍳 레시피 추천받기 CTA → 레시피 탭으로.

계산: 8축 점수 가중평균 → 5등급(S/A/B/C/D) → 자연어 라벨 + 식재료 카운트
15

36종 영양 신호등

👩 부모

고객은 KDRI 2025 36 영양소를 잘챙김/조금부족/결핍위험 3색으로 한눈에 볼 수 있다.

계산: ALG-EVAL-02 참조 — 빈도 → 충족률 → 색상
16

신호등 자세히 보기 (게이지바)

👩 부모

고객은 신호등 카드 클릭으로 36종 가로 게이지바 + 빈도 자연어("거의 매일"/"드물게"/"못 만남")를 볼 수 있다.

17

탄·단·지 + BMI 종합

👩 부모

고객은 빈도 + BMI percentile 조합으로 다량영양소가 과한지 부족한지 정밀 평가를 받을 수 있다.

로직: ALG-EVAL-04 참조
18

결핍 영양 → 보충 식재료 보기

👩 부모

고객은 빨강 영양소(예: 콜린) 아래에 자동 추천된 보충 식재료 카드를 클릭해 도감 진입할 수 있다.

19

3일 식단 진단 (8축)

👩 부모

고객은 식품군다양성·식감단계·KDRI 36영양·반복도·알레르겐·가공식품(초가공/일반)·제철·조리스타일 8축을 한 카드에서 자연어로 받을 수 있다.

로직: ALG-EVAL-01 참조
20

식단표 평가받기 (어린이집·유치원·가정 식단)

👩 부모

고객은 종이 식단표 사진 1장 업로드로 한 달치 8축 평가를 30초 안에 받을 수 있다.

흐름: daycare-eval 진입 → 만 나이 선택 → 사진 업로드 → ALG-OCR-01 → ALG-EVAL-05 → 결과 카드
프라이버시기관 이름 X · 사진 24h 후 자동 삭제
21

식단표 결과 → 보충 레시피 받기

👩 부모

고객은 평가받은 결과의 부족 영양소를 채우는 레시피를 받기 위해 도감 앱으로 자연스럽게 진입한다.

흐름: 결과 모달 CTA → /dogam.html?source=daycare&grade=B+&deficits=콜린,비D,EPA-DHA → 환영 배너 + AI 식단 모달 자동 오픈
22

오늘 만난 영양 가족 (8 식품군)

👩 부모

고객은 WHO MDD 8 식품군 카테고리 그리드(곡물·콩·유제품·고기생선·계란·진한채소·기타채소·과일)로 오늘 만난 그룹을 한눈에 본다. 만난 식품군은 회색→컬러로 활성화, 카운트 "5 / 8 충족" 표시.

22b

식감/메뉴 반복 narrative 알림

👩 부모

고객은 8축 진단의 warn 항목(식감 단계·메뉴 반복)을 별도 narrative 카드로 받고, 클릭으로 레시피 탭 자동 이동한다.

예시: "이번 주 죽·미음 비중 65%예요 → 내일 시작 추천: 당근 스틱 + 후무스" / "한 주 동안 닭죽이 5번 → 로테이션: 버섯·콩·생선으로 베이스 바꾸기"

2-5. 카테고리 D — 레시피 (UC-23 ~ UC-28)

23

레시피 추천받기

👩 부모

고객은 홈 점수 카드 CTA를 눌러 4원칙 기반 맞춤 레시피 6-8개를 즉시 받을 수 있다.

로직: ALG-RECO-01 (Phase 0~3) 참조
24

레시피 클릭 → 상세 보기

👩 부모

고객은 레시피 카드 클릭 시 재료(1인분 g)·조리 순서·우리 아이 적용 팁·매칭 정보 통합 모달을 본다.

통합 템플릿 renderRecipeDetailHTML()
25

AI 3일 식단 받기

👩 부모

고객은 "AI에게 3일 식단 받기" 클릭으로 일자별 4끼(아침·점심·간식·저녁) × 3일 = 12끼 맞춤 식단을 받는다.

로직: ALG-RECO-02 (3day plan generator)
26

레시피 → 오늘 식단 기록

👩 부모

고객은 레시피 상세 모달 하단의 "📝 오늘 식단으로 기록" 버튼으로 즉시 meal_log 생성한다.

27

레시피 → 내일 식단 예약

👩 부모

고객은 레시피 상세에서 "📥 내일 식단에 추가" 버튼으로 미래 끼니 예약 + 장보기 리스트 생성 가능.

28

레시피 탭 4원칙별 보기

👩 부모

고객은 레시피 탭에서 4원칙(선호+도전·원물·가림·영양보강) 섹션별로 분류된 추천을 본다.

2-6. 카테고리 E — 도감 (UC-29 ~ UC-32)

29

도감 (식재료 친해지기 훈련)

👩 부모

고객은 식재료별 노출·먹은 횟수, 마지막 만난 시점, 친해지기 진행도를 본다.

30

식재료 클릭 → 영양 특성 + 패턴 맞춤 요리

👩 부모

고객은 식재료 클릭 시 KDRI 영양 특성·흡수율 팁·우리 아이 패턴 분석·맞춤 요리 5종(원칙별 분류)을 받는다.

31

이번 주 시도해볼 TOP 10

👩 부모

고객은 오래 못 만난·거부도 높은 식재료 TOP 10을 빈도순 랭킹으로 본다.

32

친해지기 SOS 키트 추천

👩 부모

고객은 22번 노출에도 8번만 먹는 식재료 발견 시 SOS 6단계 키트 추천 알림을 받는다.

2-7. 카테고리 F — 공유·학습·알림·운영 (UC-33 ~ UC-34)

33

공식 블로그 후킹 카드 (가로 스크롤)

👩 부모

고객은 홈 하단 보라색 그라데이션 카드 가로 스크롤로 최신 블로그 8편의 후킹된 제목(예: "단맛 좋아하는 우리 아이 유전일까?", "편식 2년 골든타임", "NHS 의사 거부 한 마디 비법")을 본다. 우상단 "전체 357편 →" 버튼으로 인덱스 진입. 각 카드 클릭 = 해당 글 새 창.

제목 후킹 패턴: 질문형("혹시 ARFID?")·위험형("위험한 이유")·권위형("NHS 의사")·호기심형("그 DB")
33b

평가 결과 공유 (카드 PNG · 친구)

👩 부모

고객은 daycare-eval 결과 카드를 PNG로 저장하거나 카카오·인스타로 공유한다. 공유 시 출처(어린이집·유치원 이름) 자동 제거 — 점수와 보충 가이드만 노출.

흐름: 결과 모달 → "📥 카드 저장" (html2canvas) / "📤 친구에게" (navigator.share API) → 자동 복사
33c

알림 (top-bar 🔔)

👩 부모

고객은 상단 알림 아이콘 (빨간 dot) 클릭으로 미확인 알림(식단 미기록·신호등 변화·키트 추천·새 블로그)을 본다.

알림 종류: 식단 미기록 푸시(저녁 8시) · 신호등 빨강 변화 · 키트 추천 (SOS 6단계) · 신규 블로그 (주 1회)
34

운영자 — 신규 식재료 검수

🧑‍🔬 영양사

영양사는 부모가 입력한 신규 식재료 목록을 검토하고 영양 정보·알레르겐을 확정해 마스터 DB에 승격할 수 있다.

흐름: 운영 대시보드 → status='pending_review' 큐 → 검수 → verified 승격 → 다음 사용자부터 정확 매칭

평가 엔진 — Pure Rule (5 알고리즘 상세)

3-0. 엔진 아키텍처

flowchart TD IN["📝 입력
meal_logs (최근 3-7일)
+ child profile (age·sex·allergens)
+ growth_stats (최신 BMI)"] PRE["🔧 전처리
1. 식재료 정규화 (alias → id)
2. 끼니별 영양 합산
3. accept_rate 계산"] A1["ALG-EVAL-01
8축 식단 진단"] A2["ALG-EVAL-02
36 영양 신호등
(빈도 → 충족률)"] A3["ALG-EVAL-03
BMI percentile
(KDC lookup)"] A4["ALG-EVAL-04
탄·단·지 + BMI
(빈도 × BMI 매트릭스)"] A5["ALG-EVAL-05
OCR 식단표 평가
(8축 변형)"] POST["📤 출력
scores JSONB + grade + 자연어 진단"] IN --> PRE PRE --> A1 & A2 & A3 & A5 A3 --> A4 A2 --> A4 A1 & A2 & A4 & A5 --> POST classDef in fill:#E3F2FD,stroke:#3498DB,color:#1F2D3D classDef proc fill:#E8F5E9,stroke:#16A085,color:#1F2D3D classDef out fill:#FFF8E1,stroke:#F9A825,color:#1F2D3D class IN in class PRE,A1,A2,A3,A4,A5 proc class POST out

3-1. 5개 평가 알고리즘 상세

ALG-EVAL-01

8축 식단 진단

WHO·HabEat·Satter·CEBQ·Solid Starts 기반 학술 8축 평가.

입력
meal_logs 3-7일
출력
8축 × {pct, status, detail}
복잡도
O(N·M) N=끼니, M=평균재료
의존
ingredients.category

축별 계산식

  1. ① 식품군 다양성 (WHO MDD): 8 식품군(곡물·콩·유제품·고기생선·계란·진한채소·기타채소·과일) 중 등장 종류 → pct = (등장/8) × 100
  2. ② 식감 단계 (HabEat): texture_level 분포 vs 연령 권장 분포 → 0-100
  3. ③ KDRI 36 영양 (보건복지부): 36종 대비 식재료 커버 비율 (ALG-EVAL-02)
  4. ④ 메뉴 반복도: 동일 메뉴 등장 횟수 — 밥·김치·우유 등 주식 제외, 적을수록 A
  5. ⑤ 알레르겐 표기 (식약처): 17대 알레르겐 표기·안전
  6. ⑥ 가공식품 (NOVA): 초가공(강한 감점) + 일반 가공(약한 0.3 가중) 2단계 — 카레·돈가스 등 집밥은 제외
  7. ⑦ 제철 식재료 (농진청): 해당 월 제철 활용도
  8. ⑧ 조리 스타일 다양성: 한식·양식·일식·중식 등 cuisine 다양성
  9. ※ 식사 환경·식욕 응답·새 시도·자율성(Satter DOR·CEBQ) 등 정성 신호는 8축 점수가 아니라 AI 코치 엔진(정성 시계열)에서 별도로 다룸 — 점수=룰, 정성=LLM
def axis_diversity(logs):
    families_met = set()
    for log in logs:
        for ing in log.ingredients:
            families_met.add(FOOD_FAMILY_MAP[ing.category])
    pct = len(families_met) / 8 * 100
    return {
        'pct': pct,
        'status': 'green' if pct >= 80 else 'orange' if pct >= 50 else 'red',
        'detail': f'{len(families_met)}/8 식품군',
        'narrative': generate_diversity_narrative(families_met, missing=ALL_FAMILIES - families_met)
    }
예시 출력: {pct: 62.5, status: 'orange', detail: '5/8 식품군', narrative: '곡물·콩·유제품·고기·과일은 충분히 만났어요. 진한 채소·기타 채소·계란 3가지가 빠져있어요.'}
ALG-EVAL-02

36종 KDRI 신호등 (빈도 → 충족률 추정)

그램 단위 정확 측정 불가 → 끼니 등장 빈도 기반 추정. KDRI 2025 만 1-2세 RNI/AI 기준.

입력
meal_logs 7일 + ingredients.nutri_per_100g
출력
36 영양소 × {pct, status, freq_label}
복잡도
O(N·M·K) K=영양소 36
의존
kdri_rni, ingredients.nutri_per_100g

단계

  1. 각 끼니의 식재료별 영양 기여도 계산: contribution_pct = (nutri_per_100g × estimated_g/100) / KDRI_RNI[age_band][nutrient]
  2. 30% 이상 기여하면 "이 끼니에 등장" 카운트 → nutrient_meals[n] += 1
  3. 주간 권장 빈도와 비교: freq_pct = nutrient_meals / (days × 2) × 100
  4. status 분류: ≥80 green / 50-80 orange / <50 red
  5. 자연어 빈도 라벨: 90+ "거의 매일", 75-90 "주 4-5회", ... 15< "거의 못 만남"
def signal_36_nutrients(child_id, days=7):
    logs = get_meal_logs(child_id, days=days)
    age_band = get_age_band(child_id)  # '1-2y'
    nutrient_meals = defaultdict(int)

    for log in logs:
        contributions = defaultdict(float)
        for ing in log.ingredients:
            est_g = ing.estimated_g or CATEGORY_AVG_G[ing.category]
            for nutrient, amt_per_100g in ing.nutri_per_100g.items():
                rni = KDRI_RNI[age_band].get(nutrient)
                if not rni: continue
                contributions[nutrient] += (amt_per_100g * est_g / 100) / rni
        for n, c in contributions.items():
            if c >= 0.3:
                nutrient_meals[n] += 1

    weekly_target = days * 2
    return {n: {
        'pct': nutrient_meals[n] / weekly_target * 100,
        'status': classify(nutrient_meals[n] / weekly_target),
        'freq_label': freq_to_label(nutrient_meals[n] / weekly_target * 100)
    } for n in KDRI_36_NUTRIENTS}
예시: 철 → 7일 × 2 = 14 권장 끼니. 8 끼니에 30%+ 기여 → 57% → orange → "주 3-4회"
ALG-EVAL-03

BMI percentile (KDC 표준성장도표)

한국질병관리청 2017 소아청소년 성장도표 LMS 파라미터로 percentile 정확 계산.

입력
height, weight, sex, age_months
출력
pct_height, pct_weight, pct_bmi + status
의존
KDC_LMS_TABLE (월별)
  1. BMI 계산: BMI = weight_kg / (height_cm/100)²
  2. 월령 기반 LMS 파라미터 lookup: L, M, S = KDC_LMS[sex][age_months]['bmi']
  3. Z-score 계산: Z = ((BMI/M)^L - 1) / (L·S) (L≠0)
  4. Z-score → percentile (정규분포 cumulative): pct = round(scipy.stats.norm.cdf(Z) * 100)
  5. 분류: <5p 저체중 / 5-85p 정상 / 85-95p 과체중 / 95p+ 비만
예시 (지우 만 28개월 여, 88.5cm/12.4kg): BMI=15.83, KDC 28m 여 LMS(BMI)={L:-0.85, M:15.99, S:0.071} → Z=-0.13 → pct=45% → 정상
ALG-EVAL-04

탄·단·지 + BMI 종합

빈도(매일·가끔·드물게) × BMI percentile 매트릭스로 다량영양소 양 평가.

입력
macro 빈도 + BMI status
출력
탄·단·지 × {status, message}
MATRIX = {
    ('daily', 'normal'):     {'carb': 'ok', 'protein': 'ok', 'fat': 'ok'},
    ('daily', 'underweight'):{'carb': 'ok', 'protein': 'boost', 'fat': 'warn_low'},
    ('daily', 'overweight'): {'carb': 'warn_high', 'protein': 'ok', 'fat': 'warn_high'},
    ('rare', 'underweight'): {'carb': 'warn_low', 'protein': 'warn_low', 'fat': 'warn_low'},
    # ... 모든 조합
}
MESSAGES = {
    'warn_low':  '양 부족 — 견과·아보카도·EPA+DHA 보강',
    'warn_high': '양 조절 — 잡곡 비중↑, 간식 줄이기',
    'boost':     '성장기 필요 — 충분히 OK',
}
ALG-EVAL-05

식단표 OCR 평가 (8축 변형)

한 달치 식단표 한 장에서 OCR로 추출한 메뉴를 8축으로 평가. 가정 기록과 다른 점: 가공식품 비율·계절성 추가.

입력
OCR 결과 (~30일 메뉴)
출력
8축 점수 + 총점 + 등급
  1. OCR 결과 → recipes 매칭 (메뉴명 fuzzy match) → 영양 합계 계산
  2. 다양성·식감·반복·KDRI 36종 (ALG-EVAL-01~02 재사용)
  3. 알레르겐 표시 안전: 알레르겐 번호 표기 비율
  4. 가공식품 비율: 가공식품 카테고리 등장 비율
  5. 계절성·국산: 4월=봄나물 매핑 + 원산지 표기 점수
  6. 총점 가중평균 → 5등급

추천 엔진 — Hybrid (7 알고리즘 상세)

4-0. 엔진 아키텍처

flowchart TD REQ["📥 요청
POST /api/recipe-suggest
{child_id, type, context}"] CACHE{"💾 캐시
profile_hash 일치?"} P0["ALG-RECO-00
식습관 프로파일 자동 집계"] P1A["원칙 1
선호 + 도전"] P1B["원칙 2
오랜만 → 원물"] P1C["원칙 3
가림 형태"] P1D["원칙 4
영양 보강"] RANK["ALG-RECO-05
후보 랭킹
(가중합)"] P2["ALG-RECO-06
LLM 정제
Claude Sonnet 4.7"] P3["💾 캐시 저장
24h TTL"] OUT["📤 응답
6-8 레시피 + 자연어"] REQ --> CACHE CACHE -->|HIT| OUT CACHE -->|MISS| P0 P0 --> P1A & P1B & P1C & P1D P1A & P1B & P1C & P1D --> RANK RANK --> P2 P2 --> P3 P3 --> OUT classDef io fill:#FFF8E1,stroke:#F9A825,color:#1F2D3D classDef proc fill:#E8F5E9,stroke:#16A085,color:#1F2D3D classDef llm fill:#F3E5F5,stroke:#9C27B0,color:#1F2D3D class REQ,OUT,CACHE,P3 io class P0,P1A,P1B,P1C,P1D,RANK proc class P2 llm
ALG-RECO-00

Phase 0 — 식습관 프로파일 자동 집계

입력
meal_logs 30일
출력
profile dict (식재료·베이스·식감·신호등)
profile = {
    'ingredient_stats': {
        '시금치': {
            'exposure_count': 22,
            'eat_count': 8,
            'accept_rate': 0.36,
            'last_seen_days_ago': 3,
            'preferred_forms': ['즙', '다진'],
            'rejected_forms': ['통째']
        }, ...
    },
    'base_stats': {
        '계란찜': {'eat_count': 43, 'preference_score': 0.95}, ...
    },
    'texture_distribution': {'죽': 0.65, '매시': 0.25, ...},
    'nutrient_status': {'iron': 'red', 'choline': 'red', ...}
}
ALG-RECO-01

원칙 1: 선호 베이스 × 도전 식재료

WITH preferred_bases AS (
  SELECT r.base_type, SUM(CASE WHEN ml.reaction IN ('잘먹음','또달라') THEN 1 ELSE 0) AS cnt
  FROM meal_logs ml JOIN meal_log_ingredients mli ON ml.id = mli.meal_log_id
                  JOIN recipes r ON (...메뉴명 매칭...)
  WHERE ml.child_id = $1 AND ml.eaten_at > NOW() - INTERVAL '30 days'
  GROUP BY r.base_type ORDER BY cnt DESC LIMIT 5
),
challenge_ings AS (
  SELECT id FROM ingredients
  WHERE id IN (SELECT ing_id FROM low_accept_view WHERE child_id = $1)
  LIMIT 10
)
SELECT r.*,
  base_score(r.base_type) * concealment_score(r.id) AS score
FROM recipes r
WHERE r.base_type IN (SELECT base_type FROM preferred_bases)
  AND EXISTS (
    SELECT 1 FROM recipe_ingredients ri
    WHERE ri.recipe_id = r.id
      AND ri.ingredient_id IN (SELECT id FROM challenge_ings)
      AND ri.form IN ('즙','다진','매시')
  )
ORDER BY score DESC LIMIT 50;
ALG-RECO-02

원칙 2: 잘먹지만 오랜만 → 원물 노출

  1. 대상 식재료: accept_rate ≥ 0.7 AND last_seen_days_ago > 30 AND 모든 preferred_forms이 가린 형태('즙','다진','매시')
  2. 레시피 필터: 해당 재료를 통째·스틱·큐브 형태로 사용하는 레시피
  3. texture_level > 현재 사용자 최대 texture (점진 단계 ↑)
  4. 랭킹: age_appropriateness(texture, age_months) × loved_score(ing)
ALG-RECO-03

원칙 3: 극도 거부 → 형태 안 보이게

  1. 대상: accept_rate < 0.3 AND exposure_count ≥ 5
  2. 레시피 필터: concealment[ing] == 'high' (즙·페이스트)
  3. 추가: base_type ∈ preferred_bases (베이스도 친숙)
  4. 랭킹: concealment_score × base_preference_score
ALG-RECO-04

원칙 4: 부족 영양 보강

WITH red_nutrients AS (
  SELECT nutrient FROM signal_36_view
  WHERE child_id = $1 AND status = 'red'
  ORDER BY pct ASC LIMIT 5
)
SELECT r.*, (
  SELECT SUM(
    (r.nutri_total->>n.nutrient)::DECIMAL /
    (SELECT rni FROM kdri_rni WHERE age_band = $2 AND nutrient_code = n.nutrient)
  )
  FROM red_nutrients n
) AS boost_score
FROM recipes r
WHERE r.nutrient_highlights && ARRAY(SELECT nutrient FROM red_nutrients)
ORDER BY boost_score DESC LIMIT 80;
ALG-RECO-05

후보 랭킹 (가중합 score)

score = (
    base_pref_weight * base_preference_score(recipe.base_type, profile.base_stats) +
    concealment_weight * concealment_score(recipe, challenge_ings) +
    nutri_weight * nutrient_boost_score(recipe, red_nutrients) +
    novelty_weight * (1 if recipe not in recent_30days else 0) +
    age_fit_weight * age_appropriateness(recipe.age_group, child.age_months)
)
# 기본 가중치 (튜닝 가능)
DEFAULTS = {'base_pref':0.3, 'concealment':0.2, 'nutri':0.3, 'novelty':0.1, 'age_fit':0.1}
ALG-RECO-06

Phase 2 — LLM 정제 (Claude Sonnet 4.7)

Phase 1 결과 190 후보 → Claude에 전달 → 원칙별 최적 + 자연어 이유 생성.

def refine_with_llm(profile, candidates_190):
    system = """당신은 만 0-5세 영유아 식습관 데이터를 분석해..."""
    user = f"""
## 프로파일
{profile_to_natural_language(profile)}

## 190 후보 (Phase 1)
{json.dumps(candidates_190, ensure_ascii=False)}

## 출력 형식 (JSON Schema)
{
  "principle_1": [{"recipe_id": "uuid", "reason": "3-4문장 자연어"}, ...],
  "principle_2": [...], "principle_3": [...], "principle_4": [...]
}
"""
    response = anthropic.messages.create(
        model="claude-sonnet-4-7",
        max_tokens=4000,
        system=[{"type":"text","text":system,"cache_control":{"type":"ephemeral"}}],
        messages=[{"role":"user","content":user}]
    )
    return parse_and_validate(response.content)
호출 비용
~₩40/회 (캐시 hit 시 ~₩10)
응답 시간
3-8초 (스트리밍)
ALG-RECO-07

캐싱 — profile_hash 기반

def compute_profile_hash(profile):
    # 변동성 낮은 핵심만 해싱 → 캐시 hit rate ↑
    key_fields = {
        'top5_bases': sorted(top_5(profile.base_stats))[:5],
        'red_nutrients': sorted(profile.red_nutrients)[:5],
        'challenge_ings': sorted(top_10_challenge(profile))[:10],
        'age_band': profile.age_band,
    }
    return sha256(json.dumps(key_fields, sort_keys=True)).hexdigest()
캐시 hit rate 목표
≥ 70%
TTL
24시간

OCR 엔진 — CLOVA(식단표 전사) + Claude Vision(기록용 음식 사진) (6 알고리즘 상세)

5-0. 엔진 아키텍처

sequenceDiagram autonumber participant U as 👩 부모 participant API as Edge API participant Store as Supabase Storage participant Cache as ocr_cache participant V as CLOVA(식단표)/Vision(사진) participant Eval as 평가 엔진 participant DB as daycare_evals U->>API: POST /api/ocr-menu (FormData: image) API->>API: ALG-OCR-01 전처리 (회전·압축) API->>API: sha256(image) → hash API->>Cache: SELECT WHERE image_hash=hash alt 캐시 HIT Cache-->>API: extracted JSON else 캐시 MISS API->>Store: 업로드 (24h TTL) Store-->>API: signed URL API->>V: ALG-OCR-02 Vision 호출 + 프롬프트 V-->>API: 메뉴·날짜·알레르겐 JSON API->>API: ALG-OCR-03 정규화 (메뉴명→recipe_id) API->>API: ALG-OCR-04 알레르겐 매핑 API->>API: ALG-OCR-05 신뢰도 스코어링 API->>Cache: INSERT (영구) end API->>Eval: ALG-EVAL-05 8축 평가 Eval-->>API: scores + grade API->>DB: INSERT daycare_evals (24h TTL) API-->>U: 평가 결과 카드
ALG-OCR-01

이미지 전처리

  1. EXIF orientation 자동 회전
  2. 해상도 최적화: 긴 변 1600px 이하로 리사이즈 (Vision API 토큰 절감)
  3. 대비/밝기 자동 보정 (Pillow ImageOps.autocontrast)
  4. JPEG quality=85로 재인코딩
  5. sha256 해시 생성
ALG-OCR-02

Claude Vision 프롬프트

VISION_PROMPT = """
이 한국 어린이집·유치원·가정 식단표 이미지를 분석하여
다음 JSON 스키마로 출력해주세요. 부정확한 부분은 confidence<0.7로 표기.

{
  "year_month": "YYYY-MM",
  "facility_type_hint": "어린이집|유치원|가정|기타",
  "days": [
    {
      "date": "M/D",
      "weekday": "월|화|...",
      "is_event_day": false,
      "meals": {
        "오전간식": [{"name":"메뉴", "allergen_numbers":[1,5]}],
        "점심": [...],
        "오후간식": [...]
      }
    }
  ],
  "footer": {
    "nutritionist": "이름 or null",
    "allergen_legend": [{"num":1, "name":"난류"}, ...],
    "origin": {"쌀":"국내산", "쇠고기":"호주산", ...}
  },
  "confidence": 0.0-1.0
}

규칙:
- 메뉴명에서 알레르겐 번호 ⓞⓘⓢ는 allergen_numbers 배열로 분리
- 날짜는 YYYY-MM-DD 풀 포맷 X, M/D만
- 식재료 단순 나열이 아닌 메뉴 단위로 분리 (예: '잡곡밥/된장국/시금치나물' → 3개)
"""
ALG-OCR-03

메뉴 정규화 (메뉴명 → recipe_id)

  1. 메뉴명 텍스트 클렌징: 알레르겐 번호·괄호·특수문자 제거
  2. recipes.name LIKE 매칭 (fuzzy: trigram similarity)
  3. top-3 후보 → similarity ≥ 0.7 이면 매칭 성공
  4. 매칭 실패 시 LLM에 "이 메뉴는 어떤 base_type·texture? 식재료는?" 추가 호출
  5. recipe_id 또는 ad-hoc recipe 생성
ALG-OCR-04

알레르겐 매핑

  1. footer.allergen_legend → 숫자↔이름 매핑 테이블 구축
  2. 각 메뉴의 allergen_numbers → 한글 알레르겐 변환
  3. 아이 프로필 allergens와 교집합 → 위험 메뉴 자동 플래그
  4. UI: "⚠️ ○○ 메뉴에 우유 포함" 알림
ALG-OCR-05

신뢰도 스코어링 + 사용자 수정

  1. 전체 confidence = Vision의 self-report × 매칭 success rate
  2. 0.85+ : 자동 진행
  3. 0.7-0.85: 사용자에게 확인 UI ("이 메뉴 맞나요?")
  4. 0.7-: 수동 입력 모드 전환
  5. 사용자 수정 사항 → 다음 OCR 학습 데이터로 누적
ALG-OCR-06

식사 사진 OCR (식단 기록용)

daycare-eval과 별도 — 부모가 매일 음식 사진 올릴 때 식재료 자동 추출.

FOOD_VISION_PROMPT = """
이 음식 사진을 보고 식재료를 추출하세요.
하나의 메뉴에 들어간 모든 식재료를 나열 (소스·향신 포함).

{
  "menu_hint": "추정 메뉴명 (예: '시금치 두부 죽')",
  "ingredients": [
    {"name": "시금치", "confidence": 0.95},
    {"name": "두부", "confidence": 0.92},
    {"name": "쌀", "confidence": 0.88}
  ],
  "cooking_method_hint": "죽|볶음|국|...",
  "texture_hint": "매시|핑거|...",
  "overall_confidence": 0.0-1.0
}
"""