본문으로 건너뛰기

사주고사 제작기 — 6일차

·10 min read

한 줄 요약

사주 지식 없는 일반인을 위한 "인생 추리" 모드를 추가했다. 에세이 대신 객관식, AI 채점 대신 클라이언트 로직, Supabase 대신 localStorage. 뺄수록 진입장벽이 낮아졌다.


왜 만들었나

피드백이 한결같았다. "사주를 몰라서 못 풀겠어요."

기존 전문가 모드는 사주 팔자를 보고 에세이를 쓰는 구조다. 사주 지식이 없으면 텍스트 에어리어 앞에서 멍하니 앉아 있게 된다. 재미있는 서비스인데 진입장벽이 너무 높았다.

해결 방향은 명확했다 — 사주를 몰라도 즐길 수 있는 모드를 하나 더 만들자. 핵심 컨셉: 한 캐릭터의 일대기를 탄생부터 죽음까지 라운드별 객관식으로 추리한다. 사주 힌트는 오행을 일반어로 번역해서 제공한다. "壬水 일간이 봄에 태어나" 대신 "물의 기운이 강한 사람 — 틀에 갇히는 걸 극도로 싫어하는 자유로운 영혼".


설계 결정들

서버 vs 클라이언트

전문가 모드는 서버 의존도가 높다. Gemini API로 AI 채점하고, Supabase에 결과를 저장하고, 서버에서 점수를 계산한다. 추리 모드에 같은 구조를 쓸 이유가 없었다.

전문가 모드: 클라이언트 → API(채점) → Supabase → 결과 페이지
추리 모드:   클라이언트 → useReducer → 결과(같은 페이지)

객관식은 정답이 이미 정해져 있다. 서버에 물어볼 게 없다. useReducer로 게임 상태(intro → playing → showing-result → finished)를 관리하고, 라운드 간 API 호출 없이 전부 클라이언트에서 처리한다. DB 저장도 v1에서는 생략 — localStorage에 플레이한 캐릭터 ID만 기록해서 중복 방지한다.

정답 셔플은 서버에서

시나리오 데이터에서 choices[0]이 항상 정답이다. 클라이언트에서 셔플하면 개발자 도구로 원본 배열을 볼 수 있으니, 서버 컴포넌트에서 Fisher-Yates 셔플 후 shuffledChoicescorrectChoice를 넘긴다.

// page.tsx (서버 컴포넌트)
function prepareRounds(scenario) {
  return scenario.rounds.map((round) => {
    const indexed = round.choices.map((text, i) => ({ text, originalIndex: i }));
    // Fisher-Yates shuffle
    for (let i = indexed.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [indexed[i], indexed[j]] = [indexed[j], indexed[i]];
    }
    return { ...round, shuffledChoices: indexed, correctChoice: round.choices[0] };
  });
}

클라이언트에는 셔플된 배열과 정답 텍스트만 전달된다. 완벽한 보안은 아니지만(네트워크 탭에서 볼 수 있다), 캐주얼 치팅은 막는다.

랜딩 페이지 리팩토링

기존 StartForm 컴포넌트를 LandingClient로 교체했다. 모드 셀렉터(인생 추리 / 전문가 풀이)를 상단에 두고, 선택에 따라 하위 UI가 바뀐다.

Before: page.tsx → StartForm (난이도 선택 + 시작)
After:  page.tsx → LandingClient → ModeSelector + (추리 시작 | 난이도 선택 + 시작)

인생 추리가 기본 모드다. 진입장벽이 낮은 모드를 기본값으로 두고, 전문가 풀이는 "이미 사주를 아는 사람"을 위한 서브 옵션으로 뺐다.


컴포넌트 구조

app/detective/[characterId]/
  page.tsx              ← 서버: 시나리오 로드 + 셔플
  detective-client.tsx  ← 클라이언트: useReducer 게임 로직
 
components/detective/
  character-intro.tsx   ← 게임 시작 전 캐릭터 소개
  round-card.tsx        ← 사주 힌트 + 문제 + 4지선다
  round-result.tsx      ← 정답/오답 + 스토리 조각
  progress-dots.tsx     ← ⭕❌⬜ 진행 표시
  timeline-result.tsx   ← 최종 타임라인 + 점수
  detective-share.tsx   ← Wordle 스타일 공유 텍스트

