← Mealplanning 페이지로

팀 프로젝트에서 작성한 당시 기록을 바탕으로 한 공개용 정제 사본입니다. 원본 문서 · 적용 결과 리포트 — 리뷰 수집 + 가격 이상탐지 스키마  |  작성 시점 · 2026-07-29
계정·내부 주소·팀 내부 식별정보는 공개용으로 정리했으며, 공개된 측정값과 기술적 판단의 의미는 원본과 같습니다.

왜 쟀나

설계는 DB에 접속하지 못하는 환경에서 스키마 파일만 보고 만들어졌습니다. 그 전제가 실제 DB와 어긋나지 않는지 확인하고 적용하려고, 실 DB에 붙는 환경에서 검증했습니다.

무엇이 나왔나

사전 점검을 통과해 DDL 3개를 적용하고 크롤러 실동작까지 확인했습니다. 설계 수정 사항은 없었습니다.

측정하면서 두 가지를 발견했습니다. 가격 이력이 아직 짧아 기준선이 미성숙했고, 원설계처럼 두 소매의 가격을 합치면 편차가 중앙값 기준 2배 넘게 부풀어 탐지가 죽는다는 것이었습니다.

무엇을 얻고 무엇을 포기했나

아래부터 당시 작성한 기록을 공개용으로 정제한 전문입니다.

적용 결과 리포트 — 리뷰 수집 + 가격 이상탐지 스키마 (2026-07-29)

설계 문서 recipe-review-handoff.md — 당시 별도 설계 핸드오프 문서에 기록"무엇을 왜 바꾸려는가" 다. 이 리포트는 "실제로 무엇이 바뀌었는가" 다. 설계는 DB에 접속하지 못한 PC에서 스키마 파일만 보고 만들어졌고(핸드오프 §12), 이 문서는 실 DB에 붙는 환경에서 검증·적용한 결과다. 수신: 데이터 담당자 · 작성: AI 파트 · 대상 DB: [내부 주소 비공개]/foodbudget

0. 결론

프리플라이트 PASS → DDL 3개 적용 완료 → 크롤러 실동작 검증 완료. 스키마를 고정해도 된다(G4).

핵심 4가지만 먼저:

  1. 설계 전제는 실 DB와 어긋나지 않았다. 프리플라이트 실패 0건. 설계 수정 사항 없음.
  2. ⚠️ 가격 기준선이 아직 미성숙하다 — 이력이 07-13에 시작해 oasis 15일 · kurly 9일 (7일 연속 결손)이다. 그대로 두면 오탐이 나므로 성숙도 게이트를 코드로 넣어 미성숙 기준선의 급락은 기록만 하고 발행하지 않게 했다. §4 · §6.5.1.
  3. 가격 이상탐지 설계 이중화를 실측으로 판정하고 통합했다. 원설계의 기준선은 두 소매를 합쳤는데, 실측 결과 σ 가 중앙값 2.08배 부풀어 z 가 절반이 되고 탐지가 죽는다. 소스별로 교정하고(무손실 — 당시 0행), 번들 스키마 + PR #353 코드로 합쳤다. §6 참조.
  4. 적용 중 드러난 결함 6건을 전수 해결·검증했다 — 프리플라이트가 실패를 보고 못 하고 죽는 문제, 기준선 소스 합침, 근거 상품 소실, 미성숙 발행, 이미지 누락, 문서 불일치. §6.5.

파싱 신뢰도는 크게 올랐다. 설계 시점의 검증은 표본 1건이었으나(핸드오프 §5), 이번 실측에서 no_review 0.0% 로 확인됐다.

🔴 운영으로 남은 하나: 컬리 크롤 결손이 계속되면 컬리 기준선은 28일에 도달하지 못해 알림이 영영 나가지 않는다. 크롤 안정성이 곧 기능 가동 조건이다(§4).

1. 프리플라이트 결과 (G0)

=== 프리플라이트: 설계 전제 검증 (읽기 전용) ===
접속 DB=foodbudget / 사용자=fbapp

