docs(lps): 프로젝트 문서화 — README + docs/(아키텍처·DB·API·운영)

비개발자/기획자/개발자 누구나 이해하도록 일목요연하게 정리. 길이 분산 위해 분할.

- README.md: 3줄 요약 + 워크플로우 다이어그램 + 주요기능 + 빠른시작 + 문서 목차
- docs/architecture.md: 구성요소·처리 파이프라인·재시도/프록시/봇감지/이력 원리
- docs/database.md: 테이블 4종(job/price_history/search_negative/bot_detection) + 코드값
- docs/api.md: 엔드포인트 요청/응답 예시(curl/Postman), 상태·코드 요약
- docs/operations.md: 실행·로그·DB조회·테스트·문제해결·배포유의

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-09 11:27:39 +09:00
parent 5906acc48a
commit 8bf34ea346
5 changed files with 534 additions and 51 deletions

View File

@ -1,59 +1,112 @@
# LPS (Lowest Price Search) # LPS — 인터넷 최저가 검색 솔루션
인터넷 최저가를 찾는 솔루션. **프레임워크는 `backend` 와 동일**하게 구성한 골격이며, > 상품 정보를 넣으면 **네이버·쿠팡을 뒤져 "같은 상품"의 최저가를 찾아** 돌려주고, 그 가격을 **시간에 따라 기록**해 그래프로 볼 수 있는 시스템입니다.
구체적인 도메인 로직(크롤링/오픈API 연동/최저가 산정 등)은 아직 정해지지 않았다.
## 스택 / 아키텍처 (backend 미러링) ---
- **FastAPI** 앱 (`router/router.py`) + `web_main.py` 부트스트랩
- **설정**: `config/` — TOML 로더(`config.local.toml`) + pydantic 모델, `APP_ENV`(기본 local) ## 🧭 이게 뭔가요? (비개발자용 3줄 요약)
- **DB**: `common/database/db_session_manager.py` — 논리 DB × Read/Write 엔진, service→람다 위임 패턴
- 엔진은 lazy 생성이라 **DB 없이도 부팅/healthz 동작**한다. 테이블·crud 가 생기면 그때 실제 접속. 1. "맥심 커피 (1박스, 160개입)" 같은 상품 정보를 보내면,
- **공통 응답 규약**: `common/models/gmodel.py` — `Res_WebPacketProtocol.result`(성공/코드/설명) 2. 시스템이 **네이버·쿠팡을 실제로 검색**하고, **AI가 "진짜 같은 상품"만 골라** 최저가를 알려줍니다. (빨대·커버 같은 **엉뚱한 액세서리는 걸러냅니다**)
- **결과 코드**: `common/enums.py` — `ErrorType`, `DBType`, `DBWRType` 3. 같은 상품을 **여러 번 조회하면 가격 변화가 쌓여서**, 네이버/쿠팡/최종 최저가를 **그래프**로 볼 수 있습니다.
- 계층 컨벤션: `router`(컨트롤러) → `services`(비즈니스) → `crud`(DB 접근)
**왜 유용한가?** 사람이 일일이 검색·비교하지 않아도, 필요한 상품만(조회할 때만) 자동으로 최저가를 찾고 가격 추이를 남깁니다.
---
## ⚙️ 어떻게 동작하나요? (워크플로우)
```
[1] 검색 요청 [2] 대기줄(큐) [3] 일꾼(워커)가 처리
상품 정보 전송 ─────▶ 순서대로 쌓임 ─────▶ 네이버 + 쿠팡 동시 검색
(POST /search) (즉시 접수번호 반환) │
▼
[4] 걸러내기 + AI 판정
가격 이상치 제거 → "같은 상품"만 선별
│
▼
[5] 최저가 확정 + 기록
네이버/쿠팡/최종 최저가 저장 → 그래프용 이력 적재
│
▼
[6] 완료(결과 조회 가능)
```
**핵심 포인트**
- **즉시 응답 + 나중 처리**: 요청하면 바로 "접수번호(job_id)"를 주고, 실제 검색은 뒤에서 진행됩니다. (검색은 몇 초~수십 초 걸림)
- **못 찾으면 검색어를 바꿔 재시도**: "맥심 커피"로 안 나오면 "맥심 모카골드 커피믹스"처럼 **AI가 검색어를 다듬어** 다시 시도하고, 그래도 없으면 "없음"으로 정리합니다. (무한 재시도 안 함)
- **차단 대응**: 쿠팡이 봇으로 감지하면 **다른 IP로 바꿔** 재시도하고, 감지 이력도 기록합니다.
---
## ✨ 주요 기능
| 기능 | 설명 |
|------|------|
| 멀티 소스 검색 | 네이버 쇼핑 API + 쿠팡(봇 차단 우회) 동시 검색·병합 |
| AI 같은 상품 판정 | "진짜 그 상품"만 선별 (액세서리·다른 규격 제외) |
| 검색어 자동 정제 | 0건이면 정밀/광역 검색어로 재시도 |
| 최저가 이력 그래프 | 조회 시점마다 네이버/쿠팡/최종 최저가를 시계열로 기록 |
| 안정적 큐 처리 | 작업 유실 없이 순서대로, 실패 시 자동 재시도 |
| 프록시 IP 회전 | 차단 회피용 IP 자동 순환(DECODO) |
---
## 🚀 빠른 시작
```bash
cd lps
# 1) 설정 파일 준비 (DB·API 키 등)
cp config/config.local.toml.example config/config.local.toml # 값 채우기
# 2) API 서버 실행 (요청 접수)
./run_local_server.sh # → http://localhost:9600/docs
# 3) 워커 실행 (실제 검색 수행) — 별도 터미널
python worker_main.py
```
간단 테스트:
```bash
curl -X POST localhost:9600/v1/lps/search -H 'Content-Type: application/json' \
-d '{"data":[{"product_code":"T1","product_name":"맥심 커피","specification":"1박스, 160개입"}]}'
```
> 자세한 실행/설정은 [운영 가이드](docs/operations.md) 참고.
---
## 📚 문서
| 문서 | 대상 | 내용 |
|------|------|------|
| **[아키텍처](docs/architecture.md)** | 개발자/기획자 | 구성요소, 처리 파이프라인, 재시도·프록시·AI 동작 원리 |
| **[데이터베이스](docs/database.md)** | 개발자/기획자 | 테이블 4종 구조와 코드값 |
| **[API 사용법](docs/api.md)** | 연동 개발자 | 엔드포인트·요청/응답 예시(Postman/curl) |
| **[운영 가이드](docs/operations.md)** | 운영자/개발자 | 실행·로그 보기·DB 조회·문제 해결 |
---
## 📁 폴더 구조
## 폴더 구조
``` ```
lps/ lps/
├── web_main.py # 진입점 ├── web_main.py # API 서버 진입점 (요청 접수)
├── requirements.txt / Dockerfile / .dockerignore ├── worker_main.py # 워커 진입점 (실제 검색 수행)
├── run_local_server.sh # 로컬 실행(대화형), 포트 9600 ├── run_local_server.sh # 로컬 API 실행 스크립트
├── pytest.ini / conftest.py ├── config/ # 설정(config.local.toml — 포트/DB/API키, 미커밋)
├── config/ ├── common/ # 공통(enums, DB 세션, 모델, 로거)
│ ├── config_loader.py / config_models.py / server_configs.py │ └── database/model/models.py # DB 테이블 정의
│ └── config.local.toml.example # cp 해서 config.local.toml 로 사용(시크릿, 미커밋) ├── crud/ # DB 접근 (job_crud, price_history, negative_cache, bot_detection)
├── common/ ├── services/
│ ├── enums.py / logger.py / singleton.py │ ├── search/ # 소스 어댑터 (coupang, naver) + 프록시·필터
│ ├── utils/gtime.py │ ├── pipeline/ # 필터·이상치·최저가 정렬
│ ├── models/gmodel.py # 프로토콜 base │ └── ai/ # AI 유사도 판정·검색어 생성 (OpenAI)
│ └── database/{db_session_manager.py, model/models.py(MAIN_BASE)} ├── worker/ # 워커 루프·핸들러·알림(NOTIFY)
├── router/ ├── router/v1/lps/ # API 라우터
│ ├── router.py # app + /healthz (도메인 라우터 미등록) └── tests/ # 테스트
│ └── v1/validator/dependencies.py# RemoveNoneResponse
├── services/ # (비어있음) 도메인 서비스 추가 위치
├── crud/ # (비어있음) DB 접근 계층 추가 위치
└── tests/test_health.py # 스모크 테스트
```
## 로컬 실행
```bash
cp config/config.local.toml.example config/config.local.toml # 설정+시크릿 전부(포트/DB/API 키)
./run_local_server.sh # → http://localhost:9600/docs
```
> `config.local.toml` 한 파일에 설정과 시크릿(API 키)을 통합 관리(git 미추적).
> 배포는 이 파일을 마운트하거나, 환경별로 바뀌는 값만 env override(DB_HOST 등).
> 워커는 별도 프로세스: `python worker_main.py`
## 테스트
```bash
python -m pytest # tests/ (기본 healthz 스모크)
``` ```
## 포트 ## 포트
- backend 9300 / negodata 9400 / agent 9500 과 겹치지 않도록 **LPS 는 9600** 사용. backend 9300 / negodata 9400 / agent 9500 과 겹치지 않게 **LPS는 9600**.
## 새 도메인 추가 순서 (backend 컨벤션)
1. `common/database/model/models.py` 에 테이블 정의(+ `DBType`)
2. `crud/<domain>_crud.py` (ABC 인터페이스 + 구현)
3. `services/<domain>_service.py` (비즈니스 로직)
4. `router/v1/<domain>/{protocol.py, <domain>.py}` 작성 후 `router/router.py` 에서 `include_router`

