o2o-negosium-original/lps/docs/result-states.md
민헌 95086cb3f6 feat(negodata): 4단계 — 사용자 화면에서 '없음'과 '확인 못함'을 구분
마지막 단계. 차단당해 못 본 몰이 화면에서 '–'(없음)로 보여 사용자가 "쿠팡엔 더 싼 게 없구나"로
오해하던 문제를 끝낸다. 안 본 걸 없다고 말하지 않는다.

사용자 화면은 **셋으로 접는다**(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>
2026-08-07 11:21:56 +09:00

14 KiB
Raw Blame History

최저가 검색 결과 상태 정의

← README로

가격을 못 찾았다는 한 문장 안에 뜻이 정반대인 상황들이 섞여 있다.

  • 그 몰에 정말 그 상품이 없다 → 사실이다. 사용자는 받아들이면 된다.
  • 그 몰이 우리를 막아서 못 봤다 → 사실이 아니다. 더 싼 값이 있었을 수 있다.

지금 화면은 둘 다 – 로 똑같이 보여준다(2026-08-06 확인). 그래서 사용자는 "쿠팡엔 더 싼 게 없구나"로 읽지만 실제로는 안 본 것일 수 있다. 이 문서는 그 구분을 위해 상태를 정의한다.

상태는 두 층이다. 몰(소스) 단위가 근본이고, 상품 단위는 그것을 합친 결론이다.

그리고 보는 사람이 둘이다 — 운영자(lps-admin)는 원인까지 알아야 하고, 실 사용자(negodata)는 행동에 필요한 만큼만 알면 된다. 상태는 하나로 정의·저장하고 표시 단계에서 각자에 맞게 접는다(3절).


1. 몰(소스) 단위 상태

한 상품을 한 몰에서 찾을 때 나올 수 있는 결과. 괄호 안은 지금 코드가 그 상태를 아는지.

상태 뜻 사용자에게 재시도 의미
matched 수집·매칭 성공, 가격 확보 가격 표시 불필요
no_match 수집은 됐으나 같은 상품이 없음(액세서리·다른 규격만 나옴) "해당 상품 없음" 검색어를 고치면 달라질 수 있음
empty 그 몰의 검색 결과 자체가 0건 "해당 상품 없음" 검색어 의존
blocked 안티봇 차단 — 못 봤다 "확인 못함" ✅ 유효(IP 회전으로 회복)
env_blocked 회전해도 안 되는 차단(환경·게이트웨이 설정) "확인 못함" ❌ 사람이 고쳐야 함
unavailable 가용 IP 없음·프록시 사망 등 일시적 실패 "확인 못함" ✅ 잠시 후 유효
skipped 그 소스를 아예 안 씀(오픈마켓 폴백 OFF 등) 열 자체를 안 보임 해당 없음

핵심 경계는 no_match/empty(= 사실) 와 blocked/env_blocked/unavailable(= 미확인) 사이다. 앞의 둘은 "없다"고 말해도 되고, 뒤의 셋은 말하면 안 된다.

지금 코드가 아는 것 / 잃는 것

어댑터는 이미 이 구분을 알고 있다. AdapterError 에 blocked·fatal 플래그가 있고, 0건일 때 detect_block 으로 '차단인가 정상 빈결과인가'를 판정한다(browser_base.py).

0건 응답 → detect_block(html, ...) → marker
   marker 있음 → blocked=True   (fatal 마커면 fatal=True)
   marker 없음 → blocked=False  ← '정상 빈결과' = empty

그런데 핸들러가 그 플래그를 버린다:

# worker/handlers.py — _search_round
per_source[src] = {"error": f"{type(res).__name__}: {res}"}   # ← blocked/fatal 유실, 문자열만 남음

⚠️ 그리고 0건도 예외로 온다(return 은 상품이 1건 이상일 때만). 즉 empty 와 blocked 가 둘 다 AdapterError 로 도착하는데, 구분 플래그를 버리므로 핸들러 이후로는 갈라낼 수 없다.

상태 어댑터가 아는가 핸들러가 아는가 price_history 에 남는가
matched ✅ ✅ ✅ by_mall 에 등장
no_match — ✅ (수집>0, 매칭 0) ❌ 유도만 가능, 기록 없음
empty ✅ blocked=False ❌ 유실 ❌
blocked ✅ blocked=True ❌ 유실 ❌
env_blocked ✅ fatal=True ❌ 유실 ❌
unavailable ✅ 별도 메시지 ❌ 유실 ❌

→ 고칠 지점은 한 곳이다: _search_round 가 플래그를 구조화해 넘기고, 그게 price_history 까지 가면 된다. 새로 알아내야 할 정보는 없다 — 이미 아는 걸 흘리고 있을 뿐이다.


2. 상품 단위 상태

몰별 상태를 합친 결론. price_history.outcome 이 이 값이다.

상태 조건 뜻 현재 지원
found 매칭 1건 이상 최저가 확정 ✅
not_found 모든 소스가 사실 응답(no_match/empty)이고 매칭 0 확정적 없음 ✅
error 모든 소스가 미확인(blocked 계열) 아무것도 확인 못함 ✅ (2026-08-06 추가)
partial 일부 소스만 미확인 아래 참고 ⚠️ 부분 지원

partial 이 중요한 이유

partial 은 독립된 상태가 아니라 found/not_found 에 붙는 꼬리표다. 조합이 넷인데 사용자에게 주는 의미가 전혀 다르다.

조합 뜻 사용자에게 할 말
found 전 소스 확인, 최저가 확정 "최저가 N원"
found + partial 찾긴 했지만 못 본 몰이 있다 "최저가 N원 (쿠팡은 확인 못함)"
not_found 전 소스 확인, 확정적 없음 "찾지 못했습니다"
not_found + partial 결론이 아니다 — 못 본 몰에 있었을 수 있다 "확인하지 못했습니다"

특히 마지막이 위험하다. 이걸 not_found 로 뭉뚱그리면:

  • 사용자는 "없구나"로 오해하고
  • 네거티브 캐시에 들어가면 TTL 동안 재검색까지 막힌다

→ 그래서 부분 실패 상태의 not_found 는 네거티브 캐시에 넣지 않는다(2026-08-06 반영). partial 플래그 자체는 job.result 에 있지만 price_history 에는 없어 화면까지 가지 못한다.


3. 화면 표기 — 보는 사람이 다르면 다르게 접는다

상태는 하나만 정의하고 상세하게 저장한다. 접는 건 표시 단계에서 한다. 저장을 단순화하면 관리자가 원인을 못 보고, 표시를 상세화하면 사용자가 못 읽는다. 두 화면 모두 price_history 를 읽으므로(lps-admin: 잡·이력 조회 / negodata: 최저가 모달), 데이터는 공유하고 투영만 달리한다.

3-1. lps-admin (운영자) — 상세하게

목적이 진단이다. "왜 그랬나"에 답할 수 있어야 하므로 7상태를 그대로 보여주고 근거를 붙인다.

표시 내용
몰별 상태 matched / no_match / empty / blocked / env_blocked / unavailable 그대로
근거 차단 마커(wtm_captcha·사용권한이 제한된…), ip_request_no, 포트
조치 힌트 env_blocked → "설정·환경 문제, 회전 무효" / blocked → "IP 평판, 자동 회복"

env_blocked 와 blocked 를 반드시 갈라야 한다 — 사람이 개입해야 하는가가 갈리기 때문이다. 이 구분이 없으면 운영자가 자동 회복될 일에 매달리거나, 손봐야 할 설정을 방치한다.

3-2. negodata (실 사용자) — 접어서

목적이 행동이다. 사용자가 할 수 있는 건 '이 가격을 쓴다 / 다시 시도한다 / 넘어간다' 셋뿐이라, 그 이상 나눠 보여줄 이유가 없다. 원인이 달라도 사용자의 다음 행동이 같으면 같은 표기로 접는다.

내부 상태 사용자 표기 접는 이유
matched 가격
no_match · empty – 둘 다 "이 몰엔 없다"가 결론. 원인(불일치냐 0건이냐)을 알아도 사용자가 할 일이 안 바뀐다
blocked · env_blocked · unavailable 확인 못함 원인이 달라도 사용자 행동은 '나중에 다시' 하나뿐. 차단 종류를 노출하면 불안만 준다
skipped 열 숨김 안 쓴 소스를 '없음'처럼 보이면 안 된다

결과 열도 같은 원칙으로 접는다.

사용자 표기 조건
탐색 성공 found 이고 기존보다 쌈
변동 없음 found(더 싸진 않음) 또는 not_found
일부 확인 못함 partial — 결과가 확정이 아님을 알린다
탐색 실패 error — 전부 미확인
탐색 중 아직 결과 행이 없음

'일부 확인 못함'은 접지 않는다. 다른 항목과 달리 이건 사용자의 행동을 바꾼다 — "이 가격이 최종인가"의 답이 달라지고, 다시 시도할 이유가 생긴다. 사용자에게 필요한 건 어느 몰이 왜 막혔는지가 아니라 결과가 완전하지 않다는 사실 하나다.


4. 현재 상태와 남은 일

된 것

  • error(전부 미확인)를 not_found 와 분리 — 화면이 "탐색 실패"로 구분해 보여준다
  • 부분 실패 시 네거티브 캐시 오염 방지
  • 한 소스가 막혀도 살아있는 소스로 잡을 정상 종료

진행 상황

1단계 — 몰별 상태 보존 ✅ 완료 (feat/source-state)

  • common/enums.py 에 SourceState(7상태) 추가. .confirmed 로 '봤다/못 봤다' 경계를 한곳에 둔다.
  • AdapterError.state — 어댑터가 이미 알던 구분을 실어 보낸다. state 를 안 주면 blocked/fatal 에서 유도하고, 모르면 UNAVAILABLE(= 못 봤다)로 둔다. EMPTY 를 기본값으로 하면 확인도 안 한 몰을 '없음'으로 단정하게 되기 때문이다.
  • browser_base 의 raise 지점 5곳에 상태를 실었다. 갈림은 0건 종착 지점 하나다: blocked=False → EMPTY(정말 없다) / blocked=True → BLOCKED(못 봤다).
  • _search_round 가 문자열 대신 {"state": ..., "count"|"error": ...} 를 남긴다. EMPTY 는 '정상 응답'으로 세므로, 쿠팡에 정말 없을 때 잡이 재시도로 낭비되지 않는다.
  • _finalize_states — 수집만 된 소스를 AI 판정 뒤 MATCHED/NO_MATCH 로 확정한다.

검증(9조합 실측): empty → partial=False(확정) / blocked·env_blocked·unavailable → partial=True(미확정). 테스트 12건 추가.

2단계 — 저장할 자리 ✅ 완료 (feat/source-state)

  • price_history.sources(JSONB) — 몰별 상태를 그대로 담는다. 열린 스키마라 몰이 늘거나 상태에 근거를 덧붙여도 마이그레이션이 필요 없다.
  • price_history.partial(bool) — 결과가 완전한가. sources 에서 유도할 수 있지만 굳이 컬럼으로 둔다: 소비자가 '어떤 상태가 확인된 것인가'라는 판단 규칙까지 알아야 하면 상태 정의가 두 곳으로 흩어진다. 판단은 LPS 가 끝내고 소비자는 사실 하나만 읽는다.
  • 부분 인덱스 ix_price_history_partial — '확인 못한 결과'만 뽑는 운영 점검용(작게 유지된다).
  • 마이그레이션: postgres-init/dbeaver/7_lps_source_state_dbeaver.sql (운영 적용 필요)

검증(실 DB): 쿠팡 차단과 쿠팡 0건은 by_mall 이 둘 다 ['naver'] 로 같지만 partial(true/false)과 sources.coupang.state(blocked/empty)가 두 경우를 갈라낸다. 테스트 3건 추가.

3단계 — lps-admin 상세 표시 ✅ 완료 (feat/source-state)

운영자 목적은 진단이라 상태를 접지 않는다 — blocked(자동 회복)와 env_blocked(사람이 고쳐야 함)를 뭉뚱그리면 회복될 일에 매달리거나 손봐야 할 설정을 방치한다.

  • API: /v1/lps/products 와 /v1/lps/products/{code}/history 둘 다 sources·partial 을 싣는다. 이력은 시점마다 실린다 — 최신 상태를 과거 시점 옆에 붙이면 오해를 부르기 때문이다.
  • lps-admin/src/lib/sourceState.ts: 상태별 라벨·색·설명·confirmed 를 한곳에 둔다. 미지의 상태가 와도 화면이 깨지지 않는다(값 그대로 표시하고 '모름'으로 취급).
  • 상품 목록: 못 본 몰이 있으면 일부 확인 못함 배지(툴팁에 어느 몰인지).
  • 몰별 비교 카드 위: 몰별 상태 줄 + 수집 건수 + 실패 사유 원문(툴팁). 가격표에 없는 몰이 왜 없는지를 여기서 답한다.

검증: ASGI 직접 호출로 두 엔드포인트 모두 sources·partial 확인(한글 사유 포함). tsc 오류 없음. 테스트 3건 추가(목록 노출 / 시점별 상태 / 옛 행 호환).

4단계 — negodata 사용자 화면 ✅ 완료 (feat/source-state)

3-2절대로 셋으로 접었다. 사용자가 할 수 있는 건 '쓴다/다시 시도/넘어간다' 뿐이라, 원인이 달라도 다음 행동이 같으면 같은 표기다.

몰 칸 조건
가격 matched
– no_match · empty — 확인했고 없었다
확인 못함 blocked · env_blocked · unavailable — 못 봤다

체인 전체를 이었다: price_history.sources/partial → negodata 읽기 계약 → 동기화 → item_internet_lowest_prices → API(LowestPriceEntry) → 화면. 마이그레이션: postgres-init/alters/2026-08-07-iilp-source-state.sql (운영 적용 필요)

E2E 검증(실 DB, 두 시나리오): by_mall 은 둘 다 naver 뿐인데 쿠팡 칸이 확인 못함(차단) / –(0건)으로 갈린다. tsc 오류 없음, negodata 97 · lps 292 passed.

폴더 관례상 negodata 는 인수인계 대상이나, 사용자 요청으로 이번 건도 예외 적용.


4단계 모두 완료. 남은 개선 여지(선택):

  • no_match(같은 상품 아님)와 empty(검색 0건)를 사용자 화면에서 굳이 나눌 필요는 없다고 봤다 — 나중에 "왜 없지?"라는 문의가 잦아지면 재검토.
  • 잡 목록(lps-admin Jobs)에는 아직 몰별 상태를 안 붙였다. 상품 화면에서 보이므로 우선순위는 낮다.

이 문서는 정의다. 구현 전에 용어를 맞추기 위한 것이고, 실제 반영 여부는 위 4절이 소스다.