o2o-site-AEO/docs/COLLECTION_SEO_AEO_FLOW.md
Mina Choi e0d45eda97 [fix] postgres-init,solution/backend,docs: 스키마 재편이 안 닿은 자리를 전부 잡는다 — init.sql · ORM 인덱스 · 테스트
0005 가 도메인 스키마를 걷어내고 표 이름을 옮겼는데, 문자열로 표 이름을 들고 있던 자리들이
따라오지 않았다. import 도 타입검사도 pyflakes 도 못 잡는 종류라 전부 **실행되는 순간에만**
터졌고, 그동안 pytest 는 569건이 통째로 죽어 있어 아무것도 못 잡고 있었다.

**init.sql 이 새 DB 를 옛 구조로 세우고 있었다**
64ce467 이 이 파일에 94줄을 더하기만 하고 삭제를 0줄 했다. 그래서 이 파일 한 벌로 세운 DB 는
`place.place_links`·`job.jobs` 를 갖고 ORM 은 `public.place_channels`·`public.jobs` 를 찾는다 —
기동은 정상이고 첫 쿼리에서 죽는다. "init.sql 은 새 DB 를 세우는 전체 DDL 이고 계속 최신을
유지한다"(migrations/README.md)는 계약이 깨져 있었다.
- public 한 벌 · 표 14개로 다시 썼다. 옛 스키마가 있는 DB 에서 다시 돌면 RAISE EXCEPTION 으로
  멈춘다 — 그대로 두면 public 에 빈 표가 생기고 0005 가 "relation already exists" 로 실패해
  데이터가 옛 스키마에 갇힌다
- 말미에 **마이그레이션 기준선**을 심는다. 없으면 새 DB 에서 migrate.py 가 0001 부터 다시 돌다가
  `schema "local" does not exist` 로 죽는다

**운영 버그 둘** — 두 DB(새로 세운 것 · 마이그레이션으로 따라온 것)를 pg_dump 로 찍어 비교해 찾았다
- `upsert_weather` 의 ON CONFLICT 술어에 `kind IS NULL` 이 빠져 **날씨 캐시 저장이 계속 실패**하고
  있었다(0007 이 인덱스에 그 조건을 더했다). 캐시라 화면이 안 죽고 로그에만 남았다.
  포스트그레스는 술어가 인덱스 술어를 함의하는지 보고 아니면 "no unique or exclusion constraint
  matching" 으로 거절한다 — 컬럼도 표도 멀쩡해서 눈으로는 원인이 안 보인다
- ORM 의 `area_contents` 인덱스 정의가 0004·0007·0008 을 하나도 안 따라왔다. 테스트 DB 는 이
  모델로 세워지므로 **테스트가 운영과 다른 제약 아래에서 돌고 있었다**

**0009** — 두 DB 비교에서 나온 어긋남 셋(데이터는 안 건드린다)
- `idx_site_contents_site` 가 기존 DB 에만 없었다(0003 이 유니크만 걸었다) — 섹션 조회가 시퀀셜 스캔
- `places.external_place_id` VARCHAR(32) → (64). ORM 은 64 다 — 긴 id 가 잘리면 동일 업소 판정이 틀린다
- RENAME 이 안 따라간 PK 제약 이름 9개(`facts_pkey` → `place_facts_pkey` …)

**테스트를 살린다**
- conftest 의 TRUNCATE 가 표 이름을 **손으로 나열**하고 있었다. 0005 가 이름을 옮기자 전 테스트가
  `relation "place_aliases" does not exist` 로 죽었다 — 이제 ORM 메타데이터에서 뽑아 다시 어긋날 수 없다
- `test_schema_ddl` 이 모델 표를 `"None.users"` 로 조회해 **한 표도 비교하지 않고 통과**하고 있었다.
  init.sql 이 조용히 어긋난 동안 이 테스트는 초록이었다. 비교한 표 수를 세는 단언을 더한다
