본문으로 건너뛰기

사주고사 제작기 11. 스크롤 서사 UI와 추리 결과 공유 URL

·8 min read·11 / 12

추리 모드에서 라운드별 스토리가 누적되는 "스크롤 서사 UI"를 추가하고, 추리 결과를 Supabase에 저장해 공유 URL을 지원하도록 확장했다.


1. 문제 인식

추리 모드의 각 라운드가 독립적으로 느껴졌다. 30대 문제를 풀 때 유년기~20대에 무슨 일이 있었는지 맥락이 없어서, "퀴즈 앱"처럼 느껴지지 "누군가의 인생을 읽어나가는" 경험이 아니었다.

기존 플로우:
  라운드 1 (유년기) → 정답 공개 → 라운드 2 (10대) → 정답 공개 → ...
  ↑ 각 라운드가 독립적, 이전 스토리를 기억해야 하는 건 유저 몫
 
원하는 경험:
  스토리가 연속 문장으로 쌓이다가 → 현재 시기에서 "..." 으로 끊김
  → 정답 공개 시 끊긴 부분이 fade-in으로 채워짐
"다음 문장을 열기 위해 문제를 푼다"는 동기 부여

2. 스크롤 서사 UI

2-1. StoryScroll 컴포넌트

순수 표시용 컴포넌트. 게임 로직(reducer)은 건드리지 않았다.

interface StoryScrollProps {
  characterName: string;
  phases: LifePhase[];       // 각 라운드의 시기 라벨
  storyPieces: string[];     // 전체 라운드의 스토리
  revealedCount: number;     // 공개된 스토리 수 (= answers.length)
  currentPhase: LifePhase;   // 현재 풀고 있는 라운드의 시기
  isRevealing: boolean;      // showing-result 상태인지
}

렌더링 규칙:

  • 1라운드 playing: 표시 안 함 (아직 공개된 스토리 없음)
  • 1라운드 showing-result: 첫 스토리 fade-in
  • 2라운드+ playing: 이전 스토리 전부 + {currentPhase}: ... 힌트
  • 2라운드+ showing-result: 이전 스토리 + 새 스토리 fade-in
┌──────────────────────────────────┐
│  📖 나은빈의 인생 서사            │
│                                   │
│  유년기                           │  ← text-purple-400
"부산 영도에서 태어난 아이는..."
│                                   │
│  10대                             │
"중국어를 독학하며..."
│                                   │
│  20: ...                        │  ← 미공개 힌트
└──────────────────────────────────┘

2-2. React Hooks 규칙 주의

초기 구현에서 early return 뒤에 useEffect를 배치하는 실수가 있었다. React hooks는 렌더링마다 동일한 순서로 호출되어야 하므로(조건부 호출 불가), hook을 먼저 선언하고 early return을 그 아래로 이동했다.

// ❌ 잘못된 순서 — hooks 규칙 위반
if (!shouldShow) return null;
useEffect(() => { ... }, []);
 
// ✅ 올바른 순서
useEffect(() => { ... }, []);
if (!shouldShow) return null;

2-3. 스크롤바가 텍스트를 가리는 문제

max-h-48 overflow-y-auto로 스크롤 영역을 제한했는데, macOS의 오버레이 스크롤바가 텍스트 위에 겹쳤다. 컨테이너에 p-4 pr-0, 스크롤 영역에 pr-4를 적용해서 스크롤바 공간을 확보했다.

macOS의 오버레이 스크롤바는 레이아웃 공간을 차지하지 않고 콘텐츠 위에 덮어씌워지는 방식이라, 별도의 패딩으로 수동 확보가 필요하다.

2-4. fade-in 애니메이션

globals.cssstory-fade-in keyframe을 추가하고, Tailwind arbitrary value로 적용했다.

@keyframes story-fade-in {
  0%   { opacity: 0; transform: translateY(8px); }
  100% { opacity: 1; transform: translateY(0); }
}
className="animate-[story-fade-in_0.6s_ease-out]"

Tailwind의 arbitrary value 문법(animate-[...])을 사용하면 tailwind.config에 커스텀 애니메이션을 등록하지 않아도 된다.


3. 추리 결과 저장 + 공유 URL

3-1. 왜 Supabase에 저장하는가

텍스트 공유(✅/❌)만으로도 핵심 경험은 전달되지만, 공유 URL 클릭 시 결과 페이지 + OG 미리보기를 제공하기 위해 서버 저장이 필요했다. 한 건당 ~750B로 Supabase 무료 플랜(500MB) 기준 약 70만 건 저장 가능하므로 용량 문제는 없다.

3-2. 구현 구조

게임 종료 (FINISH)
  → useEffect → POST /api/detective/submit
    → repository.createDetectiveResult() → Supabase INSERT
    → resultId 반환 → 공유 URL에 포함
 
공유 URL: /detective/result/[resultId]
  → 서버 컴포넌트 → getDetectiveResultById() → TimelineResult 렌더
OG 이미지: opengraph-image.tsx → 등급/점수 동적 생성

reducer에 SAVE_START / SAVE_COMPLETE / SAVE_FAIL 액션을 추가하되, 저장 실패해도 게임 결과 표시에는 영향 없다. fire-and-forget 패턴으로, 저장은 부가 기능이지 핵심 플로우를 막지 않는다.


4. 공유 텍스트 리뉴얼

기존의 Wordle 스타일 O/X를 서사 추리 컨셉에 맞게 변경했다.

// 기존
사주고사 — 나은빈의 일대기
O 유년기
X 10대
3/6 맞춤 | sajugosa.vercel.app
 
// 변경 후
📖 사주고사 — 나은빈의 인생 추리
 
✅ 유년기
❌ 10대
✅ 20대
 
4/6 적중
 
사주로 인물의 인생 서사를 추리해보세요!
https://sajugosa.vercel.app/detective/result/abc123

5. 배운 것

  • 서사적 맥락이 게임 동기를 바꾼다. 같은 객관식이라도 "이전 스토리가 쌓이고 다음을 열어야 한다"는 프레이밍이 있으면 퀴즈가 아니라 이야기 체험이 된다.
  • 순수 표시 컴포넌트로 분리하면 기존 로직을 건드리지 않고 UX를 크게 바꿀 수 있다. reducer, RoundCard, RoundResult 수정 제로.
  • 오버레이 스크롤바는 레이아웃 공간을 차지하지 않는다. pr 패딩으로 수동 확보 필요.

6. 남은 과제

  • 스토리 조각이 많아지면(7~8라운드) max-h-48이 작게 느껴질 수 있음 — 반응형 높이 조정 검토
  • 추리 결과 페이지에서 "나도 추리하기" → 같은 캐릭터로 시작하는 딥링크 지원
  • 공유 URL의 OG 이미지에 캐릭터 이름/스토리 요약 포함

커밋 로그

d55daf4 feat: 스크롤 서사 UI + 추리 결과 저장/공유 강화