설계는 DB에 접속하지 못하는 환경에서 스키마 파일만 보고 만들어졌습니다. 그 전제가 실제 DB와 어긋나지 않는지 확인하고 적용하려고, 실 DB에 붙는 환경에서 검증했습니다.
사전 점검을 통과해 DDL 3개를 적용하고 크롤러 실동작까지 확인했습니다. 설계 수정 사항은 없었습니다.
측정하면서 두 가지를 발견했습니다. 가격 이력이 아직 짧아 기준선이 미성숙했고, 원설계처럼 두 소매의 가격을 합치면 편차가 중앙값 기준 2배 넘게 부풀어 탐지가 죽는다는 것이었습니다.
아래부터 당시 작성한 기록을 공개용으로 정제한 전문입니다.
설계 문서recipe-review-handoff.md— 당시 별도 설계 핸드오프 문서에 기록는 "무엇을 왜 바꾸려는가" 다. 이 리포트는 "실제로 무엇이 바뀌었는가" 다. 설계는 DB에 접속하지 못한 PC에서 스키마 파일만 보고 만들어졌고(핸드오프 §12), 이 문서는 실 DB에 붙는 환경에서 검증·적용한 결과다. 수신: 데이터 담당자 · 작성: AI 파트 · 대상 DB:[내부 주소 비공개]/foodbudget
프리플라이트 PASS → DDL 3개 적용 완료 → 크롤러 실동작 검증 완료. 스키마를 고정해도 된다(G4).
핵심 4가지만 먼저:
파싱 신뢰도는 크게 올랐다. 설계 시점의 검증은 표본 1건이었으나(핸드오프 §5), 이번 실측에서 no_review 0.0% 로 확인됐다.
🔴 운영으로 남은 하나: 컬리 크롤 결손이 계속되면 컬리 기준선은 28일에 도달하지 못해 알림이 영영 나가지 않는다. 크롤 안정성이 곧 기능 가동 조건이다(§4).
=== 프리플라이트: 설계 전제 검증 (읽기 전용) ===
접속 DB=foodbudget / 사용자=fbapp
A2 크롤 대상(source=10K): 6873 건
B3 최근 30일 중 크롤된 일수: 15 일
--- 경고 (진행 가능, 인지 필요) ---
⚠️ B3 관측 15일 < 28일 — 기준선이 미성숙, 오탐 구간이다(탐지 게이트 필요)
PREFLIGHT PASS — 전제가 모두 성립한다. DDL 을 적용해도 된다.
| 항목 | 결과 |
|---|---|
| 실패 | 0건 |
| 경고 | 1건 — B3(관측 15일) |
| 종료코드 | 0 |
notify.notification.type CHECK 의 LOW_PRICE — 통과(실패 목록에 없음). 스키마 파일에만 있고 실 DB에 반영 안 됐을 가능성을 검사한 항목인데, 실제로 반영돼 있었다. 알림 insert 가 런타임에 실패할 위험은 없다.source='10K' 이고 src_recipe_id 가 있는 레시피.프리플라이트 PASS 확인 후 && 로 이어서 실행했다(프리플라이트가 실패하면 DDL이 돌지 않는다). 전부 -v ON_ERROR_STOP=1.
| 순서 | 파일 | 결과 | 생성물 |
|---|---|---|---|
| 0 | 2026-07-29_preflight.sql | PASS (읽기 전용) | — |
| 1 | 2026-07-29_recipe_review.sql | COMMIT | 테이블 4 · 인덱스 3 |
| 2 | 2026-07-29_price_anomaly.sql | COMMIT | 테이블 3 · 인덱스 3 |
| 3 | 2026-07-29_extract_job_link.sql | COMMIT | 컬럼 1 · 인덱스 1 · COMMENT |
\d 대조)| 테이블 | FK | CHECK | 인덱스 |
|---|---|---|---|
recipe_review | 1 | 0 | 3 |
recipe_review_crawl | 1 | 1 | 2 |
recipe_review_sentiment | 1 | 1 | 2 |
recipe_review_summary | 1 | 0 | 1 |
price_baseline | 1 | 0 | 2 |
price_anomaly | 2 | 1 | 3 |
price_alert_sent | 1 | 0 | 2 |
CHECK 제약 3종이 설계대로 붙었다:
recipe_review_crawl_status_check CHECK (status IN ('ok','no_review','fail'))
recipe_review_sentiment_label_check CHECK (label IN ('positive','negative','neutral'))
price_anomaly_source_check CHECK (source IN ('kurly','oasis'))
sourceCHECK 는 실 데이터와 일치한다 —retail_product의 source 는kurly(3,626) ·oasis(1,807) 둘뿐이다.
기존 테이블 변경 1건: recipebook.extract_job.user_recipe_id (bigint, nullable, DEFAULT 없음 → 테이블 재작성 없음). FK → user_recipe(id) ON DELETE SET NULL, 부분 인덱스 extract_job_orphan_done_idx (status='DONE' AND user_recipe_id IS NULL) 생성 확인.
멱등 재실행: 3개 파일을 한 번 더 실행 → 에러 0건.
프리플라이트 실패 0건. 실 DB가 설계 전제와 어긋난 부분이 없어 스키마·마이그레이션을 그대로 적용했다.
다만 적용 후 가격 데이터를 직접 측정하면서 설계 결함 1건을 찾아 교정했다 — price_baseline 의 기준선 단위를 품목별에서 (품목, 소스)별로 바꿨다. 프리플라이트는 "테이블·컬럼이 있는가"를 보지 그 설계 가정이 데이터와 맞는가는 보지 않으므로 잡히지 않는 종류다. 근거와 조치는 §6.1.
DB 설계는 무결했지만, 안전장치인 프리플라이트 스크립트에 결함이 있었다.
DECLARE fails text[] := '{}';
...
fails := fails || 'A1 recipe 테이블이 없다 …'; -- ❌ malformed array literal
text[] || <맨 문자열 리터럴> 은 PostgreSQL이 우변을 배열 리터럴로 해석해 실패한다. format(...) 을 쓴 줄(B3·A2 경고)만 text 로 확정돼 정상 동작했다.
무엇이 문제인가 — 실패 메시지를 담는 15개 fails 줄이 전부 이 형태였다. 즉 검사가 하나라도 실패했다면 프리플라이트는 PREFLIGHT FAIL 을 출력하는 대신 배열 오류로 죽었을 것이다. 우리가 PASS를 받은 것은 *아무것도 실패하지 않았기 때문*이지, 실패 경로가 검증됐기 때문이 아니다.
실제로 DDL 적용 후 재실행하자 D1 분기(recipe_review 가 이미 존재)가 처음 발동하면서 이 결함이 드러났다:
ERROR: malformed array literal: "D1 recipe_review 가 이미 존재 — …"
대응 — 맨 리터럴 17곳에 ::text 캐스트를 붙였다. 재실행 결과 경고 경로가 정상 동작한다:
⚠️ B3 관측 15일 < 28일 — 기준선이 미성숙, 오탐 구간이다(탐지 게이트 필요)
⚠️ D1 recipe_review 가 이미 존재 — 마이그레이션은 건너뛴다. 컬럼 일치를 수동 확인할 것
⚠️ D2 price_anomaly 가 이미 존재 — 컬럼 일치를 수동 확인할 것
PREFLIGHT PASS
핸드오프 §8이 "이 프리플라이트 자체도 실행 검증되지 않았다" 고 미리 밝혀 둔 그대로였다. 이제 프리플라이트는 재실행 가능하다.
최근 30일 중 가격이 크롤된 날은 15일뿐이다. 설계가 요구하는 28일의 절반이다.
이것이 뜻하는 바:
price_baseline · price_anomaly 테이블은 준비됐다. 스키마는 문제없다.obs_count 가 오탐 게이트다")이 바로 이 상황을 위한 장치다. 표본수가 임계 미만이면 탐지를 건너뛰도록 배치에서 게이트를 걸어야 한다 — 코드 상수가 아니라 price_baseline.obs_count 데이터로 판정한다.원인을 데이터로 파고든 결과(§6.5.1 상세):
| 소스 | 크롤 일수 | 원인 |
|---|---|---|
| oasis | 15일 | 가격 이력이 2026-07-13 시작 — 30일 창이 아직 물리적으로 못 찬다 |
| kurly | 9일 | 위 + 07-14~07-20 7일 연속 결손 · 07-29 결손 |
07-18·19(토·일)는 양쪽 모두 결손이다. 크론은 매일이므로 스케줄이 아니라 실행 실패다.
조치 완료(코드): 이 경고를 문서가 아니라 코드로 강제했다 — 성숙도 게이트 (MATURE_SAMPLES=28)가 미성숙 기준선의 급락을 기록은 하되 발행하지 않는다. 실 DB에서 현재 탐지 7건이 전부 발행 제외됨을 확인했다. 이력이 쌓이면 자동으로 풀린다.
컬리 결손의 원인은 규명됐고 이미 해소돼 있다(§6.7.2) — Playwright 브라우저 리비전 불일치였고, Dockerfile 버전 핀 + 빌드타임 스모크로 고쳐져 07-21 이후 8일 연속 성공이다. 배포 이미지 crawler-kurly:1.1.9 로 chromium 실기동을 확인했다.
따라서 남은 것은 시간뿐이다 — 매일 성공 가정 시 2026-08-18 에 28일 도달, 그때 성숙도 게이트가 자동으로 풀린다. 그전까지 탐지 배치는 기록만 하고 발행하지 않는다.
⚠️ 일자 경계가 UTC 였던 문제도 함께 고쳤다(§6.7.1) — 그전 관측일수는 KST 기준과 어긋날 수 있었다.
설계 시점에 미검증이던 항목 — DB 연결 · DDL 적용 · SQL 실제 실행(핸드오프 §5의 ❌ 3종) — 이 단계에서 한 번에 해소됐다.
| 단계 | 명령 | 결과 |
|---|---|---|
| 대상 조회 | --dry-run --limit 20 | URL 20개 출력 · 쓰기 0 ✅ |
| 시범 적재 | --limit 10 | 리뷰 62건 · 실패 0 ✅ |
| 재개 확인 | --limit 10 재실행 | 이전 10건 건너뛰고 새 10건 처리 ✅ |
| 분포 확인 | --limit 100 | 리뷰 2,122건 · no_review 0 ✅ |
| 전량 | (인자 없음) | §5.2 |
핸드오프 §5는 파싱 검증이 표본 1건임을 경고하며, "no_review 비율이 비정상적으로 높으면 파서가 못 잡는 레이아웃이 있다는 신호" 라고 판정 기준을 남겼다.
결과: no_review 0.00% · 실패 0건. 표본 1건이던 파싱 신뢰도가 6,873 레시피 전수로 확인됐다.
| 항목 | 값 |
|---|---|
| 크롤 대상 | 6,873 레시피 (source='10K') |
| 완주 | 6,873 / 6,873 (잔여 0) |
| 수집 리뷰 | 141,298건 |
no_review | 0건 (0.00%) |
fail | 0건 |
중복 (recipe_id, seq) | 0 |
| 빈 본문 | 0 |
| 소요 시간 | 약 1시간 40분 (워커 3 · 딜레이 0.6~1.2s) |
| 레시피당 리뷰 | 최소 1 · 중앙값 4 · 평균 20.6 · 최대 739 |
| 본문 평균 길이 | 43.9자 |
핸드오프 §5의 판정 기준을 그대로 적용하면 통과다 — "no_review 비율이 비정상적으로 높으면 파서가 못 잡는 레이아웃이 있다는 신호" 였는데, 6,873건 전수에서 0% 다. 3단 파서(CSS·헤딩·평문)가 실제 레이아웃 분포를 모두 덮는다.
📌 동적 로딩 절단은 여전히 한계로 남는다(핸드오프 §5). 상세 페이지 HTML 에 처음 실린 후기까지만 수집하므로 레시피당 실제 후기 수보다 적을 수 있다. 최대 739건이 잡힌 레시피가 있어 실사용에는 충분해 보이지만, 긍정 비율(%) 이 표본 편향을 갖는지는 미확인이다 — 감정분석 착수 전 몇 건 육안 대조를 권한다.
--retry-failed 회귀 확인핸드오프 §5가 "수정 완료" 로 기록한 버그(LEFT JOIN 의 ON 절에 상태 조건 → 전량 재크롤)를 실 DB에서 검증했다.
normal_mode | retry_mode | ok_rows | fail_rows | ok_leaked_into_retry
6853 | 6853 | 20 | 0 | 0
ok_leaked_into_retry = 0 — 이미 성공한 레시피는 재크롤 대상에 섞이지 않는다. 원래 버그는 확실히 고쳐져 있다. ✅retry_mode 가 normal_mode 와 같은 것은 버그가 아니다 — 실패가 0건이고 미시도가 6,853건이라 (미시도 OR 실패) = 미시도가 된다.📌 문서-코드 경미한 불일치: docstring 은 "status='fail'만 다시 대상에 넣는다" 라고 쓰여 있으나, 실제 조건은(c.recipe_id is null or c.status = 'fail')= 미시도 + 실패다. 전량 수집이 끝난 뒤에는 미시도가 0이라 결과가 같아지므로 실사용 영향은 없다. docstring 을 코드에 맞추는 편이 정확하다.
이 번들이 만들어진 뒤 레포가 앞서 나갔다. 핸드오프 §9.4는 "탐지 배치·컨슈머 코드는 아직 없다" 를 전제하지만, 그 사이 #9가 구현돼 PR #353로 올라가 있었다. 같은 기능을 다른 방식으로 구현한 설계가 두 벌 존재했고, 어느 쪽이 옳은지 실 데이터로 판정한 뒤 하나로 합쳤다.
원설계 price_baseline 의 PK 는 (item_id, as_of) 로 컬리·오아시스를 한 기준선에 합쳤다. 근거는 "100g 로 정규화하면 같은 축에 쌓이므로 baseline 이 2배 속도로 축적된다" 였다. 실 DB 측정 결과 그 전제가 성립하지 않는다.
| 측정 | 결과 |
|---|---|
| 두 소스가 모두 파는 품목 | 175개 |
| 같은 품목의 100g 단가 차이 | 중앙값 41.9% (61%가 30%↑ · 45%가 50%↑) |
| 소스를 합쳤을 때 σ 부풀림 | 중앙값 2.08배 (53%가 2배↑ · 31%가 5배↑) |
z = (x − μ) / σ 이므로 σ 가 2배면 모든 z 가 절반이 된다. z ≤ −2.0 임계에서 실제 −2.4짜리 급락이 −1.2로 찍혀 탐지되지 않는다. σ가 5배 부푼 31% 구간은 사실상 탐지 불능이다.
표본이 2배가 되는 이득은 μ·σ 추정의 정밀도에 그치지만, σ 부풀림은 탐지 자체를 무력화한다. 교환이 성립하지 않는다. 두 소매가 같은 품목을 다른 가격대에 파는 것은 아티팩트가 아니라 사실(매장 포지셔닝)이므로, "평상시 가격"도 소스별로 정의되어야 한다.
→ 적용: 2026-07-29b_price_baseline_per_source.sql 로 price_baseline 에 source 를 추가하고 PK 를 (item_id, source, as_of) 로 교정했다. 세 테이블 모두 0행일 때 고쳐 무손실이다. 기존 마이그레이션 파일은 건드리지 않았다(G4 규칙).
두 설계는 서로 다른 것을 잘하고 있었다. 하나를 버리는 대신 합쳤다.
| 축 | 채택 | 근거 |
|---|---|---|
| 기준선 단위 | (품목, 소스) | §6.1 실측 |
| 기준선·이상치 영속 | 번들 (price_baseline·price_anomaly) | 알림 근거 재현 + 오탐률 사후 측정. PR #353은 계산 후 버려 근거가 남지 않았다 |
| 근거 스냅샷 | 번들 (retail_product_id·crawled_at·price NOT NULL) | 합성금액 금지를 스키마가 강제 |
| 발송 멱등 | 양쪽 다 | 아래 참조 |
| Kafka 토픽 | price.anomaly.detected (PR #353) | 브로커에 이미 생성·코드·k8s 매니페스트·테스트가 이 이름으로 검증 완료 |
| 최소 하락률 게이트 | PR #353 | 원설계에 없다. z 단독은 σ 극소 품목을 상위로 올려 체감 없는 알림을 만든다(실측) |
| 노출 상한 TOP_N=20 | PR #353 | 팀 결정 |
발송 멱등을 둘 다 두는 이유 — 서로 다른 실패를 막는다.
price_alert_sent (anomaly_id, user_id) PK → 같은 이상치의 재전달(Kafka at-least-once)쿨다운만 있으면 8일째 재전달된 옛 이벤트가 다시 나가고, PK만 있으면 매일 새로 탐지된 같은 품목이 매일 나간다. 컨슈머는 알림 생성과 이력 기록을 한 문장(CTE) 으로 처리한다 — 두 문장으로 나누면 사이에서 죽었을 때 알림만 남고 이력이 비어 재처리 시 중복 발송된다.
원래 탐지기는 min(price/weight_g*100) 으로 집계해 어느 상품이 그 최저가를 만들었는지 잃었다. price_anomaly 가 retail_product_id·crawled_at·price 를 NOT NULL 로 요구하면서 이 결함이 드러났다 — 번들 스키마가 제 역할을 한 것이다.
_DAILY_SQL 을 row_number() ... rn = 1 로 바꿔 최저가를 만든 실제 행을 함께 가져온다.--persist 플래그 추가. --emit 은 이를 함의한다 — 근거 없이 알림만 나가면 "왜 이게 급락인가"에 답할 수 없고 오탐률도 측정할 수 없다.published_at 을 찍는다. 미발행분은 published_at IS NULL 로 남아 재시도 대상이 된다(부분 인덱스가 이미 있다).price_anomaly.drop_pct 컬럼 추가 — 알림 문구가 "▼26% 급락"으로 나가는데 그 수치가 기록에 없어 근거 재현이 불완전했다.| 항목 | 결과 |
|---|---|
| dry-run(기본) | 시계열 522개 · 5,562행 스캔 · 7건 탐지 · 기록 0 |
--persist | price_baseline 491건(2소스) · price_anomaly 7건 |
| 근거 무결성 | 실제가 7,900원 → 100g 1,580원 (합성 아님) |
| 재실행 멱등 | 재실행 후에도 7건 (UNIQUE 동작) |
--emit | 발행 7건 · published_at 7건 기록 |
| tracked fan-out | 1차 알림 1 + price_alert_sent 1 → 재전달 시 0행·알림 불변 |
| 테스트 | 27 passed |
검증에 쓴 행은 전부 삭제·롤백했다. 현재price_baseline·price_anomaly·price_alert_sent는 0행이며, 운영 배치는 아직 스케줄되지 않았다(§8).
적용·검증 과정에서 결함 6건이 드러났다. 전부 고치고 실물로 재검증했다.
| # | 결함 | 어떻게 드러났나 | 해결 | 검증 | ||
|---|---|---|---|---|---|---|
| 1 | 프리플라이트가 실패를 보고 못 하고 죽는다 (`text[] \ | \ | 맨 리터럴`) | DDL 적용 후 재실행에서 D1 분기 첫 발동 | 맨 리터럴 17곳에 ::text 캐스트 | 재실행 → 경고 3건 정상 출력·PREFLIGHT PASS |
| 2 | price_baseline 이 두 소매를 한 기준선에 합침 | 실 가격 데이터 직접 측정 | PK → (item_id, source, as_of) | \d 로 PK 확인 · 배치가 2소스 491건 기록 | ||
| 3 | 탐지기가 근거 상품을 집계로 잃음 | price_anomaly 의 NOT NULL 3컬럼을 못 채움 | row_number() … rn=1 로 실제 행 동반 | 실제가 7,900원 → 100g 1,580원 기록 확인 | ||
| 4 | 미성숙 기준선에서 알림이 나감 | 탐지 7건 중 6건이 N=8 컬리 | 성숙도 게이트(MATURE_SAMPLES=28) — 기록은 하되 발행 제외 | 실 DB --emit → 7건 전부 발행 제외·기록 7건 유지 | ||
| 5 | 파이프라인 이미지에 신규 모듈 없음 | 컨테이너 --emit 이 ModuleNotFoundError | 재빌드(빌드 설정은 정상 — COPY pipelines/ 통째) | 로컬 재빌드 → 6개 파일·신규3+기존4 모듈 임포트 OK | ||
| 6 | --retry-failed docstring 이 코드와 불일치 | 대상 건수 회귀 검증 | docstring 을 실제 동작(미시도+실패)으로 정정 | 동작 변경 없음 — 문서만 정정 |
리포트 §4의 B3 경고("테이블은 준비됐어도 탐지 배치는 아직 못 돌린다")를 문서가 아니라 코드로 강제했다. 원인을 데이터로 파고든 결과가 근거다:
| 소스 | 최근 30일 크롤 일수 | 비고 |
|---|---|---|
| oasis | 15일 | 07-13 시작(이력 자체가 젊다) |
| kurly | 9일 | 07-14~07-20 7일 연속 결손 + 07-29 결손 |
게이트는 시계열별로 건다. 소스마다 성숙 속도가 다른데 한쪽이 늦다고 다른 쪽을 막을 이유가 없고, 이력이 쌓이면 자동으로 풀린다. 기록(--persist)은 게이트와 무관하게 계속된다 — 오탐 분석에 필요한 것은 남겨야 임계를 조정할 수 있다.
⚠️ 성숙도 게이트: 7건 발행 제외 (표본 < 28일 — 기준선이 아직 '평상시'를 대표하지 못한다)
→ 발행 대상 0건. 이력이 더 쌓이면 자동으로 풀린다 (검증 목적이면 --allow-immature).
⚠️ 남은 것은 코드가 아니라 운영이다 — 컬리 크롤의 7일 연속 결손과 주말 결손은 데이터 담당자 영역이다. 게이트가 오탐은 막아 주지만, 결손이 계속되면 컬리는 영영 28일에 도달하지 못해 알림이 나가지 않는다. §9 참조.
리뷰 백필과 무관해 보이지만, 같은 원인(크롤러가 못 쪼갠 원문)을 다룬다.
recipe_ingredient.ner_status='RAW' 인 1,143개 레시피는 재료가 통째로 한 칸에 들어 있었다:
[주재료] 소고기(치맛살)(100g), 김(6g(3장)), 튀김가루(20g)<br>[양념] …
•필수 재료 : 비름나물(70g), 양파(10g), 마늘(3g), 생강(1g)
ingredient_name 이 비고 item_id 가 하나도 안 붙어, 이 레시피들은 재료비·재고매칭·추천·랭킹 어디에도 잡히지 않았다 — 재료가 없는 것과 같았다.
ml/ingredient-ner CRF 는 바로 이 분포로 학습됐다(gold F1 0.924). 채팅에 쓰면 안 된다는 기존 판정은 *대화 문장*에 대한 것이고, 레시피 원문 구조화는 README 가 명시한 정당한 용도다.
| 결과 | 값 |
|---|---|
| 추출 재료 | 12,662개 |
item_id 매칭 | 12,295 (97.1%) |
| 분량 확보(재료비 산출 가능) | 11,692 (92.3%) |
| 되살아난 레시피 | 1,142개 |
| 전체 재료 매칭률 | 92.5% → 93.2% |
실측으로 잡은 결함 2건
'새우두부계란찜\n연두부' 가 한 스팬으로 나왔다. 학습 때는 제목줄을 마스킹하지만 추론 경로엔 그 처리가 없었다. → 줄 단위 처리 + 헤더줄 제거."소박이 : \n방울토마토 150g(5개)"). → 재료명 직후 계량만 받고 애매하면 비운다. 틀린 분량은 재료비를 그대로 왜곡하므로, 비우는 편이 낫다.검증: 재실행 멱등(추가 0건) · 되살아난 재료 8/8 이 to_grams 그램 환산 성공 → 재료비 계산 경로에 실제로 연결됨. 배치는 기본 dry-run 이며 --apply 없이는 쓰지 않는다.
📌ner_status의 정본 값은NER_PARSED다(스키마 CHECK 가 이미 정의). 처음에 임의로NER을 썼다가 CHECK 제약에 막혔다 — DB가 먼저 잡아 줬고 부분 적용은 없었다(트랜잭션).
리뷰 백필을 검증하며 드러난 인접 문제들이다. 데이터 담당자가 알아야 할 것만 추린다.
탐지 배치가 crawled_at::date 로 일자를 끊는데, DB 세션 TZ 가 UTC 다. 크롤 시각을 보면:
| 소스 | UTC | KST |
|---|---|---|
| 컬리 | 18:30 | 03:30 (다음날) |
| 오아시스 | 19:10 / 04:10 | 04:10 (다음날) / 13:10 |
실측 결과 최근 7일 전부 한 UTC 날짜에 서로 다른 KST 날짜 2개가 섞였다. 그대로 두면 "오늘 최저가"가 *오아시스 오후 + 다음날 새벽 + 다음날 컬리*의 혼합이 되어 μ·σ 가 왜곡된다.
→ 탐지 배치와 프리플라이트 B3 를 KST 영업일(AT TIME ZONE 'Asia/Seoul')로 고쳤다. 한국 서비스의 영업일은 KST 다. 적용 후 스캔 5,562→5,557행, 탐지 7건으로 결과는 안정적이다.
poller-kurly.log 에 원인이 남아 있었다:
playwright._impl._errors.Error: Executable doesn't exist at
/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-linux
playwright-stealth 를 무핀(>=1.0)으로 두어 2.x 가 playwright 1.61 을 견인했고, 베이스 이미지 (v1.47)의 chromium 리비전과 어긋나 브라우저 바이너리가 없었다. 07-14~07-20 결손의 원인이다.
이미 고쳐져 있다 — crawler/kurly/Dockerfile 이 베이스 태그 == pip 버전으로 핀하고 (v1.61.0-jammy / playwright==1.61.0 / playwright-stealth==2.0.3), 빌드 시점에 chromium 을 실제로 띄우는 스모크까지 넣었다. 배포 중인 crawler-kurly:1.1.9 로 실검증했다:
| 이미지 | chromium | 기동 |
|---|---|---|
| 1.1.4 (구) | chromium-1134 | ❌ 실패 |
| 1.1.9 (배포 중) | chromium-1228 + headless_shell-1228 | ✅ OK |
07-21 이후 8일 연속 성공이다. KST 07-22 하루 공백은 크론 스케줄 이관 흔적으로 (CRON_TZ=Asia/Seoul 을 Debian cron 이 파싱하지 않아 전 스케줄이 9시간 조기 실행 → UTC 환산) 일회성이다.
📌 정정: 이 리포트 초안은 "컬리 결손이 계속되면 28일에 영영 도달 못 한다" 고 적었으나 사실이 아니다. 장애는 일회성이었고 이미 해소됐다. 매일 성공 가정 시 2026-08-18 에 28일에 도달한다 — 그때 성숙도 게이트가 자동으로 풀린다.
shelf_life_ref 의 item_id 앵커가 121/461(26%)뿐이라 재고의 expire_at 이 NULL 로 남고 임박 알림이 동작하지 않았다. 실측으로 원인을 분해하니:
| 미커버 재고 29건 | 처방 |
|---|---|
| 상비 양념 15건(소금·설탕·후추·식용유…) | 수작업 큐레이션 — AI 가 가장 크게 틀리는 구간 |
| 신선식품 8건 | AI 초안(nova-micro) |
| 가공식품 6건 | 대상 아님 — 포장에 기한이 표기돼 있다 |
AI 를 대상에서 빼야 했던 근거(실측): nova-micro 는 후추 FREEZER 6~12일, 된장 냉동 1~6일, 호두·아몬드·현미에 일괄 6~12일 을 반환했다. 상온 장기 보관품에 짧은 냉장/냉동 창을 붙이는 퇴화 출력이다. 짧게 잡는 것이 안전해 보이지만 반대다 — 멀쩡한 후추가 12일 뒤 임박 알림으로 떠서 유저가 버린다. 식비 절약이라는 제품 목적과 정면으로 어긋난다.
→ 적용: 상비 35종 CURATED 시드(2026-07-29c) + 신선식품 AI 초안 147행. 커버리지 26.2% → 65.7%, 재고 미커버 29 → 6건.
설계 판단 — "모름"과 "추정 대상 아님"을 구분한다(내부 정보)
expire_at=NULL 로 같지만, 시스템이 둘을 구분할 수 있게 된다. 커버리지 측정·AI 초안 대상 선정·검수 우선순위가 이 구분에 의존한다.UI 방침 확정(2026-07-29) — 화면에는 아무 문구도 표시하지 않는다.
list_expiring 이 이미 expire_at is not null 로 거른다 — 데이터만으로 100% 달성돼 있어 문구가 더할 게 없다. 불필요한 주장은 반박 가능성만 늘린다.frontend/src/pages/Fridge.tsx): "소금·설탕처럼 기한을 따로 두지 않는 재료는 기한이 비어 있어요 — 포장 표기를 확인해 주세요."2026-07-29d). 물엿·올리고당(당류, 점도·색 변화) · 청주·맛술(개봉 후 산화) · 다시다·치킨스톡(유지 성분 산패)은 "기한 없음"이 아니라 2~3년이다. 기한 없음으로 남은 것은 소금·설탕·식초·꿀·후추류 9종뿐이다.안전장치
CURATED > FOODKEEPER > AI_DRAFT). 합성 검증: 같은 (품목, 보관)에 AI_DRAFT 99일을 넣어도 조회는 CURATED 14일을 고른다.lookup_shelf_life 가 source 를 함께 돌려준다 — UI 가 "AI 추정"임을 드러낼 수 있어야 한다.ROOM 180 · FRIDGE 60)을 코드로 건다. FREEZER 는 대상에서 제외했다 (퇴화 출력). 제외하면 그 보관은 추정 없음 = 현행과 동일 → 회귀 없이 오탐만 제거된다.핸드오프 §11 기준표 그대로다.
| 게이트 | 항목 | 판정 |
|---|---|---|
| G0 | PREFLIGHT PASS · 종료코드 0 | ✅ |
| G0 | 실패 0건 | ✅ (경고 1건: B3) |
| G1 | 두 파일 에러 없이 COMMIT | ✅ (3파일) |
| G1 | 테이블 7개 생성 | ✅ |
| G1 | FK 실제로 걸림 | ✅ (FK 8개) |
| G1 | 재실행 안전 | ✅ (에러 0) |
| G2 | 대상 조회 (--dry-run) | ✅ URL 20개 · 쓰기 0 |
| G2 | 적재 | ✅ |
| G2 | 멱등 (중복 0) | ✅ dup_groups = 0 |
| G2 | 재개 | ✅ 이전분 건너뜀 |
| G3 | 리뷰 Kafka 변동 0 | ✅ pipelines/ 에 recipe_review 참조 없음 |
| G3 | 기존 컨슈머 무영향 | ✅ recipe.crawl.raw 컨슈머 변경 0 |
| G3 | 폴러 compose 등록 | ✅ 등록·YAML 검증 완료 |
| G3 | FB_POLLER_RECORDS 출력 | ✅ 출력 확인 |
| G4 | 고정 선언 | ✅ 스키마 고정 가능 |
G3 전 항목 통과. 남은 것은 크론 1줄뿐인데, 이는 정기 크롤을 실제로 시작시키는 스위치라 담당자 판단으로 남겨 뒀다(§8).
| 항목 | 상태 | 비고 |
|---|---|---|
| 리뷰 — Kafka | ✅ 변동 0 | 설계대로. 크롤러가 PG에 직접 insert |
| 리뷰 — 컨슈머 | ✅ 변동 0 | 신규 컨슈머 없음 |
| 가격 — 토픽 | ✅ price.anomaly.detected 로 단일화 | 브로커 생성·코드·k8s 매니페스트·테스트가 이 이름으로 검증 완료(§6.2) |
compose — poller-recipe-review | ✅ 등록 완료 | profiles: ["poller"] 게이트라 기본 up 에는 안 뜬다 |
| 크론 — 리뷰 수집 | ⬜ 미등록 | 아래 1줄 |
| 크론 — 가격 탐지 | ⬜ 미등록 | §6 결정 후 |
compose 는 등록했다(YAML 파싱·앵커 상속 검증 완료). 크론 1줄만 남았다 — 이것이 실제로 정기 크롤을 시작시키는 스위치라 담당자 판단으로 남겨 뒀다:
0 21 * * 2,6 __RUN__ poller-recipe-review # = 일·수 06:00 KST — poller-recipe(05:00) 뒤
폴러 이미지에 크롤러와 의존성(psycopg·dotenv·requests·bs4·urllib3)이 모두 들어감을 재빌드로 확인했다 — 별도 설치 없이 그대로 돈다.
⚠️ 크론 등록은 정기 크롤 시작을 뜻한다. 전량 첫 수집은 이번에 완주했으므로, 이후는 신규 레시피분만 잡는 증분 실행이 된다. ⚠️ 슬롯 여유는 30분뿐이다. 제안된0 21(06:00 KST)과 기존poller-es-recipes(30 21= 06:30 KST) 사이가 30분이다. 증분 실행은 신규 레시피 수십 건 수준이라 충분하지만, 어떤 이유로 전량이 다시 돌면 겹친다. 두 폴러는 서로 다른 저장소를 건드리므로(리뷰=PG, es-recipes=ES) 데이터 충돌은 없고 폴러 호스트 자원 경합만 발생한다.
| 항목 | 담당 | 비고 |
|---|---|---|
| ~~가격 이상탐지 설계 단일화~~ | — | ✅ 완료(§6). 세 테이블 모두 코드가 읽고 쓴다 |
| ~~드러난 결함 6건~~ | — | ✅ 전수 해결·검증(§6.5) |
| 파이프라인 이미지 재빌드·푸시 | 데이터 | 현 배포 이미지엔 신규 모듈이 없다. 로컬 재빌드로 해소 확인(§6.5 #5) — 레지스트리 푸시·배포만 남았다 |
| ~~컬리 크롤 안정화~~ | — | ✅ 원인 규명·해소 확인(§6.7.2). Playwright 리비전 불일치였고 이미 수정됨. 2026-08-18 에 28일 도달 예정 |
| 소비기한 AI 초안 검수 | 데이터 | source='AI_DRAFT' 147행은 검수 전 값이다. 확인 후 CURATED 로 승격할 것(§6.7.3) |
| 소비기한 표시 정책 결정 | 기획/프론트 | 명시적 무기한(days NULL) 품목을 "무기한"으로 보일지 기한칸을 숨길지 — 데이터는 양쪽 다 지원한다 |
| 리뷰 폴러 크론 1줄 | 데이터 | compose 는 등록 완료. 크론이 정기 크롤 시작 스위치(§8) |
| 가격 크롤 스케줄 점검 | 데이터 | §4. 30일 중 15일만 관측 — 절반이 빠졌다 |
| 탐지 배치 오탐 게이트 | AI | obs_count 기반. 관측 28일 도달 전까지 무동작 |
| compose · 크론 등록 | 데이터 | §8. YAML·크론 줄은 그대로 쓰면 된다 |
--retry-failed docstring 정정 | AI | §5.2. 동작은 정상, 문서만 불일치 |
| 감정분류 구현 | AI | 모델 확정됨(apac.amazon.nova-micro-v1:0) — 데이터가 생겼으므로 착수 가능 |
| 요약 구현 | AI | 모델 미확정. 원문이 DB에 있어 사람 확인 가능 |
PSQL="psql -h [내부 주소 비공개] -U fbapp -d foodbudget -v ON_ERROR_STOP=1"
$PSQL -f docs/prd/migrations/2026-07-29_preflight.sql \
&& $PSQL -f docs/prd/migrations/2026-07-29_recipe_review.sql \
&& $PSQL -f docs/prd/migrations/2026-07-29_price_anomaly.sql \
&& $PSQL -f docs/prd/migrations/2026-07-29_extract_job_link.sql
python crawler/10k_recipe/review_crawler.py --dry-run --limit 20
python crawler/10k_recipe/review_crawler.py --limit 100
python crawler/10k_recipe/review_crawler.py
크롤러 의존성: psycopg[binary] · python-dotenv · requests · beautifulsoup4 · urllib3. .env 는 레포 루트에 있어야 한다(pipelines/ingest/_db.py 가 parents[2] 로 찾는다).
되돌리기는 각 마이그레이션 파일 하단의 롤백 SQL 참조 — 기존 테이블을 건드리지 않으므로 신규분만 DROP 하면 원상복구된다.