- 테스트 SQL 15곳의 옛 표 이름, `_run_worker` 1틱 문제(수집 뒤 따라오는 LOCAL_SYNC 를 집어 가
  정작 기다리던 잡이 PENDING 으로 남았다), 지역 캐시 픽스처(읽는 코드가 옳게 거르는데 테스트가 빨개졌다)

**문서**
- `docs/DATA_MODEL.md` 신설 — 표 14개가 무엇을 담고 누가 쓰는지, 값 하나가 DB 에서 페이지까지
  가는 길, 두 번 도는 게이트, **DB 에 없는 것**
- `SERVERS.md` DB 절을 마이그레이션 체계로. 배포에 `migrate.py` 를 넣는다 — 코드만 갈면 컨테이너는
  정상으로 뜨고 가게 등록·수집·발행만 죽는다
- ARCHITECTURE 2절의 프리렌더 컨테이너가 `solution-frontend` 로 적혀 있었다. 굽는 건
  `solution-prerender` 고 전자는 운영에서 뜨지도 않는다 — AGENTS.md 가 함정으로 적어 둔 그 혼동을
  문서가 만들고 있었다
- 옛 표 이름 잔재(`place_links`·`local_contents`·`job.jobs`·`company.users`·`fact.facts`·`ai_check_results`)

검증: 빈 컨테이너에 init.sql 로 세운 DB ↔ 마이그레이션으로 따라온 DB 를 `pg_dump --schema-only`
로 비교 — 표·인덱스·제약·컬럼 전부 동일. pytest 583건 중 581 통과(남은 2건은 `.env` 누수·
레이트리밋 카운터로 환경 문제다). 구글 로그인 21건 포함.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:39:43 +09:00

9.0 KiB

수집·LLM·SEO/AEO 동작 구조

이 문서는 사업장 등록부터 정적 사이트 발행까지의 현재 구현을 설명한다.

1. 전체 흐름

업종·상호 입력
  → 외부 장소 검색
  → 사용자 동일 업소 확인
  → 채널 URL 등록 또는 발견
  → 사용자 채널 확인
  → 확정 URL 크롤링
  → 정보·사진 검토
  → Gemini 콘텐츠 생성
  → 템플릿 편집
  → 정적 빌드 및 발행 검수
  → 발행

핵심 원칙은 다음과 같다.

  • 검증된 사업장만 수집하고 발행한다.
  • 확정된 채널 URL만 크롤링한다.
  • 자동 수집값은 사용자 승인 전까지 사이트에 노출하지 않는다.
  • LLM은 확인된 사실을 표현할 뿐 새로운 사실을 만들지 않는다.
  • JSON-LD와 실제 화면 내용이 다르면 발행하지 않는다.

2. 사업장 확인

사용자가 업종과 상호를 입력하면 네이버 지역검색 등의 외부 장소 API로 후보를 조회한다. 사용자가 자기 사업장을 선택하면 주소, 좌표와 외부 식별자를 저장하고 verified_at을 기록한다.

검증되지 않은 사업장은 수집과 발행이 제한된다.

주요 코드:

  • solution/backend/services/place_service.py
  • solution/backend/router/v1/place/

3. 채널 URL 확보

직접 등록

사용자가 네이버 플레이스 URL 등을 입력한다. 사용자가 직접 가져온 URL은 해당 사용자의 채널 확인으로 처리한다.

자동 발견

추가 채널까지 AI로 찾기 옵션을 켜고 자동 찾기를 실행하면 Perplexity Sonar를 한 번 호출한다.

  • 입력: 상호, 주소, 업종
  • 출력: 채널 URL 후보
  • 검색 대상: 야놀자, 여기어때, 네이버 플레이스 등 허용된 도메인
  • 제외 대상: 서비스 홈, 목록, 블로그·카페 후기

Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지 않는다. URL 후보만 place_channels에 미확정 상태로 저장한다.

사용자가 내 채널 맞아요로 확인한 링크만 크롤링과 사이트 노출에 사용한다.

