기술 포스트

로딩바 하나에 세 번 속았다


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가 범인이다"

/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 진입 지연 제거

여전히 스피너가 떴다.

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

하루 동안 밟은 길
  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가 만든 것처럼" 균등해 보이던 문제도 다룬다. 그것도 원인이 세 번 바뀌었다.

Share
  • DevTI
  • Next.js
  • react-query
  • RSC
  • 디버깅
로딩바 하나에 세 번 속았다 — 디코드랩(DCODELAB)