o2o-negosium-original/lps/docs/result-states.md
민헌 d1d1ed6ec5 feat(lps-admin): 3단계 — 몰별 확인 상태를 운영 화면에 노출
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>
2026-08-07 11:05:31 +09:00

211 lines
12 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단계 — 몰별 상태 보존 ✅ 완료** (`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 가 `–`(없음)와 `확인 못함`(미확인)을 구분하지 못한다 — 사용자 화면은 3가지로 접는다(3-2절)
> 이 문서는 **정의**다. 구현 전에 용어를 맞추기 위한 것이고, 실제 반영 여부는 위 4절이 소스다.