주요 코드:

  • solution/backend/services/external/perplexity.py
  • solution/backend/services/prompts/channel_discovery.py
  • solution/frontend/src/features/onboarding/useCollectFlow.ts
  • solution/frontend/src/features/onboarding/ChannelConfirmPanel.tsx

4. 크롤링

수집 잡은 다음 순서로 실행된다.

채널 발견
  → 확정 링크 조회
  → URL별 어댑터 선택
  → 채널 데이터 파싱
  → 업종 스키마 적용
  → fact·사진 후보 저장

업종마다 허용하는 정보가 다르다.

  • 카페: 메뉴, 음료, 좌석, 영업시간 등
  • 음식점: 메뉴, 음식, 룸, 예약 관련 정보 등
  • 숙박: 객실, 입·퇴실, 인원, 편의시설 등
  • 관광·체험: 프로그램, 장비, 운영 정보 등

정확한 필드 목록은 solution/backend/common/category_schema/(업종별 JSON + 로더)가 관리한다. 어댑터가 값을 찾더라도 업종 스키마에 없는 항목은 정식 fact로 사용하지 않는다.

주요 코드:

  • solution/backend/services/collect_service.py
  • solution/backend/services/collector/
  • solution/backend/common/category_schema/
  • solution/backend/services/fact_service.py

5. 정보 승인

크롤링 결과는 후보 상태로 저장된다.

  • UNVERIFIED: 아직 사용자가 확인하지 않은 정보
  • PENDING_OWNER: 기존 노출값과 다른 재수집 후보
  • VERIFIED: 사용자가 확인한 정보
  • CORRECTED: 사용자가 직접 수정한 정보

발행 스냅샷에는 VERIFIED, CORRECTED 등 발행 가능한 상태만 포함한다. 재수집이 사용자가 수정한 값을 자동으로 덮어쓰지 않는다.

6. LLM 사용 위치

Gemini Vision

수집한 사진에 다음 정보를 생성한다.

  • 업종별 사진 라벨
  • 접근성용 alt 문구
  • 신뢰도

결과는 입력 순서가 아닌 ref로 연결한다. 신뢰도가 낮은 결과는 사람 확인 대상으로 남긴다.

관련 파일:

  • solution/backend/services/external/gemini.py
  • solution/backend/services/prompts/vision.py
  • solution/backend/services/vision_service.py

Gemini Text

확인된 fact만 이용해 다음 콘텐츠를 만든다.

  • 사업장 소개문
  • 검색 결과용 meta description
  • FAQ
  • 각 문장의 근거 fact key

생성 후 ground_check가 다음 항목을 코드로 검사한다.

  • 근거에 없는 숫자와 가격
  • 근거에 없는 시설
  • false, 불가, 없음 값의 반대 표현
  • 과장 또는 홍보성 표현
  • 근거 fact key가 없는 FAQ

검사를 통과하지 못한 결과는 반려 사유와 함께 저장한다.

관련 파일:

  • solution/backend/services/external/gemini_text.py
  • solution/backend/services/prompts/copy.py
  • solution/backend/services/copy_service.py

7. 정적 빌드와 발행

발행 가능한 데이터로 snapshot 생성
  → 렌더러용 payload 생성
  → 정적 HTML 생성
  → 실제 HTML과 JSON-LD 비교
  → 발행 게이트 검사
  → site_version 저장
  → 발행

방문자가 보는 페이지는 DB를 실시간 조회하지 않는 정적 HTML이다. 검색 크롤러도 JavaScript 실행 없이 주요 정보를 읽을 수 있다.

사이트 하나 = 한 장이다(2026-08-31 결정, 라우터 없음). 근거는 ARCHITECTURE.md 5절.

검수 실패 시 버전은 FAILED로 남고 publish_logs에 사유를 기록한다. 검수 게이트를 우회하는 발행 옵션은 없다.

관련 파일:

  • solution/backend/services/build_service.py
  • solution/backend/services/snapshot.py
  • solution/backend/services/site_payload.py
  • solution/backend/services/publish_gate.py
  • solution/site/

