사주고사 제작기 12. 시나리오 80개 확장, 8-도메인 재설계, 캐릭터 사주 UI
추리 시나리오를 40개에서 80개로 확장하고, 8-도메인 구조로 전면 재설계했다. 그 과정에서 Edge 번들 초과, React key 중복, Supabase 크래시까지 줄줄이 터졌다.
1. 시나리오 40개 → 80개 확장
1-1. 동기
기존 40개 캐릭터(c1~c40)로는 재방문 유저가 금방 중복 시나리오를 만나게 된다. 오행 조합, 시대, 직업, 인생 궤적의 다양성을 넓혀 콘텐츠 수명을 늘리기로 했다.
1-2. 신규 40개 다양성 확보
| 축 | 기존(~c40) | 신규 확장(c41~c80) |
|---|---|---|
| 오행 | 주요 조합 위주 | 희귀 조합 (화+금 충돌, 수+금 공존 등) |
| 시대 | 1955~2005 | 1945 |
| 직업 | 예술/IT/사업 중심 | 군인, 외과의, 바둑, 프로게이머, 형사, K-pop, 셰프, 번역가 등 |
| 궤적 | 5가지 패턴 | 권력 추락, 귀농, PTSD 회복, 자수성가, 윤리적 딜레마 등 |
배치 파일 단위(4개)로 서브에이전트 10개를 병렬 실행했다. batch9~18로 분산해 각각 pnpm type-check 통과 후 메인 파일에 import 추가.
2. 8-도메인 구조 전면 재설계
2-1. 기존 구조의 문제
기존 라운드 구조는 유년기 / 10대 / 20대 / 30대 / 40대 / 50대 / 말년 형태였다. 나이 순서에 묶여 있어서 "연애 스타일", "재정 위기" 같은 인생 핵심 사건을 자유롭게 배치하기 어려웠다.
2-2. 8-도메인 구조
성장환경 → 청소년기 → 연애 → 직업 → 위기 → 재물 → 결혼 → 말년시간 축 대신 인생 주제 단위로 분리했다. 각 도메인이 독립적으로 설계되어 캐릭터의 특성을 더 뾰족하게 드러낼 수 있다.
- 위기: 카지노 파산, SNS 역풍, 우울증 등 구체적 사건명 포함
- 재물: 금액 명시 (예: 3억 손실, 월 매출 2,000만원 등)
- 연애: 연애 스타일·이별 패턴 추리
기존 80개 시나리오 전체를 새 구조로 재생성했다. LifePhase 타입은 구버전 값도 유지해 하위 호환을 보장했다.
3. Edge Function 사이즈 초과
3-1. 증상
Vercel 배포 시 빌드 에러:
Error: The Edge Function "result/[submissionId]/opengraph-image" size is 1.08 MB
and your plan size limit is 1 MB.3-2. 원인
opengraph-image.tsx (runtime: 'edge')
→ import { getQuestionForSubmission } from 'repository'
→ import { detectiveScenarios } from 'detective-scenarios'
→ batch1 + ... + batch18 (전체 번들링)40개일 때 간신히 통과하던 번들이 80개로 늘면서 초과했다.
3-3. 해결
OG 이미지 3개의 런타임을 edge → nodejs로 전환. Node.js 런타임은 번들 사이즈 제한이 없다.
변경 대상:
- app/result/[submissionId]/opengraph-image.tsx
- app/detective/[characterId]/opengraph-image.tsx
- app/detective/result/[resultId]/opengraph-image.tsx트레이드오프: cold start가 Edge보다 느릴 수 있지만, OG 이미지는 크롤러가 주로 요청하므로 체감 영향 없음.
4. 캐릭터 사주 UI (SajuInfoToggle)
4-1. 기획
게임 중 캐릭터의 사주팔자와 AI 해석을 접기/펼치기로 볼 수 있는 토글 컴포넌트. 사주 힌트를 단서로 활용하게 유도하는 게 목표다.
구성:
- 생년월일시 정보
- 사주팔자 4열 카드 (
SajuPillarsCard) - AI 해석 5섹션 아코디언: 종합운 / 성격 / 직업·재물 / 대인관계 / 조언
4-2. 섹션별 컬러 차별화
처음엔 모든 섹션이 동일한 회색 스타일이라 가독성이 나빴다. 섹션별로 색을 배정해 한눈에 구분되게 개선했다.
| 섹션 | 컬러 | 의도 |
|---|---|---|
| 종합운 | 보라 (purple) | 브랜드 컬러, 전체 요약 |
| 성격 | 하늘 (sky) | 투명하고 차분한 톤 |
| 직업/재물 | 황금 (amber) | 돈과 직업의 연상 |
| 대인관계 | 장미 (rose) | 인간관계의 온기 |
| 조언 | 에메랄드 (emerald) | 성장과 희망 |
열린 섹션은 좌측에 컬러 보더(border-l-2), 헤더와 내용에 미묘한 틴트 배경 적용.
4-3. AI 해석 사전 생성 전략
처음엔 게임 중 /api/detective/saju를 호출해 런타임에 Gemini로 해석을 생성하는 방식이었다. 80개 캐릭터를 유저가 만날 때마다 Gemini를 호출하는 건 비효율적이고 느리다.
사전 생성 스크립트를 만들었다:
pnpm generate:saju
# tsx --env-file=.env.local scripts/generate-detective-saju.ts- 80개 캐릭터를 순차 처리
- 매 캐릭터 생성 후 즉시 파일 저장 (중단 시 재시작 가능)
- 이미 생성된 캐릭터는 스킵
문제: Gemini free tier 일일 한도(20req/day)로 1회 실행에 17개만 생성됐다. 내일 이어서 실행하면 됨(resume-safe 구조로 만들어뒀으니).
4-4. 하이브리드 API 라우트
/api/detective/saju?characterId=xxx
↓ 1순위: detective-saju-interpretations.json (사전 생성)
↓ 2순위: runtime Gemini 호출 (폴백)
↓ 3순위: 실패 → 502 반환80개 모두 사전 생성 완료 전까지는 SajuInfoToggle을 UI에서 임시 비활성화.
5. React key 중복 버그
배포 후 콘솔에 key 충돌 경고가 발생했다:
Encountered two children with the same key, `금 (金) 과다`원인: character-intro.tsx에서 trait.element를 key로 사용했는데, 동일 오행이 두 번 나올 수 있었다.
전체 코드베이스 스캔 후 3곳 수정:
| 파일 | 수정 전 | 수정 후 |
|---|---|---|
character-intro.tsx | key={trait.element} | key={element-i} |
round-card.tsx | key={i} | key={choice.originalIndex} |
saju-pillars-card.tsx | key={label} | key={label-i} |
round-card.tsx는 choices가 매 게임 셔플되므로 originalIndex 사용이 특히 중요하다. 단순 index 사용 시 React가 DOM을 잘못 추적할 수 있다.
6. 에러 처리 개선
6-1. Supabase 저장 실패 시 빈 body 크래시
/api/quiz/submit에서 createSubmission에 try/catch가 없어서, Supabase 연결 실패(무료 플랜 일시정지 등) 시 서버가 빈 body로 크래시했다.
클라이언트에서:
Failed to execute 'json' on 'Response': Unexpected end of JSON inputcreateSubmission을 try/catch로 감싸 명확한 에러 JSON을 반환하도록 수정했다.
6-2. 에러 메세지 사용자 친화적으로
기존 에러 메세지들이 "잠시 후 다시 시도해주세요"로 통일돼 있었는데, Supabase 일일 한도 초과나 서비스 점검은 잠깐 기다려도 해결이 안 된다.
변경:
"결과 저장에 실패했습니다."→"현재 서버에 일시적인 오류가 발생했습니다. 나중에 다시 시도해주세요."- detective 저장 실패: 결과 화면은 그대로 보여주되 토스트로 "결과를 저장하지 못했습니다. 공유 링크를 사용할 수 없습니다." 안내
7. 배운 것
- 콘텐츠 볼륨이 인프라 제약을 만든다. 시나리오 2배 → 번들 2배 → Edge 1MB 초과. 정적 데이터가 큰 프로젝트는 import 체인을 의식해야 한다.
- AI 사전 생성은 free tier 일일 한도의 벽이 있다. 80개를 한 번에 처리하려 하지 말고, resume-safe 스크립트로 나눠서 실행하는 게 맞다.
- key는 항상 안정적이고 유일해야 한다. 특히 셔플처럼 순서가 바뀌는 리스트에서 index key는 React reconciliation을 망가뜨린다.
- 빈 body 크래시는 가장 나쁜 종류의 에러다. 클라이언트는 "Unexpected end of JSON input"을 보고 무슨 일인지 알 수 없다. 모든 async throw는 try/catch로 감싸서 의미있는 JSON을 반환해야 한다.
8. 남은 과제
pnpm generate:saju재실행으로 나머지 63개 캐릭터 사전 생성 완료- 완료 후 SajuInfoToggle 다시 활성화
repository.ts를 quiz/detective 도메인별로 분리하면 edge 런타임 복귀 가능- 시나리오 정적 데이터 Supabase 이관 검토 (80개 이상 시)
커밋 로그
2b985df fix: c21~c40 Character 레코드 추가 (404 해결)
9796d60 feat: 추리 시나리오 40개 추가 (c41~c80, batch9~18)
4f8981c feat: 80개 시나리오 8-도메인 구조 전면 재생성 + 라운드 시간 측정
ea37ec6 feat: 캐릭터 사주 UI 색상 차별화 + 사전 생성 API 라우트 추가
3ad8bfa fix: character-intro traits 중복 key 에러 수정
870a45c fix: React key 중복/불안정 이슈 수정
c31fbc3 fix: quiz 제출/리롤 에러 메세지 사용자 친화적으로 개선
a04659f fix: Supabase 저장 실패 시 빈 body 크래시 방지 + 에러 메세지 개선