o2o-negosium-original/lps/docs/result-states.md
민헌 1e966f7121 docs(lps): 결과 상태 — 운영자와 실사용자를 나눠 접는 원칙 추가
보는 사람이 둘인데 하나로 뭉뚱그리고 있었다.
  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>
2026-08-07 10:11:40 +09:00

170 lines
9.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 최저가 검색 결과 상태 정의
[← README로](../README.md)
`가격을 못 찾았다`는 한 문장 안에 **뜻이 정반대인 상황들**이 섞여 있다.
- 그 몰에 정말 그 상품이 없다 → 사실이다. 사용자는 받아들이면 된다.
- 그 몰이 우리를 막아서 못 봤다 → **사실이 아니다**. 더 싼 값이 있었을 수 있다.
지금 화면은 둘 다 `–` 로 똑같이 보여준다(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
```
그런데 **핸들러가 그 플래그를 버린다**:
```python
# 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. `_search_round` 가 `AdapterError.blocked/fatal` 을 버린다 → 몰별 상태를 못 만든다 *(가장 근본)*
2. `price_history` 에 몰별 상태·`partial` 을 담을 자리가 없다 → 두 화면 모두 못 읽는다
3. lps-admin 이 몰별 상태·원인을 못 보여준다(잡 목록의 outcome 까지만)
4. negodata 가 `–`(없음)와 `확인 못함`(미확인)을 구분하지 못한다
**1 → 2 → (3, 4)** 순서다. 1을 안 고치면 2가 담을 내용이 없고, 2가 없으면 3·4가 읽을 게 없다.
3과 4는 같은 데이터에서 각자 다르게 접는 것이므로 순서가 없다 — 병행 가능하다.
> 이 문서는 **정의**다. 구현 전에 용어를 맞추기 위한 것이고, 실제 반영 여부는 위 4절이 소스다.