문서 읽는 데 63분 · D5

D-5: 데이터 페칭 & 캐싱 ① — 무엇을 굳히고 무엇을 흘려보낼까

목차 52
전체 59강 중 26강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

ℹ️TypeScript · React · Next.js 트랙이에요. html-css-js에서 만든 순수 JS 화면을 React 컴포넌트로 다시 짭니다. 웹 기초를 먼저 익히고 오면 좋아요.

지난 시간 마지막에 질문 하나를 남겨두고 끝냈습니다. 홈 화면은 왜 계속 빠른가, 하는 질문이었죠.

안녕하세요, 홍순구입니다. 오늘은 그 질문에서 곧장 출발할게요.

기억을 되살려 보면, 우리는 지난 시간에 홈 피드도 프로필도 전부 진짜 서버에서 가져오게 바꿨는데요. /jaehoon은 그 대가로 1,210밀리초가 걸리게 됐지만, 홈은 안 그랬어요. 2.5밀리초에 나오고, 연습용 서버를 아예 꺼버려도 게시물 열 장이 멀쩡하게 떴습니다.

같은 앱, 같은 await fetch인데 한쪽은 매번 물어보고 한쪽은 아무것도 안 물어봅니다. 오늘 우리는 이 차이가 어디서 오는지 알아내고, 그 선택을 우리 손으로 되찾아옵니다.

💡 오늘 수업의 핵심 — "Next는 무엇을 굳힐지 안 정해줍니다. 우리가 정하고, 안 정하면 빌드가 멈춥니다"

이 이야기는 여러분이 검색으로 만날 자료 대부분과 어긋날 거예요. 몇 년 전 Next는 fetch를 하면 일단 캐시부터 했기 때문에, 개발자가 "이건 캐시하지 마세요"라고 말려야 했습니다. 지금은 정반대라서, 아무것도 안 굳히는 것이 기본이고 굳히고 싶으면 직접 말해야 합니다.

기본이 안 굳히는 것이라면서, 오늘 홈 화면은 굳어 있었죠. 이 어긋남이 오늘의 출발점입니다.

캐시라는 말 자체는 처음이 아니에요. C-6에서 우리는 staleTime으로 "받아온 값을 언제부터 낡았다고 볼 것인가"를 정했는데, 그건 브라우저 안에서의 결정이었습니다. 오늘은 같은 결정을 서버에서 하는데, 그만큼 판돈이 훨씬 큽니다. 한 사람이 아니라 모든 사람이 그 답을 나눠 쓰거든요.

🎯 학습 목표

  • 같은 화면을 두 번 열어 서버가 몇 번 불리는지 세고, 캐시가 있는 곳과 없는 곳을 가릅니다.
  • Cache Components를 켜고, 빌드가 멈추며 알려주는 세 갈래로 화면마다 결정을 내립니다.
  • 피드는 <Suspense>로 흘려보내고, 프로필 조회는 'use cache'로 굳힙니다.
  • 아무 말 안 해도 굳는 값과, 무슨 수를 써도 안 굳는 값을 구분합니다.
텍스트
 오늘의 여정

 1. 요청과 요청 사이       같은 화면을 두 번 열면 서버는 두 번 답한다
 2. 스위치를 켠다          빌드가 멈추면서 안 정한 곳을 전부 불러준다
 3. 피드는 흘려보낸다       껍데기가 먼저 나가고 목록이 뒤따라온다
 4. 프로필은 굳힌다        서버 요청 4건 -> 2건
 5. 굳는 것과 안 굳는 것    Math.random() 은 왜 막히나

마지막으로 하나만 미리 말해둘게요. 오늘 우리는 지난 시간에 비싸게 산 것 하나를 도로 잃습니다. 무엇을 잃는지, 그게 왜 되돌릴 수 없는지는 수업 끝에서 만나요.


Step 1: "요청과 요청 사이"

지난 시간에 답을 미뤄둔 것

프로필 화면을 만들면서 이런 이야기를 했습니다. 껍데기와 페이지가 같은 주소로 각각 물어보는데 서버로는 한 번만 나간다고요. Next가 같은 요청을 알아서 합쳐준다고 했죠.

그때 조건을 하나 붙이고 넘어갔습니다. "단 한 요청 안에서만."

오늘은 그 조건 바깥을 봅니다. 한 요청 안에서는 합쳐준다면, 요청과 요청 사이에는 어떻게 될까요? 사용자가 같은 프로필을 두 번 열면 서버는 두 번 답할까요, 아니면 아까 받아둔 것을 다시 쓸까요?

말로는 아무리 해도 알 수 없으니까, 세어봅시다.

세는 방법부터 만듭니다

연습용 API 서버에 스위치를 하나 달아뒀어요. 이렇게 띄우면 들어온 요청을 한 줄씩 찍어줍니다.

Bash
API_STUB_LOG=1 node apps/api-stub/server.mjs

이제 브라우저에서 화면을 한 번 열 때마다 터미널에 이런 줄이 쌓입니다.

텍스트
 [api-stub] GET /api/posts
 [api-stub] GET /api/users/jaehoon

측정 도구가 생겼으니 실험을 해봅시다.

화면 한 번에 서버는 몇 번 불릴까

앱을 빌드해서 띄우고 /jaehoon을 딱 한 번 연 다음 터미널을 봅니다.

텍스트
 [api-stub] GET /api/users/jaehoon
 [api-stub] GET /api/posts?username=jaehoon
 [api-stub] GET /api/users/jaehoon/tags

서버는 3건을 받았는데, 우리 코드가 부르는 함수는 3개가 아니라 4개죠.

텍스트
 /jaehoon 을 한 번 열었을 때 우리 코드가 부르는 것

 layout.tsx  ->  findProfile("jaehoon")       ->  /api/users/jaehoon
 page.tsx    ->  fetchProfile("jaehoon")      ->  /api/users/jaehoon        <- 같은 주소
             ->  fetchPostsByUsername(...)    ->  /api/posts?username=...
             ->  fetchTopTags("jaehoon")      ->  /api/users/jaehoon/tags

 우리가 부른 것 4  ->  서버가 받은 것 3

껍데기가 "이 사람 있나요" 하고 묻고, 페이지가 "이 사람 팔로워 몇 명인가요" 하고 묻습니다. 둘 다 같은 주소인데, 서버 쪽 로그에는 한 줄만 찍혔습니다. 지난 시간에 이야기한 그 합치기가 눈으로 확인된 겁니다.

그럼 두 번 열면

같은 주소를 두 번 더 열어봅니다. 아까 받아둔 답이 어딘가 남아 있다면 서버는 조용해야겠죠.

텍스트
 /jaehoon 1회   서버가 받은 것 3건
 /jaehoon 1회   서버가 받은 것 3건
 합계           6건

6건입니다. 아무것도 안 아꼈어요.

여기서 합치기의 정체가 드러납니다. 그건 캐시가 아니었습니다. 한 번의 요청을 처리하는 동안, 그 안에서 같은 주소가 겹치면 한 번만 나가게 해주는 것뿐이에요. 그 요청이 끝나면 기억은 통째로 사라집니다.

텍스트
 요청 1 처리 중  |  같은 주소 겹침 -> 합친다        3건
 ---- 끝 ----    |  여기서 기억이 사라진다
 요청 2 처리 중  |  같은 주소 겹침 -> 합친다        3건

그러니까 우리 프로필 화면은 사람이 백 번 열면 서버에 삼백 번 물어봅니다. 팔로워 수처럼 하루에 몇 번 바뀔까 말까 한 값까지 매번요.

그런데 홈은 세어도 0입니다

같은 실험을 홈에서 해봅시다. 몇 번을 열어도 결과가 이렇습니다.

텍스트
 /  1회   서버가 받은 것 0건
 /  1회   서버가 받은 것 0건
 /  1회   서버가 받은 것 0건

한 건도 안 나가는데, 지난 시간에 연습용 서버를 꺼도 홈이 멀쩡했던 이유가 바로 이겁니다. 애초에 물어보질 않으니 서버가 살았는지 죽었는지 알 필요가 없습니다.

두 화면을 나란히 놓으면 이렇게 갈립니다.

서버 요청 응답 시간 서버를 끄면
/ 매번 0건 2.5ms 게시물이 그대로 뜬다
/jaehoon 매번 3건 1,210ms 500 에러

같은 앱입니다. 같은 await fetch를 쓰는데도 한쪽은 아무것도 안 묻고 한쪽은 매번 세 번 묻습니다.

빌드 로그의 라우트 표가 이 차이를 이미 말해주고 있었어요.

텍스트
 ○ /              <- 빌드할 때 한 번 그려서 굳혔다
 ƒ /[username]    <- 요청이 올 때마다 새로 그린다
 ○ /explore

는 빌드하는 순간 한 번 물어보고 그 답을 HTML에 넣어 저장해뒀다는 표시입니다. 그 뒤로는 파일을 꺼내주기만 하니까 2.5밀리초에 나오는 거예요.

이게 왜 이상한가

여기서 오늘 수업의 모순이 완성됩니다.

Next 16은 아무것도 캐시하지 않는 것이 기본입니다. 공식 문서가 그렇게 적어뒀는데도, 우리 홈은 아무도 시키지 않았는데 굳어 있습니다.

⚠️ 이건 취향 문제가 아니라 버그예요. 지금 이 앱을 배포하면 새 게시물이 올라와도 홈에는 영영 안 보이는데, 다시 빌드해서 배포할 때까지 그렇습니다.

반대로 프로필처럼 매번 물어보는 것도 정답은 아닙니다. 팔로워 수를 초당 천 번 물어볼 이유가 없죠.

우리에게 필요한 건 화면마다 다르게 정하는 것입니다. 피드는 매번 새로, 팔로워 수는 굳혀두는 식인데, 지금 우리는 그 결정을 내린 적이 없어요. Next가 알아서 정해버렸고, 하필 반대로 정했습니다.

다음 단계에서 그 결정권을 가져옵니다.

💡 한 줄 정리

같은 주소로 가는 요청을 합쳐주는 것은 한 번의 요청을 처리하는 동안만 유효합니다. 요청이 끝나면 사라지니까 캐시가 아닌데도, 홈 화면은 아무도 시키지 않았는데 굳어 있고, 그건 우리가 고쳐야 할 문제입니다.

