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

5.8 KiB

외부 API 원가

사이트 1건을 만드는 데 나가는 외부 API 비용만 다룬다. 상한은 $1(1,400원) 이고, 문서가 아니라 코드가 강제한다(3절). 제품 제약으로서의 근거는 PRODUCT.md 8절.

⚠️ 이 상한에 개발비를 섞지 않는다. 코드를 짜는 데 든 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.

2. 단가표

코드의 단일 출처는 solution/backend/common/cost.pyRATES 다. 아래는 그 요약이다.

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. 상한을 코드가 강제한다

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 공식 컬렉션에 직접 API 건당 과금·유료 티어가 안내돼 있지 않다. 현재 0원으로 분리 기록하되 영구 무료로 약속하지 않는다. X는 현재 URL 포함 생성 $0.20/요청을 안내하므로 고정비라는 기존 계획을 폐기했다. X 어댑터는 넣지 않는다.

비용 처리
사이트당 변동비 Gemini 초안, 알림톡 발송. 초안 최대 3회 재요청, 버전당 원고 1건. 알림톡 단가 미확정이라 활성화 전 계약 확인
계정/계약당 고정비 계약에 있다면 별도 운영비. 사이트 생성 CostMeter에 배분하지 않음
개발비 일회성 구현·심사 대응 비용. 사이트 원가와 분리

Provider.THREADS는 0원, 공개 가격 확정 근거가 없어 confirmed=False로 기록한다. 월 고정비라는 잘못된 근거로 confirmed=True를 넣지 않는다. assert_rates_confirmed()에 Threads를 포함하는 실배치는 운영 과금 확인 전 차단된다. 현재 SNS 서비스는 생성 호출 횟수 상한만 강제하며 누적 사이트 예산 연동은 별도 보완이 필요하다. Gemini 비용을 무료로 간주하지 않는다.