o2o-site-AEO/docs/PRODUCT.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

7.9 KiB

PRODUCT — 무엇을, 누구를 위해, 무엇을 안 하는가

이 문서는 제품 판단의 단일 출처다. 코드에서 읽을 수 없는 것만 적는다 — 누구를 위한 것인지, 무엇을 성공으로 볼 것인지, 그리고 하지 않기로 한 것이 무엇인지. 구현이 궁금하면 ARCHITECTURE.md, 미결 사항은 DECISIONS.md.


1. 한 문장

상호명 하나를 입력하면, AI 검색이 그 가게를 공식 홈페이지 기준으로 설명하게 만드는 정적 홈페이지를 만들어 발행한다.

"예쁜 사이트를 싸게"가 아니다. 예쁜 사이트는 이미 흔하다. 우리가 파는 건 검색·AI 답변에서의 1차 출처 지위다. 이 문장이 제품의 모든 트레이드오프를 정한다 — 아래 3절이 그 결과다.

2. 문제

소상공인은 이미 네이버 플레이스·인스타그램에 정보를 올려 두고 있다. 그런데:

  • 그 정보는 AI 검색엔진이 읽지 못한다. 네이버·카카오 생태계는 AI 크롤러를 통째로 차단한다 (실측 근거: DATA_SOURCE_RESEARCH.md 1절).
  • 그래서 ChatGPT·Perplexity·Gemini 가 그 가게를 설명할 때 근거로 삼을 1차 출처가 없다. 블로그 후기나 오래된 기사에서 추측한다.
  • 사장님이 직접 홈페이지를 만들어도, CSR 로 만들면 크롤러는 <div id="root"></div> 만 읽고 떠난다.

빈 판이다. 경쟁자가 스스로 문을 잠갔기 때문에, 크롤러가 읽을 수 있는 정적 HTML 하나만 정확히 놓아도 그 가게의 정답이 된다.

3. 그래서 정한 제품 원칙

이건 취향이 아니라 1절에서 기계적으로 따라 나온 결론이다. 어길 거면 1절부터 다시 논의한다.

원칙 어디에 박혀 있나
발행물은 정적 HTML — 서버도 JS 실행도 없이 읽힌다 크롤러가 읽어야 존재하는 것이다 solution/site 프리렌더
확인된 값만 발행한다 틀린 정보를 1차 출처로 만들면 제품이 해를 끼친다 publish_gate.py 규칙 1
고유 콘텐츠 0건이면 발행 거부 같은 템플릿 대량 생성은 검색엔진의 스팸 판정 대상 publish_gate.py 규칙 2
JSON-LD 값 = 화면 값 어긋나면 구조화 데이터 조작이다. 색인에서 통째로 불신당한다 publish_gate.py 규칙 3, 빌드 실패
백엔드는 HTML 을 만들지 않는다 렌더링과 데이터를 한 몸으로 묶으면 둘 다 못 고친다 payload JSON 경계
뚫어야 볼 수 있는 데이터는 안 쓴다 봇 탐지 우회는 결론과 무관하게 영구 금지 DECISIONS.md 1-1

4. 사용자 — 셋이고, 요구가 서로 반대다

사장님 우리 회사 운영자 손님 · AI 크롤러
무엇을 한다 내 가게 정보 확인·수정, 사진 고르기, 발행 전체 사이트 품질·발행 상태 관리, 수집 소스 운영 읽는다
원하는 것 몇 번 눌러서 끝나기 무엇이 왜 막혔는지 보이기 정확한 사실, 즉시
로그인 최소 (막히면 만들어 보지도 못한다) 엄격 없음
화면 성격 위저드 + 에디터 대시보드 + 목록 정적 문서
현재 코드 solution/frontend /builder admin/frontend /places, /local-content solution/site

앞의 둘은 2026-08-31 에 두 앱으로 갈랐다 — 한 앱이면 내부 화면 코드가 사장님 번들에 그대로 실려 나가기 때문이다. 근거와 경계는 ARCHITECTURE.md 4절.

권한 코드는 common/enums.py UserRole: 1 USER(사장님) / 2 OWNER(고객사 최상위) / 3 DEVELOPER(내부 운영 — 고객사에 존재를 노출하지 않는다).