🙋 학생 질문 — "튜터님, 껍데기랑 페이지가 같은 걸 두 번 부르는 게 애초에 이상한데요? 한 번만 부르게 짜면 되잖아요"

정확한 지적이에요. 그렇게 짤 수도 있는데, 껍데기에서 한 번 불러서 페이지로 내려주면 됩니다.

그러려면 껍데기가 페이지가 무엇을 필요로 하는지 알아야 하는데, 지금 껍데기는 "이 사람이 있는가"만 궁금하고 팔로워 수는 관심이 없어요. 그걸 굳이 받아서 아래로 전달하게 만들면, 나중에 페이지가 필요한 값이 하나 늘 때마다 껍데기도 같이 고쳐야 합니다.

C-3에서 값을 여러 단계 아래로 내려보내다가 지쳤던 것을 기억하시는데, 그 문제가 서버에서도 똑같이 생깁니다.

서버 컴포넌트의 방식은 그래서 반대예요. 필요한 쪽이 직접 부릅니다. 겹치는 것은 프레임워크가 알아서 없애주고요. 각 컴포넌트가 자기 데이터를 스스로 챙기니까 서로를 몰라도 됩니다.

오늘 확인했듯 그 없애주기는 한 요청 안에서만 돌 뿐이라, 요청과 요청 사이는 우리가 직접 정해줘야 해요.


Step 2: "스위치를 켭니다"

한 줄입니다

결정권을 가져오는 방법은 설정 파일에 한 줄을 더하는 겁니다.

TypeScript
// apps/web-next/next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  typedRoutes: true,
  // 굳힐 것과 흘려보낼 것을 우리가 직접 고르겠다고 선언하는 스위치.
  cacheComponents: true,
};

export default nextConfig;

이 스위치의 이름은 Cache Components입니다. 켜면 Next가 화면을 미리 그려보다가, 우리가 아직 정하지 않은 곳을 만나면 그냥 멈춰버립니다.

빌드해봅시다.

Bash
npm run build

빌드가 멈춥니다

텍스트
  Next.js 16.3.1 (Turbopack)
 - Cache Components enabled

 ✓ Compiled successfully in 1027ms
   Running TypeScript ...
   Finished TypeScript in 527ms ...
   Generating static pages using 7 workers (1/6)
 Error: Route "/[username]": ...

종료 코드는 1. 빌드가 실패했습니다.

⚠️ 여기서 지난 시간에 배운 것을 다시 씁니다. Compiled successfully라는 초록 글자가 이미 지나갔죠. 그래도 빌드는 실패입니다. 빌드 성공 여부는 화면에 지나간 글자가 아니라 종료 코드로 판단하세요.

에러는 하나만 보이는데, 실패한 화면이 하나뿐일까요? 아니에요. Next는 첫 실패에서 그냥 멈춰버리는 것뿐이라, 전부 보려면 이렇게 부릅니다.

Bash
npx next build --debug-prerender

에러가 우리를 대신해 목록을 만들어줍니다

이번엔 에러 목록이 전부 나오는데, 홈 화면부터 볼게요.

텍스트
 Error: Route "/": Next.js encountered uncached or runtime data during prerendering.

     at get (lib/api.ts:16:26)
     at fetchPosts (lib/api.ts:32:10)
     at FeedList (app/components/FeedList.tsx:8:33)
     at HomePage (app/page.tsx:9:7)

읽어보면, 파일 이름과 줄 번호까지 짚어줍니다. app/page.tsx 9번째 줄의 FeedListfetchPosts를 부르고, 그게 lib/api.ts 16번째 줄의 fetch로 이어진다고요.

이게 이 스위치의 진짜 값어치입니다. Next가 지금 하는 말은 이거예요.

"네가 여기서 서버에 뭘 물어보는데, 이걸 굳혀야 할지 매번 물어야 할지 나는 모르겠다. 네가 정해라."

그리고 정하는 방법까지 알려줍니다.

텍스트
 Ways to fix this:
   - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
   - [cache]  For uncached data (`fetch`, database calls): cache the access with "use cache"
   - [block]  Set `export const instant = false` to allow a blocking route

세 갈래입니다. 그리고 이 세 갈래가 오늘 남은 수업의 목차예요.

텍스트
 [stream]  껍데기를 먼저 내보내고, 데이터는 준비되면 뒤따라 보낸다   -> Step 3
 [cache]   미리 물어봐서 굳혀둔다                                  -> Step 4
 [block]   정적 껍데기 만들기를 포기하고 다 될 때까지 기다린다        -> 오늘은 미룬다

나머지 두 건

프로필 화면에서는 에러가 두 개 나옵니다. 하나는 홈과 같은 이야기예요. 데이터를 물어보는데 안 정했다는 것.

두 번째가 뜻밖입니다.

텍스트
 Error: Route "/[username]": Next.js encountered URL data `usePathname()`
        in a Client Component outside of `<Suspense>`.
        digest: 'CLIENT_HOOK_DYNAMIC'
     at app/components/HeaderNav.tsx:17:20

HeaderNav입니다. 머리말이요. 데이터를 가져오는 코드가 아니라 지금 어느 주소에 있는지 읽는 코드가 걸렸어요.

여기가 오늘 가장 곱씹을 만한 곳인데, HeaderNav는 가장 바깥 껍데기에 있으니 모든 화면에 깔려 있습니다. 그런데도 에러는 /[username]에서만 났고, 홈에서는 아무 말이 없었습니다.

왜 그럴까요?

텍스트
 /              주소가 무엇인지 빌드할 때 이미 안다   -> 미리 그릴 수 있다
 /[username]    주소가 무엇이 될지 빌드할 때 모른다   -> 미리 못 그린다

usePathname은 "지금 주소"를 읽습니다. 홈의 주소는 언제나 /니까 빌드할 때 답을 아는 반면, /[username]jaehoon이 올지 minji가 올지 요청이 와야 압니다.

같은 컴포넌트인데 어느 화면에 놓이느냐로 판정이 갈립니다. 지난 시간들에서 "선을 어디에 긋느냐로 값이 달라진다"는 이야기를 계속 했었죠. 이번엔 선을 우리가 그은 게 아니라 주소의 모양이 그었습니다.

아무 말도 안 들은 화면

에러 목록을 다시 보세요. /explore가 없습니다.

탐색 화면은 아직 lib/posts.ts 파일에 적힌 배열을 그대로 씁니다. 파일에서 값을 꺼내는 것은 언제 해도 같은 답이 나와요. 그러니 Next가 물어볼 것이 없습니다. 그냥 미리 그려두면 끝이에요.

지난 시간에 탐색 화면을 일부러 파일 상수로 남겨뒀는데, 오늘 그게 대조군이 됩니다. 이 화면이 왜 조용한지는 Step 5에서 정면으로 다룰게요.

⚠️ 여기서 함정 하나. 빌드를 이미 한 번 돌린 뒤라면 .next 폴더에 지난번 결과가 남아 있어서, Next는 바뀐 게 없는 화면을 그냥 재사용합니다. 실험 결과가 이상하면 폴더를 지우고 다시 해보세요.

Bash
rm -rf .next && npm run build

오늘 나오는 숫자를 그대로 재현하려면 이 습관이 필요합니다.

💡 한 줄 정리

cacheComponents: true 한 줄을 켜면 Next는 우리가 결정을 안 내린 곳에서 빌드를 멈춥니다. 그리고 파일과 줄 번호로 그 곳을 전부 짚어주고, [stream]·[cache]·[block] 세 갈래 중에 고르라고 합니다. 불편한 게 아니라 미룬 결정을 눈앞에 꺼내주는 것이에요.

🙋 학생 질문 — "잘 돌아가던 빌드를 죽이는 설정을 왜 켜나요? 안 켜면 되잖아요"

안 켜도 앱은 돌아갑니다. 지금까지 잘 돌아갔죠.

문제는 잘못 돌아가고 있는데 아무도 안 알려줬다는 겁니다. 우리 홈 화면은 지난 시간에 이미 굳어버렸는데, 빌드도 통과했고 화면도 멀쩡하게 떴고 오히려 2.5밀리초로 제일 빨랐습니다. 배포했으면 새 게시물이 안 올라오는 것을 사용자가 먼저 발견했을 거예요.

스위치를 켜면 그런 것을 배포 전에 잡지만, 그 대신 지금 우리가 겪는 일이 생기죠. 미뤄뒀던 결정을 한꺼번에 청구받는 것인데, 이건 취향이 아니라 시점의 문제예요. 결정을 지금 내릴지, 사용자가 버그로 발견해줄 때 내릴지.

정직하게 말하면 값이 공짜는 아닌데, 이미 크게 만들어둔 앱에 이 스위치를 켜면 고칠 곳이 수십 군데 쏟아질 수 있습니다. Next도 그래서 이걸 기본으로 켜두지 않고 우리가 켜게 해뒀습니다.


Step 3: "피드는 흘려보냅니다"

홈부터 정합시다

세 갈래 중에 홈 피드는 무엇을 골라야 할까요?

굳히면 안 됩니다. 그게 지금 우리를 괴롭히는 바로 그 문제니까요. 새 게시물이 올라오면 보여야 하는 화면이에요.

그럼 [stream]입니다. 화면을 두 부분으로 나누는 거예요.

텍스트
 홈 화면

   피드                   <- 언제나 똑같다. 미리 그려둘 수 있다
   ----------------
   게시물 목록            <- 요청이 와야 안다. 그때 채워 보낸다

머리말과 제목처럼 안 바뀌는 부분은 미리 그려서 저장해두고 목록만 요청이 올 때 채우는데, 그러면 사용자는 껍데기를 곧바로 보고 목록은 잠시 뒤에 채워지는 것을 봅니다.

이 기다림을 표시하는 도구는 이미 배웠습니다. <Suspense>요.

감싸기만 하면 됩니다

tsx
// apps/web-next/app/page.tsx
import { Suspense } from 'react';
import { FeedList } from '@/app/components/FeedList';

