본문으로 건너뛰기

사주고사 제작기 05. 기술 부채 청산과 번들 정리

·8 min read·5 / 13

기능 추가를 멈추고 쌓인 것들을 정리했다. 더한 것보다 뺀 게 많은 하루였다.

나중에 쓸까 싶어 남겨둔 것들을 지웠다

초기에 설치해둔 shadcn 컴포넌트 중 select, radio-group, label, progress, input 다섯 개가 한 번도 안 쓰이고 있었다. 객관식을 들어내면서 쓸 일이 없어진 것들이다. 지우니 번들이 눈에 띄게 줄었다. 나중에 필요하면 그때 다시 설치하면 된다.

next-themes도 뺐다. 다크 모드를 지원하려고 넣었는데 이 서비스는 다크 테마 고정이다. 라이트 모드로 바꿀 수 없는데 테마 프로바이더가 돌고 있었다.

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

토스트 라이브러리의 다크 테마만 고정값으로 넘겼다. 의존성 하나가 줄면 번들 크기만 주는 게 아니다. next-themes는 첫 렌더에서 테마를 확정하려고 하이드레이션에 관여하는데, 그 과정에서 잠깐 다른 테마가 보이는 깜빡임이 생길 수 있다. 그 가능성 자체가 없어졌다.

같은 데이터를 두 번 조회하고 있었다

결과 페이지에서 같은 submission을 page.tsxgenerateMetadata에서 각각 조회했다. Next.js는 이 둘을 따로 실행하기 때문에 DB를 두 번 때린다.

React의 cache()로 감쌌다.

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

같은 요청을 처리하는 동안 같은 인자로 부르면 첫 결과를 돌려준다. 요청이 끝나면 캐시도 사라져서 다음 사용자에게 남은 데이터가 새지 않는다. 별도 상태 관리 없이 함수를 감싸는 것으로 끝났다.

랜딩 전체가 클라이언트 컴포넌트였다

app/page.tsx가 통째로 "use client"였다. 두 가지가 걸렸다. 서버에서 프리렌더링이 안 되니 크롤러가 볼 HTML이 없고, 100줄짜리 컴포넌트가 통째로 클라이언트 번들에 들어간다.

localStorage를 읽고 라우팅하는 부분만 StartForm으로 떼어내고 페이지는 서버 컴포넌트로 되돌렸다.

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

서버 컴포넌트가 정적 HTML을 내려주고, 상호작용이 필요한 폼만 클라이언트에서 하이드레이션된다. "use client"를 페이지 최상단에 붙이면 그 아래 전부가 클라이언트가 되기 때문에, 경계를 가능한 한 아래로 내리는 게 요령이었다.

검색에 노출할 페이지를 하나로 좁혔다

서치콘솔에 등록하면서 크롤러가 볼 페이지를 명시했다. Next.js Metadata API로 app/robots.tsapp/sitemap.ts를 만들었다.

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

검색 유입이 의미 있는 페이지는 메인 하나뿐이다. 퀴즈와 결과는 동적 콘텐츠라 URL만 열면 맥락이 없다. 결과 페이지가 수천 개씩 인덱싱되면 서로 비슷한 페이지가 잔뜩 쌓여 오히려 손해다.

결과 페이지에는 generateMetadata로 동적 OG 태그를 붙였다. 인덱싱 대상은 아니지만 카카오톡이나 트위터에 공유할 때 점수와 등급이 미리보기에 뜬다. 크롤러에게는 닫아두고 공유 미리보기는 열어두는 건 별개로 설정할 수 있다.

무한정 기다리는 경로를 막았다

Gemini API 호출에 AbortController로 30초 타임아웃을 걸었다. AI 채점이 끝나지 않는 최악의 경우에 사용자가 계속 대기하는 대신, 타임아웃이 나면 키워드 매칭 폴백으로 넘어간다.

Supabase에서 가져온 matchedKeywords, strengths, improvementsnull로 오는 경우도 막았다. 컬럼을 추가하기 전에 만들어진 레코드에는 값이 없어서, 배열 메서드를 부르는 순간 런타임 에러가 났다. 빈 배열로 폴백하게 했다.

공유 버튼에서는 window.location.origin을 컴포넌트 렌더링 중에 참조하고 있었다. 서버에는 window가 없어서 에러가 난다. 클릭 핸들러 안으로 옮겼다. 핸들러는 브라우저에서만 실행되니 window가 반드시 있다.

막힌 지점과 원인

문제원인해결
같은 submission을 두 번 조회page.tsxgenerateMetadata가 각각 실행됨React cache()로 요청 단위 캐싱
랜딩이 크롤러에게 빈 페이지페이지 전체가 클라이언트 컴포넌트클라이언트 로직만 분리하고 페이지는 서버 컴포넌트로
배열 필드에서 런타임 에러컬럼 추가 이전 레코드의 값이 null빈 배열 폴백
SSR에서 window 참조 에러렌더링 시점에 window.location 접근클릭 핸들러 내부로 이동
AI 채점이 끝나지 않을 위험타임아웃 없이 응답 대기AbortController 30초 제한 후 폴백

모바일 viewport는 maximumScale: 1userScalable: false로 바꿔 핀치 줌을 껐다. textarea에 포커스가 갈 때 화면이 확대되면서 레이아웃이 틀어지고 스와이프 제스처와도 부딪혔다. 확대가 필요한 사용자에게는 불리한 선택이라, 나중에 레이아웃 쪽으로 다시 풀어야 할 자리로 남겨뒀다.

생성 문제 콘텐츠 품질 보강과 문제·인물 데이터 이관, rate limiter Redis 전환은 여전히 남아 있다.