컴포넌트 하나가 하나의 역할만 한다. detective-client.tsx가 오케스트레이터 역할을 하고, 나머지는 순수 프레젠테이션 컴포넌트다.


공유 포맷: Wordle에서 배운다

결과 공유는 Wordle 스타일 텍스트를 채택했다. 시각적이고, 스포일러가 없고, 복사-붙여넣기에 최적화되어 있다.

사주고사 — 김하늘의 일대기
 
⭕ 유년기
⭕ 10대
❌ 20대
⭕ 20대 후반
⭕ 30대
❌ 말년
 
4/6 맞춤 | sajugosa.vercel.app

오답 라운드의 내용은 표시하지 않는다. 스포일러 방지 + "뭘 틀렸는지 궁금하면 직접 해봐" 효과.

Web Share API(모바일)와 클립보드 복사(데스크톱)를 모두 지원한다. 기존 전문가 모드의 share-buttons.tsx 패턴을 그대로 따랐다.


시나리오 데이터

8개 캐릭터 시나리오를 정적 데이터로 작성했다. 캐릭터당 5~6라운드.

  • c1(김하늘), c2(이서연): 기존 story 데이터에서 수작업 추출. 이미 풍부한 서사가 있어서 라운드로 쪼개기만 했다.
  • c3~c8: 기존 generated-questions의 캐릭터 프로필을 바탕으로 시나리오를 새로 구성.

사주 힌트는 오행의 특성을 일상 언어로 번역한 것이다. "壬水"를 "물의 기운이 강한 사람"으로, "火가 과다"를 "뜨거운 감정을 안으로 삼키는 성향"으로. 사주를 모르는 사람도 직관적으로 이해할 수 있다.


빌드 결과

Route                                    Size  First Load JS
/                                    1.75 kB       127 kB
ƒ /detective/[characterId]             3.59 kB       129 kB
ƒ /api/detective/random                  146 B       102 kB

추리 모드 전체가 3.59 kB. AI 채점 로직, Supabase 클라이언트가 없으니 당연히 가볍다. 전문가 모드 퀴즈 페이지(3.31 kB)와 비슷한 수준.


배운 것

  1. 진입장벽을 낮추는 가장 좋은 방법은 서버를 빼는 것이다. API 호출이 없으면 로딩이 없고, DB가 없으면 에러가 없다. 추리 모드는 서버 의존성을 극단적으로 줄인 덕분에 즉각적인 반응성을 얻었다.

  2. 같은 데이터로 두 가지 경험을 만들 수 있다. 김하늘의 story는 전문가 모드에서는 정답 서사이고, 추리 모드에서는 라운드별 스토리 조각이 된다. 데이터를 재활용하되 인터랙션을 바꿔서 완전히 다른 게임이 됐다.

  3. useReducer는 다단계 UI에 딱이다. intro → playing → showing-result → finished. 4개 상태를 useState로 관리하면 상태 간 전이가 복잡해진다. reducer에 액션(START, SELECT, NEXT_ROUND, FINISH)을 정의하니 상태 전이가 명시적이고 디버깅이 쉬워졌다.

  4. Wordle가 증명한 것 — 공유 포맷이 바이럴을 만든다. 텍스트 기반 결과 공유는 어디서든 붙여넣기 가능하고, 이모지가 시선을 끌며, 스포일러 없이 호기심을 자극한다. 이미지 기반 공유보다 복사가 쉽고 가볍다.


남은 과제

  • 추리 모드 시나리오 추가 (현재 8개 → 목표 20개+)
  • 시나리오 Gemini 배치 생성 자동화 스크립트
  • 추리 결과 Supabase 저장 (v2, 통계/리더보드용)
  • 결과 페이지 전용 URL (공유 링크에서 직접 결과 확인)
  • generated-questions 콘텐츠 품질 보강
  • 문제/인물 데이터 Supabase 이관