134
lps/docs/api.md Normal file
View File

@ -0,0 +1,134 @@
# API 사용법
[← README로](../README.md)
- **베이스 URL**(로컬): `http://localhost:9600`
- **Swagger 문서**: `http://localhost:9600/docs` (브라우저에서 바로 테스트 가능)
- 모든 응답에는 공통 **결과 봉투** `result`가 붙습니다:
```json
"result": { "success": true, "code": 0, "desc": "SUCCESS" }
```
(실패 시 `success:false`, `code`/`desc`에 오류 코드)
---
## 1. 검색 요청 — `POST /v1/lps/search`
상품 리스트를 보내면 상품마다 검색 작업을 큐에 넣고 **접수번호(job_id)**를 즉시 반환합니다. (실제 검색은 뒤에서 진행)
**요청**
```json
{
"data": [
{
"product_code": "T1", // (필수) 상품 식별 코드 — 이력·중복방지 키
"product_name": "맥심 커피", // (필수) 상품명
"specification": "1박스, 160개입", // (선택) 규격 — 자유 서술, 형식 무관
"model": "모카골드", // (선택) 모델명
"company": "동서식품", // (선택) 제조사/브랜드
"price": "25000", // (선택) 현재가 — 있으면 가격 범위 필터 기준
"job_type": "single" // (선택) 요청 유형 → 우선순위
}
]
}
```
> **specification은 나눌 필요 없이** "1박스, 160개입"처럼 통째로 넣으면 AI가 해석합니다.
**`job_type` → 우선순위** (낮을수록 먼저): `new`(1) · `single`(2, 기본) · `negowiz`(3) · `batch`(4)
**응답**
```json
{
"result": { "success": true, "code": 0, "desc": "SUCCESS" },
"accepted": 1,
"items": [ { "product_code": "T1", "job_id": "c885...", "duplicated": false } ]
}
```
- `duplicated: true` → 같은 상품이 이미 처리 대기/진행 중이라 중복 접수 생략(그 경우 `job_id` 없음).
**curl**
```bash
curl -X POST localhost:9600/v1/lps/search -H 'Content-Type: application/json' \
-d '{"data":[{"product_code":"T1","product_name":"맥심 커피","specification":"1박스, 160개입"}]}'
```
---
## 2. 작업 상태·결과 — `GET /v1/lps/jobs/{job_id}`
접수번호로 진행 상태와 결과를 조회합니다. (`PENDING` → `RUNNING` → `DONE`)
**응답 (완료 시)**
```json
{
"result": { "success": true, "code": 0, "desc": "SUCCESS" },
"job_id": "c885...",
"status": "DONE", // PENDING / RUNNING / DONE / DEAD
"attempts": 1,
"output": {
"outcome": "found", // found / not_found
"query": "맥심 커피",
"lowest": { "price": 25200, "source": "coupang", "name": "맥심모카골드 ...", "detail_url": "..." },
"top": [ /* 최저가 상위 N개 */ ],
"sources": { "naver": {"count": 40}, "coupang": {"count": 40} },
"stages": [ {"stage":"outlier","in":80,"out":76}, {"stage":"ai_match","in":76,"out":1}, {"stage":"top_n","in":1,"out":1} ]
}
}
```
- `output.stages` = 각 단계에서 몇 건이 걸러졌는지(디버깅·품질 확인용).
- 없는 job_id/잘못된 형식 → `result.desc = "LPS_JOB_NOT_FOUND"`.
```bash
curl localhost:9600/v1/lps/jobs/c885...
```
---
## 3. 최저가 이력(그래프) — `GET /v1/lps/products/{product_code}/history`
같은 상품을 여러 번 검색하면 쌓인 스냅샷을 **시각 오름차순**으로 반환합니다. 프론트에서 그래프로 그립니다.
**쿼리 파라미터**: `limit` (기본 100, 최대 1000)
**응답**
```json
{
"result": { "success": true, "code": 0, "desc": "SUCCESS" },
"product_code": "T1",
"points": [
{
"triggered_at": "2026-07-09T10:48:47", // X축
"outcome": "found",
"matched_count": 1,
"naver": 25200, // 네이버 최저가
"coupang": 24800, // 쿠팡 최저가
"final": 24800, // 최종 최저가 (Y축)
"final_source": "coupang",
"naver_name": "...", "naver_url": "...", "coupang_name": "...", "coupang_url": "..."
}
]
}
```
- `naver`/`coupang`가 `null`인 지점 = 그 시점에 해당 소스엔 그 상품이 없었음(그래프 선 공백).
```bash
curl "localhost:9600/v1/lps/products/T1/history?limit=100"
```
---
## 4. 큐 상태 — `GET /v1/lps/queue/stats`
```json
{ "result": {...}, "counts": { "PENDING": 0, "RUNNING": 1, "DONE": 12, "DEAD": 0 } }
```
## 5. 헬스체크 — `GET /healthz`
서버 기동 시각을 반환(살아있는지 확인용).
---
## 상태 코드 요약
- **작업 상태**: `PENDING`(대기) · `RUNNING`(처리중) · `DONE`(완료) · `DEAD`(실패-확인필요)
- **결과 outcome**: `found`(찾음) · `not_found`(검색했으나 같은 상품 없음)

