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

12 KiB
Raw Blame History

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.pyusage.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 를 쓸지 말지는 "일이 병렬인가" 보다 "부모 컨텍스트가 큰가" 로 판단하는 편이 비용상 맞다.
  • 2ac02dde99be2a6c 로 이어진 세션(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건.

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.pytokens= 와 검색 횟수를 찍는 정도).

파이프라인을 돌리기 전에 api_call_logs 같은 테이블(공급자 · 엔드포인트 · 토큰/호출수 · 추정단가 · place_id · 시각)을 얹어두면, 이 문서를 손으로 갱신하지 않고 쿼리로 뽑을 수 있다.


4. 재집계 방법

Claude 사용액은 아래로 다시 뽑는다 (세션 트랜스크립트 기준, 메시지 ID로 중복 제거):

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 실호출 여부 확인:

cd ~/.claude/projects && grep -c "api.perplexity.ai\|generativelanguage.googleapis.com\|dapi.kakao.com" \
  -- -Users-minachoi-projects-o2o-web4ai*/*.jsonl
# 매칭이 있어도 대부분 소스 작성 텍스트다 — tool_use 의 command/url 필드인지 확인할 것