로딩바 하나에 세 번 속았다
Article
1편에서 백엔드를 숨기지 않기로 했다. Spring Boot가 데이터의 주인이고 Next는 그것을 호출하는 렌더링 레이어다.
그러면 다음 질문이 온다. Next는 그 REST를 어디서 부르는가. 서버에서 부르나, 브라우저에서 부르나.
하나를 고르지 않았다
보통은 "RSC냐 react-query냐"로 편을 갈라 묻는다. 이 프로젝트는 셋을 공존시켰다. 대신 화면마다 고를 기준을 정했다.
읽기 — Next 서버에서
RSC 서버 패칭 + revalidate
- 공개 화면이고 SEO가 중요할 때
- 카드 상세 · 공유 · 랜딩
- HTML에 내용이 실려 나간다
serverGetPublic()+revalidate: 3600
쓰기 · 상호작용 — 브라우저에서
react-query
- 로그인이 필요하거나 바로 반응해야 할 때
- 좋아요 · 댓글 · 폼
- 낙관적 업데이트로 즉시 바뀐다
useMutation+onMutate
백엔드를 안 타는 일
Next Route Handler
- Spring 이 할 수 없거나 하면 안 되는 일
- 문의 · 버그 리포트 메일
- API 키를 서버에만 둔다
app/api/contact/route.ts
앞의 둘은 기준이 두 줄이다. 공개 + SEO면 서버에서 읽고, 상호작용이면 브라우저에서 쓴다. 실무에서 흔히 하이브리드라 부르는 그것이다.
셋째는 성격이 다르다. 백엔드를 일부러 안 태우는 경로다. 문의·버그 리포트 폼이 그렇다. 이유가 둘인데 둘 다 인프라 사정이다.
그런데 목록 화면만 원칙을 어겼다
원칙대로라면 카드 목록 /cards는 공개 + SEO 중요이므로 서버에서 읽어야 한다. 실제로 그렇게 만들었다. 서버 컴포넌트에서 prefetchQuery로 목록을 받고, dehydrate해서 HydrationBoundary로 넘긴다. RSC와 react-query가 공존하는 교과서 패턴이다.
그리고 그 화면이 제일 느렸다.
증상이 이상했다. 상세 화면에서 헤더의 "카드" 링크를 눌러 목록으로 들어가면 회색 스피너가 3~4초 돌았다. 그런데 브라우저 뒤로가기로 같은 목록에 들어가면 즉시 떴다.
뒤로가기
상세 → (back) → 목록
- 즉시 뜬다
- 스피너가 없다
헤더 링크
상세 → "카드" 클릭 → 목록
- 회색 스피너가 3~4초
- 네트워크는 120ms도 안 걸리는데
네트워크 탭을 보면 요청은 빨랐다. 그런데 화면은 멈춰 있었다. 서버가 뭔가를 기다리고 있다고 생각했다. 그게 첫 번째 오해였다.
헛짚기 1 — "서버 prefetch의 await가 범인이다"
/cards는 searchParams를 쓰니 동적 라우트다. 빌드 리포트에도 ƒ (Dynamic)으로 찍힌다. 그러면 진입할 때마다 서버 컴포넌트가 await prefetchQuery(...)로 백엔드 응답을 기다린다. 백엔드는 무료 티어라 1.2초쯤 걸린다.
말이 된다. 그 await를 지웠다.
perf: 목록 진입 블로킹 제거 — 서버 prefetch await 삭제, 클라이언트 캐시 우선
결과는 더 나빠졌다. 서버가 데이터를 안 넘기니 브라우저가 처음부터 받아야 했고, 스피너가 오히려 더 확실하게 떴다. 되돌렸다.
revert: 목록 페이지/useCards/CardSwiperList 원래(SSR) 상태로 복원
헛짚기 2 — "정적으로 만들고 캐시를 미리 데우면 된다"
서버가 기다리는 게 문제라면, 서버가 아예 안 기다리게 하면 된다. 페이지를 정적으로 두고, 앱이 로드될 때 목록 캐시를 미리 데워두자.
perf: 목록 정적화 + 앱 로드 시 목록 캐시 프리워밍
이번에도 안 됐다. 그런데 이때는 다른 것들도 같이 만지고 있었다 — 레이아웃 전환, 스크롤 처리, 이미지 컴포넌트. 무엇이 원인인지 분간이 안 됐다.
그래서 전부 되돌렸다. 원인을 못 찾겠으면 변수를 줄이는 게 먼저다.
revert: 네비게이션 인프라(목록 page.tsx·LayoutTransition·QueryProvider)를 어제 상태로
헛짚기 3 — "ISR로 하면 되겠지"
동적이라 매번 서버를 타는 게 문제니까, ISR로 두면 되지 않나. searchParams 의존을 없애고 기본 목록만 서버에서 받아 revalidate: 60으로 캐시한다. 그러면 빌드 리포트가 ƒ Dynamic에서 ○ Static으로 바뀐다.
perf: 목록 페이지를 동적 SSR → ISR(정적+재검증)로 전환해 forward 진입 지연 제거
여전히 스피너가 떴다.
여기서 멈췄다. 세 번을 고쳤는데 세 번 다 안 됐다. 셋 다 서버가 데이터를 어떻게 가져오는가를 고치는 시도였다. 그 방향이 통째로 틀렸을 가능성을 그제야 생각했다.
- 증상헤더로 목록 진입 시 스피너 3~4초. 뒤로가기는 즉시
- 헛짚기 1서버 prefetch 의
await가 범인 → 지웠더니 더 나빠졌다 - 헛짚기 2정적화 + 캐시 프리워밍 → 다른 변경과 섞여 분간 불가. 전부 되돌림
- 헛짚기 3ISR 로 전환 →
○ Static이 됐는데도 스피너 그대로 - 빌드 산출물을 열었다
.next/server/app/cards.html— 카드가 0개 - 진짜 원인서버가 무엇을 하든 그 결과가 화면에 닿지 못하고 있었다
빌드 산출물을 열어봤다
추측을 멈추고 결과물을 직접 봤다. 빌드가 만든 정적 HTML을 그냥 열었다.
cat .next/server/app/cards.html
카드가 한 장도 없었다.
ƒ Dynamic이든 ○ Static이든, await가 있든 없든, ISR이든 아니든 — 서버가 만든 HTML에는 애초에 카드가 없었다. 있는 건 Suspense의 fallback, 회색 빈 화면 하나뿐이었다.
이유는 한 줄이었다.
// CardSwiperList.tsx
"use client";
const searchParams = useSearchParams();
검색·필터·정렬·스와이프 위치를 URL 쿼리로 다룬다. 그 URL을 읽는 useSearchParams는 클라이언트 전용 훅이다. 그러니 이 컴포넌트는 서버에서 렌더될 수가 없다. 서버 렌더 결과물에는 언제나 fallback만 남는다.
즉 서버에서 목록을 아무리 잘 가져와도, 그것을 그릴 컴포넌트가 서버에서 안 돈다. 카드는 늘 브라우저가 그린다.
그러면 스피너는 어디서 왔나. 서버가 캐시를 못 채웠으니, 목록에 진입할 때마다 react-query 캐시가 비어 있었다. isLoading = true가 되고 회색 스피너가 뜬다. 그리고 브라우저가 처음부터 다시 받는다 — 약 960ms.
뒤로가기가 빨랐던 이유도 이제 설명된다. 뒤로가기는 이미 데워진 캐시로 돌아가는 것이다. 캐시가 살아 있으니 스피너가 없다. 앞으로 들어갈 때만 캐시가 비어 있었다. 비대칭의 정체가 서버가 아니라 캐시였다.
- 01
서버가 목록을 가져온다
여기까지는 잘 됐다.
prefetchQuery도, ISR 도 동작했다. - 02
그런데 그릴 컴포넌트가 서버에서 안 돈다
useSearchParams는 클라이언트 전용이다. 서버 HTML 에는Suspensefallback 만 남는다."use client"; const searchParams = useSearchParams(); - 03
그래서 브라우저의 캐시가 빈 채로 시작한다
서버가 가져온 데이터는 화면에 닿지 못하고 버려진다.
- 04
isLoading = true→ 회색 스피너그리고 브라우저가 처음부터 다시 받는다. 약 960ms + 렌더.
- 05
뒤로가기는 캐시가 살아 있다
그래서 즉시 떴다. 서버 문제가 아니라 캐시 문제였다.
고친 방법 — 서버를 포기하고 캐시를 데운다
원인을 알고 나니 답은 짧았다. 서버가 이 화면을 못 그린다면, 서버에게 시키기를 그만두고 브라우저의 캐시를 미리 채우면 된다.
페이지는 순수 정적으로 단순화했다.
// app/(public)/cards/page.tsx
export default function CardsPage() {
return (
<Suspense fallback={<div className="min-h-dvh w-full bg-zinc-950" />}>
<CardSwiperList />
</Suspense>
);
}
그리고 앱이 로드될 때 목록을 미리 받아 캐시를 데워둔다.
// QueryProvider.tsx
useEffect(() => {
client.prefetchQuery({
queryKey: ["cards", DEFAULT_LIST_FILTERS],
queryFn: () => cardApi.getCards(DEFAULT_LIST_FILTERS),
});
}, [client]);
이제 목록에 들어가면 캐시가 이미 차 있다. 스피너가 뜰 일이 없다. 뒤로가기처럼 즉시 뜬다. 라우트 전환 자체는 정적 페이지라 Next의 프리패치로 즉시다.
perf: 목록을 정적 페이지 + 앱 로드 시 프리워밍으로 — 헤더 진입 로딩 제거
되돌렸던 두 번째 시도의 프리워밍이 결국 답이었다. 방법이 틀렸던 게 아니라, 왜 그게 답인지를 몰랐던 것이다. 몰랐으니 다른 변경들과 섞어 넣었고, 안 되니까 통째로 되돌렸다.
배운 것
세 번 다 서버 쪽을 고쳤다. await를 지우고, 정적으로 바꾸고, ISR을 붙였다. 셋 다 "서버가 데이터를 늦게 가져온다"는 가정 위에 있었다.
그 가정을 한 번도 검증하지 않았다. 검증했다면 5분이면 끝났다 — cat .next/server/app/cards.html 한 줄이었다.
세 번째 시도에서 특히 위험했다. 빌드 리포트가 ○ Static으로 바뀌는 걸 보고 "됐다"고 판단했다. 리포트는 라우트가 미리 렌더됐다고 말했을 뿐, 그 안에 무엇이 들었는지는 말한 적이 없다. 도구가 준 신호를 내가 원하는 뜻으로 읽었다.
로딩바는 증상이었다. 그런데 증상이 하도 그럴듯해서 — "느리다 = 서버가 느리다" — 세 번이나 그쪽만 팠다.
그리고 두 번째 시도에서 한 가지를 더 배웠다. 원인을 모를 때 여러 개를 동시에 고치면, 고쳐져도 왜 고쳐졌는지 모른다. 실제로 그때 프리워밍은 답에 닿아 있었는데, 다른 변경들과 섞여 있어서 되돌려버렸다. 답을 손에 쥐었다가 놓쳤다.
다음 편에서는 이 화면 위에 얹은 성능 장치들을 쓴다. LCP 이미지를 첫 네 장에만 우선 로드한 이유, 고스트 레이어 두 겹을 쓰면서 이미지 요청은 한 번만 나가게 한 방법, 그리고 통계 차트가 "AI가 만든 것처럼" 균등해 보이던 문제 — 그것도 원인이 세 번 바뀌었다.