5. 지금 하는 것 (범위)

  • 업종: 숙박 · 카페 · 음식점 · 관광체험 (PlaceCategory). 업종 추가 = 코드 1줄 + 스키마 파일 1개
  • 수집 → 사장님 확인 → LLM 생성 → 편집 → 발행 게이트 → 정적 발행 → IndexNow 통보
  • 사이트 하나 = 한 장(2026-08-31 결정). 쪼개면 페이지가 얇아지고 검색엔진이 색인에서 버린다 — 자세한 근거는 ARCHITECTURE.md 5절
  • 발행 후 색인 통보: 네이버 · Bing · Yandex (IndexNow). 구글은 IndexNow 미지원 → 사이트맵 제출

6. 하지 않는 것 (Non-goals)

여기 적힌 걸 하자는 제안이 오면, 하기 전에 이 줄을 지우는 합의부터 한다.

안 한다
네이버 플레이스를 복제한 사이트 AI 크롤러가 못 읽는 걸 옮겨 봐야 목표(1절)에 기여가 0이다. 게다가 robots.txt 위반
봇 탐지 우회 크롤링 (헤드리스 브라우저, IP 회전, 핑거프린트 위조) 영구 금지. 야놀자 v 여기어때 = 민사 10억 배상 + 복제 금지 선례
범용 홈페이지 빌더 / 자유 편집 자유도를 주면 게이트(3절)를 우회할 수 있다. 게이트가 제품이다
이미지 호스팅 지금은 네이버 CDN 핫링크. ⚠️ 중기 리스크는 DEPLOY.md 1절 참고
예약·결제 처리 사이트는 예약 채널로 보낸다. 거래를 품지 않는다
CSR 발행 사이트 크롤러가 못 읽으면 만든 의미가 없다

7. 성공 기준

제품이 동작한다고 말할 수 있는 조건 (코드가 이미 강제하는 것):

  1. 발행된 사이트가 게이트 3규칙을 통과한다 — 미검증 fact 0, 고유 콘텐츠 ≥ 1, JSON-LD 불일치 0
  2. /s/<slug>끝 슬래시 없이도 200 (사장님이 주소창에 치는 형태)
  3. 크롤러가 JS 없이 본문·JSON-LD·llms.txt 를 전부 읽는다
  4. 사이트맵과 IndexNow 통보가 실제로 존재하는 주소를 가리킨다 (조용히 틀리는 종류라 자동 검증이 유일한 방어 — scripts/check_search_ready.py)

사업 기준 — 아직 정하지 않았다. 정하면 여기에 날짜와 함께 적는다. 후보: 발행 후 N일 내 AI 답변 인용률 / 구글 색인 등재율 / 사장님 발행 완주율.

8. 제약

비용은 사이트당 변동비·계정/계약당 고정비·일회성 개발비로 구분한다. SNS의 Gemini 생성·알림톡 건당 발송은 변동비다. Threads 직접 API에 공개 과금은 확인되지 않았다. 고정비를 사이트 생성 미터에 배분해 배치 크기에 따라 게이트 판정이 달라지게 하지 않는다. API_USAGE 5절 참조.

  • 제품 원가 상한: 사이트 1건당 $1 (약 1,400원). Perplexity·Kakao·Gemini 호출 합계. 이 상한이 "LLM 을 몇 번 부를 수 있나"를 정한다. 현황: API_USAGE.md (★ 개발비와 섞지 말 것 — 그건 일회성이다)
  • 법적 제약DECISIONS.md 1절이 단일 출처다. 수집 어댑터를 추가하기 전에 읽는다.
  • 발행 호스트는 백엔드·프론트 두 곳에 있고 값이 같아야 한다. 그리고 payload JSON 에 구워져 들어간다 — 바꾸면 재발행이 필요하다. (AGENTS.md 함정 목록)

9. 아직 안 정한 것

정해지는 대로 이 절에서 위로 올린다. 코드로 미리 풀지 않는다. ★ 개발 착수 전에 확정해야 할 결정 목록은 DEVELOPMENT_DIRECTION.md P0 가 단일 출처다 — 여기 복사하지 않는다. 아래는 그중 제품 정의에 해당하는 것만 남긴다.

  • 사업 성공 지표 (7절)
  • 관리자가 사장님 콘텐츠를 어디까지 고칠 수 있나 (DECISIONS.md 1-3)
  • 과금 모델 — 건당 / 구독 / 무료+상위요금제
  • 사장님 해지 시 발행된 사이트의 운명 (DECISIONS.md 1-4)
  • 고객사(에이전시) 다중 입점 여부 — companies 테이블은 이미 있으나 제품 결정은 미정