o2o-site-AEO/docs/API_USAGE.md
hbyang 0e0f2cf038 [feat] solution,postgres-init,docs: SNS 게재 — 사장님이 누르면 쓰고, 승인받아, 사장님 계정으로 올린다
발행한 사이트로 사람을 데려올 경로가 제품 안에 없었다. IndexNow 통보와 사이트맵뿐이고 그건
검색엔진이 언제 읽을지 우리가 모른다. 이제 사장님이 [Threads에 알리기] 를 누르면 확인된 fact 로
짧은 글을 쓰고, 승인을 받아 사장님 개인 계정으로 올린다. 올린 글은 발행본 맨 아래에도 실린다.

★ 이 레포가 처음으로 ①외부에 쓰기를 하고 ②남의 계정 자격증명을 보관하고 ③되돌릴 수 없는
  행위를 한다. 아래 결정이 전부 여기서 나왔다.

승인을 다시 둔다 — 7절("승인 없이 나간다")의 예외다(DECISIONS 7-1). 기준은 문장의 참/거짓이
아니라 명의(사장님 계정의 발언) · 회수 가능성(없다) · 무엇이 주로 틀리나(문장이 아니라 링크 —
`_publish_target` 이 계산하므로 앞 게이트가 못 본다)다. 7절의 함정은 구조로 막았다:
시작이 사장님 클릭이라 "안 눌러서 영영 안 나감" 이 생기지 않고, 승인 경로가 둘(화면·알림톡)이며,
미승인은 EXPIRED 로 화면에 보이게 남는다.

★ 게시는 `domain` 이 확정된 사이트에만. 비면 슬러그가 상호명에서 파생돼(`_publish_target`)
  상호를 고치는 순간 주소가 바뀌고, 이미 올라간 글의 링크는 404 가 된다 — 그 글은 수정할 수 없다.
★ 승인은 GET 이 아니라 POST. 메신저 링크 미리보기·백신·프리페치가 사람이 누르기 전에 URL 을
  연다. 일회성은 토큰이 아니라 `status='PENDING_APPROVAL'` 조건이 붙은 단일 UPDATE 가 보장한다.
★ 사진은 올리지 않는다 — 1-2 의 격리("나중에 필터로 뺀다")가 SNS 에서는 구조적으로 불가능하다.
  필터가 아니라 첨부 코드를 아예 만들지 않았다.
★ 게시는 기본으로 꺼져 있다(`SOCIAL_POSTING_ENABLED=0`). 플랫폼 계약과 1-4(해지 시 처리)
  결론을 확인한 뒤 사람이 연다 — 1-4 가 이 기능의 전제조건이 됐다.

플랫폼은 스레드다. X 는 URL 이 든 글에 요청당 $0.20 이 안내돼 있어 "계정 단위 고정비" 라는
처음 가정이 틀렸다(사이트마다 나가는 변동비다). 어댑터 경계는 두되 X 어댑터는 넣지 않았다.

- place_social_posts · owner_social_accounts 신설(init.sql + 0012·0013). 승인 대기는 잡이 아니라
  행의 상태다 — 잡으로 매달면 lease 만료로 DEAD 가 된다
- services/social_service · social_account_service · notify_service · external/{threads,alimtalk,social}
- router/v1/social — GET 은 상태를 바꾸지 않고, POST 가 링크·계정을 재검사한 뒤 CAS 한다
- 빌더 SocialPanel(발행 완료 화면) + 무인증 승인 페이지 `/approve/:postId`
- 발행본 SocialPostsSection — 정적 카드 + 원문 링크. 위젯·임베드 없음. 고유 콘텐츠 계수에서 제외
- nginx: `/approve/` 는 no-referrer · no-store · noindex + 액세스 로그 끔

밟은 함정 둘
- ORM 기본값에 쉼표가 딸려 들어갔다: `text("'[]',")` → `DEFAULT '[]', NOT NULL` 로 나가
  CREATE TABLE 이 통째로 실패. 운영 DB 는 init.sql 로 만들어져 안 드러나고 ORM 이 스키마를
  만드는 테스트 DB 에서만 터진다 — 09-10 의 `now()` 기본값 사고와 같은 자리다
- 승인 스윕이 1분 주기라 쓰기 커넥션을 계속 집어 들었다 → 5분. 이 스윕은 만료 표시와 중단 정리뿐이라
  분 단위 정밀도가 필요 없다

검증: 백엔드 645 passed / 5 failed(전부 환경 — 프론트 소스 부재·레이트리밋).
★ 테스트에 실제 API 키가 새면 BUILD 잡이 Suno·Perplexity 를 진짜로 부른다(실측: 한 파일 12분 →
키를 비우면 10초). 키를 비운 상태가 정상 실행 조건이다.
에디터 목록 대조(test_site_theme) 22건 통과 · tsc·eslint 통과 · vitest 62 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 15:46:44 +09:00

98 lines
5.8 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` · 시각)를 얹어두면, 이 문서를 손으로 갱신하지 않고 쿼리로 뽑을 수 있다.
## 5. SNS 비용 (2026-09-14)
사용자 결정: X 대신 Threads. [Meta 공식 컬렉션](https://www.postman.com/meta/threads/documentation/dht3nzz/threads-api)에 직접 API 건당 과금·유료 티어가 안내돼 있지 않다. 현재 0원으로 분리 기록하되 영구 무료로 약속하지 않는다.
X는 현재 [URL 포함 생성 $0.20/요청](https://docs.x.com/x-api/getting-started/pricing)을 안내하므로 고정비라는 기존 계획을 폐기했다. X 어댑터는 넣지 않는다.
| 비용 | 처리 |
|---|---|
| 사이트당 변동비 | Gemini 초안, 알림톡 발송. 초안 최대 3회 재요청, 버전당 원고 1건. 알림톡 단가 미확정이라 활성화 전 계약 확인 |
| 계정/계약당 고정비 | 계약에 있다면 별도 운영비. 사이트 생성 CostMeter에 배분하지 않음 |
| 개발비 | 일회성 구현·심사 대응 비용. 사이트 원가와 분리 |
`Provider.THREADS`는 0원, 공개 가격 확정 근거가 없어 confirmed=False로 기록한다.
월 고정비라는 잘못된 근거로 confirmed=True를 넣지 않는다. `assert_rates_confirmed()`에 Threads를 포함하는
실배치는 운영 과금 확인 전 차단된다. 현재 SNS 서비스는 생성 호출 횟수 상한만 강제하며 누적 사이트 예산 연동은
별도 보완이 필요하다. Gemini 비용을 무료로 간주하지 않는다.