o2o-site-AEO/docs/API_USAGE.md
Mina Choi 4871e50327 문서: 앱을 가른 뒤 낡아진 서술을 고치고, 개발과 무관해진 기록을 지운다
지운 것 — 앞으로의 개발에 쓸 데가 없다.
- solution/backend/demo_site.html: 어떤 스크립트도 만들지 않는 고아 산출물이고,
  손으로 쓴 HTML 이라 "백엔드는 HTML 을 만들지 않는다" 와도 어긋난다.
- solution/README.md: front/ · admin/.env · 사이트당 rooms/index.html·sitemap.xml 처럼
  지금은 전부 틀린 서술이었다. 살아 있는 두 가지(빌더 CSR vs 발행물 SSG 대비표,
  하이드레이션 블롭에 미검증 값이 샜던 실측)는 ARCHITECTURE 로 옮겼다.
- docs/API_USAGE.md 의 Claude 개발비 집계: 2026-08-27 스냅샷과 재집계 스크립트는
  일회성 지출 기록이라 제품 원가와 성격이 다르다. 문서를 외부 API 원가 하나로 좁혔다.

고친 것 — 코드를 따라가지 못하던 서술.
- 코드 경로가 solution/backend 로 옮겨진 뒤 `backend/...` 로 남아 있던 포인터 전부.
  가리키는 자리가 없는 경로는 문서가 아니라 함정이다.
- ARCHITECTURE: 트리의 front→frontend, 컨테이너 표에 api-admin(:9801)·admin(:3002) 추가.
- ★ ARCHITECTURE·AGENTS 의 "admin 전용 라우터가 0개" 는 사실이 아니었다.
  /v1/admin/local-content 가 admin 전용인데 :9800 에도 마운트돼 있다 —
  포트를 가른 논리에 아직 남은 구멍이라 그렇게 적었다.
- DECISIONS: 결론난 것을 미결로 두면 함정이 된다. 작업 큐(2026-08-27 결론),
  날씨 캐시 TTL 1시간, jobs 테이블, media 조회 API, 수집 체인을 결론으로 옮기고
  네이버 플레이스 대 TourAPI 실측(2026-08-31)을 1-1 에 이었다.
- API_USAGE: 어댑터가 다 붙고 TourAPI 키도 나왔다. "실호출 0건" 은 낡은 서술이었다.
- backend/README: 16→17 테이블(jobs), 없어진 alters/, MockAdapter 만이라는 서술,
  cd backend 경로, media·local 라우터 누락.
2026-08-31 16:58:09 +09:00

82 lines
4.5 KiB
Markdown