// 이 컴포넌트는 서버에서만 돈다. 브라우저로는 결과 글자만 간다.
export default function HomePage() {
  return (
    <main className="mx-auto max-w-xl p-6">
      <h1 className="mb-4 text-xl font-bold">피드</h1>
      {/* 피드는 굳히면 안 된다. 껍데기를 먼저 내보내고 목록은 요청 때 흘려보낸다. */}
      <Suspense fallback={<p className="text-sm text-black/40">피드를 불러오는 중…</p>}>
        <FeedList />
      </Suspense>
    </main>
  );
}

FeedList는 한 글자도 안 고쳤습니다. 감싸기만 했어요.

지난 시간에 <Suspense>를 썼을 때는 느린 조각을 떼어내서 나머지를 먼저 보내려고 썼는데, 오늘은 목적이 하나 더 붙어요. 이 경계가 Next에게 "여기 안쪽은 미리 그리지 마세요"라고 알려주는 표시가 됩니다.

라우트 표가 바뀝니다

빌드하고 표를 봅시다.

텍스트
 ◐ /
 ○ /_not-found
 ◐ /[username]
 ○ /explore

 ○  (Static)             prerendered as static content
 ◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

ƒ가 사라졌습니다. 대신 처음 보는 가 생겼어요.

지금까지 우리 화면은 둘 중 하나였습니다. 통째로 미리 그려두거나(), 통째로 요청 때 그리거나(ƒ). 는 그 사이입니다. 한 화면 안에서 미리 그린 부분과 그때그때 그리는 부분이 섞여 있다는 뜻이에요.

이 방식에 이름이 붙어 있는데, 부분 프리렌더링(Partial Prerendering)이라고 부르고 Cache Components를 켜면 이게 기본 동작이 됩니다.

텍스트
 옛날 방식        이 화면은 정적이야           아니면   이 화면은 동적이야
 지금 방식        이 화면의 여기는 정적이고,   여기는 동적이야

화면 단위로 정하던 것을 조각 단위로 정하게 된 겁니다.

정말 풀렸는지 확인합니다

말은 이렇게 하는데, 정말 풀렸을까요? 재봅시다.

텍스트
                    지난 시간          지금
 홈 1회당 서버 요청    0건               1건
 첫 글자가 닿는 시간   2.5ms             20.8ms
 전체                 2.5ms             463ms

홈도 이제 매번 물어보는데, 그만큼 느려져서 2.5밀리초가 463밀리초가 됐습니다.

느려진 게 아니라 원래 이만큼 걸리는 일이었던 겁니다. 지난 시간에 프로필이 6밀리초에서 1,210밀리초가 됐을 때와 같은 이야기예요. 홈은 그동안 일을 안 하고 있었을 뿐입니다.

대신 첫 글자는 20.8밀리초에 닿습니다. 껍데기가 먼저 나가니까요. 사용자는 흰 화면을 463밀리초 동안 보는 게 아니라, 20밀리초에 제목을 보고 그다음에 목록이 채워지는 것을 봅니다.

결정적인 확인은 이겁니다. 앱을 띄워둔 채로 연습용 서버를 완전히 꺼버리세요.

텍스트
                          지난 시간              지금
 서버를 끄고 홈을 열면      게시물 열 장이 그대로   "피드를 불러오는 중…" 만 남는다

게시물이 사라졌는데, 홈이 이제 진짜로 서버에 물어보고 있다는 뜻입니다. 굳어 있던 것이 풀렸습니다.

프로필은 오늘 못 끝냅니다

홈은 됐는데 프로필이 남았고, 그마저 오늘 깔끔하게 못 끝냅니다.

이유는 아까 본 그 에러예요. 머리말의 usePathname이 걸려 있고, 껍데기가 "이 사람이 있는가"를 서버에 물어보는 것도 걸려 있습니다. 이걸 제대로 풀려면 화면 구조를 다시 짜야 하니, 오늘은 세 번째 갈래를 씁니다. [block]이요.

tsx
// apps/web-next/app/[username]/layout.tsx
// 이 주소는 아직 정적 껍데기를 못 만든다 — 기다렸다 내보내는 것을 허용한다.
// 왜 못 만드는지, 어떻게 넓히는지는 다음 시간에 다룬다.
export const instant = false;

이 한 줄은 Next에게 "이 주소는 미리 그리기 검사를 건너뛰어라"라고 말합니다. 문제를 푼 게 아니라 미룬 거예요. 주석에도 그렇게 적어뒀습니다.

⚠️ 이런 탈출구를 쓸 때는 왜 썼는지를 반드시 코드에 남기세요. 안 그러면 반년 뒤에 이 줄을 발견한 사람이 지워도 되는 건지 몰라서 그대로 둡니다. 탈출구는 부채예요. 부채는 적어둬야 갚습니다.

이제 빌드가 통과합니다. 종료 코드 0.

💡 한 줄 정리

굳히면 안 되는 데이터는 <Suspense>로 감싸서 껍데기를 먼저 내보내고 나중에 채웁니다. 라우트 표의 가 그 결과예요. 화면 단위로 정적이냐 동적이냐를 고르던 것이, 이제 한 화면 안에서 조각별로 갈립니다.

🙋 학생 질문 — "지난 시간에 만든 loading.tsx랑 뭐가 다른가요? 그것도 기다리는 동안 뭘 보여주는 거였잖아요"

같은 뿌리에서 나왔지만 덮는 범위가 다릅니다.

loading.tsx주소 한 칸 전체를 덮습니다. 그 화면으로 들어가는 동안 페이지를 통째로 뼈대로 바꿔요. 파일 이름만으로 동작하니 편한 대신, 무엇을 뼈대로 바꿀지 우리가 고를 수 없습니다.

<Suspense>우리가 감싼 만큼만 덮습니다. 오늘 홈에서 한 것처럼 제목은 그대로 두고 목록만 뼈대로 만들 수 있어요.

오늘 한 가지 뜻이 더 붙었는데, Cache Components를 켠 뒤로 <Suspense>는 "여기 안쪽은 미리 그리지 마세요"라는 표시이기도 합니다. 지난 시간에는 기다림을 예쁘게 보여주는 도구였는데, 오늘부터는 무엇을 굳히고 무엇을 안 굳힐지 가르는 경계선이기도 한 거예요.

같은 도구인데 켜는 스위치 하나로 뜻이 하나 더 생긴 겁니다.


Step 4: "프로필은 굳힙니다"

이건 매번 물어볼 값이 아닙니다

이제 두 번째 갈래를 씁니다. [cache]요.

프로필 화면이 서버에 묻는 것 세 가지를 다시 보죠.

텍스트
 이 사람이 있는가          껍데기가 묻는다
 팔로워 몇 명인가          페이지가 묻는다
 게시물은 무엇이 있는가     페이지가 묻는다
 자주 쓴 해시태그는         페이지가 묻는다

이 중에 초당 천 번 물어봐야 하는 것이 있나요?

게시물 목록은 그렇습니다. 방금 올린 게시물이 안 보이면 이상하니까요. 하지만 "이 사람이 존재하는가"는 어떤가요. 계정이 사라지는 일은 드뭅니다. 팔로워 수도 마찬가지예요. 1,240명이던 것이 1,241명이 되는 데 몇 초 늦는다고 아무도 화내지 않습니다.

이런 값들이 'use cache'의 대상입니다.

함수 안에 한 줄

TypeScript
// apps/web-next/lib/api.ts
/**
 * 프로필 머리의 집계값은 초당 천 번 물어볼 이유가 없다 — 굳혀두고 쓴다.
 * 인자로 받은 username 이 그대로 캐시 칸을 가르는 열쇠가 된다.
 */
export async function fetchProfile(username: string): Promise<Profile> {
  'use cache';
  return get<Profile>(`/users/${encodeURIComponent(username)}`);
}

함수 첫 줄에 'use cache'를 적었습니다. 생김새가 낯익죠? 'use client'와 똑같은 방식이에요. 파일이나 함수 맨 위에 문자열 한 줄을 놓아 이 코드를 어떻게 다룰지 알려줍니다.

한 가지 조건이 있습니다. 'use cache'를 붙이는 함수는 반드시 async여야 해요. 그래서 원래 Promise를 그냥 돌려주던 함수에 async를 붙였습니다.

그런데 요청이 늘었습니다

붙였으니 재봅시다. /jaehoon을 세 번 여는 실험이에요.

텍스트
 회 1   서버 요청 4건     users/jaehoon 이 두 줄
 회 2   서버 요청 3건     users/jaehoon 이 한 줄
 회 3   서버 요청 3건

이상합니다. 캐시를 붙였는데 첫 번째 요청이 3건에서 4건으로 늘었어요.

Step 1에서 확인한 것을 떠올려보세요. 껍데기와 페이지가 같은 주소를 부르면 서버로는 한 번만 나갔는데, 지금은 두 번 나갔어요.

캐시가 그 합치기를 깨뜨린 겁니다.

텍스트
 붙이기 전     findProfile ─┐
                            ├─ 같은 처리 안에 있으니 합쳐진다      1건
               fetchProfile ─┘

 붙인 뒤       findProfile ───── 평범한 요청 처리 안              1건
               fetchProfile ──── 캐시 전용 칸막이 안              1건
                                 서로를 못 본다                   합계 2건

'use cache'를 붙인 함수는 격리된 곳에서 돕니다. 캐시에 담길 값이 바깥 상황에 따라 달라지면 안 되니까요. 그래서 바깥의 합치기 장부를 못 봅니다.

두 번째 요청부터 3건으로 줄어든 것은 캐시가 맞았기 때문이에요. 첫 번째만 실제로 물어보고, 그 뒤로는 fetchProfile이 저장해둔 답을 씁니다.

그럼 껍데기도 같이 굳힙니다

정리하면 이렇습니다. 캐시를 붙여서 하나를 아꼈는데, 합치기가 깨져서 하나를 잃었어요. 본전입니다.

답은 간단해요. 껍데기가 묻는 것도 굳히면 됩니다. "이 사람이 있는가" 야말로 매번 물어볼 값이 아니니까요.

