o2o-negosium-original/lps/docs/result-states.md
민헌 04b697a9eb docs(lps): 검색 결과 상태 정의 — '못 찾음'과 '못 봄'을 가른다
'가격을 못 찾았다' 한 문장에 뜻이 정반대인 상황이 섞여 있다: 그 몰에 정말 없는 것(사실)과
그 몰이 우리를 막아 못 본 것(미확인). 지금 화면은 둘 다 '–' 로 똑같이 보여준다.
구현 전에 용어를 맞추려고 상태를 정의한다.

코드를 확인해 **실제로 구분 가능한 것**만 정의했다:

몰(소스) 단위 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>
2026-08-07 09:46:23 +09:00

134 lines
6.6 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 확인). 그래서 사용자는 "쿠팡엔 더 싼 게
없구나"로 읽지만 실제로는 **안 본 것**일 수 있다. 이 문서는 그 구분을 위해 상태를 정의한다.
상태는 두 층이다. **몰(소스) 단위**가 근본이고, **상품 단위**는 그것을 합친 결론이다.
---
## 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. 화면 표기 매핑
| 몰 열 | 조건 |
|------|------|
| 가격 | `matched` |
| `–` | `no_match` / `empty` — **사실로서 없음** |
| `확인 못함` | `blocked` / `env_blocked` / `unavailable` — **미확인** |
| 결과 열 | 조건 |
|--------|------|
| 탐색 성공 | `found` 이고 기존보다 쌈 |
| 변동 없음 | `found`(더 싸진 않음) 또는 `not_found` |
| **일부 확인 못함** | `partial` — 결과가 확정이 아님을 알린다 |
| 탐색 실패 | `error` — 전부 미확인 |
| 탐색 중 | 아직 결과 행이 없음 |
---
## 4. 현재 상태와 남은 일
**된 것**
- `error`(전부 미확인)를 `not_found` 와 분리 — 화면이 "탐색 실패"로 구분해 보여준다
- 부분 실패 시 네거티브 캐시 오염 방지
- 한 소스가 막혀도 살아있는 소스로 잡을 정상 종료
**안 된 것**
1. `_search_round` 가 `AdapterError.blocked/fatal` 을 버린다 → 몰별 상태를 못 만든다 *(가장 근본)*
2. `price_history` 에 몰별 상태·`partial` 을 담을 자리가 없다 → negodata 가 못 읽는다
3. 화면이 `–`(사실로서 없음)와 `확인 못함`(미확인)을 구분하지 못한다
1 → 2 → 3 순서로 풀어야 한다. 1을 안 고치면 2·3은 담을 내용이 없다.
> 이 문서는 **정의**다. 구현 전에 용어를 맞추기 위한 것이고, 실제 반영 여부는 위 4절이 소스다.