사주고사 제작기 12. 시나리오 80개 확장과 Edge 번들 초과
시나리오를 40개에서 80개로 늘렸다. 콘텐츠만 두 배가 된 게 아니라 배포가 막히고 콘솔에 경고가 뜨고 API가 크래시했다.
다양성 축을 정해놓고 늘렸다
캐릭터 40개로는 재방문할 때 금방 본 시나리오를 다시 만난다. 그냥 숫자만 채우면 비슷한 인물이 늘어날 뿐이라 축을 먼저 정했다.
| 축 | 기존 | 신규 확장 |
|---|---|---|
| 오행 | 주요 조합 위주 | 희귀 조합 (화와 금 충돌, 수와 금 공존 등) |
| 시대 | 1955~2005 | 1945~2010 (해방세대부터 알파세대까지) |
| 직업 | 예술·IT·사업 중심 | 군인, 외과의, 바둑, 프로게이머, 형사, 셰프, 번역가 |
| 궤적 | 5가지 패턴 | 권력 추락, 귀농, PTSD 회복, 자수성가, 윤리적 딜레마 |
배치 파일 4개 단위로 서브에이전트 10개를 병렬로 돌렸다. 각 배치가 pnpm type-check를 통과한 뒤에 메인 파일에 import를 추가하는 순서로 갔다. 한 번에 다 만들고 마지막에 검사하면 어디서 깨졌는지 찾기가 어렵다.
나이 순서에 묶여 있어서 재미있는 걸 못 물었다
기존 라운드는 유년기, 10대, 20대, 30대 순으로 나이를 따라갔다. 그러다 보니 연애 스타일이나 재정 위기 같은 인생의 핵심 사건을 원하는 자리에 넣기 어려웠다. 30대 라운드에서 물어볼 게 없는 캐릭터도 있고, 반대로 20대에 사건이 몰린 캐릭터도 있다.
시간 축을 버리고 주제 축으로 바꿨다.
성장환경 → 청소년기 → 연애 → 직업 → 위기 → 재물 → 결혼 → 말년도메인마다 물어볼 게 정해지니 질문이 뾰족해졌다. 위기 도메인에는 카지노 파산이나 SNS 역풍, 우울증 같은 구체적인 사건명이 들어가고, 재물 도메인에는 3억 손실이나 월 매출 2,000만원처럼 금액이 붙는다. 추상적인 서술 대신 숫자와 사건이 들어가니 선택지끼리 구별도 쉬워졌다.
기존 80개를 전부 새 구조로 다시 만들었다. LifePhase 타입에는 구버전 값도 남겨서 이미 저장된 결과가 깨지지 않게 했다.
배포가 1MB 제한에 걸렸다
Error: The Edge Function "result/[submissionId]/opengraph-image" size is 1.08 MB
and your plan size limit is 1 MB.import 체인을 따라가 보니 이랬다.
OG 이미지를 만드는 데 필요한 건 제출 결과 하나인데, repository를 거치면서 시나리오 데이터 전체가 딸려 들어갔다. 40개일 때 간신히 통과하던 게 80개가 되며 넘었다.
OG 이미지 세 개의 런타임을 edge에서 nodejs로 바꿨다. Node.js 런타임은 번들 크기 제한이 없다. 콜드 스타트가 Edge보다 느릴 수 있는데, OG 이미지는 사람이 아니라 크롤러가 주로 요청하는 것이라 체감 차이가 없다.
근본 원인은 repository가 quiz와 detective 도메인을 한 파일에서 다루는 것이다. 도메인별로 쪼개면 Edge로 돌아갈 수 있다.
게임 중에 Gemini를 부르는 건 너무 느렸다
게임 화면에서 캐릭터의 사주팔자와 AI 해석을 접었다 펼 수 있는 토글을 만들었다. 힌트를 단서로 쓰게 유도하는 게 목적이다. 생년월일시와 사주팔자 카드, 해석 다섯 섹션이 들어간다.
처음엔 유저가 열 때마다 런타임에 Gemini를 호출했다. 80개 캐릭터를 사람이 만날 때마다 부르는 건 느리고 비싸다.
사전 생성 스크립트로 옮겼다.
pnpm generate:saju
# tsx --env-file=.env.local scripts/generate-detective-saju.ts80개를 순차 처리하는데, 캐릭터 하나가 끝날 때마다 즉시 파일에 저장하고 이미 있는 건 건너뛴다. 중간에 끊겨도 다시 돌리면 이어서 간다.
이 구조가 바로 필요했다. Gemini 무료 플랜 일일 한도가 하루 20건이라 한 번 실행에 17개만 생성됐다. 전부 만들고 마지막에 저장하는 방식이었으면 한도에 걸린 순간 다 날아갔다.
API 라우트는 사전 생성분을 먼저 보고, 없으면 런타임 호출로 폴백하고, 그것도 실패하면 502를 준다. 80개가 다 채워질 때까지는 토글을 화면에서 임시로 감춰뒀다.
섹션 색도 나눴다. 처음엔 다섯 섹션이 다 회색이라 구분이 안 됐다. 종합운은 보라, 성격은 하늘색, 직업과 재물은 황금색, 대인관계는 장미색, 조언은 에메랄드로 배정하고 열린 섹션 왼쪽에 컬러 보더를 줬다.
셔플되는 리스트에 index를 key로 썼다
배포하고 콘솔을 보니 경고가 떠 있었다.
Encountered two children with the same key, `금 (金) 과다`trait.element를 key로 쓰고 있었는데 같은 오행이 두 번 나올 수 있었다. 전체를 훑어 세 군데를 고쳤다.
| 파일 | 수정 전 | 수정 후 |
|---|---|---|
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가 특히 중요했다. 선택지는 매 게임 셔플되는데 배열 index를 key로 쓰면, 순서가 바뀔 때 React가 같은 자리에 있는 다른 항목을 같은 것으로 본다. DOM 요소를 재사용하면서 내용만 바꾸기 때문에 애니메이션이나 포커스가 엉뚱한 곳에 남는다. 셔플 전 원래 인덱스를 쓰면 항목마다 고정된 값이 된다.
클라이언트가 원인을 알 수 없는 에러를 받았다
/api/quiz/submit에서 createSubmission에 try-catch가 없었다. Supabase 연결이 끊기면 서버가 빈 응답으로 죽는다.
Failed to execute 'json' on 'Response': Unexpected end of JSON input클라이언트에는 이렇게 찍힌다. 서버가 왜 죽었는지 알려주는 정보가 하나도 없다. createSubmission을 감싸서 의미 있는 에러 JSON을 돌려주게 했다.
에러 문구도 손봤다. 전부 "잠시 후 다시 시도해주세요"로 통일돼 있었는데, Supabase 일일 한도 초과나 점검 중일 때는 잠깐 기다려도 안 된다. 서버에 일시적인 오류가 있으니 나중에 다시 시도해달라는 쪽으로 바꿨다.
추리 결과 저장에 실패하면 결과 화면은 그대로 보여주고 토스트로 공유 링크를 쓸 수 없다고만 알린다. 저장 실패가 게임 결과를 못 보게 만들 이유가 없다.
막힌 지점과 원인
| 문제 | 원인 | 해결 |
|---|---|---|
| Edge Function 1MB 초과로 배포 실패 | OG 이미지가 repository를 거쳐 시나리오 전체를 번들링 | 런타임을 nodejs로 전환 |
| 게임 중 사주 해석이 느림 | 유저가 열 때마다 Gemini 런타임 호출 | 사전 생성 스크립트 + 파일 우선 조회 |
| 사전 생성이 17개에서 멈춤 | Gemini 무료 플랜 일일 20건 한도 | 건별 즉시 저장으로 이어서 실행 가능하게 |
| React key 중복 경고 | 중복 가능한 값과 배열 index를 key로 사용 | 안정적인 고유값으로 교체 |
| 클라이언트가 JSON 파싱 에러만 봄 | Supabase 실패 시 서버가 빈 body로 크래시 | try-catch로 감싸 에러 JSON 반환 |
콘텐츠를 두 배로 늘렸더니 인프라 제약이 먼저 나타났다. 정적 데이터가 큰 프로젝트에서는 import 체인이 곧 번들 크기라는 걸 배포가 막히고 나서야 봤다.
나머지 63개 사전 생성과 토글 재활성화, repository 도메인 분리가 남았다. 시나리오가 더 늘면 정적 데이터를 DB로 옮기는 것도 검토해야 한다.