TypeScript
// apps/web-next/lib/api.ts
/**
 * 없는 사람이면 예외 대신 null 을 준다 — 없는 것과 못 가져온 것은 다르게 다뤄야 한다.
 * 이 사람이 있는지 없는지도 매 요청 물어볼 값은 아니라 같이 굳힌다.
 */
export async function findProfile(username: string): Promise<Profile | null> {
  'use cache';
  const response = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}`);
  // ... 아래는 그대로
}

다시 재봅니다.

텍스트
 회 1   서버 요청 4건
 회 2   서버 요청 2건      게시물과 해시태그만 남았다
 회 3   서버 요청 2건
 회 4   서버 요청 2건

2건. 프로필을 묻던 두 요청이 통째로 사라졌습니다. 사람이 백 번 열어도 프로필 조회는 처음 한 번뿐이에요.

캐시는 어떻게 칸을 나눌까

여기서 질문 하나. jaehoon의 팔로워 수를 굳혀뒀는데, minji를 열면 어떻게 될까요? 설마 재훈이의 팔로워 수가 나오진 않겠죠.

확인해봅시다.

텍스트
 /minji   서버 요청 4건

4건입니다. 처음 여는 화면이니 당연히 캐시가 없는데, 이건 곧 jaehoon의 답과 minji의 답이 서로 다른 칸에 담긴다는 뜻입니다.

무엇이 칸을 갈랐을까요? 함수에 넘긴 인자입니다.

텍스트
 fetchProfile("jaehoon")  ->  칸 A 에 저장
 fetchProfile("minji")    ->  칸 B 에 저장
 fetchProfile("jaehoon")  ->  칸 A 에서 꺼냄

인자가 캐시의 열쇠가 되는 것이라서, 주석에도 "인자로 받은 username이 그대로 캐시 칸을 가르는 열쇠가 된다"고 적어뒀어요.

⚠️ 여기서 실무의 함정이 하나 나옵니다. 인자가 열쇠라면, 인자에 안 들어간 값은 열쇠에 안 들어갑니다. 만약 어떤 함수가 인자로 받지 않은 값을 몰래 참조해서 답이 달라진다면, 그 차이는 캐시가 구분하지 못해요. 한 사람의 답이 다른 사람에게 나갈 수 있다는 뜻입니다.

그래서 규칙은 이렇게 외우세요. 굳힐 함수는 필요한 것을 전부 인자로 받는다.

화면은 안 빨라졌습니다

마지막으로 시간을 봅시다. 요청을 4건에서 2건으로 줄였으니 화면도 빨라졌겠죠?

텍스트
 회 1   전체 1,226ms
 회 2   전체 1,211ms
 회 3   전체 1,210ms

거의 안 변했습니다.

당황스럽지만 생각해보면 당연해요. 프로필 화면은 게시물과 해시태그를 동시에 기다리는데, 해시태그 집계가 1,200밀리초짜리입니다. 프로필 조회는 400밀리초짜리였고, 어차피 나란히 기다리고 있었어요. 제일 늦게 오는 것이 안 바뀌었으니 전체 시간도 안 바뀝니다.

텍스트
 굳히기 전    프로필조회 ▓▓▓▓
              게시물     ▓▓▓▓
              해시태그   ▓▓▓▓▓▓▓▓▓▓▓▓   <- 이게 끝나야 화면이 완성
              합계 1,210ms

 굳힌 뒤      프로필조회 (캐시에서 즉시)
              게시물     ▓▓▓▓
              해시태그   ▓▓▓▓▓▓▓▓▓▓▓▓   <- 여전히 이게 끝나야 한다
              합계 1,210ms

그럼 헛수고였나요? 아닙니다. 줄어든 것은 시간이 아니라 서버가 받는 일의 양이에요.

사용자가 만 명이면 서버는 프로필 조회를 이만 번 덜 받습니다. 화면이 빨라지는 것과 서버가 덜 죽는 것은 다른 이야기고, 둘 다 필요한 이야기예요.

지난 시간에도 비슷한 경험을 했죠. 껍데기에서 게시물 수를 빼도 첫 글자 시간이 안 줄었던 것 기억하시나요. 재보기 전에는 무엇이 병목인지 모릅니다. 그리고 병목이 아닌 곳을 고치면 숫자는 안 움직여요.

💡 한 줄 정리

'use cache'는 함수 첫 줄에 놓는 한 줄이고, 그 함수에 넘긴 인자가 캐시의 열쇠가 됩니다. 캐시된 함수는 격리된 곳에서 돌아 바깥의 요청 합치기를 못 보니, 한쪽만 굳히면 오히려 요청이 늘 수 있어요. 그리고 요청 수가 준다고 화면이 빨라지는 건 아닙니다.

🙋 학생 질문 — "굳혀둔 팔로워 수는 그럼 영원히 안 바뀌나요? 새로 팔로우해도요?"

좋은 질문이고, 오늘 답을 다 못 드리는 질문이에요.

우선 영원하지는 않습니다. 캐시에 담긴 값에는 수명이 있는데, 우리가 아무 말도 안 했으니 지금은 기본 수명이 적용되고 있습니다. 시간이 지나면 알아서 다시 물어봐요.

수명만으로는 부족한데, 누군가 팔로우 버튼을 눌렀으면 그 순간 숫자가 바뀌어야 하지만 시간이 지나기를 기다릴 수는 없으니까요.

방법은 두 가지인데, 수명을 우리가 정하는 방법과 특정 값을 지목해서 "이건 이제 낡았다"고 알리는 방법이요. 둘 다 다음 시간에 다룹니다.

지금 기억할 것은 이거예요. 굳히기로 결정하는 것과, 언제 갈아 끼울지 정하는 것은 별개의 결정입니다. 오늘은 앞의 것만 했어요. 뒤의 것을 안 정하면 화면에 낡은 숫자가 남습니다.


Step 5: "굳는 것과 안 굳는 것"

탐색 화면은 왜 조용했을까

Step 2에서 넘어간 질문을 이제 봅시다. 스위치를 켰을 때 홈과 프로필은 에러를 냈는데 /explore는 아무 말이 없었어요. 라우트 표에서도 여전히 입니다.

탐색 화면 코드를 보면 답이 나옵니다.

tsx
// apps/web-next/app/explore/page.tsx
import { allPosts } from '@/lib/posts';

export default function ExplorePage() {
  return (
    <main className="mx-auto max-w-3xl p-6">
      <h1 className="mb-4 text-xl font-bold">탐색</h1>
      <ul className="grid grid-cols-3 gap-2">
        {allPosts.map((post) => (
          // ...
        ))}
      </ul>
    </main>
  );
}

allPosts는 파일에 적혀 있는 배열입니다. 이 코드는 지금 실행하나 한 시간 뒤에 실행하나 똑같은 답을 냅니다.

Next는 이런 것을 예측 가능한 값으로 봅니다. 답이 언제나 같으니 미리 그려서 저장해두면 그만이에요. 물어볼 것이 없으니 우리에게도 안 물어봅니다.

파일에서 읽어오는 것, 계산 결과, 모듈에서 가져온 상수. 이런 것들이 전부 여기 들어갑니다. 'use cache'를 붙일 필요조차 없어요. 붙이지 않아도 이미 굳습니다.

반대쪽 끝을 만나봅시다

그럼 반대는 뭘까요. 절대로 미리 그릴 수 없는 것이요.

탐색 화면을 조금 고쳐서 직접 만나봅시다. 매번 다른 순서로 섞어서 보여주는 거예요.

tsx
export default function ExplorePage() {
  const shuffled = [...allPosts].sort(() => Math.random() - 0.5);
  // ... shuffled 를 그린다
}

빌드해보세요.

텍스트
 Error: Route "/explore": Next.js encountered the unstable value
        `Math.random()` while prerendering.

종료 코드 1. 막혔습니다.

당연하죠. Math.random()은 부를 때마다 다른 값이 나옵니다. 빌드할 때 한 번 뽑아서 저장해두면 모든 사용자가 똑같이 섞인 순서를 보게 돼요. 그건 안 섞은 것과 같습니다.

Date.now()도 같은 취급을 받습니다. 빌드 시각을 저장해두면 사용자는 몇 시간 전 시각을 보게 되니까요.

여기서도 갈래를 줍니다

에러를 더 읽어보세요.

텍스트
 Ways to fix this:
   - [dynamic] Render at request time by adding a dynamic data access
               (e.g. `await connection()`) before this call
   - [cache]   Prerender and cache the value with "use cache"
   - [client]  Render the value on the client with "use client"

Step 2와 다른 세 갈래입니다. 그때는 [stream]·[cache]·[block] 이었는데 이번엔 [dynamic]·[cache]·[client]예요.

Next는 막을 때마다 그 상황에 맞는 선택지를 줍니다. 데이터를 못 굳히는 문제와 값이 매번 달라지는 문제는 성격이 다르니 해법도 다른 거죠.

여기서는 이렇게 읽으면 됩니다.

텍스트
 [dynamic]  매번 달라야 한다면, 요청이 올 때까지 기다렸다 뽑아라
 [cache]    모두가 같은 값을 봐도 된다면, 한 번 뽑아 굳혀라
 [client]   서버가 관여할 일이 아니라면, 브라우저에서 뽑아라

우리는 사람마다 다른 순서를 보여주고 싶으니 첫 번째입니다.

요청이 올 때까지 기다리게 하기

tsx
import { Suspense } from 'react';
import { connection } from 'next/server';

async function ShuffledGrid() {
  // 요청이 실제로 들어올 때까지 기다린다 — 그래야 매번 다른 순서를 만들 수 있다.
  await connection();
  const shuffled = [...allPosts].sort(() => Math.random() - 0.5);
  // ... shuffled 를 그린다
}

connection()"진짜 요청이 들어올 때까지 여기서 멈춰라"라고 말하는 함수입니다. 빌드하는 동안에는 요청이 없으니 이 아래 코드는 실행되지 않아요. 그러니 Math.random()이 빌드 때 뽑힐 일이 없습니다.

이렇게 하면 이 조각은 요청 때 그려지니, <Suspense>로 감싸서 껍데기를 먼저 내보내야 합니다. Step 3에서 한 것과 같아요.

빌드하면 통과하고, 라우트 표에서 탐색 화면이 옮겨갑니다.

텍스트
 고치기 전    ○ /explore      통째로 미리 그려둔다
 고친 뒤      ◐ /explore      제목은 미리, 격자는 요청 때

라우트 표가 우리가 내린 결정을 기록하고 있는 겁니다. 화면 하나를 고칠 때마다 이 표에서 기호가 움직여요.

확인이 끝났으면 이 실험은 되돌립니다. 탐색 화면은 굳어 있어도 되는 화면이고, 오늘은 대조군으로 남겨두는 편이 더 쓸모 있으니까요.

오늘의 결정표

다섯 단계를 지나면서 우리가 내린 결정을 한 곳에 모아봅시다.

화면 무엇을 골랐나
/ 피드 흘려보낸다 새 게시물이 바로 보여야 한다
/[username] 프로필 조회 굳힌다 몇 초 늦어도 아무도 안 다친다
/[username] 게시물·해시태그 흘려보낸다 방금 올린 것이 보여야 한다
/explore 아무것도 안 한다 파일 상수라 저절로 굳는다
/[username] 껍데기 미룬다 구조를 다시 짜야 해서 오늘은 못 한다

이 표에 정답 열이 없다는 것을 봐주세요. "몇 초 늦어도 되는가"는 기술이 답할 수 있는 질문이 아닙니다. 우리 서비스에서 그 숫자가 얼마나 중요한지를 아는 사람이 답할 질문이에요.

Cache Components가 한 일은 그 질문을 빌드가 멈추는 형태로 꺼내준 것뿐입니다. 답은 우리가 했어요.

💡 한 줄 정리

파일 상수처럼 언제 실행해도 답이 같은 값은 아무 말 안 해도 굳습니다. 반대로 Math.random()처럼 부를 때마다 달라지는 값은 무슨 수를 써도 미리 못 그려서, connection()으로 요청 때까지 미루거나 굳히거나 브라우저로 보내야 해요.

🙋 학생 질문 — "Math.random()이 막히는 건 알겠는데, 그럼 그런 코드가 있는지 제가 다 찾아다녀야 하나요?"

아니요, 그래서 이 스위치를 켜는 겁니다.

외워야 할 목록을 드리면 오히려 위험해요. 목록에서 빠진 것을 만나면 그대로 통과시켜버리니까요. 대신 빌드가 대신 찾아줍니다. Math.random()도, Date.now()도, 무작위 값을 만드는 다른 함수들도 전부 자기 이름이 붙은 에러로 나와요.

개발 중이라면 더 편합니다. 브라우저 화면 위에 바로 뜨거든요.

그러니 학습 순서를 이렇게 잡으시면 됩니다. 목록을 외우지 말고, 막혔을 때 에러를 읽는 법을 익히세요. 오늘 우리가 두 번 만난 그 Ways to fix this 목록이 매번 다르게 나온다는 것만 기억하시면 됩니다. 상황마다 맞는 선택지를 주니까요.


마무리

잃어버린 것을 찾으러 갑시다

수업을 시작할 때 약속했죠. 오늘 우리는 지난 시간에 비싸게 산 것 하나를 도로 잃는다고요.

지난 시간 일곱 번째 단계를 떠올려보세요. 우리는 프로필 껍데기가 서버에 "이 사람이 있는가"를 물어보게 만들었습니다. 그 대가로 첫 글자가 닿는 시간이 7밀리초에서 408밀리초로 늘었고, 대신 없는 사람의 주소가 404를 돌려주게 됐어요. 401밀리초를 내고 404를 산 거래였습니다.

오늘 그 거래가 취소됐습니다. 없는 사람 주소를 열어보세요.

텍스트
                        지난 시간 끝     오늘 끝
 /jaehoon 첫 글자        408ms           5.4ms
 /zzzzz 상태 코드        404             200

401밀리초가 돌아왔고, 404가 사라졌습니다. 정확히 반대 방향으로 거래가 뒤집힌 거예요.

화면은 멀쩡합니다. "없는 주소예요"도 그대로 뜨고, 검색 엔진에게 색인하지 말라는 표시도 그대로 붙어 있어요. 딱 하나, 응답의 맨 앞에 찍히는 숫자만 200입니다.

이유는 지난 시간에 이미 배운 그 법칙이에요. 상태 코드는 응답의 맨 앞에 나갑니다. 우리가 껍데기를 먼저 내보내기로 한 순간, 그 앞부분은 이미 사용자에게 떠났어요. 뒤늦게 "사실 없는 사람이었어요"라고 해도 앞으로 돌아가 숫자를 고칠 수가 없습니다.

텍스트
 껍데기 먼저 보내기 전     서버가 다 알아본 뒤에 응답을 시작 -> 404 를 쓸 수 있다
 껍데기 먼저 보낸 뒤       응답은 이미 나갔다              -> 숫자를 못 고친다

⚠️ 그리고 이게 오늘 수업에서 제일 중요한 대목입니다. 우리는 이걸 고른 적이 없어요. 스위치 하나를 켰을 뿐인데 지난 시간에 공들여 산 것이 조용히 되팔렸습니다. 아무 에러도 안 났고, 화면도 멀쩡했고, 오히려 400밀리초 빨라졌으니 성능 지표만 보면 축하할 일처럼 보입니다.

지난 시간 과제에서 비슷한 것을 이미 겪었죠. 파일 하나를 가장 바깥에 뒀더니 404가 200으로 바뀌던 것이요. 같은 법칙을 두 번째로 만나는 겁니다. 도구를 바꿀 때는 얻은 것만 보지 말고, 조용히 사라진 것이 있는지 봐야 해요.

오늘 배운 핵심 세 가지

💡 하나 — Next는 무엇을 굳힐지 안 정해줍니다. 안 정하면 빌드가 멈춰요. cacheComponents 스위치는 기능을 켜는 게 아니라, 우리가 미뤄뒀던 결정을 파일과 줄 번호로 청구합니다. 켜기 전에도 결정은 이미 내려져 있었어요. 다만 Next가 대신 내렸고, 홈 화면의 경우엔 우리가 원하지 않는 쪽으로 내려져 있었습니다.

💡 둘 — 고르는 일은 기술이 아니라 서비스가 답할 질문입니다. <Suspense>로 흘려보낼지 'use cache'로 굳힐지는 문법 문제가 아니에요. "이 값이 몇 초 낡아도 괜찮은가"를 아는 사람이 답할 문제입니다. 새 게시물은 안 되고 팔로워 수는 됩니다. 그 판단은 코드에 안 적혀 있어요.

💡 셋 — 껍데기를 먼저 내보내기로 하면 응답의 앞부분은 되돌릴 수 없습니다. 첫 글자를 400밀리초 앞당기는 것과 정확한 상태 코드를 주는 것은 같이 가지기 어렵습니다. 오늘 우리는 앞의 것을 얻고 뒤의 것을 잃었어요. 고칠 문제가 아니라 고를 문제입니다.

다음 시간 예고

오늘 우리는 굳히기로 결정만 했는데, 굳혀둔 것에 대해 아무것도 안 정했어요.

팔로워 수를 캐시에 넣어뒀는데, 그게 언제까지 유효할까요? 누군가 팔로우 버튼을 누르면 그 숫자는 어떻게 갱신될까요? 오늘 과제를 풀다 보면 빌드 로그에 처음 보는 열이 두 개 나타날 겁니다. 무언가의 수명이 이미 정해져 있다는 뜻인데, 우리는 그걸 정한 적이 없죠. 기본값이 우리 대신 정해준 겁니다. 다음 시간에 그 값을 우리 손으로 가져옵니다.

오늘 미뤄둔 부채도 하나 있습니다. 프로필 껍데기에 적어둔 그 한 줄인데, 왜 그 화면만 미리 못 그리는지, 머리말의 주소 읽기가 왜 홈에서는 괜찮고 프로필에서는 안 되는지, 그 한 줄을 어떻게 갚는지가 다음 시간의 본론입니다. 오늘 잃은 404는 그 과정에서 다시 만나게 될 텐데, 화면 안에서는 되찾을 수 없다는 것까지가 다음 시간의 결론이고, 그럼 어디서 되찾느냐가 남습니다.

마지막으로 오늘 한 번도 안 건드린 것이 있어요. 우리 앱은 아직 누가 보고 있는지 전혀 모릅니다. 사용자마다 다른 화면을 보여주려면 요청이 와야만 알 수 있는 것을 읽어야 하는데, 그런 값은 오늘 배운 캐시와 사이가 아주 나쁩니다. 그 둘을 어떻게 화해시키는지도 다음 시간에 다룰게요.


과제

[구현] 탐색 화면을 서버에서 가져오고, 세 갈래 중 하나를 고르기

탐색 화면(/explore)은 아직 lib/posts.ts 파일의 배열을 그대로 씁니다. 홈에서 한 것처럼 서버에서 가져오도록 바꿔보세요.

  • 바꾸면 빌드가 막힙니다. 에러에 나오는 세 갈래를 다시 읽고 하나를 고르세요.
  • 고른 이유를 두세 문장으로 적어두세요. "탐색 화면에 뜨는 게시물이 몇 초 낡아도 괜찮은가"가 판단의 기준입니다.
  • 고른 것을 적용한 뒤 빌드 로그의 라우트 표에서 /explore의 기호가 어떻게 바뀌었는지 확인하세요.
  • 세 갈래 중 다른 것을 골랐다면 표가 어떻게 달라졌을지도 예상해서 적어보세요. 궁금하면 실제로 바꿔서 확인해도 좋습니다.

[탐구] 'use cache'를 컴포넌트에 붙이면

오늘 우리는 'use cache'lib/api.ts의 함수에 붙였는데, 이 지시어는 컴포넌트에도 붙일 수 있어요.

  • app/components/FeedList.tsxFeedList 함수 첫 줄에 'use cache'를 넣고 빌드해보세요.
  • 빌드는 통과하지만, 라우트 표를 보세요. /의 기호가 어떻게 됐나요? 예상과 같은가요?
  • ⚠️ 표에는 열이 두 개 늘어나 있습니다. 무엇을 알려주는 열인지, 그 값이 어디서 왔는지 생각해보세요. 우리는 그 숫자를 정한 적이 없습니다.
  • 이 상태로 앱을 띄우고 연습용 서버를 꺼보세요. Step 3에서 확인했던 것과 비교하면 무슨 일이 벌어진 건가요?
  • 확인이 끝나면 되돌리고, 왜 이건 답이 아닌지를 한 문단으로 적어주세요.

[탐구] 굳힌 함수 안에서 Math.random()을 부르면

Step 5에서 Math.random()은 미리 그릴 수 없다고 배웠는데, 에러가 준 갈래 중에 [cache]도 있었죠.

  • lib/api.tsfetchProfile 안, 'use cache' 아래에 Math.random()을 부르는 줄을 넣고 빌드해보세요.
  • Step 5에서는 막혔는데 여기서는 어떤가요? 결과를 먼저 예상하고 확인하세요.
  • 결과가 그렇게 나오는 이유를 설명해보세요. "미리 그릴 수 없다"와 "굳혀도 된다"가 왜 충돌하지 않는지가 핵심입니다.
  • 확인이 끝나면 되돌려주세요.

[탐구] 연습용 서버 없이 빌드하면

지난 시간에 우리는 API 서버를 안 띄우고 빌드하면 ECONNREFUSED로 빌드가 죽는 것을 봤습니다.

  • 지금 다시 해보세요. 서버를 끄고 빌드합니다.
  • ⚠️ 반드시 rm -rf .next를 먼저 하세요. 안 그러면 Next가 지난번 결과를 재사용해서 실험이 성립하지 않습니다. 이게 오늘 과제에서 제일 많이 틀리는 곳이에요.
  • 결과가 지난 시간과 다릅니다. 무엇이 달라졌길래 그런지 설명해보세요.
  • 한 걸음 더 나아가, 이 변화는 배포 과정에 어떤 의미가 있을까요? 빌드하는 기계가 API 서버에 닿을 수 있어야 하는가 아닌가로 생각해보세요.

생각해볼 주제

1. 그 값은 몇 초 낡아도 되나

오늘 우리는 팔로워 수를 굳히고 게시물 목록은 굳히지 않았습니다. 코드로는 'use cache' 한 줄 차이지만, 그 한 줄을 결정한 것은 코드가 아니었어요.

여러분이 실제 팀에서 이 결정을 내려야 한다면 누구에게 무엇을 물어보시겠습니까? 그리고 "잘 모르겠으면 일단 굳히지 말자"는 안전해 보이는 기본값인데, 그게 항상 안전할까요? 굳히지 않아서 생기는 비용은 화면에 안 보이니 생각해볼 만합니다.

2. 빌드를 멈추는 도구

오늘 켠 스위치는 잘 돌아가던 빌드를 멈춰 세웠습니다. 우리는 화면 세 개짜리 앱이라 금방 고쳤지만, 화면이 백 개인 앱이라면 어땠을까요.

이런 도구가 좋은 도구일까요? 만약 여러분이 이미 서비스 중인 큰 앱의 담당자인데 이 스위치를 켜라는 제안을 받았다면, 무엇을 근거로 찬성하거나 반대하시겠습니까? 반대한다면 그 결정을 언제까지 미룰 수 있을지도 함께 생각해보세요.

3. 조용히 사라진 것

오늘 404가 200으로 바뀌었습니다. 에러도 안 났고 경고도 없었고, 오히려 화면은 400밀리초 빨라졌어요. 성능 지표만 보는 사람에게는 개선으로 보입니다.

이런 종류의 회귀는 어떻게 발견할 수 있을까요? 사람이 더 꼼꼼히 보면 되는 일인지, 아니면 다른 장치가 필요한 일인지 생각해보세요. 만약 장치가 필요하다면, 그 장치는 무엇을 지켜보고 있어야 할까요.

✅ 예시 답안정답 보기

먼저 직접 풀어보신 뒤에 펼쳐보세요. 오늘 과제에는 정답이 하나가 아닌 문제가 섞여 있습니다. 무엇을 골랐느냐보다 왜 골랐는지 적었느냐가 점수입니다.


🎯 [과제 1 예시답안] 탐색 화면을 서버에서 가져오고, 세 갈래 중 하나를 고르기 (25점)

채점 포인트

항목 배점 기준
서버에서 가져오도록 고침 5 fetchPosts를 재사용했다 (새 함수를 만들지 않았다)
빌드가 막히는 것을 확인 5 종료 코드 1 과 세 갈래 목록을 로그에서 읽었다
하나를 고르고 적용 5 셋 중 무엇이든 적용해 빌드를 통과시켰다
고른 이유를 적었다 5 "몇 초 낡아도 되는가" 를 기준으로 판단한 흔적이 있다
다른 선택의 결과를 예상 5 라우트 표가 어떻게 달라질지 적었다

먼저 막히는 것을 봅니다

allPostsawait fetchPosts()로 바꾸면 이렇게 막힙니다.

텍스트
 Error: Route "/explore": Next.js encountered uncached or runtime data during prerendering.

 Ways to fix this:
   - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
   - [cache]  For uncached data (`fetch`, database calls): cache the access with "use cache"
   - [block]  Set `export const instant = false` to allow a blocking route

Step 2에서 홈이 막혔을 때와 똑같은 세 갈래입니다. 이제 우리가 고를 차례예요.

풀이 예시 A — 흘려보내기

tsx
// apps/web-next/app/explore/page.tsx
import { Suspense } from 'react';
import { fetchPosts } from '@/lib/api';

async function ExploreGrid() {
  const posts = await fetchPosts();
  return (
    <ul className="grid grid-cols-3 gap-2">
      {posts.map((post) => (
        <li key={post.id} className="aspect-square rounded bg-black/5 p-3 text-sm">
          <p className="font-semibold">@{post.username}</p>
          <p className="mt-1 text-black/60">좋아요 {post.likeCount}</p>
        </li>
      ))}
    </ul>
  );
}

export default function ExplorePage() {
  return (
    <main className="mx-auto max-w-3xl p-6">
      <h1 className="mb-4 text-xl font-bold">탐색</h1>
      <Suspense fallback={<p className="text-sm text-black/40">불러오는 중…</p>}>
        <ExploreGrid />
      </Suspense>
    </main>
  );
}

데이터를 가져오는 부분을 안쪽 컴포넌트로 빼고 <Suspense>로 감쌌습니다. 홈에서 한 것과 같은 모습이에요.

텍스트
 ◐ /explore

풀이 예시 B — 굳히기

같은 구조에서 안쪽 함수 첫 줄에 'use cache'만 더하면 됩니다.

tsx
async function ExploreGrid() {
  'use cache';
  const posts = await fetchPosts();
  // ... 아래는 같다
}

이쪽은 표가 다르게 나옵니다.

텍스트
 Route (app)        Revalidate  Expire
 └ ○ /explore              15m      1y

로 돌아왔고, 처음 보는 열이 두 개 붙었습니다. 이건 과제 2에서 다시 만나니 거기서 이야기할게요.

어느 쪽이 정답인가

둘 다 정답입니다. 그리고 이게 이 과제의 핵심이에요.

흘려보내기 굳히기
새 게시물이 뜨는 시점 즉시 수명이 다한 뒤
서버가 받는 요청 방문마다 가끔
표에 남는 기호

탐색 화면이 무엇을 하는 화면인지에 따라 갈립니다. 지금 이 순간 올라온 게시물을 발견하는 곳이라면 흘려보내야 하고, 인기 게시물을 둘러보는 곳이라면 몇 분 낡아도 아무도 안 다칩니다. 오히려 굳히는 쪽이 서버를 덜 괴롭히죠.

교안에서는 흘려보내기를 예시로 들었지만, 굳히기를 고르고 그 이유를 적었다면 만점입니다. [block]을 골랐다면 감점인데, 그건 문제를 푼 게 아니라 미룬 것이기 때문이에요. 프로필에서 그걸 쓴 이유는 오늘 안에 못 푸는 사정이 있어서였습니다. 탐색 화면에는 그런 사정이 없어요.

자주 나오는 실수

export default 함수에 그대로 'use cache'를 붙이기. 되긴 됩니다. 그런데 그러면 제목까지 통째로 굳어서 화면을 조각으로 나눌 여지가 사라져요. 굳힐 것과 안 굳힐 것을 나중에 갈라야 할 때 다시 뜯어야 합니다.

<Suspense>로 감쌌는데 안쪽 컴포넌트를 안 만들기. <Suspense> 안에 {posts.map(...)}만 넣고 await는 바깥에 두면 아무 소용이 없습니다. 경계는 기다리는 코드를 안쪽에 두어야 뜻이 생깁니다.

💡 튜터의 한마디 — 이 과제에서 제일 중요한 칸은 채점표의 네 번째 줄입니다. 코드는 셋 다 몇 줄이면 되지만, "왜 이걸 골랐나"를 못 적으면 다음에 비슷한 화면을 만날 때 또 헤매게 돼요. 고른 이유를 코드 주석으로 남겨두는 습관을 들이시면 좋습니다.


🎯 [과제 2 예시답안] 'use cache'를 컴포넌트에 붙이면 (25점)

채점 포인트

항목 배점 기준
빌드가 통과하는 것을 확인 5 에러가 안 난다는 것을 종료 코드로 확인했다
라우트 표의 기호 변화 5 로 되돌아간 것을 찾았다
늘어난 두 열을 발견 5 Revalidate / Expire를 짚고 우리가 안 정했음을 알아챘다
서버를 끄고 확인 5 게시물이 그대로 뜨는 것을 확인했다
왜 답이 아닌지 설명 5 Step 3에서 고친 문제가 되돌아왔다고 연결했다

붙여보면 빌드는 통과합니다

tsx
// apps/web-next/app/components/FeedList.tsx
export async function FeedList() {
  'use cache';
  const posts = await fetchPosts();
  // ... 아래는 그대로
}

종료 코드 0. 에러도 경고도 없습니다. 여기까지만 보면 성공한 것 같아요.

그런데 표를 보세요

텍스트
 Route (app)        Revalidate  Expire
 ┌ ○ /                     15m      1y
 ├ ○ /_not-found
 │ └ ◐ /[username]
 └ ○ /explore

/에서 로 돌아갔습니다.

Step 3에서 우리가 <Suspense>로 만들어낸 그 가 사라졌어요. 홈이 다시 통째로 미리 그려지는 화면이 됐습니다.

결정적 확인

앱을 띄우고 연습용 서버를 꺼보세요.

텍스트
 서버 켠 상태   /   200   16ms
 서버 끈 상태   /   200    2.7ms   게시물이 그대로 뜬다

수업 첫머리에서 봤던 그 증상입니다. Step 3에서 고친 문제가 그대로 되돌아왔어요.

이유는 우리가 무엇을 굳혔는지 생각해보면 나옵니다. FeedList는 게시물 목록 전체를 그리는 컴포넌트예요. 그걸 굳혔다는 것은 "게시물 목록이라는 화면 조각을 통째로 저장해두라"는 뜻입니다. 저장해뒀으니 서버에 물어볼 일이 없죠.

텍스트
 lib/api.ts 의 함수에 붙이면      "이 데이터를 굳혀라"
 FeedList 컴포넌트에 붙이면       "이 화면 조각을 통째로 굳혀라"

같은 지시어인데 어디에 붙이느냐로 굳는 범위가 달라집니다. 프로필에서는 데이터 하나만 굳히고 싶었으니 함수에 붙인 것이 맞았고, 홈 피드는 애초에 굳히면 안 되는 것이라 어디에 붙여도 틀립니다.

늘어난 두 열

Revalidate 15mExpire 1y. 우리는 이 숫자를 적은 적이 없습니다.

굳혀둔 것에는 수명이 있고, 우리가 말을 안 하면 기본값이 대신 정해집니다. 15분이 지나면 다시 물어보고, 1년이 지나면 아예 버린다는 뜻이에요.

여기서 중요한 것은 숫자 자체가 아니라 우리가 모르는 사이에 정해졌다는 사실입니다. 팔로워 수를 굳힐 때도 이 기본값이 조용히 적용됐어요. 다음 시간에 이 숫자를 우리 손으로 가져옵니다.

자주 나오는 실수

표만 보고 넘어가기. 로 바뀐 것만 확인하고 서버를 안 꺼보면 "기호가 좀 바뀌었네"로 끝납니다. 실제로 무엇이 깨졌는지는 서버를 꺼봐야 보여요. 기호가 아니라 동작을 확인하는 습관이 이 과제의 진짜 목표입니다.

되돌리는 것을 잊기. 확인이 끝나면 반드시 'use cache' 줄을 지우세요. 안 지우면 다음 시간 실습이 처음부터 어긋납니다.

💡 튜터의 한마디 — 이 과제는 "통과했는데 틀린 것"을 만나보라고 낸 문제입니다. 빌드도 통과하고 화면도 멀쩡하고 심지어 더 빨라요. 도구가 아무 말도 안 해주는 종류의 잘못이죠. 우리가 방금 고친 문제를 다시 만들어놓고도 몰랐다면, 실무에서는 배포한 다음에 알게 됩니다.


🎯 [과제 3 예시답안] 굳힌 함수 안에서 Math.random()을 부르면 (25점)

채점 포인트

항목 배점 기준
예상을 먼저 적었는가 5 확인하기 전에 예상을 기록했다
결과 확인 5 빌드가 통과한다는 것을 종료 코드로 확인했다
왜 안 막히는지 설명 10 "모두가 같은 값을 본다" 를 근거로 들었다
두 상황의 차이 정리 5 Step 5와 여기가 무엇이 다른지 갈랐다

결과부터

빌드가 통과합니다. 종료 코드 0, 에러 없음.

Step 5에서는 똑같은 Math.random()이 빌드를 멈춰 세웠는데 말이죠. 예상이 빗나갔다면 정상입니다. 저도 처음엔 막힐 거라고 생각했어요.

눈으로 확인해봅시다

굳힌 함수가 뽑은 값을 화면에 그려놓고 세 번 열어보면 이렇게 나옵니다.

텍스트
 요청 1   0.38399973675776833
 요청 2   0.38399973675776833
 요청 3   0.38399973675776833

한 번 뽑혀서 계속 재사용됩니다.

그래서 왜 안 막히나

Step 5에서 Next가 막았던 이유를 다시 떠올려보세요. 문제는 Math.random()이라는 함수 자체가 아니었습니다. 빌드할 때 한 번 뽑은 값이 모두에게 같게 나가면 섞은 의미가 없다는 것이 문제였어요.

그런데 'use cache'를 붙였다는 건 우리가 이렇게 말한 겁니다.

"이 결과를 저장해두고 모두에게 같은 것을 주세요."

모두가 같은 값을 보는 게 원래 의도라고 선언했으니 Next가 막을 이유가 없어졌어요. Step 5의 에러 목록에 [cache]가 갈래로 들어 있던 것이 바로 이 이야기입니다.

텍스트
 그냥 Math.random()          모두 같은 값을 본다   -> 의도가 아닐 것 같다   -> 막는다
 'use cache' 안 Math.random()  모두 같은 값을 본다   -> 그게 의도라고 했다   -> 통과

같은 결과인데 한쪽은 사고고 한쪽은 의도입니다. Next는 그 차이를 스스로 알 수 없으니, 우리가 말해준 것이 있느냐로 판단해요.

그럼 언제 이게 쓸모 있나

전부에게 같아도 되지만 매번 계산하긴 아까운 값이 있습니다. 오늘의 추천 게시물을 무작위로 뽑아 하루 종일 같은 것을 보여주는 경우가 그래요. 사람마다 다를 필요는 없고, 매 요청 새로 뽑을 이유도 없죠.

반대로 사람마다 달라야 한다면 이건 답이 아닙니다. 그럴 때 Step 5에서 쓴 connection()을 씁니다.

자주 나오는 실수

"캐시를 붙이면 아무거나 다 통과하는구나"로 정리하기. 아닙니다. 캐시 안에서도 못 하는 일이 있어요. 특히 요청이 와야만 알 수 있는 값은 캐시 안에서 읽으면 에러가 납니다. 그 이야기는 다음 시간에 정면으로 다룹니다.

되돌리는 것을 잊기. lib/api.ts는 다음 시간에도 계속 씁니다. 실험한 줄은 지워주세요.

💡 튜터의 한마디 — 이 과제의 답은 "규칙을 외우면 틀리고, 이유를 알면 맞는다"입니다. Math.random()은 금지어가 아니에요. 금지된 것은 "의도치 않게 모두가 같은 값을 보게 되는 것"이고, 우리가 그걸 의도했다고 말하면 규칙이 사라집니다. 앞으로 만날 다른 제약들도 대부분 이렇게 동작해요.


🎯 [과제 4 예시답안] 연습용 서버 없이 빌드하면 (25점)

채점 포인트

항목 배점 기준
.next를 지우고 실험 5 지우지 않으면 결과가 안 나온다는 것을 알았다
결과 확인 5 빌드가 통과한다는 것을 종료 코드로 확인했다
무엇이 달라졌는지 설명 10 <Suspense> 때문에 프리렌더가 fetch를 안 부른다고 연결했다
배포 관점의 의미 5 빌드 기계가 API 서버에 닿을 필요가 없어졌다고 적었다

결과

텍스트
 지난 시간   exit 1   Error occurred prerendering page "/"  ...  ECONNREFUSED
 오늘        exit 0   ECONNREFUSED 없음

통과합니다. 서버가 안 떠 있는데도요.

무엇이 달라졌나

지난 시간에 빌드가 죽었던 이유는 이랬습니다. Next가 홈 화면을 미리 그려보려고 했고, 그리는 도중에 우리 fetch를 실제로 불렀고, 받아줄 서버가 없어서 터졌어요.

오늘 우리는 그 fetch<Suspense> 안쪽으로 옮겼는데, 그 경계는 Next에게 이렇게 말합니다.

"여기 안쪽은 미리 그리지 마세요. 요청이 올 때 그리세요."

그러니 빌드할 때 fetch가 아예 안 불립니다. 부르지 않으니 서버가 없어도 상관이 없어요.

텍스트
 지난 시간   빌드 -> 홈을 그려본다 -> fetch 호출 -> 서버 없음 -> 실패
 오늘        빌드 -> 홈 껍데기만 그린다 -> fetch 안 부름 -> 성공

.next를 지워야 하나

안 지우면 지난번 빌드 결과가 남아 있어서 Next가 그걸 재사용합니다. 바뀐 게 없는 화면은 다시 안 그려요.

서버를 껐는데도 빌드가 통과하는 일이 생기는데, 그건 오늘 배운 것 때문이 아니라 그냥 안 그려봐서입니다. 실험이 성립하지 않는 거예요.

텍스트
 .next 를 안 지우면   미리 그리기를 건너뛴다      -> 통과 (이유가 다름)
 .next 를 지우면      처음부터 다시 그린다        -> 오늘 배운 것이 드러난다

⚠️ 지난 시간 실험도 사실 같은 조건이 필요했습니다. 그때 빌드가 죽는 것을 못 봤다면 .next가 남아 있었을 가능성이 큽니다.

배포에서 이게 무슨 뜻인가

빌드하는 기계와 서비스하는 기계는 보통 다릅니다. 그리고 빌드는 사내 파이프라인 같은 데서 도는 경우가 많아요.

지난 시간 상태로는 빌드하는 기계가 API 서버에 닿을 수 있어야 했습니다. 못 닿으면 배포 자체가 실패했죠. 방화벽이나 사내망 문제로 이게 은근히 까다롭습니다.

오늘 상태에서는 그 조건이 사라졌어요. 빌드는 껍데기만 만들고, 데이터는 서비스가 뜬 다음 사용자 요청 때 가져옵니다.

물론 공짜는 아닙니다. 굳혀두기로 한 것이 있다면 그건 여전히 빌드할 때 물어봐야 해요. 무엇을 굳혔느냐에 따라 빌드가 서버를 필요로 하는 정도가 달라집니다.

자주 나오는 실수

.next 를 안 지우고 "지난 시간이랑 똑같은데요"로 결론 내기. 이 과제에서 제일 많이 나오는 오답입니다. 실험이 예상과 다르면 재는 조건을 먼저 의심하세요. 앞선 시간들에서도 계속 나온 이야기예요.

💡 튜터의 한마디 — 이 과제는 오늘 배운 것이 코드 바깥까지 영향을 준다는 것을 보여주려고 냈습니다. <Suspense>를 하나 넣었을 뿐인데 배포 파이프라인의 요구 조건이 바뀌었어요. 프론트엔드 결정이 인프라 이야기로 이어지는 곳이고, 실무에서 이런 연결을 설명할 수 있으면 신뢰를 크게 얻습니다.


생각해볼 주제

🤔 [생각해볼 주제 1] 그 값은 몇 초 낡아도 되나

문제 상황 요약

'use cache' 한 줄을 붙이느냐 마느냐는 코드로는 사소한 차이지만, 그 결정은 코드가 답할 수 없는 질문 위에 서 있습니다. 팔로워 수는 굳혀도 되고 게시물 목록은 안 되는 이유가 문법에는 없습니다.

튜터의 가이드 및 해설

먼저 이 질문을 개발자 혼자 답하려 하지 마세요. 대부분의 경우 답을 아는 사람은 따로 있습니다.

물어볼 대상은 이렇게 나뉩니다.

물어볼 사람 물어볼 것
기획·운영 이 숫자가 늦게 반영되면 사용자가 항의하나요
데이터·분석 이 값이 실제로 얼마나 자주 바뀌나요
백엔드 이 조회가 서버에 얼마나 부담인가요

세 번째를 빠뜨리기 쉬운데, 캐시는 화면 문제이기 전에 부하 문제입니다. 굳히지 않기로 하면 그 비용은 우리 화면이 아니라 남의 서버에서 나가요. 프론트엔드가 혼자 "안전하게 매번 물어보자" 고 정하면, 그 결정의 청구서는 백엔드에게 갑니다.

그리고 "모르겠으면 굳히지 말자"가 안전한 기본값인지 따져봅시다. 낡은 데이터가 보이는 잘못은 눈에 보입니다. 사용자가 화면을 보고 "숫자가 이상한데요"라고 말해줘요. 반면 매번 물어봐서 생기는 비용은 화면에 안 보입니다. 서버 요금 고지서나, 트래픽이 몰린 날의 장애로 뒤늦게 나타나죠.

두 잘못은 발견되는 속도가 다른데, "굳히지 말자"는 그래서 안전한 게 아니라 문제를 늦게 발견하는 쪽입니다. 진짜 안전한 기본값은 "잘 모르겠으면 짧게 굳히고 관찰한다"에 가까워요.

실무에서 쓸 만한 판단 순서는 이렇습니다. 먼저 이 값이 틀렸을 때 누가 다치는지를 봅니다. 돈이나 재고처럼 틀리면 사고가 나는 값은 굳히지 않습니다. 다음으로 얼마나 자주 바뀌는지와 얼마나 자주 읽히는지를 비교해요. 하루에 한 번 바뀌는데 초당 천 번 읽힌다면 굳히지 않을 이유가 없습니다.

🎯 면접관을 홀리는 핵심 멘트

"캐시 여부는 기술 결정이 아니라 제품 결정이라고 봅니다. 저는 세 가지를 확인하고 정합니다. 틀렸을 때 누가 다치는가, 얼마나 자주 바뀌는가, 얼마나 자주 읽히는가. 특히 '잘 모르겠으니 캐시하지 말자'는 안전해 보이지만 사실 비용을 백엔드로 떠넘기면서 문제를 늦게 발견하는 선택이라, 저는 짧게 캐시하고 관찰하는 쪽을 기본으로 둡니다."

🤔 [생각해볼 주제 2] 빌드를 멈추는 도구

문제 상황 요약

Cache Components는 잘 돌아가던 빌드를 멈춰 세웠습니다. 우리는 화면 세 개짜리 앱이라 금방 고쳤지만, 이미 서비스 중인 큰 앱이라면 같은 스위치가 수십 수백 곳을 한꺼번에 청구할 수 있습니다.

튜터의 가이드 및 해설

핵심은 이 도구가 없던 문제를 만든 게 아니라는 점입니다. 우리 홈 화면은 스위치를 켜기 전에도 이미 굳어 있었어요. 스위치는 그것을 드러냈을 뿐입니다.

찬반은 "문제가 있느냐"가 아니라 "언제 알 것이냐"로 갈립니다.

지금 안다 나중에 안다
비용 개발자 시간, 한 번에 몰림 사용자 신고, 장애 대응
시점 우리가 고른다 하필 바쁠 때 온다
범위 전부 한꺼번에 보인다 한 건씩 뒤늦게

큰 앱일수록 두 번째 줄이 무섭습니다. 백 곳이 잘못돼 있다면, 그건 스위치를 안 켜도 백 곳이 잘못돼 있는 거예요. 그중 몇 개가 언제 터질지 모를 뿐입니다.

그럼에도 반대할 만한 상황은 분명히 있습니다. 다음 주에 큰 출시가 있다면 지금은 아니죠. 팀이 이 개념을 아직 모른다면 스위치부터 켜는 것은 순서가 틀렸습니다. 고칠 곳이 백 곳인데 그걸 한 사람이 다 판단해야 한다면, 그 사람은 "이 값이 몇 초 낡아도 되는가"를 백 번 혼자 결정하게 되고 그건 거의 확실히 틀립니다.

실무의 답은 대개 "켠다, 다만 한 번에 켜지 않는다"입니다. 트래픽이 적은 화면부터 옮기고, 각 결정을 코드 주석으로 남기고, 옮긴 화면이 잘 도는지 확인한 뒤 다음으로 갑니다.

언제까지 미룰 수 있느냐는 질문에는 정직하게 답하는 편이 좋습니다. 지금 방식이 없어지지는 않으니 기술적으로는 오래 미룰 수 있지만, 미루는 동안 새로 만드는 화면도 전부 옛 방식으로 쌓입니다. 부채는 이자가 붙습니다. 그래서 "언제 켤까"보다 "새로 만드는 것부터 새 방식으로 할까"가 더 나은 질문인 경우가 많습니다.

🎯 면접관을 홀리는 핵심 멘트

"저는 이런 도구를 '문제를 만드는 도구'가 아니라 '문제를 앞당겨 보여주는 도구'로 봅니다. 켜기 전에도 잘못은 이미 거기 있었으니까요. 다만 큰 앱이라면 한 번에 켜자고 하지는 않겠습니다. 캐시 여부는 화면마다 사람이 판단해야 하는 문제라, 한꺼번에 백 개를 판단하면 그중 상당수가 틀리거든요. 저라면 새로 만드는 화면부터 적용하고 기존 화면은 트래픽이 낮은 쪽부터 옮기자고 제안하겠습니다."

🤔 [생각해볼 주제 3] 조용히 사라진 것

문제 상황 요약

없는 사람의 프로필 주소가 404를 돌려주다가 200을 돌려주게 됐습니다. 에러도 경고도 없었고 화면은 오히려 400밀리초 빨라졌습니다. 성능만 보면 개선으로 보이는 회귀입니다.

튜터의 가이드 및 해설

먼저 이런 회귀가 왜 안 잡히는지 봅시다. 우리가 평소에 확인하는 것들을 떠올려보세요. 타입 검사, 린트, 빌드, 화면 확인. 오늘 이 넷을 전부 통과했습니다. 심지어 화면을 눈으로 봐도 "없는 주소예요"가 멀쩡히 떠요.

깨진 것은 화면이 아니라 응답의 성질입니다. 사람이 브라우저로 보는 것 중에 상태 코드는 없어서, 눈으로 하는 확인은 원리적으로 이걸 못 잡습니다.

"더 꼼꼼히 보자"가 답이 아닌 이유가 여기 있습니다. 볼 수 없는 것을 더 열심히 볼 수는 없으니까요. 필요한 것은 주의력이 아니라 그것을 볼 수 있는 도구입니다.

무엇을 지켜봐야 할까요. 원칙은 "화면이 아니라 계약을 지켜본다"입니다.

지켜볼 것
없는 주소의 상태 코드 검색 엔진과 모니터링이 이걸로 판단한다
로그인 필요한 주소의 상태 코드 401 이 200 이 되면 보안 문제로 번진다
검색 엔진 색인 표시 잘못 붙으면 없는 페이지가 검색에 뜬다

이런 것들은 사람이 아니라 테스트가 봐야 합니다. 주소를 열고 상태 코드를 확인하는 검사는 몇 줄이면 되고, 한 번 써두면 오늘 같은 회귀를 배포 전에 잡아요. 우리 과목에서도 테스트를 정식으로 다루는 시간이 뒤에 있습니다.

하나 더 짚자면, 이번 회귀는 우리가 코드를 잘못 짜서 생긴 게 아닙니다. 설정 한 줄을 켰더니 프레임워크의 동작이 바뀌었는데, 그래서 "우리 코드를 리뷰한다"로는 안 잡힙니다. 리뷰에서 볼 수 있는 것은 cacheComponents: true 한 줄뿐이고, 그 줄만 봐서 404가 사라진다는 것을 아는 사람은 드물어요.

여기서 실무 교훈이 나옵니다. 도구나 프레임워크를 올릴 때는 코드 변경량이 작을수록 오히려 위험합니다. 한 줄짜리 변경이 수백 곳의 동작을 바꿀 수 있고, 리뷰는 그 한 줄만 보거든요. 이럴 때야말로 눈이 아니라 자동화된 검사가 필요합니다.

🎯 면접관을 홀리는 핵심 멘트

"이런 회귀는 사람이 더 꼼꼼히 봐서 잡을 수 없다고 생각합니다. 화면은 멀쩡했고 깨진 건 상태 코드였는데, 브라우저로 보는 사람 눈에 상태 코드는 안 보이니까요. 그래서 저는 화면 대신 계약을 지키는 테스트를 두자고 합니다. 없는 주소는 404, 권한 없는 주소는 401 같은 것들이요. 특히 프레임워크 설정 한 줄짜리 변경이 제일 위험하다고 봅니다. 바뀌는 동작은 수백 곳인데 리뷰에서 보이는 건 그 한 줄뿐이거든요."

전체 목록 리액트