본문으로 건너뛰기

Next.js 16 성능 문제를 측정하고, 서버 조회를 전부 클라이언트로 옮기기

· 약 2분
Seung hyo, Lee
Front End Engineer @ Assistfit

Next.js 16 브랜치를 QA 환경에 올려 보니 정적 페이지는 빨라졌는데 동적 페이지가 크게 느려졌다. 회원 상세 페이지의 문서 TTFB가 기존 392ms에서 2,813ms가 됐고, Server Action 뒤 화면이 반영되기까지 10초 가까이 걸렸다.

원인을 둘로 나누기​

감으로 고치지 않고 시나리오별로 재고 기록하는 문서부터 만들었다. 원인은 두 갈래였다.

하나는 Server Action 직후의 prefetch 폭주였다. Next.js 15부터 revalidatePath가 클라이언트 라우터 캐시를 통째로 비우고 staleTimes.dynamic 기본값이 0이 됐다. 여기에 16의 prefetch 방식이 겹쳐 액션이 끝나면 화면에 보이는 링크 30여 개가 한꺼번에 다시 prefetch됐다. 아래 설정으로 14 시절 기본값을 되돌리니 추가 prefetch가 0건이 됐다.

experimental: {
staleTimes: { dynamic: 30, static: 300 },
},

다른 하나는 호스팅이었다. 우리가 쓰는 호스팅의 SSR 런타임은 공식적으로 Next.js 15까지만 지원했고, 런타임 메모리를 늘릴 설정도 없었다. 그래서 두 가지를 PoC로 비교했다. 15로 내리기만 하면 1,322ms로 일부만 회복됐다. 16에서 회원 상세 페이지를 클라이언트 조회로 바꾸면 402ms가 나왔다. 회의에서 15를 베이스로 두고 클라이언트 전환을 같이 가져가기로 정했다.

도메인별 전환​

회원 도메인을 먼저 끝까지 옮기고 측정한 뒤 나머지로 넓혔다. 서버 GET은 클라이언트 조회 훅으로, Server Action은 React Query mutation으로 바꿨다. 결제 삭제 액션의 체감 시간은 5.6초에서 0.5초대로 줄었다. 중간에 서버 컴포넌트 아래 클라이언트 컴포넌트가 섞인 트리에서 무한 렌더 루프가 나와, 자식 트리까지 한 번에 클라이언트로 맞추는 규칙을 정했다.

가장 많이 나온 버그는 캐시 무효화 키 불일치였다. 조회 훅과 mutation이 queryKey 문자열을 각자 적다 보니, 목록은 'ot-list'로 읽는데 무효화는 'ot'로 하는 식의 어긋남이 생겼다. 정적 파서로 useQuery 키 157개와 invalidateQueries 키를 전수 대조하고 컨벤션을 문서로 남겼다.

  • 첫 토큰은 kebab-case 도메인-리소스, 두 번째는 스코프 ID(타입까지 통일)
  • 키는 상수로 한 곳에 두고 조회와 무효화가 같이 import
  • 무효화 헬퍼는 세 번 이상 재사용되거나 도메인을 넘나들 때만 만든다

전환을 마치고 호출처가 0이 된 서버 fetch 함수 약 100개와 barrel 파일 12개를 지웠다. 오늘 대시보드에 남아 있던 마지막 SSR 조회를 옮기면서 서버에서 데이터를 가져오는 페이지가 0개가 됐다. 도메인마다 preview 환경에서 돌릴 QA 시나리오 문서를 만들어 하나씩 통과시킨 게 큰 도움이 됐다.