성향 테스트 서비스는 대개 두 부분으로 나뉜다. 질문에 답하는 테스트 화면과, 결과를 보여 주는 결과 화면이다. 많은 테스트 사이트가 결과 화면을 자바스크립트로 그린다. 응답을 계산한 뒤 같은 페이지에서 결과 문구를 바꿔 끼우는 방식이다. 만들기는 쉽지만 결과 페이지가 검색에 잡히지 않고, 공유 링크를 열면 빈 화면이나 테스트 첫 화면이 나온다.

Type288은 반대로 갔다. 288개 결과를 모두 독립된 정적 HTML 페이지로 빌드 시점에 만들어 두고, 테스트가 끝나면 해당 결과 주소로 이동한다. 이 글은 그 구조와, 정적 페이지 위에 "방금 테스트한 나만의 점수"를 얹는 방법을 정리한다.

Type288 결과 페이지 하나(ENFJ-1w2). 정적으로 미리 만들어 둔 288개 결과 중 하나로, 행동 코드와 날개, 캐릭터 이미지, 공유·저장 버튼이 보인다

288개 경로를 빌드 시점에 만들기

Next.js의 output: "export" 설정에서는 서버가 없으므로 동적 경로의 모든 값을 빌드할 때 알아야 한다. generateStaticParams가 그 목록을 돌려준다.

// src/app/result/[id]/page.tsx
export const dynamicParams = false;
export function generateStaticParams() {
  return results.map((result) => ({ id: result.id.toLowerCase() }));
}

두 가지를 신경 썼다.

dynamicParams = false 는 목록에 없는 경로를 404로 처리하라는 뜻이다. static export에서는 어차피 목록 밖의 경로가 생성되지 않지만, 개발 서버에서도 같은 동작을 보장해 오타 난 링크를 일찍 발견할 수 있다.

경로는 소문자로 통일했다. 결과 ID는 데이터에서 ISTJ-1w9처럼 대문자이지만, URL은 /result/istj-1w9/처럼 소문자를 쓴다. 정적 호스팅에서는 대소문자가 다른 경로가 서로 다른 파일로 취급될 수 있어서, 공유된 링크의 대소문자가 섞이면 404가 날 수 있다. 데이터를 읽는 쪽도 resultById를 소문자 키로 만들어 맞췄다.

export const resultById = new Map(results.map((r) => [r.id.toLowerCase(), r]));

페이지마다 다른 메타데이터와 구조화 데이터

결과 페이지가 검색과 공유에서 제 역할을 하려면 페이지마다 제목, 설명, 대표 이미지가 달라야 한다. generateMetadata는 빌드 시점에 경로별로 호출된다.

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { id } = await params;
  const result = resultById.get(id);
  if (!result) return {};
  const title = `${result.name} (${result.id}) 성향 결과`;
  const description = `${result.name}: ${result.characteristic.summary}`.slice(0, 160);
  const image = resultImage(result.id, "media");
  return {
    title, description,
    alternates: { canonical: `/result/${id}/` },
    openGraph: { type: "article", title: `${title} | Type288`, description,
                 url: `${site.url}/result/${id}/`, images: [{ url: image, width: 800, height: 800 }] },
    twitter: { card: "summary_large_image", title, description, images: [image] },
  };
}

설명은 160자로 잘라 검색 결과에서 문장이 중간에 끊겨 보이는 일을 줄였다. 본문에는 Article과 BreadcrumbList JSON-LD를 넣어 "Type288 > 288가지 유형 > 결과 이름"의 위치를 검색 엔진에 알려 준다. 사이트맵에도 288개 경로를 모두 넣었다.

한 가지 아쉬운 점은 공유 이미지다. 캐릭터 이미지가 800×800 정사각형이라, 가로로 긴 미리보기를 쓰는 summary_large_image 카드에서는 위아래가 잘려 보일 수 있다.

3MB 데이터는 번들에 넣지 않는다

