o2o-site-AEO/docs/API_USAGE.md
Mina Choi 9d25ed613e 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다
최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.

  backend/ frontend/{admin,site,shared}  →  solution/{backend,front,site,shared} + admin/

## 왜

내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.

그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
  local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
  나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
  (앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).

## admin 에 백엔드를 두지 않았다

내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.

## admin 의 `@` 는 solution/front/src 를 가리킨다

내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.

admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.

## 그 밖

- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
  127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
  VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
  compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
  디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
  (conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
  APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.

검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:12:09 +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.

# API 사용 이력 · 비용 기록
**집계 시각: 2026-08-27 08:59 KST**
집계 대상: o2o-web4ai 프로젝트 전체 (backend · solution/front · solution/site)
---
## 0. 먼저 — 섞으면 안 되는 두 가지 비용
| | 무엇 | 언제 나가나 | 현재 |
|---|---|---|---|
| **A. 제품 원가** | 사이트 **1건 만들 때** 나가는 외부 API 비용 (Perplexity·Kakao·Gemini) | 업소 1곳 처리할 때마다 **반복** | **0원** (아직 한 번도 호출 안 함) |
| **B. 개발비** | 이 코드를 **짜는 데** 쓴 Claude API 비용 | 개발 기간 동안 **한 번** | $70.07 |
**A 의 상한은 $1(1,400원)이다.** B 는 사이트를 만들 때마다 나가는 돈이 아니다 — 코드가 완성되면 끝나는 일회성 지출이다. 이 문서의 2절은 전부 **B** 얘기다.
---
## 0-1. 한 줄 요약
| 구분 | 실제 호출 | 비용 |
|---|---|---|
| **외부 유료 API** (Perplexity · Kakao · Gemini · TourAPI) | **0건** | **0원** |
| **개발 도구 API** (Claude Code / Anthropic Messages API) | 348 요청 | **$70.07** (≈ 98,100원, 1,400원/USD 가정) |
지금까지 나간 돈은 **전부 개발 과정의 Claude API 요금**이고, 제품이 쓸 외부 API는 아직 **한 번도 실제로 때리지 않았다.**
---
## 1. [A] 제품 원가 — 외부 API 계약 현황과 실호출 이력
### 1-1. 키 상태
| API | 용도 | 키 설정 | 어댑터 | 실호출 |
|---|---|---|---|---|
| Perplexity Sonar (`api.perplexity.ai`) | 채널 URL 발견 | ✅ 설정됨 | `backend/services/external/perplexity.py` 구현 완료 | **0건** |
| Kakao Local (`dapi.kakao.com`) | 동일 업소 검증 · 주변 정보 | ⬜ 비어 있음 | 미구현 (설계만) | **0건** |
| Gemini (Google AI Studio) | 사진 분류 · 카피 작성 | ✅ 설정됨 | 미구현 | **0건** |
| TourAPI (`data.go.kr`) | 축제 · 관광지 | ⬜ 비어 있음 | 미구현 | **0건** |
| Open-Meteo | 날씨 | 키 불필요 | `frontend/.../useWeather.ts` | **0건** (프론트 코드만 작성) |
> `.env` 의 키가 비어 있으면 해당 어댑터만 비활성되고 서버는 그대로 뜬다 — 설계상 의도된 동작.
### 1-2. 실호출이 0건인 근거
1. **코드 레벨** — 전체 세션 트랜스크립트에서 위 호스트로 나간 HTTP 요청이 없다. 등장하는 건 전부 소스 작성·문서 편집 텍스트다.
2. **테스트 레벨**`backend/tests/` 안에서 실제 네트워크를 타는 코드가 없다. `test_perplexity.py` · `test_collector.py` 는 전부 목(mock) 기반이고, `httpx.get/post` 직접 호출은 0건.
3. **설정 레벨**`DECISIONS.md` 의 결정대로 **`APP_ENV=test``.env` 를 읽지 않는다.** 실키가 테스트로 새어 외부 API 요금이 나가는 경로 자체를 막아 뒀다.
4. **DB 레벨** — 어떤 테이블에도 외부 API 응답이 적재된 흔적이 없다 (호출 로그 테이블도 아직 없다 — 3-2 참고).
### 1-3. 단가표 (호출 시작 시 적용될 요금)
| API | 과금 방식 | 단가 |
|---|---|---|
| Perplexity Sonar | **토큰 요금 + 검색 호출 요금이 별도로 붙는다** | `sonar` 기본, `sonar-pro` 는 더 비쌈. 호출당 검색 횟수는 이미 로그로 남기고 있음 (`perplexity.py` 의 `usage.num_search_queries`) |
| Kakao Local | 무료 쿼터 초과분 종량 | 키워드/카테고리 검색 **2원**, 좌표 변환 **0.5원** (키워드가 4배 비싸다) |
| Gemini | 토큰 종량 (Google AI Studio) | 모델·티어별 |
| TourAPI | 공공데이터 무료 (일 트래픽 제한) | 0원 |
| Open-Meteo | 무료 | 0원 |
> ⚠️ **Kakao 무료 쿼터 함정** — 무료 쿼터는 개발자 계정의 "첫 번째 활성 앱" 에만 붙는다. dev/stage/prod 앱을 따로 파면 **하나만 무료**다. 앱을 나누기 전에 확인할 것.
---
## 2. [B] 개발비 — Claude (Anthropic Messages API)
> 사이트 1건당 원가가 **아니다.** 코드를 짜는 데 든 일회성 비용이다.
### 2-1. 총계
| 항목 | 값 |
|---|---|
| 기간 | 2026-08-26 16:23 ~ 2026-08-27 08:58 KST (약 16.6시간) |
| 모델 | `claude-opus-5` (1M 컨텍스트) 단일 |
| 과금 요청 수 | **348건** (메시지 ID 기준 중복 제거) |
| 출력 토큰 | 450,509 |
| 캐시 쓰기 (1h TTL) | 2,020,375 |
| 캐시 쓰기 (5m TTL) | 96,213 |
| 캐시 읽기 | 76,004,125 |
| 캐시 미적용 입력 | 690 |
| 입력 환산 합계 | 78,121,403 |
| **총 비용** | **$70.07** |
### 2-2. 비용 내역
| 항목 | 토큰 | 요율 ($/MTok) | 금액 |
|---|---|---|---|
| 캐시 읽기 (기본 입력가 ×0.1) | 76,004,125 | $0.50 | **$38.00** |
| 캐시 쓰기 1h (기본 입력가 ×2) | 2,020,375 | $10.00 | **$20.20** |
| 출력 | 450,509 | $25.00 | **$11.26** |
| 캐시 쓰기 5m (기본 입력가 ×1.25) | 96,213 | $6.25 | **$0.60** |
| 캐시 미적용 입력 | 690 | $5.00 | $0.003 |
| | | **합계** | **$70.07** |
> 각 항목은 센트 단위 반올림이라, 눈으로 더하면 합계와 1센트 차이가 날 수 있다.
**캐시 효과** — 프롬프트 캐싱이 없었다면 같은 작업이 **$401.87** 이었다. 즉 **$331.80 (82.6%) 를 캐시로 아꼈다.**
동시에, 비용의 **54%($38.00)가 캐시 *읽기*** 다. 출력 토큰 값($11.26)의 3배가 넘는다 — 긴 컨텍스트를 반복해서 다시 읽는 패턴이 이 프로젝트 비용 구조를 지배한다는 뜻이다. 줄이려면 출력을 줄이는 게 아니라 **컨텍스트를 작게 유지**해야 한다.
### 2-3. 세션별 내역
| 세션 | 종류 | 시각 (KST) | 요청 | 출력 토큰 | 캐시 읽기 | 비용 |
|---|---|---|---|---|---|---|
| `4db7aca5` | 메인 (backend 주력) | 08-26 16:23 ~ 08-27 08:55 | 157 | 266,385 | 35,748,750 | **$32.65** |
| `99be2a6c` | 메인 | 08-26 17:24 ~ 08-27 08:58 | 92 | 124,641 | 16,660,246 | **$19.79** |
| `19be092e` | 메인 (solution/front) | 08-27 08:42 ~ 08:58 | 29 | 25,903 | 2,476,298 | $3.37 |
| `agent-a58…` | 서브에이전트 | 08-27 08:51 ~ 08:57 | 13 | 6,173 | 5,741,359 | $3.21 |
| `agent-afa…` | 서브에이전트 | 08-27 08:52 ~ 08:55 | 14 | 423 | 6,132,381 | $3.21 |
| `6052d29a` | 메인 (이 기록 작성 세션) | 08-27 08:52 ~ 08:58 | 21 | 23,936 | 1,678,581 | $2.92 |
| `agent-aa5…` | 서브에이전트 | 08-27 08:52 ~ 08:58 | 9 | 68 | 3,905,434 | $2.07 |
| `agent-ab1…` | 서브에이전트 | 08-27 08:52 ~ 08:57 | 8 | 165 | 3,466,831 | $1.90 |
| `d56c8333` | 메인 | 08-27 08:52 ~ 08:53 | 5 | 2,815 | 194,245 | $0.95 |
| `2ac02dde` | 메인 (중복 세션) | — | 0 | 0 | 0 | $0.00 |
**읽을 거리 두 가지:**
- **서브에이전트 4개 합계 $10.39** — Perplexity/Kakao 클라이언트를 병렬로 짠 fork 들이다. 출력 토큰은 다 합쳐 6,829개뿐인데 캐시 읽기가 **1,925만 토큰**이다. **fork 는 부모 컨텍스트를 통째로 들고 시작하기 때문에**, 실제 작업이 짧아도 부모 컨텍스트가 크면 읽기 요금이 그만큼 붙는다. 병렬 fork 를 쓸지 말지는 "일이 병렬인가" 보다 **"부모 컨텍스트가 큰가"** 로 판단하는 편이 비용상 맞다.
- **`2ac02dde` 는 `99be2a6c` 로 이어진 세션(fork/resume)이라 메시지가 전부 중복** — 0원으로 처리했다. 세션 간 중복을 제거하지 않으면 408건 / **$79.16** 이 나와 **$9.09 과대집계**된다. 재집계할 때 메시지 ID 중복 제거를 반드시 넣을 것.
### 2-4. 이 숫자를 읽을 때 주의할 점
- **구독제(Max/Pro)로 쓰고 있다면 이 금액이 그대로 청구되지는 않는다.** 토큰 사용량을 공개 API 요율로 환산한 "이만큼 썼다" 값이다. 종량제 API 키로 같은 작업을 돌렸다면 나왔을 금액.
- 요율 기준: Opus 5 입력 $5 / 출력 $25 per MTok, 캐시 읽기 ×0.1, 캐시 쓰기 5m ×1.25 · 1h ×2.
- 1M 컨텍스트 구간에 별도 프리미엄이 붙는 경우 실제 금액은 이보다 높을 수 있다 — **하한선으로 읽을 것.**
- 원화 환산은 1,400원/USD 가정이다. 실제 청구 환율과 다를 수 있다.
- **이 스냅샷은 찍는 순간에도 움직인다.** 집계 시각 기준으로 `4db7aca5`·`99be2a6c`·`19be092e`·`6052d29a` 등 여러 세션이 동시에 살아 있었다. 4절의 스크립트로 언제든 다시 뽑을 것.
---
## 3. 앞으로 할 일
### 3-1. 비용이 실제로 발생하기 시작하는 지점
수집 파이프라인이 도는 순간부터다:
```
상호명 → [Perplexity: 채널 URL 발견] ← 토큰 + 검색 호출 과금
→ [Kakao Local: 동일 업소 검증] ← 건당 2원
→ 크롤링(무료)
→ [Gemini: 사진 분류 + 카피] ← 토큰 과금
```
업소 1건당 Perplexity 1~2회 + Kakao 여러 건 + Gemini(사진 수만큼)가 붙는다. **업소당 원가를 먼저 실측한 뒤 배치를 키우는 순서**가 안전하다.
### 3-2. 사이트 1건당 예산: **$1 (1,400원)** — 코드로 강제한다
제품 제약이 정해졌다: **업소 1곳의 사이트를 만드는 외부 API 비용은 $1 을 넘으면 안 된다.**
문서에만 적으면 지켜지지 않으므로 가드를 구현했다 — `backend/common/cost.py` (`CostMeter`), 테스트 `backend/tests/test_cost.py` 8건.
```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건.
**⚠️ 단가표에 아직 추정치가 섞여 있다.** 확정된 건 카카오뿐이다(레포에 2원/0.5원이 적혀 있다). Perplexity·Gemini 는 공식 단가표를 확인해서 `RATES` 를 교체해야 한다. 그때까지 `assert_rates_confirmed()` 가 실배치를 막는다 — 추정치 위에 "예산 안에 들어온다" 는 결론을 세우지 않기 위해서다.
### 3-3. 지금 없는 것 — 호출 로그 테이블
현재 DB 스키마(`backend/common/database/model/models.py`)에 **API 호출·비용을 적재하는 테이블이 없다.** 지금은 애플리케이션 로그로만 남는다 (`perplexity.py` 가 `tokens=` 와 검색 횟수를 찍는 정도).
파이프라인을 돌리기 전에 `api_call_logs` 같은 테이블(공급자 · 엔드포인트 · 토큰/호출수 · 추정단가 · place_id · 시각)을 얹어두면, 이 문서를 손으로 갱신하지 않고 쿼리로 뽑을 수 있다.
---
## 4. 재집계 방법
Claude 사용액은 아래로 다시 뽑는다 (세션 트랜스크립트 기준, 메시지 ID로 중복 제거):
```bash
cd ~/.claude/projects && python3 - <<'PY'
import json,glob,collections,os
files=sorted(glob.glob("-Users-minachoi-projects-o2o-web4ai*/*.jsonl")
+ glob.glob("-Users-minachoi-projects-o2o-web4ai*/*/subagents/*.jsonl"),
key=lambda p:-os.path.getsize(p))
IN,OUT,CR,W5,W1H = 5.0/1e6, 25.0/1e6, 0.50/1e6, 6.25/1e6, 10.0/1e6 # Opus 5 요율
seen=set(); t=collections.Counter()
for f in files:
for line in open(f,encoding="utf-8",errors="replace"):
try: o=json.loads(line)
except: continue
m=o.get("message") or {}; u=m.get("usage")
if not u: continue
rid=m.get("id")
if rid:
if rid in seen: continue # ★ fork/resume 중복 제거 — 빠뜨리면 과대집계
seen.add(rid)
cc=u.get("cache_creation") or {}
w5=cc.get("ephemeral_5m_input_tokens",0); w1=cc.get("ephemeral_1h_input_tokens",0)
if w5+w1==0: w5=u.get("cache_creation_input_tokens",0)
t["in"]+=u.get("input_tokens",0); t["out"]+=u.get("output_tokens",0)
t["w5"]+=w5; t["w1h"]+=w1; t["cr"]+=u.get("cache_read_input_tokens",0); t["n"]+=1
cost=t["in"]*IN+t["out"]*OUT+t["w5"]*W5+t["w1h"]*W1H+t["cr"]*CR
print(dict(t)); print(f"요청 {t['n']}건 / ${cost:.2f}")
PY
```
외부 API 실호출 여부 확인:
```bash
cd ~/.claude/projects && grep -c "api.perplexity.ai\|generativelanguage.googleapis.com\|dapi.kakao.com" \
-- -Users-minachoi-projects-o2o-web4ai*/*.jsonl
# 매칭이 있어도 대부분 소스 작성 텍스트다 — tool_use 의 command/url 필드인지 확인할 것
```