사주고사 제작기 11. 스크롤 서사 UI와 추리 결과 공유 URL
추리 모드에서 라운드별 스토리가 누적되는 "스크롤 서사 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.css에 story-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/abc1235. 배운 것
- 서사적 맥락이 게임 동기를 바꾼다. 같은 객관식이라도 "이전 스토리가 쌓이고 다음을 열어야 한다"는 프레이밍이 있으면 퀴즈가 아니라 이야기 체험이 된다.
- 순수 표시 컴포넌트로 분리하면 기존 로직을 건드리지 않고 UX를 크게 바꿀 수 있다. reducer, RoundCard, RoundResult 수정 제로.
- 오버레이 스크롤바는 레이아웃 공간을 차지하지 않는다.
pr패딩으로 수동 확보 필요.
6. 남은 과제
- 스토리 조각이 많아지면(7~8라운드)
max-h-48이 작게 느껴질 수 있음 — 반응형 높이 조정 검토 - 추리 결과 페이지에서 "나도 추리하기" → 같은 캐릭터로 시작하는 딥링크 지원
- 공유 URL의 OG 이미지에 캐릭터 이름/스토리 요약 포함
커밋 로그
d55daf4 feat: 스크롤 서사 UI + 추리 결과 저장/공유 강화