8. SEO 구현

발행본에는 다음 항목을 생성한다.

  • 정적 HTML
  • title과 meta description
  • canonical URL
  • Open Graph 메타 태그
  • 이미지 alt 문구
  • robots.txt · sitemap.xml — ★ 오리진 루트에만 굽는다(사이트별로 만들지 않는다). 크롤러는 robots.txt 를 오리진 루트에서만 읽고(RFC 9309), 사이트맵은 전 사이트를 한 파일에 담는다
  • 사업장 JSON-LD
  • FAQPage JSON-LD
  • 공식 채널 sameAs
  • datePublished, dateModified

관련 파일:

  • solution/site/src/seo/head.ts
  • solution/site/src/seo/jsonld.ts
  • solution/site/src/seo/robots.ts
  • solution/site/src/seo/sitemap.ts

9. AEO 구현

답변형 검색 서비스가 사업장을 식별하고 답변할 수 있도록 다음 신호를 제공한다.

  • 검증된 상호, 주소와 좌표
  • 업종별 구조화 데이터
  • 질문형 FAQ와 FAQPage JSON-LD
  • 화면에 표시되는 핵심 답변 블록
  • 확인된 사실을 정리한 llms.txt
  • 사실 출처와 검증 시각
  • 공식 채널 sameAs
  • 발행일과 수정일

JSON-LD 값은 화면에도 동일하게 존재해야 한다. 렌더러가 양쪽을 비교하고 불일치하면 발행 게이트가 거절한다.

관련 파일:

  • solution/site/src/seo/llms.ts
  • solution/site/src/seo/verify.ts
  • solution/site/src/sections/AnswerBlock.tsx
  • solution/backend/services/publish_gate.py

10. SEO·AEO 진단 점수

내부 운영 화면(admin)은 현재 저장 정보와 최신 발행본을 기준으로 SEO와 AEO 준비도를 각각 100점으로 계산한다.

SEO 항목:

  • 정적 빌드
  • 도메인과 canonical
  • robots, sitemap, JSON-LD
  • 주소와 전화번호
  • 확인된 정보량
  • alt가 있는 이미지
  • 고유 콘텐츠
  • 동일 업소 검증

AEO 항목:

  • 사업장 엔티티 식별
  • 좌표와 지역 문맥
  • 확인된 사실량
  • FAQ
  • JSON-LD와 llms.txt
  • 출처와 검증 시각
  • 최신 발행본

이 점수는 Google, Naver 또는 외부 SEO 도구의 공식 점수가 아니다. 우리 솔루션이 통제할 수 있는 검색 준비도 지표이며 순위나 AI 인용을 보장하지 않는다.

관련 파일:

  • solution/backend/services/seo_audit.py
  • solution/backend/router/v1/site/site.py
  • admin/frontend/src/pages/SeoAuditPage.tsx

API:

GET /v1/place/{place_id}/site/audit

11. 블로그 기능 범위

현재 블로그 글 자동 작성과 네이버 블로그 자동 발행은 구현되어 있지 않다.

현재 지원 범위는 공식 블로그 URL을 채널로 등록하고, 사용자가 확인한 뒤 사이트 푸터에 공식 채널 링크로 표시하는 것이다. 블로그·카페 후기는 자동 수집의 공식 사실 출처로 사용하지 않는다.

12. 외부 API와 과금 발생 지점

단계 외부 서비스 호출 조건
사업장 검색 네이버 지역검색 또는 카카오 로컬 사업장 후보 검색
채널 발견 Perplexity Sonar 사용자가 추가 채널 발견 옵션을 선택한 경우
사진 분석 Gemini Vision 분석할 사진이 있는 경우
소개문·FAQ Gemini Text 확인된 근거 fact가 있는 경우

일반 크롤링과 정적 빌드는 별도의 LLM 호출을 하지 않는다. 채널 발견 옵션은 해당 발견 요청 한 번에만 적용되며 이후 크롤링이나 재수집으로 자동 승계되지 않는다.