A2 크롤 대상(source=10K): 6873 건
B3 최근 30일 중 크롤된 일수: 15 일

--- 경고 (진행 가능, 인지 필요) ---
  ⚠️  B3 관측 15일 < 28일 — 기준선이 미성숙, 오탐 구간이다(탐지 게이트 필요)

PREFLIGHT PASS — 전제가 모두 성립한다. DDL 을 적용해도 된다.
항목결과
실패0건
경고1건 — B3(관측 15일)
종료코드0

실측치가 중요한 두 항목


2. 적용된 DDL

프리플라이트 PASS 확인 후 && 로 이어서 실행했다(프리플라이트가 실패하면 DDL이 돌지 않는다). 전부 -v ON_ERROR_STOP=1.

순서파일결과생성물
02026-07-29_preflight.sqlPASS (읽기 전용)
12026-07-29_recipe_review.sqlCOMMIT테이블 4 · 인덱스 3
22026-07-29_price_anomaly.sqlCOMMIT테이블 3 · 인덱스 3
32026-07-29_extract_job_link.sqlCOMMIT컬럼 1 · 인덱스 1 · COMMENT

실제 생성된 모양 (\d 대조)

테이블FKCHECK인덱스
recipe_review103
recipe_review_crawl112
recipe_review_sentiment112
recipe_review_summary101
price_baseline102
price_anomaly213
price_alert_sent102

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'))
source CHECK 는 실 데이터와 일치한다 — 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건.


3. 설계에서 바뀐 것

DB 설계 — 적용 시점엔 없음, 이후 실측으로 1건 교정

프리플라이트 실패 0건. 실 DB가 설계 전제와 어긋난 부분이 없어 스키마·마이그레이션을 그대로 적용했다.

다만 적용 후 가격 데이터를 직접 측정하면서 설계 결함 1건을 찾아 교정했다 — price_baseline 의 기준선 단위를 품목별에서 (품목, 소스)별로 바꿨다. 프리플라이트는 "테이블·컬럼이 있는가"를 보지 그 설계 가정이 데이터와 맞는가는 보지 않으므로 잡히지 않는 종류다. 근거와 조치는 §6.1.

⚠️ 프리플라이트 스크립트 자체의 결함 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이 "이 프리플라이트 자체도 실행 검증되지 않았다" 고 미리 밝혀 둔 그대로였다. 이제 프리플라이트는 재실행 가능하다.

4. ⚠️ B3 — 가격 기준선이 아직 미성숙하다

최근 30일 중 가격이 크롤된 날은 15일뿐이다. 설계가 요구하는 28일의 절반이다.

이것이 뜻하는 바:

원인을 데이터로 파고든 결과(§6.5.1 상세):

소스크롤 일수원인
oasis15일가격 이력이 2026-07-13 시작 — 30일 창이 아직 물리적으로 못 찬다
kurly9일위 + 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 기준과 어긋날 수 있었다.


5. 크롤러 실동작 (G2)

설계 시점에 미검증이던 항목 — DB 연결 · DDL 적용 · SQL 실제 실행(핸드오프 §5의 ❌ 3종) — 이 단계에서 한 번에 해소됐다.

단계별 결과

단계명령결과
대상 조회--dry-run --limit 20URL 20개 출력 · 쓰기 0 ✅
시범 적재--limit 10리뷰 62건 · 실패 0 ✅
재개 확인--limit 10 재실행이전 10건 건너뛰고 새 10건 처리 ✅
분포 확인--limit 100리뷰 2,122건 · no_review 0 ✅
전량(인자 없음)§5.2

5.1 파싱 신뢰도 — 설계의 최대 불확실성이 해소됐다

핸드오프 §5는 파싱 검증이 표본 1건임을 경고하며, "no_review 비율이 비정상적으로 높으면 파서가 못 잡는 레이아웃이 있다는 신호" 라고 판정 기준을 남겼다.

결과: no_review 0.00% · 실패 0건. 표본 1건이던 파싱 신뢰도가 6,873 레시피 전수로 확인됐다.