80
lps/docs/architecture.md Normal file
View File

@ -0,0 +1,80 @@
# 아키텍처 — 어떻게 동작하는가
[← README로](../README.md)
## 1. 구성요소 (한눈에)
| 구성요소 | 파일 | 역할 |
|---------|------|------|
| **API 서버** | `web_main.py` | 검색 요청을 받아 **큐에 적재**만 함(빠르게 응답). 상태·이력 조회 제공 |
| **큐(대기줄)** | `crud/job_crud.py` + `job` 테이블 | 할 일을 순서대로 안전하게 보관 (PostgreSQL 사용) |
| **워커(일꾼)** | `worker_main.py`, `worker/` | 큐에서 하나씩 꺼내 **실제 검색·판정·저장** 수행 |
| **소스 어댑터** | `services/search/` | 네이버·쿠팡에서 상품 수집 (소스별 방식 캡슐화) |
| **파이프라인** | `services/pipeline/` | 수집 결과를 필터·이상치 제거·최저가 정렬 |
| **AI** | `services/ai/` | "같은 상품" 판정 + 검색어 생성 (OpenAI) |
> **API와 워커를 분리**한 이유: 요청 접수는 즉시(가벼움), 실제 검색은 무거움(브라우저·AI). 분리하면 요청이 밀리지 않고, 워커만 따로 늘릴 수 있습니다.
## 2. 처리 파이프라인 (워커가 하는 일)
한 건의 검색 작업은 아래 단계를 거칩니다. 각 단계의 통과 건수는 `stages`로 기록됩니다(관측).
```
① 네거티브 캐시 확인
최근 "없음"으로 확인된 상품이면 → 재검색 생략(비용 절약)
② 소스 검색 (재정제 루프, 최대 3라운드)
라운드1: 원본 검색어 → 라운드2: AI 정밀 검색어 → 라운드3: 광역 검색어
각 라운드에서 네이버 ∥ 쿠팡 동시 검색 후 병합
③ 필터
- mall 필터 (필요 시 특정 쇼핑몰만/제외)
- 가격 밴드 (요청에 현재가가 있으면 ±범위 밖 제거)
④ 이상치 제거 (IQR)
비정상적으로 싸거나 비싼 항목 제거 (오매칭·묶음 등)
⑤ AI 같은 상품 판정
후보 중 "찾는 상품과 동일한 것"만 선별 (액세서리·다른 규격 제외)
→ 매칭 있으면: 최저가순 정렬 → 상위 N개 반환 (found)
→ 매칭 0건 + 소스 정상: 다음 라운드로
→ 매칭 0건 + 소스 차단: 작업 실패 처리(뒤에서 재시도)
⑥ 결과 저장
최저가 확정 + 최저가 이력 스냅샷 기록
```
모든 라운드에서 못 찾으면 → **not_found(정상 종료)** + 네거티브 캐시에 기록.
## 3. 재시도 로직 — "왜 실패했나"에 따라 다르게
실패는 성격이 다르므로 **두 종류로 분리**해서 처리합니다. (섞으면 무한 재시도·오작동)
| 실패 종류 | 예시 | 대응 |
|----------|------|------|
| **기술적 실패** | 네트워크·쿠팡 차단·API 한도·AI 오류 | 잠시 후 재시도(지수 백오프), 여러 번 실패하면 **DEAD**(사람이 확인) |
| **검색어 문제** | "맥심 커피"가 너무 광범위 → 0건 | **검색어를 바꿔** 재시도(정밀→광역), 다 실패하면 **not_found** |
| **진짜 없는 상품** | 실제로 안 파는 상품 | 재시도 무의미 → **not_found로 정상 종료** (에러 아님) |
**무한 재시도 방지**: 기술적 재시도(횟수 상한)·검색어 재시도(라운드 상한) 둘 다 유한합니다. "못 찾음"은 **실패가 아니라 정상적인 답**으로 처리해 쌓이지 않습니다.
## 4. 쿠팡 봇 차단 대응
- 쿠팡은 **Akamai 봇 차단**(JS 챌린지)이 있어, 일반 HTTP로는 못 뚫습니다 → **실제 Chrome 브라우저**(Patchright)로 통과합니다.
- **프록시 IP 회전(DECODO)**: 같은 IP로 계속 두드리면 차단되므로, 일정 시간마다 다른 IP로 바꿉니다. (매 요청마다 바꾸면 오히려 의심받아 **일정 시간 유지 후 회전**)
- **감지 시 즉시 IP 전환**: 차단이 감지되면 새 IP로 바꿔 재시도하고, **감지 이력**(몇 번째 요청에서 걸렸는지)을 기록해 패턴을 분석합니다.
- **대역폭 절약**: 이미지·폰트 등 불필요한 리소스는 받지 않아 프록시 비용을 줄입니다.
## 5. 최저가 이력 (그래프)
- **트리거 기반**: 자동 배치로 전 상품을 주기 조회하지 않고, **실제 조회된 상품만** 그 시점에 기록 → 트래픽·비용 절약.
- 검색할 때마다 **네이버 최저가 / 쿠팡 최저가 / 최종 최저가**를 스냅샷으로 남깁니다.
- 그래프: X축 = 조회 시각(불규칙), Y축 = 가격, 3개 선. (한쪽 소스에 그 상품이 없던 시점은 선이 비어있음 — 정상)
## 6. 설계 원칙 (참고)
- **PostgreSQL을 큐로 제대로 사용**: 별도 메시지 브로커(Redis 등) 없이, 원자적 작업 할당 + 자동 복구로 유실·중복 없이 처리.
- **소스 어댑터 패턴**: 네이버·쿠팡의 수집 방식 차이를 어댑터 안에 가두고, 코어는 정규화된 결과만 다룸 → 새 쇼핑몰 추가가 쉬움.
- **AI로 매칭**: 상품명 형식이 제각각이라 규칙으로 고정 파싱하지 않고, AI가 "같은 상품인지" 판단.
더 깊은 내부 구현은 각 파일 상단 주석에 정리되어 있습니다.

