본문으로 건너뛰기

사주고사 제작기 06. 사주 몰라도 즐기는 인생 추리 모드

·9 min read·6 / 13

피드백이 한결같았다. 사주를 몰라서 못 풀겠다는 것이었다.

텍스트 에어리어 앞에서 멈춰버렸다

전문가 모드는 사주 팔자를 보고 에세이를 쓰는 구조다. 사주 지식이 없으면 빈 입력창 앞에서 뭘 써야 할지 모른 채 멈춘다. 서비스가 재미없어서가 아니라 시작조차 못 하는 문제였다.

사주를 몰라도 즐길 수 있는 모드를 하나 더 만들기로 했다. 한 캐릭터의 일대기를 탄생부터 죽음까지 라운드별 객관식으로 추리하는 구조다.

사주 힌트는 오행을 일반어로 옮겨서 준다. "壬水 일간이 봄에 태어나" 대신 "물의 기운이 강한 사람 — 틀에 갇히는 걸 극도로 싫어하는 자유로운 영혼"으로 적는다. 용어를 몰라도 성격 묘사로 읽히면 추리할 수 있다.

객관식은 서버에 물어볼 게 없었다

전문가 모드는 서버 의존도가 높다. Gemini로 채점하고 Supabase에 저장하고 서버에서 점수를 낸다. 추리 모드에 같은 구조를 쓸 이유가 없었다. 정답이 이미 정해져 있어서 채점이 비교 한 번이면 끝난다.

useReducer로 게임 상태를 관리한다. intro에서 playing으로, 선택하면 showing-result로, 마지막 라운드가 끝나면 finished로 간다. 라운드가 넘어갈 때마다 상태 전이가 정해져 있어서 useState 여러 개보다 useReducer 하나가 맞았다.

DB 저장은 이번 버전에서 생략했다. localStorage에 플레이한 캐릭터 ID만 남겨 중복 출제를 막는다.

정답을 클라이언트에서 섞으면 원본이 보인다

시나리오 데이터는 choices[0]이 항상 정답이다. 이걸 클라이언트에서 섞으면 개발자 도구로 섞기 전 배열을 볼 수 있다. 서버 컴포넌트에서 섞어서 넘긴다.

// 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] };
  });
}

클라이언트에는 섞인 배열과 정답 텍스트만 간다. 완벽하진 않다. 네트워크 탭을 열면 서버가 내려준 props에 정답이 들어 있다. 다만 배열 순서만 보고 첫 번째를 고르는 수준의 치팅은 막힌다. 정답을 아예 안 보내려면 매 라운드 서버에 채점을 요청해야 하는데, 그러면 클라이언트만으로 끝내는 구조를 포기해야 한다.

진입장벽 낮은 쪽을 기본값으로 뒀다

기존 StartFormLandingClient로 바꾸고 모드 선택기를 상단에 뒀다.

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   ← 공유 텍스트 생성

detective-client.tsx만 상태를 들고, 나머지는 props를 받아 그리기만 하는 컴포넌트다.

공유 포맷은 Wordle을 따랐다

결과 공유를 Wordle 스타일 텍스트로 만들었다. 한눈에 보이고, 스포일러가 없고, 복사해서 붙여넣기 좋다.

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

라운드 제목만 있고 무엇을 골랐는지는 없다. 받은 사람이 스포일러를 당하지 않으면서 궁금해지는 지점이 생긴다.

모바일에서는 Web Share API를, 데스크톱에서는 클립보드 복사를 쓴다. 전문가 모드의 공유 버튼 패턴을 그대로 따랐다.

시나리오는 정적 데이터로 8개

캐릭터당 5~6라운드로 8개를 썼다. 김하늘과 이서연은 기존 서사가 이미 풍부해서 라운드로 쪼개기만 했고, 나머지 여섯은 기존 캐릭터 프로필을 바탕으로 새로 구성했다.

빌드 결과를 보니 추리 모드 전체가 3.59 kB다.

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

AI 채점 로직과 Supabase 클라이언트가 안 들어가니 가볍다. 전문가 모드 퀴즈 페이지가 3.31 kB인 것과 비슷한 수준인데, 하는 일은 게임 한 판이 전부 들어 있다.

막힌 지점과 원인

문제원인해결
사주를 모르면 시작조차 못 함에세이 입력이 사주 지식을 전제객관식 추리 모드 신설, 오행을 일상어로 번역
클라이언트 셔플 시 정답 노출원본 배열에서 choices[0]이 항상 정답서버 컴포넌트에서 셔플 후 전달
라운드 상태 관리가 흩어짐상태 전이가 정해져 있는데 useState로 분산useReducer로 단일 상태 기계 구성

정답이 props에 실려 나가는 건 여전히 남아 있다. 클라이언트만으로 게임을 끝내는 구조를 유지하는 한 피하기 어렵다.

시나리오를 20개 이상으로 늘리는 것과 배치 생성 자동화, 추리 결과를 저장해 공유 링크에서 바로 결과를 보는 기능이 다음 차례다.