"첫 진입할 때랑 페이지 이동할 때 너무 느려요. 한 번 들렀던 페이지는 빠른데 처음 들어가는 페이지들은 너무 느려요."
성능 이슈를 받으면 손이 먼저 움직입니다. 이미지를 줄여볼까, 번들을 쪼개볼까 하고요. 그런데 이 제보에는 쓸 만한 단서가 하나 들어 있었습니다. 한 번 들른 페이지는 빠르다. 뭔가가 캐시되고 있다는 뜻이고, 그럼 범위가 꽤 좁혀집니다.
결과적으로 원인은 네 개였습니다. 그리고 가장 큰 원인은 제가 그 리디자인에서 직접 심은 것이었습니다.
1. 무엇을 재야 하는가
"첫 진입만 느리고 재방문은 빠르다"면 후보는 대략 셋입니다.
후보
첫 진입
재방문
정적 자원(폰트·JS·CSS) 다운로드
느림
브라우저 캐시로 빠름
클라이언트 데이터 페칭
느림
쿼리 캐시로 빠름
서버 렌더링 / 미들웨어
느림
여전히 느림
세 번째는 매번 발생하니까 "재방문은 빠르다"를 설명하지 못합니다. 비용이긴 하지만 이 증상의 주범은 아니라는 뜻이죠. 1번과 2번을 먼저 봐야 합니다.
이 구분을 안 하고 들어가면 엉뚱한 곳을 파게 됩니다. 저는 여기서 브라우저 콘솔을 열었습니다.
js
// 이 페이지가 실제로 받아온 것들const rs = performance.getEntriesByType('resource');const fonts = rs.filter(r => r.name.includes('.woff2'));const nav = performance.getEntriesByType('navigation')[0];console.log({ fontRequests: fonts.length, domContentLoaded: Math.round(nav.domContentLoadedEventEnd),});
code
{ fontRequests: 197, domContentLoaded: 192 }
폰트 파일 197개. 페이지 하나 열었을 뿐인데요.
2. 원인 1: next/font의 preload가 한글을 만나면
next/font는 폰트를 자동으로 셀프호스팅해주는 좋은 도구입니다. 그리고 preload 옵션의 기본값이 true입니다. 공식 문서에도 그렇게 적혀 있습니다.
라틴 폰트라면 합리적인 기본값입니다. 서브셋이 몇 개뿐이라 미리 받아두면 텍스트가 빨리 보이니까요. 문제는 한글입니다.
한글은 글자 수가 많아서 Google Fonts가 폰트를 유니코드 범위별로 90여 개 조각으로 쪼개 제공합니다. unicode-range가 붙은 @font-face가 90개쯤 생기는 거죠. 원래는 훌륭한 최적화입니다. 브라우저가 화면에 실제로 쓰인 글자의 조각만 골라서 받으니까요.
그런데 preload: true면 이 최적화가 무력화됩니다. Next.js가 그 조각들에 <link rel="preload">를 달아버리고, 브라우저는 미리 받으라니까 화면에 없는 글자의 조각까지 전부 내려받습니다.
문서에 따르면 getClaims()는 비대칭 서명 키를 쓰는 프로젝트에서 캐시된 JWKS로 로컬 검증합니다. 네트워크를 타지 않는다는 뜻이죠. 액세스 토큰 만료가 임박하면 검증 전에 세션을 먼저 갱신하므로 로그인 유지도 깨지지 않습니다. 아직 대칭 시크릿을 쓰는 프로젝트라면 getUser()와 똑같이 동작합니다.
마지막 항목이 중요합니다. 최악의 경우가 "기존과 같음"이라 마음 편히 바꿀 수 있습니다. 다만 효과를 다 보려면 Supabase 프로젝트를 비대칭 JWT 서명 키로 옮겨야 합니다.
보안 쪽도 한 줄 적어두겠습니다. 이 미들웨어는 로그인 여부만 판단하고, 실제 권한은 DB의 RLS가 쿼리마다 강제합니다. 위조된 토큰은 서명 검증에서 걸러지니 로컬 검증으로 충분합니다. 인가 결정을 미들웨어에만 의존하는 구조라면 더 신중히 봐야 합니다.
6. 마감 처리: 유휴 프리페치와 loading.tsx
원인 네 개를 고친 뒤 두 가지를 더 얹었습니다.
화면들이 같은 데이터를 쓴다면 미리 데워두기
제 앱은 5개 탭이 대부분 같은 데이터를 씁니다. 습관, 기록, 목표, 할 일, XP 정도요. 그렇다면 첫 화면이 그려진 뒤 유휴 시간에 미리 받아둘 수 있습니다.
여기서 일부러 피한 게 있습니다. queryClient.prefetchQuery()로 쿼리 키와 조회 함수를 다시 쓰지 않았습니다. 키를 한 글자라도 다르게 쓰면 캐시가 갈라져서 오히려 두 번 받게 되니까요. 기존 훅을 그대로 호출하면 키가 어긋날 일이 없습니다.
requestIdleCallback으로 미룬 이유도 단순합니다. 첫 화면과 대역폭을 다투면 첫 진입을 더 느리게 만들 뿐입니다.
효과를 검증해봤습니다. 방문하지 않은 페이지로 이동하면서 네트워크 요청 수를 셌습니다.
js
// 이동 전후 Supabase REST 호출 수 비교{ newRestCalls: 0, showsLoading: false, hasContent: true }
추가 네트워크 0건. 처음 여는 페이지가 로딩 상태 없이 바로 렌더됐습니다.
서버 렌더를 기다리는 동안
동적 라우트는 서버 렌더를 기다려야 합니다. 이때 loading.tsx가 없으면 화면이 그냥 멈춰 보입니다. 게다가 <Link> 프리페치도 로딩 경계가 있어야 제 역할을 합니다.
tsx
// app/(app)/loading.tsximport { PageSkeleton } from '@/components/page-skeleton';// 이 그룹 하위 라우트 이동 시 즉시 뼈대가 보인다export default function AppLoading() { return <PageSkeleton />;}
라우트 그룹 하나에 파일 하나만 두면 그 아래 전체가 커버됩니다.
7. 결과
항목
전
후
preload 태그
193개
5개
첫 로드 폰트 요청
197개
12개
DOMContentLoaded (로컬)
192ms
90ms
빌드 폰트 산출물
8.0MB
4.5MB
폰트 CSS
380KB
244KB
미방문 페이지 이동 시 API 호출
화면당 5~8건
0건
미들웨어 인증 왕복
매 요청
로컬 검증
마무리
제보에 이미 답의 절반이 있었습니다. "한 번 들른 페이지는 빠르다"는 한 문장이 후보를 셋에서 둘로 줄여줬고, 그 문장을 흘려듣지 않은 덕에 staleTime을 금방 찾았습니다. 성능 제보를 받으면 사용자가 정확히 어떤 상황에서 느리다고 했는지를 먼저 붙잡는 게 낫습니다.
그리고 제일 큰 원인은 제가 며칠 전에 직접 심은 것이었습니다. 한글 폰트를 예쁘게 쓰려고 무게를 넉넉히 싣고 나머지는 기본값으로 뒀는데, 그게 preload 태그 193개로 돌아왔습니다. 리디자인할 때 "잘 보이는가"만 봤고 "얼마나 받아오는가"는 안 봤던 거죠.
한글로 서비스를 만든다면 한글 웹폰트에 preload: false를 주고, 실제로 쓰는 무게만 싣는 것부터 확인해보시면 좋겠습니다. 한글은 유니코드 서브셋 분할이라는 최적화를 이미 갖고 있어서, 그걸 방해하지만 않으면 됩니다.