제 3 장

시스템 설계

1) 시스템 구성

두 개의 규칙이 동시에 걸린다. 앱 안쪽은 선형(클린 아키텍처)이고, 앱 사이는 비선형(스타 토폴로지)이다.

앱 안쪽 — 헥사고날 레이어

domain/            순수 파이썬. 프레임워크 import 금지
app/               UseCase(Protocol) · DTO · Interactor
adapter/inbound/   FastAPI 라우터 · Pydantic 스키마
adapter/outbound/  SQLAlchemy 리포지토리 · 외부 API 클라이언트
dependencies/      FastAPI Depends 팩토리

의존성은 항상 안쪽을 향한다(adapter → app → domain). kayfabe의 파일 분포는 app 81 · adapter 64 · domain 16 · dependencies 12다. 도메인이 가장 얇다 — 순수 계산만 남기고 나머지를 밖으로 밀어낸 결과다. 합성 산식(prediction_synthesis)과 결과 판정(result_adjudication)이 거기 있고, DB도 LLM도 모르므로 고정 픽스처만으로 시험된다.

앱 사이 — 스타 토폴로지

   [kayfabe] [admin] [heyman] [lion_king] [soccer] [auth]   ← SPOKE
        \        |       |        |          /
                    [ontology]                              ← HUB
  • 허브(ontology) 는 공유 커널이다. Gemini 호출, 시맨틱 라우팅, 크롤·스크랩, 분류기가 여기 있다. 스포크를 import하지 않는다 — 최하위라 위를 볼 수 없다.
  • 스포크 ↔ 스포크 직접 import는 금지다. 두 앱에 공통 로직이 필요하면 허브로 올려서 푼다.
  • auth는 아무도 import할 수 없다. 허브조차 금지다. 검증이 필요하면 jsangho.core.security를 쓴다.

이 규칙은 문서가 아니라 계약으로 강제된다. import-linter 계약 넷이 pre-commit 훅과 GitHub Actions 양쪽에서 돌고, 위반하면 커밋이 막힌다.

계약 내용
no_spoke_to_spoke 스포크끼리 직접 import 금지
star_topology_hub_only 스포크는 허브만, 허브는 스포크를 못 본다
clean_architecture_layers 앱 안의 레이어 순서
auth_isolation auth는 누구도 import 불가

새 앱을 만들면 계약에 이름을 넣어야 한다. 빠뜨리면 그 앱은 검사되지 않은 채 초록으로 통과한다.

2) 기술 스택

층 기술
프론트 Next.js 16 (App Router) · React 19 · TypeScript strict · Tailwind CSS · Radix UI · Recharts 2.15
백엔드 FastAPI 0.136 · Uvicorn · Python 3.13
ORM · 마이그레이션 SQLAlchemy 2.0 async · SQLModel · Alembic
관계형 DB PostgreSQL (Supabase) · pgvector 확장
그래프 DB Neo4j 6
캐시 Redis
LLM Google Gemini (google-genai) · 온프레미스 경로로 Ollama 어댑터
임베딩 BAAI/bge-m3 (Transformers)
형태소 Kiwi (kiwipiepy)
배포 EC2 + Docker Compose (운영) · k3s (로컬) · Vercel (프론트) · Cloudflare Tunnel

차트 라이브러리를 새로 고르지 않았다. Recharts는 이미 저장소에 있었고 /admin이 쓰고 있었다 — 새 의존성을 더하지 않는다는 결정의 결과다. React 19를 peer로 선언하고 SVG로 렌더하므로 CSS 변수 토큰을 fill·stroke에 그대로 넣을 수 있다.

의존성은 줄이는 방향으로 관리한다. 2026-09-29에 코드 참조가 0건이던 lightgbm·xgboost·ultralytics를 뺐다. xgboost 하나가 자기 99MB에 더해 nvidia-nccl-cu12 290MB를 끌고 오고 있었다 — torch는 CPU 휠인데 그쪽으로 CUDA가 들어오던 경로다.

3) 데이터 모델

kayfabe가 소유한 테이블은 열셋이고, 네 묶음으로 갈린다.

대회와 경기

테이블 내용
ple_events 대회. slug 유일 · start_date · status
ple_matches 경기. card_json에 대진 원본, point_value에 배점
ple_predictions 사용자 픽

ple_matches의 유일 제약은 (event_id, match_key) 다. 경기 id는 대회 안에서만 유일하면 되며, 실제로 SummerSlam과 Survivor Series가 ss26 접두사를 나눠 쓴다. 고치려고 한쪽을 바꾸면 이미 저장된 예측이 그 경기를 잃는다.

AI 예측과 그 계보

테이블 내용
ple_agent_predictions 최종 pick · 승률 · 확신도 · knowledge_query · synthesis_version
ple_agent_reports 에이전트별 의견 · 가중치 · 근거 문장
ple_prediction_retrievals 그때 읽은 청크의 본문 스냅샷과 개정본 시각
ple_knowledge_chunks RAG 코퍼스. 임베딩 벡터 포함

계보가 별도 테이블인 이유는 코퍼스가 판본을 하나만 갖기 때문이다. 같은 URL을 다시 수집하면 옛 청크를 지우므로, 그때 읽은 원문은 ple_prediction_retrievals에만 남는다. 해시는 대조만 되고 복원은 안 된다.

리포트와 계보는 예측에 cascade="all, delete-orphan"으로 묶인다 — 예측 하나를 지우면 딸린 행이 함께 지워진다.

기록과 챔피언십

wrestlers · championship_titles · title_acquisitions — 타이틀 이력은 위키 챔피언 보드에서 동기화한다.

포인트와 상점

point_ledger_entries · shop_items · user_shop_items — 채점 결과가 원장에 쌓이고 상점에서 쓰인다. 배점이 5의 배수인 것은 상점 가격을 소수점 없이 다루기 위해서다.

4) 정보 구조

화면은 세 갈래이고, 제 1 장 3절의 세 축과 그대로 대응한다.

/ple · /ple/[slug] · /results · /rankings · /records · /shop     예측·랭킹
/data-center  ├ /ple  ├ /wrestlers  ├ /matches                   데이터 센터
              ├ /championships  └ /analytics
/ai-lab  ├ /predictions  ├ /agents  ├ /performance               AI LAB
         ├ /knowledge  ├ /readiness  ├ /leakage
         └ /audit/[eventSlug]/[matchKey]

AI LAB 안에서도 시선의 방향이 갈린다. 여섯 화면이 뒤를 보고(이미 만들어진 예측을 판정·재현·귀속) readiness 하나만 앞을 본다. 그 여섯이 전부 같은 결론에 닿기 때문이다 — 예측을 만들기 전에 코퍼스를 손봤어야 했다. 누수는 판정이 아니라 수집에서 생긴다.

audit/[eventSlug]/[matchKey]가 가장 깊은 화면이고, 예측 한 건의 계보 전체(질의 · 읽은 청크 · 리포트 · 자격 판정 · 재현 결과)를 한 페이지에 세운다.