같은 DB(lps_db)의 같은 시기 추가분이 두 파일로 갈려 있어 순서·누락을 신경 써야 했다.
6_ 하나로 합친다(7_ 삭제) — 실행이 한 번이면 '어디까지 돌렸더라'를 기억할 일이 없다.
- 주석을 3_lps_dbeaver.sql 스타일로 통일: 객체 위 한 줄 설명 + 컬럼 인라인 주석 정렬.
기존 6_·7_ 의 긴 배경 산문은 걷어냈다 — 배경은 lps/docs/result-states.md 가 소스고,
이 파일은 '무엇을 만드는가'만 답하면 된다.
- 내용은 그대로: proxy_port(+LRU 인덱스), price_history 신뢰 신호 2·배송 3,
몰별 확인 상태 sources/partial(+부분 인덱스), 적용 확인 SELECT.
검증: 기존 lps_db 재실행(멱등 — NOTICE 만) + 빈 DB 에 3_ → 6_ 신규 설치 후
price_history 신규 7컬럼·테이블 6종 전부 확인. 테스트 DB 는 정리.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
마지막 단계. 차단당해 못 본 몰이 화면에서 '–'(없음)로 보여 사용자가 "쿠팡엔 더 싼 게 없구나"로
오해하던 문제를 끝낸다. 안 본 걸 없다고 말하지 않는다.
사용자 화면은 **셋으로 접는다**(lps/docs/result-states.md 3-2절). 할 수 있는 행동이
'쓴다/다시 시도/넘어간다' 뿐이라, 원인이 달라도 다음 행동이 같으면 같은 표기다:
matched → 가격
no_match · empty → '–' (확인했고 없었다)
blocked · env_blocked · unavailable → '확인 못함' (못 봤다)
운영자 화면(lps-admin)은 같은 데이터로 7상태를 그대로 본다 — 목적이 진단이라 접지 않는다.
체인 전체를 이었다:
- postgres-init/alters/2026-08-07-iilp-source-state.sql — item_internet_lowest_prices 에
sources/partial 추가(멱등, **운영 적용 필요**)
- models.py / lps_sync_crud 읽기 계약 / lps_sync_service 미러링 / LowestPriceEntry 프로토콜
- orval 재생성(ORVAL_INPUT 으로 저장 스펙에서 — 서버 없이). 생성 diff 는 새 필드만.
- PriceUpdateModal: 가격이 없는 몰이 '못 본 몰'이면 '–' 대신 '확인 못함'.
partial 은 LPS 가 판단해 내려준 사실을 그대로 쓴다 — 화면이 '어떤 상태가 확인된 것인가'를
다시 판정하면 상태 정의가 LPS 와 negodata 두 곳으로 흩어진다.
E2E 검증(실 DB 2시나리오): by_mall 은 둘 다 naver 뿐인데 쿠팡 칸이 '확인 못함'(차단) / '–'(0건)
으로 갈린다. tsc 오류 없음(기존 xlsx 미설치 오류는 무관). negodata 97 · lps 292 passed.
> 폴더 관례상 negodata 는 인수인계 대상이나, 사용자 요청으로 이번 건도 예외 적용.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2단계에서 저장한 sources/partial 을 운영자가 볼 수 있게 한다. 운영자 목적은 **진단**이라
상태를 접지 않는다 — blocked(IP 회전으로 자동 회복)와 env_blocked(사람이 환경·설정을 고쳐야 함)를
뭉뚱그리면 회복될 일에 매달리거나 손봐야 할 설정을 방치하게 된다.
API
- /v1/lps/products, /v1/lps/products/{code}/history 둘 다 sources·partial 을 싣는다.
- 이력은 **시점마다** 싣는다. 최신 상태를 과거 시점의 몰별 표 옆에 붙이면 '그때도 막혔던 것처럼'
보여 오해를 부른다 — 그래서 ProductItem 이 아니라 PricePoint 에 담았다.
화면
- lib/sourceState.ts: 상태별 라벨·색·설명·confirmed 를 한곳에. 미지의 상태가 와도 화면이 깨지지
않는다(값 그대로 표시 + '모름' 취급). 색은 전부 @theme 토큰 참조(raw hex 금지).
- 상품 목록: partial 이면 '일부 확인 못함' 배지 + 툴팁에 어느 몰인지.
- 몰별 비교 카드 위: 몰별 상태·수집 건수·실패 사유 원문(툴팁). 가격표에 없는 몰이 **왜** 없는지를
여기서 답한다 — by_mall 은 가격이 있는 몰만 담으므로 그 답이 여기밖에 없다.
검증: ASGI 직접 호출로 두 엔드포인트 응답 확인(한글 사유 포함), tsc 오류 없음.
테스트 3건 추가(목록 노출 / 시점별 상태가 각각 다르게 / 컬럼 추가 이전 옛 행 호환).
전체 292 passed. 진행 상황은 docs/result-states.md 4절.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1단계에서 만든 SourceState 가 job.result 에만 있어 화면까지 못 갔다. by_mall 은 **가격이 있는
몰만** 담으므로, 빠진 몰이 '거기엔 없더라'인지 '거기를 못 봤다'인지 구분할 자리가 없었다.
- price_history.sources (JSONB): 몰별 상태를 그대로 담는다.
{"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}}
열린 스키마라 몰이 늘거나 근거를 덧붙여도 마이그레이션이 필요 없다(by_mall 과 같은 방침).
- price_history.partial (bool): 결과가 완전한가. sources 에서 유도 가능하지만 컬럼으로 둔다 —
소비자가 '어떤 상태가 확인된 것인가'라는 판단 규칙까지 알아야 하면 **상태 정의가 두 곳으로
흩어진다**. 판단은 LPS 가 끝내고 소비자(negodata·lps-admin)는 사실 하나만 읽는다.
- 부분 인덱스 ix_price_history_partial — partial=true 행만 담아 작게 유지(운영 점검·알림용).
- _record_history 가 per_source 를 받아 partial 을 계산해 기록한다. 네거티브 캐시 히트 경로는
sources 없이 남긴다(부분 결과는 애초에 캐시하지 않으므로 항상 확정).
- 마이그레이션: postgres-init/dbeaver/7_lps_source_state_dbeaver.sql (재실행 안전, **운영 적용 필요**)
검증(로컬 실 DB): 쿠팡 차단 vs 쿠팡 0건은 by_mall 이 둘 다 ['naver'] 로 같지만
partial(true/false)·sources.coupang.state(blocked/empty)가 두 경우를 갈라낸다.
JSONB 는 ensure_ascii=False 로 한글 사유가 깨지지 않는 것도 테스트로 고정.
테스트 3건 추가, 전체 289 passed. 진행 상황은 docs/result-states.md 4절.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/result-states.md 의 1단계. 어댑터는 이미 구분을 알고 있는데 핸들러가 그 정보를 버리고
있었다(per_source[src] = {"error": 문자열}). 그래서 차단당해 못 본 몰이 화면에서 '그 몰엔 없음'
으로 둔갑했다. 새로 알아낼 정보는 없고, 흘리던 걸 잡아두기만 하면 된다.
- common/enums.py: SourceState 7상태 추가(MATCHED/NO_MATCH/EMPTY/BLOCKED/ENV_BLOCKED/
UNAVAILABLE/SKIPPED). `.confirmed` 프로퍼티로 **'봤다 vs 못 봤다' 경계를 한곳에** 둔다 —
이 경계가 무너지면 나머지 판단이 전부 틀어지므로 흩어놓지 않는다.
- AdapterError.state: 어댑터가 아는 구분을 실어 보낸다. blocked/fatal 은 '어떻게 대응할까'
(회전·재시도)를 위한 값이고 state 는 '사용자에게 뭐라 말할까'를 위한 값이라 쓰임이 다르다.
특히 blocked=False 하나에 결과0건(EMPTY)과 전송실패(UNAVAILABLE)가 섞여 있어 state 없이는
갈라낼 수 없었다. **기본값은 UNAVAILABLE** — 모르면 '못 봤다'가 안전하다(EMPTY 로 두면
확인도 안 한 몰을 '없음'으로 단정한다).
- browser_base: raise 지점 5곳에 상태 부여. 핵심 갈림은 0건 종착 한 곳 —
blocked=False → EMPTY(정말 없다) / blocked=True → BLOCKED(못 봤다).
- _search_round: {"state": ..., "count"|"error": ...} 로 구조화. EMPTY 는 '정상 응답'으로 세어
(confirmed) 쿠팡에 정말 없을 때 잡이 재시도로 낭비되지 않게 한다.
- _finalize_states: 수집만 된 소스를 AI 판정 뒤 MATCHED/NO_MATCH 로 확정한다. 실패 상태는
이미 확정이라 덮지 않는다.
검증(9조합 실측): empty → partial=False(확정) / blocked·env_blocked·unavailable → partial=True.
테스트 12건 추가, 전체 286 passed. 진행 상황은 docs/result-states.md 4절에 기록.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
보는 사람이 둘인데 하나로 뭉뚱그리고 있었다.
lps-admin 운영자 — 목적이 **진단**이다. '왜 그랬나'에 답해야 하므로 7상태를 그대로 보고
차단 마커·ip_request_no·포트까지 붙인다. 특히 blocked(자동 회복)와
env_blocked(사람이 고쳐야 함)를 반드시 갈라야 한다 — 개입 여부가 갈린다.
negodata 실사용자 — 목적이 **행동**이다. 할 수 있는 건 '쓴다/다시 시도/넘어간다' 셋뿐이라,
원인이 달라도 다음 행동이 같으면 같은 표기로 접는다:
no_match·empty → '–' (둘 다 결론은 '이 몰엔 없다')
blocked·env_blocked·unavailable → '확인 못함' (행동은 '나중에 다시' 하나뿐)
핵심 원칙: **상태는 하나로 정의·저장하고, 접는 건 표시 단계에서 한다.**
저장을 단순화하면 관리자가 원인을 못 보고, 표시를 상세화하면 사용자가 못 읽는다.
두 화면 모두 price_history 를 읽으므로(admin_service 확인) 데이터는 공유하고 투영만 달리한다.
예외 하나: '일부 확인 못함'(partial)은 사용자에게도 접지 않는다. 이건 행동을 바꾸기 때문이다 —
'이 가격이 최종인가'의 답이 달라진다. 다만 사용자에게 필요한 건 어느 몰이 왜 막혔는지가 아니라
결과가 완전하지 않다는 사실 하나다.
남은 일도 4단계로 갱신(1 플래그 보존 → 2 저장 자리 → 3 admin 표시 · 4 사용자 표시).
3·4 는 같은 데이터를 다르게 접는 것이라 병행 가능.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
'가격을 못 찾았다' 한 문장에 뜻이 정반대인 상황이 섞여 있다: 그 몰에 정말 없는 것(사실)과
그 몰이 우리를 막아 못 본 것(미확인). 지금 화면은 둘 다 '–' 로 똑같이 보여준다.
구현 전에 용어를 맞추려고 상태를 정의한다.
코드를 확인해 **실제로 구분 가능한 것**만 정의했다:
몰(소스) 단위 7상태 — 경계는 '사실'(no_match/empty) 대 '미확인'(blocked/env_blocked/unavailable).
앞의 둘은 "없다"고 말해도 되고, 뒤의 셋은 말하면 안 된다.
상품 단위 — found / not_found / error + partial 꼬리표. partial 은 독립 상태가 아니라
found·not_found 에 붙는데, 특히 'not_found + partial' 은 결론이 아니다(못 본 몰에 있었을 수 있음).
확인 과정에서 드러난 근본 원인:
- 어댑터는 이미 구분을 **안다** — AdapterError 에 blocked·fatal 이 있고 detect_block 이
'차단 vs 정상 빈결과'를 판정한다.
- 그런데 **핸들러가 그 플래그를 버린다**: per_source[src] = {"error": f"{...}"} — 문자열만 남는다.
- 게다가 0건도 예외로 온다(return 은 1건 이상일 때만). 즉 empty 와 blocked 가 둘 다
AdapterError 로 도착하는데 구분 플래그를 버리므로 이후로는 갈라낼 수 없다.
→ 새로 알아낼 정보는 없다. 이미 아는 걸 흘리고 있을 뿐이라, _search_round 한 곳이 출발점이다.
남은 일을 1(플래그 보존) → 2(price_history 자리) → 3(화면 표기) 순서로 정리했다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>