기술 포스트

로딩바 하나에 세 번 속았다


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
서버액션만 안 쓴다 — 1편에서 적은 대로, 토큰이 브라우저에 있어서 Next 서버가 그것을 읽을 수 없다.

앞의 둘은 기준이 두 줄이다. 공개 + SEO면 서버에서 읽고, 상호작용이면 브라우저에서 쓴다. 실무에서 흔히 하이브리드라 부르는 그것이다.

셋째는 성격이 다르다. 백엔드를 일부러 안 태우는 경로다. 문의·버그 리포트 폼이 그렇다. 이유가 둘인데 둘 다 인프라 사정이다.


그런데 목록 화면만 원칙을 어겼다

원칙대로라면 카드 목록 /cards공개 + SEO 중요이므로 서버에서 읽어야 한다. 실제로 그렇게 만들었다. 서버 컴포넌트에서 prefetchQuery로 목록을 받고, dehydrate해서 HydrationBoundary로 넘긴다. RSC와 react-query가 공존하는 교과서 패턴이다.

그리고 그 화면이 제일 느렸다.

증상이 이상했다. 상세 화면에서 헤더의 "카드" 링크를 눌러 목록으로 들어가면 회색 스피너가 3~4초 돌았다. 그런데 브라우저 뒤로가기로 같은 목록에 들어가면 즉시 떴다.

같은 화면인데 들어가는 방향에 따라 다르다

뒤로가기

상세 → (back) → 목록

  • 즉시 뜬다
  • 스피너가 없다

헤더 링크

상세 → "카드" 클릭 → 목록

  • 회색 스피너가 3~4초
  • 네트워크는 120ms도 안 걸리는데
같은 목록, 같은 데이터. 방향만 다르다. 이 비대칭이 나를 세 번 속였다.

네트워크 탭을 보면 요청은 빨랐다. 그런데 화면은 멈춰 있었다. 서버가 뭔가를 기다리고 있다고 생각했다. 그게 첫 번째 오해였다.


헛짚기 1 — "서버 prefetch의 await가 범인이다"

/cardssearchParams를 쓰니 동적 라우트다. 빌드 리포트에도 ƒ (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 진입 지연 제거

여전히 스피너가 떴다.

여기서 멈췄다. 세 번을 고쳤는데 세 번 다 안 됐다. 셋 다 서버가 데이터를 어떻게 가져오는가를 고치는 시도였다. 그 방향이 통째로 틀렸을 가능성을 그제야 생각했다.

하루 동안 밟은 길
  1. 증상헤더로 목록 진입 시 스피너 3~4초. 뒤로가기는 즉시
  2. 헛짚기 1서버 prefetch 의 await 가 범인 → 지웠더니 더 나빠졌다
  3. 헛짚기 2정적화 + 캐시 프리워밍 → 다른 변경과 섞여 분간 불가. 전부 되돌림
  4. 헛짚기 3ISR 로 전환 → ○ Static 이 됐는데도 스피너 그대로
  5. 빌드 산출물을 열었다.next/server/app/cards.html카드가 0개
  6. 진짜 원인서버가 무엇을 하든 그 결과가 화면에 닿지 못하고 있었다
셋 다 "서버가 데이터를 어떻게 가져오는가"를 고치는 시도였다. 문제는 그 앞이 아니라 그 뒤에 있었다.

빌드 산출물을 열어봤다

추측을 멈추고 결과물을 직접 봤다. 빌드가 만든 정적 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.

뒤로가기가 빨랐던 이유도 이제 설명된다. 뒤로가기는 이미 데워진 캐시로 돌아가는 것이다. 캐시가 살아 있으니 스피너가 없다. 앞으로 들어갈 때만 캐시가 비어 있었다. 비대칭의 정체가 서버가 아니라 캐시였다.

진짜로 벌어지던 일
  1. 01

    서버가 목록을 가져온다

    여기까지는 잘 됐다. prefetchQuery 도, ISR 도 동작했다.

  2. 02

    그런데 그릴 컴포넌트가 서버에서 안 돈다

    useSearchParams 는 클라이언트 전용이다. 서버 HTML 에는 Suspense fallback 만 남는다.

    "use client";
    const searchParams = useSearchParams();
  3. 03

    그래서 브라우저의 캐시가 빈 채로 시작한다

    서버가 가져온 데이터는 화면에 닿지 못하고 버려진다.

  4. 04

    isLoading = true → 회색 스피너

    그리고 브라우저가 처음부터 다시 받는다. 약 960ms + 렌더.

  5. 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가 만든 것처럼" 균등해 보이던 문제 — 그것도 원인이 세 번 바뀌었다.