288개 결과의 설명, 강점, 관계, 직업, 캐릭터 정보가 담긴 results.ko.json은 약 3MB다. 이 파일을 클라이언트 컴포넌트에서 import하면 3MB가 그대로 자바스크립트 번들에 들어가 모든 페이지 로딩이 느려진다.

Type288에서 이 데이터를 import하는 곳은 결과 페이지, 유형 목록 페이지, 홈, 사이트맵 네 곳뿐이고, 모두 서버 컴포넌트다. App Router에서 서버 컴포넌트는 빌드 시점에 HTML로 렌더링되고 그 코드는 브라우저로 전송되지 않는다. 결과적으로 각 결과 페이지의 HTML에는 그 결과 하나의 문장만 들어가고, 3MB 전체는 어디에도 실리지 않는다. 빌드 결과에서 500KB를 넘는 자바스크립트 청크가 하나도 없는 것으로 확인했다.

수치를 다시 확인했다(Node v22.22.2, macOS 26.6.2 arm64, 2026-10-23, 위 빌드 결과 out/).

  • data/results.ko.json은 2,997,482바이트, data/questions.ko.json은 44,245바이트다.
  • 클라이언트 자바스크립트는 32개 파일, 합계 1,024,237바이트(약 1MB)이고, 가장 큰 청크는 235KB다.
  • results.ko.json에서 25자 이상의 문자열 8개를 뽑아 클라이언트 자바스크립트에서 찾았을 때 발견된 것은 0개였다. 전체를 대조한 것이 아니라 표본 검사다.

반대로 테스트 화면은 응답을 받고 채점해야 하므로 클라이언트 컴포넌트다. 여기서는 44KB 정도의 문항 데이터와 채점 함수만 필요하고, 결과 설명 데이터는 필요 없다. "무엇이 브라우저에서 필요한가"를 기준으로 서버와 클라이언트의 경계를 그은 것이 번들 크기를 지킨 핵심이었다.

같은 페이지, 다른 사람: 개인 점수 얹기

결과 페이지는 정적이라 누가 열어도 같은 내용이다. 그런데 테스트를 막 마친 사람에게는 "네 축이 각각 몇 % 기울었는지", "아홉 가지 동기 점수가 어땠는지" 같은 개인 점수를 보여 주고 싶었다. 친구가 공유한 링크로 들어온 사람에게는 이 점수가 보이면 안 된다.

흐름은 이렇다.

  1. 테스트가 끝나면 브라우저에서 채점하고, 요약 결과를 sessionStorage에 저장한다.
  2. 결과 페이지 주소를 미리 받아 두고(router.prefetch), 분석 중 애니메이션이 끝나면 이동한다.
  3. 결과 페이지의 ScoreReport 컴포넌트가 sessionStorage를 읽어, 지금 보고 있는 결과와 일치하는 요약이 있으면 점수를 보여 준다.
function finish(nextAnswers: AnswerMap) {
  const summary = computeResult(mode!, nextAnswers);
  saveSummary(summary); // sessionStorage에 저장
  const href = `/result/${summary.id.toLowerCase()}/`;
  router.prefetch(href);
  setStage("analyzing");
  // ... 분석 단계 애니메이션 후 이동
  timer.current = window.setTimeout(() => router.push(href), analyzingSteps.length * stepTime + 350);
}

localStorage가 아니라 sessionStorage를 쓴 이유는 "테스트 직후 같은 탭"에서만 보여 주기 위해서다. 탭을 닫으면 사라지므로, 같은 컴퓨터를 쓰는 다른 사람이 나중에 결과 링크를 열어도 이전 사람의 점수가 보이지 않는다.

하이드레이션 불일치를 피하는 useSyncExternalStore

여기서 까다로운 문제가 하나 있다. 정적 HTML은 빌드 시점에 만들어졌으니 sessionStorage 값을 알 수 없다. 그런데 브라우저에서 첫 렌더링할 때 바로 저장값을 읽어 다른 화면을 그리면, 서버 HTML과 클라이언트 렌더링 결과가 달라져 React가 하이드레이션 오류를 낸다.