# 외부 API 원가
**사이트 1건을 만드는 데 나가는 외부 API 비용**만 다룬다. 상한은 **$1(1,400원)** 이고,
문서가 아니라 코드가 강제한다(3절). 제품 제약으로서의 근거는 [PRODUCT.md 8절](PRODUCT.md).
> ⚠️ 이 상한에 **개발비를 섞지 않는다.** 코드를 짜는 데 든 LLM 요금은 일회성 지출이고,
> 사이트를 만들 때마다 반복해서 나가는 원가와는 성격이 다르다.
---
## 1. 공급자별 상태 (2026-08-31)
| API | 용도 | 키 | 붙은 자리 | 과금 |
|---|---|---|---|---|
| **TourAPI** (`apis.data.go.kr`) | fact 주력 공급원 — 숙박·음식점 | ✅ | `collector/tour_api_adapter.py` | **무료** (개발계정 1,000건/일) |
| **네이버 지역검색** (`openapi.naver.com`) | 동일 업소 검증 후보 | ✅ | `external/naver.py` | 무료 쿼터 |
| **Perplexity Sonar** | 채널 URL 발견 | ✅ | `external/perplexity.py` | 요청 + 토큰 |
| **Gemini** (AI Studio) | 사진 분류 · 소개문/FAQ · 붙여넣기 추출 | ✅ | `external/gemini.py` `gemini_text.py` `gemini_extract.py` | 토큰 |
| **Kakao Local** (`dapi.kakao.com`) | 주변 정보 | ⬜ 미발급 | `external/kakao.py` | 건당 2원 |
| **Open-Meteo** | 날씨 | 불필요 | `external/open_meteo.py` | 무료 |
`.env` 의 키가 비어 있으면 **해당 어댑터만 비활성되고 서버는 그대로 뜬다** — 부팅이 외부 계약에
묶이면 안 되기 때문이다. 수집 어댑터는 `COLLECT_ADAPTERS` 로 배포 없이 개별로 끌 수 있다.
무엇을 어디서 가져오는지(필드 단위 카탈로그와 실측 충전율)는
[DATA_SOURCE_RESEARCH.md 8-3](DATA_SOURCE_RESEARCH.md).
## 2. 단가표
코드의 단일 출처는 `solution/backend/common/cost.py``RATES` 다. 아래는 그 요약이다.
| API | 단가 | 확정 |
|---|---|---|
| Kakao Local | 키워드/카테고리 검색 **2원**, 좌표 변환 **0.5원** | ✅ `.env.example` |
| Perplexity Sonar | 요청 7원 + 1K 토큰당 1.4원 (low context, USD_KRW 1,400) | ✅ 공식 단가표 |
| Gemini | 이미지 1원 · 1K 토큰 0.5원 | ❌ **추정치** |
| TourAPI · Open-Meteo | 0원 | ✅ |
**좌표 변환이 키워드 검색보다 4배 싸다.** 같은 공급자인데 단가가 다르므로 호출측이
`kakao_coord` 로 구분해 넘긴다. 지역 정보를 `place_id` 가 아니라 **행정구역 코드로 캐싱**하는
이유도 이것이다 — 같은 지역에 사이트 50개가 생겨도 외부 조회는 1회다.
⚠️ **Kakao 무료 쿼터 함정** — 무료 쿼터는 개발자 계정의 "첫 번째 활성 앱" 에만 붙는다.
dev/stage/prod 앱을 따로 파면 **하나만 무료**다. 앱을 나누기 전에 확인할 것.
## 3. 상한을 코드가 강제한다
```python
meter = CostMeter(place_id=42)
meter.guard(Provider.PERPLEXITY, searches=2, tokens=3000) # 호출 "전" → 넘으면 BudgetExceeded
...실제 호출...
meter.charge(Provider.PERPLEXITY, searches=2, tokens=3100) # 호출 "후" 실측 기록
```
- `guard()` 가 막으면 **그 호출을 하지 않는다.** 이미 쓴 돈은 못 돌려받지만 손실이 선형으로
늘어나는 걸 끊는다.
- 예산 70% 소진 시 경고 로그, 100% 초과 시 예외. **미터 1개 = 사이트 1건.**
- `solution/backend/common/cost.py` · 테스트 `tests/test_cost.py` 8건.
⚠️ **Gemini 단가가 아직 추정치다.** 그래서 `assert_rates_confirmed()` 가 실배치를 막는다 —
추정치 위에 "예산 안에 들어온다" 는 결론을 세우지 않기 위해서다. 공식 단가표로 `RATES`
교체하면서 `confirmed=True` 로 바꾼다.
비용이 실제로 붙는 순서:
```
상호명 → [Perplexity: 채널 URL 발견] ← 요청 + 토큰
→ [TourAPI: fact 수집] ← 무료
→ 크롤링(무료)
→ [Gemini: 사진 분류 + 카피] ← 토큰 (사진 수에 비례)
```
**업소당 원가를 먼저 실측한 뒤 배치를 키우는 순서**가 안전하다.
## 4. 아직 없는 것 — 호출 로그 테이블
DB(`solution/backend/common/database/model/models.py`, 17테이블)에 **API 호출·비용을 적재하는
테이블이 없다.** 지금은 애플리케이션 로그로만 남는다(`perplexity.py` 가 토큰과 검색 횟수를 찍는 정도).
파이프라인을 배치로 돌리기 전에 `api_call_logs`(공급자 · 엔드포인트 · 토큰/호출수 · 추정단가 ·
`place_id` · 시각)를 얹어두면, 이 문서를 손으로 갱신하지 않고 쿼리로 뽑을 수 있다.