107
lps/docs/database.md Normal file
View File

@ -0,0 +1,107 @@
# 데이터베이스 구조
[← README로](../README.md)
- **DB 이름**: `lps_db` (PostgreSQL, negosium_db와 별개)
- **테이블 정의**: `common/database/model/models.py` (SQLAlchemy) — 이 파일이 스키마의 단일 출처
- **공통 규칙**: 외래키(FK) 안 씀(무결성은 앱에서) · 코드값은 정수(SMALLINT) · 시각은 전부 `TIMESTAMPTZ`(UTC)
## 테이블 4종 한눈에
| 테이블 | 용도 |
|--------|------|
| `job` | 작업 큐 — 검색 요청을 순서대로 보관·처리 |
| `price_history` | 최저가 이력 — 그래프용 시계열 스냅샷 |
| `search_negative` | 네거티브 캐시 — "없음"으로 확인된 상품을 일정 시간 기억 |
| `bot_detection` | 봇 감지 이력 — 쿠팡이 차단한 패턴 기록 |
---
## 1. `job` — 작업 큐
| 컬럼 | 뜻 |
|------|-----|
| `job_id` | 작업 고유 ID (요청 시 반환되는 접수번호) |
| `job_type` | 작업 종류 (1=검색, 2=외부전송) |
| `status` | 상태 (아래 코드표) |
| `priority` | 우선순위(낮을수록 먼저) |
| `payload` | 요청 내용(상품 정보) JSON |
| `result` | 처리 결과 JSON (최저가·단계·소스별 건수 등) |
| `attempts` / `max_attempts` | 시도 횟수 / 최대 |
| `run_after` | 이 시각 이후 실행(재시도 대기용) |
| `lease_until` / `worker_id` | 점유 만료 시각 / 처리 중인 워커 (죽으면 자동 회수) |
| `last_error` | 마지막 오류 메시지 |
| `created_at` / `updated_at` | 생성/수정 시각 |
**status 코드값** (`JobStatus`)
| 값 | 이름 | 뜻 |
|----|------|-----|
| 1 | PENDING | 대기 중 |
| 2 | RUNNING | 처리 중 |
| 3 | DONE | 완료 (found/not_found 모두 포함) |
| 4 | DEAD | 재시도 소진 실패 (사람 확인 필요) |
**job_type 코드값** (`JobType`): 1=SEARCH(검색), 2=OUTBOX(외부전송)
---
## 2. `price_history` — 최저가 이력 (그래프)
검색할 때마다 1행씩 쌓입니다. 특정 상품의 시계열을 뽑아 그래프로 그립니다.
| 컬럼 | 뜻 |
|------|-----|
| `product_code` | 상품 식별 키(요청의 product_code) |
| `triggered_at` | 검색 실행 시각 (**그래프 X축**) |
| `outcome` | found / not_found |
| `matched_count` | AI가 "같은 상품"으로 판정한 개수 |
| `naver_lowest` / `naver_name` / `naver_url` | 네이버 최저가 + 상품명/링크 |
| `coupang_lowest` / `coupang_name` / `coupang_url` | 쿠팡 최저가 + 상품명/링크 |
| `final_lowest` | 전체 최저가 (**그래프 Y축 핵심**) |
| `final_source` | 최종 최저가가 나온 소스(naver/coupang) |
| `job_id` / `created_at` | 검색 잡 연결 / 생성 시각 |
> 한쪽 소스에 그 상품이 없던 시점은 해당 컬럼이 `null`(그래프 선이 빈다 — 정상).
---
## 3. `search_negative` — 네거티브 캐시
"검색해도 없더라"를 일정 시간(기본 24h) 기억해 **재검색 낭비를 막습니다**.
| 컬럼 | 뜻 |
|------|-----|
| `key` | 상품 식별 키(보통 product_code) |
| `until` | 이 시각까지 "없음"으로 간주 (지나면 다시 검색 허용) |
| `reason` | 사유 메모 |
| `created_at` | 생성 시각 |
---
## 4. `bot_detection` — 봇 감지 이력
쿠팡이 차단(봇 감지)했을 때 기록. "**어떤 IP로 몇 번째 요청에서 걸리나**"를 분석합니다.
| 컬럼 | 뜻 |
|------|-----|
| `source` | 소스(coupang) |
| `query` | 감지 당시 검색어 |
| `ip_request_no` | 현재 IP(브라우저)로 몇 번째 요청이었나 |
| `proxy_port` | 사용 중이던 프록시 포트(=IP 세션) |
| `elapsed_sec` | 브라우저 실행 후 경과(초) |
| `marker` | 감지 근거(차단 페이지 마커) |
| `headless` / `html_len` | 헤드리스 여부 / 응답 크기 |
| `created_at` | 감지 시각 |
**분석 예시**
```sql
-- IP당 평균 몇 요청 만에 감지되는지
SELECT avg(ip_request_no), count(*) FROM bot_detection;
```
---
## 스키마 생성/관리
- 개발·테스트: SQLAlchemy 모델에서 `create_all`로 자동 생성.
- DB 접속(로컬): `psql -h 127.0.0.1 -U postgres -d lps_db` (자세한 쿼리는 [운영 가이드](operations.md)).

