From 04b697a9eb036eaabd0a63cd02fd2a2934e966f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Fri, 7 Aug 2026 09:46:23 +0900 Subject: [PATCH] =?UTF-8?q?docs(lps):=20=EA=B2=80=EC=83=89=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EC=83=81=ED=83=9C=20=EC=A0=95=EC=9D=98=20=E2=80=94?= =?UTF-8?q?=20'=EB=AA=BB=20=EC=B0=BE=EC=9D=8C'=EA=B3=BC=20'=EB=AA=BB=20?= =?UTF-8?q?=EB=B4=84'=EC=9D=84=20=EA=B0=80=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '가격을 못 찾았다' 한 문장에 뜻이 정반대인 상황이 섞여 있다: 그 몰에 정말 없는 것(사실)과 그 몰이 우리를 막아 못 본 것(미확인). 지금 화면은 둘 다 '–' 로 똑같이 보여준다. 구현 전에 용어를 맞추려고 상태를 정의한다. 코드를 확인해 **실제로 구분 가능한 것**만 정의했다: 몰(소스) 단위 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 --- lps/README.md | 1 + lps/docs/result-states.md | 133 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 lps/docs/result-states.md diff --git a/lps/README.md b/lps/README.md index def92c2..fa14a86 100644 --- a/lps/README.md +++ b/lps/README.md @@ -194,6 +194,7 @@ docker compose up -d # negosium 스택 + lps-api·lps-worker·lps | 문서 | 대상 | 내용 | |------|------|------| | **[아키텍처](docs/architecture.md)** | 개발자/기획자 | 구성요소·파이프라인·안티봇(Akamai/Turnstile)·비용계측·동시성 | +| **[결과 상태 정의](docs/result-states.md)** | 개발자/기획자 | '못 찾음'과 '못 봄'의 구분 — 몰별·상품별 상태 정의와 화면 표기 매핑 | | **[데이터베이스](docs/database.md)** | 개발자/기획자 | 테이블 6종 구조와 코드값(+by_mall·ip_session·proxy_port 장부) | | **[API 사용법](docs/api.md)** | 연동 개발자 | 엔드포인트·요청/응답·metrics 예시 | | **[운영 가이드](docs/operations.md)** | 운영자/개발자 | 실행·병렬·관측(readyz/ops/알림)·**Docker 배포**·문제 해결 | diff --git a/lps/docs/result-states.md b/lps/docs/result-states.md new file mode 100644 index 0000000..5bd0b76 --- /dev/null +++ b/lps/docs/result-states.md @@ -0,0 +1,133 @@ +# 최저가 검색 결과 상태 정의 + +[← 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절이 소스다.