최상단을 프로젝트 단위로 평평하게 둔다 — 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
12 KiB
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건인 근거
- 코드 레벨 — 전체 세션 트랜스크립트에서 위 호스트로 나간 HTTP 요청이 없다. 등장하는 건 전부 소스 작성·문서 편집 텍스트다.
- 테스트 레벨 —
backend/tests/안에서 실제 네트워크를 타는 코드가 없다.test_perplexity.py·test_collector.py는 전부 목(mock) 기반이고,httpx.get/post직접 호출은 0건. - 설정 레벨 —
DECISIONS.md의 결정대로APP_ENV=test면.env를 읽지 않는다. 실키가 테스트로 새어 외부 API 요금이 나가는 경로 자체를 막아 뒀다. - 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건.
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로 중복 제거):
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 필드인지 확인할 것