109
lps/docs/operations.md Normal file
View File

@ -0,0 +1,109 @@
# 운영 가이드 — 실행 · 로그 · DB · 문제 해결
[← README로](../README.md)
## 1. 사전 준비
**필요한 것**: Python 3.12+ (로컬은 3.14), PostgreSQL, Google Chrome(쿠팡 크롤링용)
**설정 파일** (`config/config.local.toml`, git 미커밋)
```bash
cp config/config.local.toml.example config/config.local.toml
```
채워야 할 값:
| 섹션 | 값 |
|------|-----|
| `[MainDBConfig]` | DB 접속(host/port/id/pw, name=lps_db) |
| `[NaverConfig].keys` | 네이버 쇼핑 API 키(id/secret). 여러 개면 자동 로테이션 |
| `[OpenAIConfig]` | `api_key` (AI 판정·검색어 생성용) |
| `[DecodoConfig]` | 프록시 정보(비워두면 프록시 미사용) |
> **한 파일에 설정+시크릿 통합** 관리. 배포 시엔 이 파일을 마운트하거나, 환경별로 바뀌는 값(DB_HOST 등)만 환경변수로 덮어씁니다.
**DB 준비**: `lps_db` 생성 후 최초 실행 시 테이블 자동 생성.
```bash
createdb -h 127.0.0.1 -U postgres lps_db
psql -h 127.0.0.1 -U postgres -d lps_db -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;"
```
## 2. 실행
**API 서버** (요청 접수)
```bash
./run_local_server.sh # → http://localhost:9600/docs
```
**워커** (실제 검색 수행) — 별도 터미널
```bash
python worker_main.py
# 로그를 실시간으로 보려면:
PYTHONUNBUFFERED=1 python worker_main.py
```
> 워커 실행 시 쿠팡 크롤링용 **Chrome 창이 뜹니다**(정상). 기동 로그에 `DECODO 프록시: ON/OFF`, `AI: ON/OFF`가 표시됩니다.
## 3. 로그 보는 법 (워커 터미널)
| 로그 | 의미 |
|------|------|
| `[naver] query='...' → N건` | 네이버 검색 결과 수 |
| `[coupang] query='...' → N건 (ip_req#K)` | 쿠팡 결과 수 / 이 IP로 K번째 요청 |
| `[ai] 판정 N건 중 매칭 M건` | AI 같은상품 선별 결과 |
| `[ai] 검색어 생성 precise=... broad=...` | 0건이라 검색어 재생성 |
| `[coupang][BOT-DETECTED] ...` | **쿠팡 봇 감지** → IP 회전 |
| `[worker-0] done <id>` / `fail ... → DEAD` | 작업 완료 / 실패 |
| `[reaper] reclaimed N` | 죽은 워커 작업 회수 |
> 디버그 로그가 안 보이면 `config.local.toml`의 `[LogConfig] log_level = "debug"` 확인.
## 4. DB 조회 (유용한 쿼리)
```bash
psql -h 127.0.0.1 -U postgres -d lps_db
```
```sql
-- 큐 상태 요약 (1=대기 2=처리중 3=완료 4=실패)
SELECT status, count(*) FROM job GROUP BY status;
-- 최근 작업 결과
SELECT job_id, status, result->>'outcome' AS outcome,
result->'lowest'->>'price' AS lowest, result->'sources' AS sources
FROM job ORDER BY created_at DESC LIMIT 5;
-- 특정 상품의 최저가 이력(그래프 원본)
SELECT triggered_at, naver_lowest, coupang_lowest, final_lowest, final_source, outcome
FROM price_history WHERE product_code='T1' ORDER BY triggered_at;
-- 봇 감지 패턴 (IP당 평균 몇 요청 만에 감지?)
SELECT avg(ip_request_no), count(*) FROM bot_detection;
-- 네거티브 캐시(없음으로 기록된 상품)
SELECT key, until, reason FROM search_negative ORDER BY created_at DESC;
```
## 5. 테스트
```bash
python -m pytest
```
> ⚠️ **워커가 실행 중이면 테스트가 깨집니다** — 워커가 같은 `lps_db`의 테스트 작업을 가로채기 때문. 테스트 전 워커를 멈추세요:
> ```bash
> pkill -f worker_main.py
> ```
## 6. 문제 해결
| 증상 | 원인 / 해결 |
|------|------------|
| 포트 9600 사용 중 | `lsof -ti:9600 \| xargs kill` 후 재실행 |
| 백그라운드 실행 시 로그 안 보임 | `print` 버퍼링 → `PYTHONUNBUFFERED=1` 붙여 실행 |
| `ProcessSingleton ... profile is already in use` | Chrome 프로필 중복 — 워커를 **하나만** 실행(또는 워커별 프로필 분리 필요) |
| 쿠팡 결과 0건 + `blocked=True` | 봇 차단 → 프록시(DECODO) 설정 확인. 감지 이력은 `bot_detection` 참고 |
| AI 매칭이 0건 자주 발생 | 검색어가 모호하거나 스펙이 실제와 다름 → `product_name`/`specification`을 더 정확히 |
| `result.desc = LPS_JOB_NOT_FOUND` | 존재하지 않거나 잘못된 job_id |
## 7. 배포 시 유의 (예정)
- Chrome을 **headless**로(서버엔 화면 없음) — 튜닝 필요
- Docker 이미지에 **chromium 설치** 필요
- **프록시(residential)** 사실상 필수 — 클라우드 IP는 쉽게 차단됨
- 워커 여러 개 띄우면 **워커별 Chrome 프로필 분리** 필요
- 시크릿(config.local.toml)은 이미지에 굽지 말고 **마운트** 권장