흔한 해결책은 useEffect에서 값을 읽어 상태에 넣는 것이다. Type288은 대신 useSyncExternalStore를 썼다.

const subscribe = () => () => {}; // 이 탭 안에서 값이 바뀌는 일은 없으므로 구독할 것이 없다

export function ScoreReport({ id }: { id: string }) {
  // 세 번째 인자(서버 스냅샷)는 undefined, 브라우저에서는 이 탭의 저장값
  const raw = useSyncExternalStore(subscribe, readSummaryRaw, () => undefined);
  const summary = useMemo(() => raw === undefined ? undefined : parseSummary(raw, id), [raw, id]);

  if (summary === undefined) return <div className="score-report is-loading" aria-hidden="true" />;
  if (!summary) return <EmptyScoreInvite />; // 직접 링크로 온 사람: 테스트 안내
  return <ScoreDetails summary={summary} />;
}

useSyncExternalStore의 세 번째 인자는 서버 렌더링과 하이드레이션 중에 쓸 값이다. 여기서 undefined를 돌려주면 정적 HTML과 첫 하이드레이션은 모두 "로딩 자리"를 그려 일치하고, 하이드레이션이 끝난 직후 React가 브라우저 스냅샷(readSummaryRaw)으로 다시 렌더링한다. useEffect + useState 조합과 결과는 비슷하지만, "외부 저장소의 값을 읽는다"는 의도가 코드에 그대로 드러나고 상태 변수를 따로 둘 필요가 없다.

세 가지 상태를 구분한 것도 중요했다.

값 의미 화면
undefined 아직 모름(서버 렌더링·하이드레이션 중) 높이만 차지하는 로딩 자리
null 저장된 점수 없음 또는 다른 결과의 점수 "직접 테스트해 보세요" 안내
객체 방금 이 결과를 받은 사람 개인 점수 분포

로딩 자리가 같은 높이를 차지하게 해서 점수가 나타날 때 레이아웃이 밀리지 않게 했다.

빌드 결과물 규모

Type288을 직접 빌드해 규모를 쟀다.

  • 환경: Next.js 16.3.5(webpack), Node v22.22.2, macOS 26.6.2 arm64(Apple M5 Pro), 2026-10-23 측정. 빌드 캐시를 지우지 않은 상태에서 npm run build가 9.45초(벽시계 시간) 걸렸다.
  • 결과: out/은 파일 2,794개, 305,673,244바이트(291.5MiB)이고 그중 HTML은 296개다. 가장 큰 파일은 1.96MiB다.
확장자 파일 수 용량
.jpg 1,152 231.9MiB
.txt(RSC 페이로드) 1,178 31.7MiB
.html 296 18.6MiB
.woff2 125 7.9MiB
.js 32 1.0MiB
.css 2 0.1MiB

용량의 대부분(약 80%)이 이미지다. Cloudflare Pages는 무료 플랜에서 사이트당 파일 20,000개, 파일 하나당 25MiB까지 허용한다(Cloudflare Pages 한도). 지금은 파일 수가 한도의 약 14%, 가장 큰 파일이 약 8%라 당장은 여유가 있다. 결과 수가 크게 늘어난다면 이미지를 별도 저장소로 옮기는 것을 고려해야 한다.

정리

  • 결과가 유한하다면 모든 결과를 정적 페이지로 만들어 두는 것이 검색, 공유, 속도 모두에 유리하다.
  • 큰 콘텐츠 데이터는 서버 컴포넌트에서만 import해서 번들에서 빼고, 클라이언트에는 꼭 필요한 데이터만 보낸다.
  • 정적 페이지 위의 개인화는 sessionStorage + useSyncExternalStore로, 서버 스냅샷을 "모름"으로 두면 하이드레이션 불일치 없이 덧입힐 수 있다.