보는 사람이 둘인데 하나로 뭉뚱그리고 있었다.
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>
9.4 KiB
최저가 검색 결과 상태 정의
가격을 못 찾았다는 한 문장 안에 뜻이 정반대인 상황들이 섞여 있다.
- 그 몰에 정말 그 상품이 없다 → 사실이다. 사용자는 받아들이면 된다.
- 그 몰이 우리를 막아서 못 봤다 → 사실이 아니다. 더 싼 값이 있었을 수 있다.
지금 화면은 둘 다 – 로 똑같이 보여준다(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와 분리 — 화면이 "탐색 실패"로 구분해 보여준다- 부분 실패 시 네거티브 캐시 오염 방지
- 한 소스가 막혀도 살아있는 소스로 잡을 정상 종료
안 된 것
_search_round가AdapterError.blocked/fatal을 버린다 → 몰별 상태를 못 만든다 (가장 근본)price_history에 몰별 상태·partial을 담을 자리가 없다 → 두 화면 모두 못 읽는다- lps-admin 이 몰별 상태·원인을 못 보여준다(잡 목록의 outcome 까지만)
- negodata 가
–(없음)와확인 못함(미확인)을 구분하지 못한다
1 → 2 → (3, 4) 순서다. 1을 안 고치면 2가 담을 내용이 없고, 2가 없으면 3·4가 읽을 게 없다. 3과 4는 같은 데이터에서 각자 다르게 접는 것이므로 순서가 없다 — 병행 가능하다.
이 문서는 정의다. 구현 전에 용어를 맞추기 위한 것이고, 실제 반영 여부는 위 4절이 소스다.