항목
크롤 대상6,873 레시피 (source='10K')
완주6,873 / 6,873 (잔여 0)
수집 리뷰141,298건
no_review0건 (0.00%)
fail0건
중복 (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건이 잡힌 레시피가 있어 실사용에는 충분해 보이지만, 긍정 비율(%) 이 표본 편향을 갖는지는 미확인이다 — 감정분석 착수 전 몇 건 육안 대조를 권한다.

5.2 --retry-failed 회귀 확인

핸드오프 §5가 "수정 완료" 로 기록한 버그(LEFT JOIN 의 ON 절에 상태 조건 → 전량 재크롤)를 실 DB에서 검증했다.

normal_mode | retry_mode | ok_rows | fail_rows | ok_leaked_into_retry
       6853 |       6853 |      20 |         0 |                    0
📌 문서-코드 경미한 불일치: docstring 은 "status='fail' 다시 대상에 넣는다" 라고 쓰여 있으나, 실제 조건은 (c.recipe_id is null or c.status = 'fail') = 미시도 + 실패다. 전량 수집이 끝난 뒤에는 미시도가 0이라 결과가 같아지므로 실사용 영향은 없다. docstring 을 코드에 맞추는 편이 정확하다.

6. 가격 이상탐지 — 설계 이중화를 실측으로 판정하고 통합했다

이 번들이 만들어진 뒤 레포가 앞서 나갔다. 핸드오프 §9.4는 "탐지 배치·컨슈머 코드는 아직 없다" 를 전제하지만, 그 사이 #9가 구현돼 PR #353로 올라가 있었다. 같은 기능을 다른 방식으로 구현한 설계가 두 벌 존재했고, 어느 쪽이 옳은지 실 데이터로 판정한 뒤 하나로 합쳤다.

6.1 판정 — 기준선은 소스별이어야 한다 (원설계 정정)

원설계 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.sqlprice_baselinesource 를 추가하고 PK 를 (item_id, source, as_of) 로 교정했다. 세 테이블 모두 0행일 때 고쳐 무손실이다. 기존 마이그레이션 파일은 건드리지 않았다(G4 규칙).

6.2 통합 — 번들 스키마 + PR #353 코드

두 설계는 서로 다른 것을 잘하고 있었다. 하나를 버리는 대신 합쳤다.

채택근거
기준선 단위(품목, 소스)§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=20PR #353팀 결정

발송 멱등을 둘 다 두는 이유 — 서로 다른 실패를 막는다.

쿨다운만 있으면 8일째 재전달된 옛 이벤트가 다시 나가고, PK만 있으면 매일 새로 탐지된 같은 품목이 매일 나간다. 컨슈머는 알림 생성과 이력 기록을 한 문장(CTE) 으로 처리한다 — 두 문장으로 나누면 사이에서 죽었을 때 알림만 남고 이력이 비어 재처리 시 중복 발송된다.

6.3 코드 변경 — 근거를 잃지 않도록

원래 탐지기는 min(price/weight_g*100) 으로 집계해 어느 상품이 그 최저가를 만들었는지 잃었다. price_anomalyretail_product_id·crawled_at·price 를 NOT NULL 로 요구하면서 이 결함이 드러났다 — 번들 스키마가 제 역할을 한 것이다.

6.4 실 DB 검증

항목결과
dry-run(기본)시계열 522개 · 5,562행 스캔 · 7건 탐지 · 기록 0
--persistprice_baseline 491건(2소스) · price_anomaly 7건
근거 무결성실제가 7,900원 → 100g 1,580원 (합성 아님)
재실행 멱등재실행 후에도 7건 (UNIQUE 동작)
--emit발행 7건 · published_at 7건 기록
tracked fan-out1차 알림 1 + price_alert_sent 1 → 재전달 시 0행·알림 불변
테스트27 passed
검증에 쓴 행은 전부 삭제·롤백했다. 현재 price_baseline·price_anomaly·price_alert_sent0행이며, 운영 배치는 아직 스케줄되지 않았다(§8).

6.5 드러난 결함 — 전수 해결·검증

적용·검증 과정에서 결함 6건이 드러났다. 전부 고치고 실물로 재검증했다.

#결함어떻게 드러났나해결검증
1프리플라이트가 실패를 보고 못 하고 죽는다 (`text[] \\맨 리터럴`)DDL 적용 후 재실행에서 D1 분기 첫 발동맨 리터럴 17곳에 ::text 캐스트재실행 → 경고 3건 정상 출력·PREFLIGHT PASS
2price_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 --emit7건 전부 발행 제외·기록 7건 유지
5파이프라인 이미지에 신규 모듈 없음컨테이너 --emitModuleNotFoundError재빌드(빌드 설정은 정상 — COPY pipelines/ 통째)로컬 재빌드 → 6개 파일·신규3+기존4 모듈 임포트 OK
6--retry-failed docstring 이 코드와 불일치대상 건수 회귀 검증docstring 을 실제 동작(미시도+실패)으로 정정동작 변경 없음 — 문서만 정정

6.5.1 가장 중요한 것 — 성숙도 게이트 (#4)

리포트 §4의 B3 경고("테이블은 준비됐어도 탐지 배치는 아직 못 돌린다")를 문서가 아니라 코드로 강제했다. 원인을 데이터로 파고든 결과가 근거다:

소스최근 30일 크롤 일수비고
oasis15일07-13 시작(이력 자체가 젊다)
kurly9일07-14~07-20 7일 연속 결손 + 07-29 결손

게이트는 시계열별로 건다. 소스마다 성숙 속도가 다른데 한쪽이 늦다고 다른 쪽을 막을 이유가 없고, 이력이 쌓이면 자동으로 풀린다. 기록(--persist)은 게이트와 무관하게 계속된다 — 오탐 분석에 필요한 것은 남겨야 임계를 조정할 수 있다.

⚠️  성숙도 게이트: 7건 발행 제외 (표본 < 28일 — 기준선이 아직 '평상시'를 대표하지 못한다)
→ 발행 대상 0건. 이력이 더 쌓이면 자동으로 풀린다 (검증 목적이면 --allow-immature).
⚠️ 남은 것은 코드가 아니라 운영이다 — 컬리 크롤의 7일 연속 결손과 주말 결손은 데이터 담당자 영역이다. 게이트가 오탐은 막아 주지만, 결손이 계속되면 컬리는 영영 28일에 도달하지 못해 알림이 나가지 않는다. §9 참조.

6.6 부수 성과 — 재료 NER 서빙 개시 (#5)

리뷰 백필과 무관해 보이지만, 같은 원인(크롤러가 못 쪼갠 원문)을 다룬다.

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건

  1. 줄바꿈 오염 — 제목과 첫 재료가 붙어 '새우두부계란찜\n연두부' 가 한 스팬으로 나왔다. 학습 때는 제목줄을 마스킹하지만 추론 경로엔 그 처리가 없었다. → 줄 단위 처리 + 헤더줄 제거.
  2. 분량 오염 — 스팬 사이 구간을 통째로 쓰면 레시피 제목이 분량으로 딸려왔다 ("소박이 : \n방울토마토 150g(5개)"). → 재료명 직후 계량만 받고 애매하면 비운다. 틀린 분량은 재료비를 그대로 왜곡하므로, 비우는 편이 낫다.

검증: 재실행 멱등(추가 0건) · 되살아난 재료 8/8 이 to_grams 그램 환산 성공 → 재료비 계산 경로에 실제로 연결됨. 배치는 기본 dry-run 이며 --apply 없이는 쓰지 않는다.

📌 ner_status 의 정본 값은 NER_PARSED 다(스키마 CHECK 가 이미 정의). 처음에 임의로 NER 을 썼다가 CHECK 제약에 막혔다 — DB가 먼저 잡아 줬고 부분 적용은 없었다(트랜잭션).

6.7 인접 작업 — 같은 세션에서 함께 해소한 것

리뷰 백필을 검증하며 드러난 인접 문제들이다. 데이터 담당자가 알아야 할 것만 추린다.

6.7.1 ⚠️ 일자 경계가 UTC 였다 — 가격 이동평균의 입력이 하루 단위가 아니었다

탐지 배치가 crawled_at::date 로 일자를 끊는데, DB 세션 TZ 가 UTC 다. 크롤 시각을 보면:

소스UTCKST
컬리18:3003:30 (다음날)
오아시스19:10 / 04:1004:10 (다음날) / 13:10

실측 결과 최근 7일 전부 한 UTC 날짜에 서로 다른 KST 날짜 2개가 섞였다. 그대로 두면 "오늘 최저가"가 *오아시스 오후 + 다음날 새벽 + 다음날 컬리*의 혼합이 되어 μ·σ 가 왜곡된다.

→ 탐지 배치와 프리플라이트 B3 를 KST 영업일(AT TIME ZONE 'Asia/Seoul')로 고쳤다. 한국 서비스의 영업일은 KST 다. 적용 후 스캔 5,562→5,557행, 탐지 7건으로 결과는 안정적이다.

6.7.2 ✅ 컬리 크롤 결손 — 원인 규명·이미 해소됨

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-1228OK

07-21 이후 8일 연속 성공이다. KST 07-22 하루 공백은 크론 스케줄 이관 흔적으로 (CRON_TZ=Asia/Seoul 을 Debian cron 이 파싱하지 않아 전 스케줄이 9시간 조기 실행 → UTC 환산) 일회성이다.

📌 정정: 이 리포트 초안은 "컬리 결손이 계속되면 28일에 영영 도달 못 한다" 고 적었으나 사실이 아니다. 장애는 일회성이었고 이미 해소됐다. 매일 성공 가정 시 2026-08-18 에 28일에 도달한다 — 그때 성숙도 게이트가 자동으로 풀린다.

6.7.3 소비기한 커버리지 26% → 66% (ai-spec §6 ① 착수)

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건.

설계 판단 — "모름"과 "추정 대상 아님"을 구분한다(내부 정보)

UI 방침 확정(2026-07-29) — 화면에는 아무 문구도 표시하지 않는다.

안전장치

6.7.4 재료 NER 서빙 + 대화체 판정


7. G0~G4 판정

핸드오프 §11 기준표 그대로다.

게이트항목판정
G0PREFLIGHT PASS · 종료코드 0
G0실패 0건✅ (경고 1건: B3)
G1두 파일 에러 없이 COMMIT✅ (3파일)
G1테이블 7개 생성
G1FK 실제로 걸림✅ (FK 8개)
G1재실행 안전✅ (에러 0)
G2대상 조회 (--dry-run)✅ URL 20개 · 쓰기 0
G2적재
G2멱등 (중복 0)dup_groups = 0
G2재개✅ 이전분 건너뜀
G3리뷰 Kafka 변동 0pipelines/recipe_review 참조 없음
G3기존 컨슈머 무영향recipe.crawl.raw 컨슈머 변경 0
G3폴러 compose 등록✅ 등록·YAML 검증 완료
G3FB_POLLER_RECORDS 출력✅ 출력 확인
G4고정 선언스키마 고정 가능
G3 전 항목 통과. 남은 것은 크론 1줄뿐인데, 이는 정기 크롤을 실제로 시작시키는 스위치라 담당자 판단으로 남겨 뒀다(§8).

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) 데이터 충돌은 없고 폴러 호스트 자원 경합만 발생한다.

9. 미결 · 후속

항목담당비고
~~가격 이상탐지 설계 단일화~~완료(§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일만 관측 — 절반이 빠졌다
탐지 배치 오탐 게이트AIobs_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.pyparents[2] 로 찾는다).

되돌리기는 각 마이그레이션 파일 하단의 롤백 SQL 참조 — 기존 테이블을 건드리지 않으므로 신규분만 DROP 하면 원상복구된다.