사주고사 제작기 11. 스크롤 서사 UI와 추리 결과 공유 URL
추리 모드를 돌려보면 라운드마다 뚝뚝 끊겼다. 30대 문제를 푸는데 이 사람이 20대에 뭘 했는지 기억이 안 났다. 인생을 읽어나가는 경험이 아니라 문제를 연달아 푸는 느낌이었다.
이전 스토리를 기억하는 게 사용자 몫이었다
라운드가 끝나면 정답과 함께 그 시기의 스토리가 나오고, 다음 라운드로 넘어가면 사라졌다. 조각은 있는데 이어지지 않았다.
원하는 건 스토리가 문장으로 쌓이다가 현재 시기에서 끊기고, 정답을 맞히면 끊긴 부분이 채워지는 형태였다. 다음 문장을 열려고 문제를 푸는 구조다.
게임 로직을 안 건드리고 표시 컴포넌트만 추가했다
StoryScroll은 props만 받아 그리는 컴포넌트다. reducer도 라운드 카드도 결과 카드도 수정하지 않았다.
interface StoryScrollProps {
characterName: string;
phases: LifePhase[]; // 각 라운드의 시기 라벨
storyPieces: string[]; // 전체 라운드의 스토리
revealedCount: number; // 공개된 스토리 수 (= answers.length)
currentPhase: LifePhase; // 현재 풀고 있는 라운드의 시기
isRevealing: boolean; // showing-result 상태인지
}revealedCount를 answers.length에서 그대로 받는 게 요령이었다. 공개 여부를 따로 상태로 들면 게임 상태와 어긋날 수 있는데, 이미 있는 값에서 파생시키면 어긋날 수가 없다.
표시 규칙은 네 가지다. 첫 라운드를 푸는 중에는 아무것도 안 보이고, 첫 정답이 나오면 첫 스토리가 나타난다. 두 번째 라운드부터는 이전 스토리 전부와 현재 시기 라벨 뒤에 말줄임표가 붙고, 정답이 나오면 그 자리가 채워진다.
나은빈의 인생 서사
유년기
"부산 영도에서 태어난 아이는..."
10대
"중국어를 독학하며..."
20대: ... ← 아직 안 열린 자리early return 뒤에 useEffect를 뒀다
초기 구현에서 훅 규칙을 어겼다.
// 잘못된 순서 — hooks 규칙 위반
if (!shouldShow) return null;
useEffect(() => { ... }, []);
// 올바른 순서
useEffect(() => { ... }, []);
if (!shouldShow) return null;React는 훅을 호출 순서로 구분한다. 조건에 따라 early return이 걸리면 어떤 렌더에서는 useEffect가 불리고 어떤 렌더에서는 안 불린다. 그러면 순서가 어긋나서 다른 훅의 상태를 가져오게 된다. 훅을 전부 위로 올리고 early return을 아래로 내렸다.
macOS 스크롤바가 텍스트를 덮었다
max-h-48 overflow-y-auto로 높이를 제한했는데 스크롤바가 글자 위에 겹쳤다. macOS의 오버레이 스크롤바는 레이아웃 공간을 차지하지 않고 콘텐츠 위에 떠서 그려진다. 윈도우처럼 자리를 밀어주지 않는다.
컨테이너에 p-4 pr-0, 안쪽 스크롤 영역에 pr-4를 줘서 오른쪽 여백을 직접 확보했다. 패딩을 바깥이 아니라 스크롤되는 요소에 줘야 스크롤바와 글자 사이가 벌어진다.
새 문장이 나타날 때는 fade-in을 넣었다.
@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의 임의 값 문법을 쓰면 설정 파일에 애니메이션을 등록하지 않아도 된다. 한 군데서만 쓰는 애니메이션이라 설정을 늘릴 이유가 없었다.
결과를 저장해야 공유 링크가 살아난다
텍스트 공유만으로도 결과는 전달된다. 그런데 링크를 눌렀을 때 결과 페이지가 뜨고 미리보기 이미지가 나오게 하려면 서버에 저장돼 있어야 한다.
한 건에 750바이트 정도라 무료 플랜 500MB로 70만 건쯤 들어간다. 용량은 걱정할 게 아니었다.
reducer에 저장 시작·완료·실패 액션을 넣되, 저장이 실패해도 결과 화면은 그대로 뜬다. 저장은 부가 기능이라 핵심 흐름을 막으면 안 된다. 게임을 다 풀었는데 DB 오류로 결과를 못 보는 건 말이 안 된다.
공유 텍스트도 서사에 맞게 바꿨다
기존 형식은 맞고 틀림만 표시했다. 인생 추리라는 컨셉에 맞춰 문구를 다듬고 결과 URL을 붙였다.
사주고사 — 나은빈의 인생 추리
O 유년기
X 10대
O 20대
4/6 적중
사주로 인물의 인생 서사를 추리해보세요!
https://sajugosa.vercel.app/detective/result/abc123막힌 지점과 원인
| 문제 | 원인 | 해결 |
|---|---|---|
| 라운드가 서로 이어지지 않음 | 스토리 조각이 라운드마다 소비되고 사라짐 | 누적 표시하는 StoryScroll 추가 |
| 훅 규칙 위반 | early return 뒤에 useEffect 배치 | 훅 선언을 전부 위로 이동 |
| 스크롤바가 텍스트를 덮음 | macOS 오버레이 스크롤바가 레이아웃 공간을 안 차지함 | 스크롤 영역에 pr-4로 여백 확보 |
| 공유 링크로 결과를 볼 수 없음 | 결과가 클라이언트 메모리에만 존재 | Supabase 저장 후 resultId 기반 결과 페이지 |
기존 로직을 하나도 안 건드리고 표시 컴포넌트 하나를 얹어서 체감이 크게 달라진 게 이번의 소득이었다. 같은 객관식인데 이전 이야기가 쌓이니 다음 문장을 열고 싶어진다.
라운드가 7~8개까지 늘어나면 max-h-48이 답답할 것 같은데 아직 확인 못 했다. 결과 페이지에서 같은 캐릭터로 바로 시작하는 링크와 미리보기 이미지에 캐릭터 정보를 넣는 건 남았다.