본문으로 건너뛰기

사주고사 제작기 05. 빼니까 빨라졌다

·8 min read·5 / 5

기능 추가를 멈추고, 쌓인 기술 부채를 청산했다. 성능 최적화, SEO 기반, 접근성 개선. 빼는 작업이 더하는 작업보다 많았던 하루.


성능 최적화: 빼니까 빨라졌다

미사용 shadcn 컴포넌트 삭제

초기에 "혹시 쓸까" 싶어 설치했던 shadcn 컴포넌트들을 정리했다.

삭제: select, radio-group, label, progress, input

5개 컴포넌트가 사라지니 번들이 눈에 띄게 줄었다. "나중에 쓸 수도 있으니까"로 남겨둔 코드는 결국 쓰지 않는다. 쓸 때 다시 설치하면 된다.

next-themes 제거

다크 모드를 지원하려고 next-themes를 넣었는데, 사주고사는 다크 테마 고정이다. 라이트 모드 전환 기능이 필요 없는데 테마 프로바이더가 돌고 있었다. 제거하고 sonner(토스트)의 다크 테마만 하드코딩했다.

- <ThemeProvider attribute="class" defaultTheme="dark">
-   {children}
- </ThemeProvider>
+ <Toaster theme="dark" />

의존성 하나가 줄면 번들 사이즈만 줄어드는 게 아니다. 하이드레이션 비용, FOUC(깜빡임) 가능성, 디버깅 표면적 — 전부 줄어든다.

React cache()로 중복 쿼리 제거

결과 페이지에서 같은 submission 데이터를 page.tsx(메타데이터용)와 generateMetadata(OG 이미지용)에서 두 번 조회하고 있었다. React의 cache()로 감싸서 한 번만 DB를 때리도록 변경했다.

import { cache } from "react";
 
export const getSubmission = cache(async (id: string) => {
  // Supabase 쿼리 — 같은 렌더 사이클 내 중복 호출 시 캐시 반환
});

Next.js의 서버 컴포넌트 렌더링 중 같은 요청이면 자동으로 캐시된다. 별도 상태 관리 없이 함수 래핑 한 줄로 해결.


랜딩 페이지 서버/클라이언트 분리

기존 랜딩 페이지(app/page.tsx)는 통째로 "use client"였다. 문제:

  1. 서버에서 프리렌더링이 안 돼서 SEO에 불리
  2. 100줄짜리 컴포넌트 전체가 클라이언트 번들에 포함

클라이언트 로직(localStorage 읽기, 라우팅)을 StartForm 컴포넌트로 분리하고, 페이지 자체는 서버 컴포넌트로 전환했다.

Before: page.tsx (client, 100줄)
After:  page.tsx (server, ~15줄) + StartForm (client, 89줄)

서버 컴포넌트인 페이지가 정적 HTML을 내려주고, 인터랙션이 필요한 폼만 클라이언트에서 하이드레이션된다. 검색 엔진 크롤러가 볼 수 있는 콘텐츠가 생긴 셈.


SEO 기반 작업

robots.txt + sitemap.xml

서치콘솔에 등록하면서, 크롤러가 인덱싱할 페이지를 명시적으로 제어했다. Next.js의 Metadata API로 app/robots.ts, app/sitemap.ts를 생성.

// robots.ts — 메인만 허용, 나머지 차단
rules: [{
  userAgent: "*",
  allow: "/",
  disallow: ["/api/", "/quiz/", "/result/", "/onboarding"],
}]

사주고사에서 검색 유입이 의미 있는 페이지는 메인 하나뿐이다. 퀴즈나 결과 페이지는 동적 콘텐츠이고, URL을 직접 방문해도 맥락 없이는 의미가 없다. 인덱싱 대상을 좁히면 크롤 버짓도 아끼고, 중복 콘텐츠 이슈도 예방된다.

결과 페이지 동적 메타데이터

결과 페이지에 generateMetadata를 추가해서, 공유 시 OG 태그에 해당 문제의 점수와 등급이 포함되도록 했다. 검색 인덱싱 대상은 아니지만, 카카오톡이나 트위터에 공유할 때 미리보기가 풍부해진다.


안정성 개선

Gemini API 30초 타임아웃

Gemini API 호출에 AbortController로 30초 타임아웃을 걸었다. AI 채점이 무한정 걸리는 최악의 케이스를 차단하고, 타임아웃 시 키워드 매칭 폴백으로 자연스럽게 넘어간다.

DB 배열 필드 null 폴백

Supabase에서 가져온 matchedKeywords, strengths, improvements 필드가 null일 때 빈 배열로 폴백 처리. 초기 데이터에 해당 컬럼이 없는 레코드가 있어서 런타임 에러가 나던 문제.

window.location.origin SSR 안전 처리

공유 버튼에서 window.location.origin을 컴포넌트 렌더링 시점에 참조하고 있었다. SSR에서는 window가 없으니 에러. 이벤트 핸들러(클릭 시점) 안으로 이동시켜 해결.

핀치 줌 비활성화

viewport를 maximumScale: 1, userScalable: false로 변경. 모바일 퀴즈 앱에서 핀치 줌은 textarea 포커스 시 레이아웃을 망가뜨리고, 스와이프 제스처와 충돌한다.


오늘의 커밋 로그

a547a57 refactor: 성능 최적화, SEO, 접근성 일괄 개선
8a78806 feat: robots.txt + sitemap.xml 추가 (메인 페이지만 인덱싱)
6fc91e4 fix: viewport 핀치 줌 비활성화 (모바일 UX 개선)

배운 것

  1. 빼는 게 더하는 것보다 낫다. 미사용 컴포넌트 5개 삭제, next-themes 제거, 통합 100줄 → 15줄 분리. 코드를 지울 때마다 앱이 가벼워지고 이해하기 쉬워진다. "혹시 쓸까"로 남긴 코드는 쓰지 않는다.

  2. 서버/클라이언트 경계를 의식하라. Next.js App Router에서 "use client"를 페이지 전체에 걸면 SSR의 이점을 전부 포기하는 셈이다. 클라이언트 로직을 작은 컴포넌트로 격리하면 SEO와 성능을 동시에 챙길 수 있다.

  3. 인덱싱 대상은 좁을수록 좋다. 모든 페이지를 크롤러에 열어두는 건 기본값이지 최선이 아니다. 동적 콘텐츠, 인증이 필요한 페이지, 맥락 없이는 의미 없는 URL은 robots.txt에서 막는 게 서치콘솔 건강에 좋다.

  4. LLM API에는 반드시 타임아웃을 걸어라. 외부 API는 언제든 느려질 수 있고, 사용자는 로딩이 10초만 넘어도 이탈한다. AbortController + 폴백 전략이 최소한의 방어선이다.


남은 과제

  • generated-questions 콘텐츠 품질 보강 (story + analysis)
  • 문제/인물 데이터 Supabase 이관
  • Rate limiter Redis 전환 (Upstash)
  • Vercel Analytics 연동