같은 DB(lps_db)의 같은 시기 추가분이 두 파일로 갈려 있어 순서·누락을 신경 써야 했다. 6_ 하나로 합친다(7_ 삭제) — 실행이 한 번이면 '어디까지 돌렸더라'를 기억할 일이 없다. - 주석을 3_lps_dbeaver.sql 스타일로 통일: 객체 위 한 줄 설명 + 컬럼 인라인 주석 정렬. 기존 6_·7_ 의 긴 배경 산문은 걷어냈다 — 배경은 lps/docs/result-states.md 가 소스고, 이 파일은 '무엇을 만드는가'만 답하면 된다. - 내용은 그대로: proxy_port(+LRU 인덱스), price_history 신뢰 신호 2·배송 3, 몰별 확인 상태 sources/partial(+부분 인덱스), 적용 확인 SELECT. 검증: 기존 lps_db 재실행(멱등 — NOTICE 만) + 빈 DB 에 3_ → 6_ 신규 설치 후 price_history 신규 7컬럼·테이블 6종 전부 확인. 테스트 DB 는 정리. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
234 lines
14 KiB
Markdown
234 lines
14 KiB
Markdown
# 최저가 검색 결과 상태 정의
|
||
|
||
[← 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/6_lps_2026-08_dbeaver.sql` (2026-08 추가분 통합, **운영 적용 필요**)
|
||
|
||
검증(실 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절이 소스다.
|