D-6: 데이터 페칭 & 캐싱 ② — 언제 갈아 끼우고, 요청 때만 아는 것은 어떻게
목차 65
프로필 화면을 열어두고 다른 일을 하다가 십 분 뒤에 새로고침을 눌러봅니다. 팔로워 숫자가 그대로예요. 한 번 더 눌러도 그대로입니다. 서버는 답을 하고 있고 화면도 멀쩡한데, 그 숫자만 시간이 멈춰 있습니다.
안녕하세요, 홍순구입니다. 지난 시간에 우리는 이 숫자를 굳히기로 결정했는데, 결정한 것은 "굳힌다" 하나뿐이었어요. 언제까지 굳어 있을지는 아무도 안 정했습니다.
정한 적이 없는데 값은 정해져 있습니다. 오늘은 그 값이 어디서 왔는지 찾아내서 우리 손으로 가져오고, 그다음엔 지난 시간에 미뤄둔 부채를 갚는데, 갚고 나서도 끝내 안 돌아오는 것 하나를 만나게 될 거예요.
💡 오늘 수업의 핵심 — "굳힌다는 결정에는 기한이 붙습니다. 안 적으면 기본값이 대신 붙습니다"
캐시 이야기에서 사람들이 가장 자주 빠뜨리는 것이 기한입니다. 'use cache'를 붙이는 순간 우리는 "이 값은 재사용해도 된다" 고 말한 건데, 그 말에는 얼마 동안이 빠져 있어서 Next가 대신 채웁니다.
여기에 오늘의 두 번째 주제가 붙습니다. 캐시는 여러 사람이 나눠 쓰는 것인데, 어떤 값은 나눠 쓸 수가 없어요. 로그인한 사람이 누구인지, 어떤 설정을 켜뒀는지 같은 것들이요. 그런 값을 읽는 방법과, 그것이 캐시와 만났을 때 벌어지는 일을 오늘 갈라둡니다.
🎯 학습 목표
- 굳힌 값의 수명이
stale·revalidate·expire세 개로 나뉜다는 것을 알고,cacheLife로 직접 정합니다. cacheTag로 캐시 칸에 이름표를 붙여, 시간과 무관하게 지목해 버릴 준비를 합니다.await cookies()로 요청 때만 아는 값을 읽고, 그것을 어디서 읽느냐가 앱 전체를 좌우한다는 것을 확인합니다.- 캐시 안에서 런타임 값을 못 읽는 이유를 알고, 정적 껍데기를 넓혀 지난 시간의 부채를 갚습니다.
오늘의 여정
1. 정한 적 없는 숫자 팔로워 수의 기한은 이미 정해져 있었다
2. 수명을 우리 손에 cacheLife -- 세 값을 직접 고른다
3. 이름표를 붙인다 cacheTag -- 시간을 안 기다리고 버리기 위해
4. 서버가 쿠키를 읽는다 글자 크기를 브라우저에서 서버로 옮긴다
5. 캐시 안에서는 못 읽는다 빌드는 통과하는데 화면이 깨진다
6. 머리말이 막히는 이유 같은 컴포넌트가 홈에선 되고 프로필에선 안 된다
7. 껍데기를 넓힌다 미뤄둔 한 줄을 걷어낸다
8. 404 는 안 돌아온다 고칠 수 있는 것과 없는 것을 가른다
Step 1: "정한 적 없는 숫자"
굳어 있다는 것을 눈으로 봅시다
지금 프로필 화면에는 팔로워 숫자만 있습니다. 그 숫자가 언제 센 것인지는 안 나와 있어서 굳어 있어도 티가 안 납니다.
집계값에는 언제 기준인지가 같이 오는 게 맞아서, 연습용 API 서버가 그 시각을 같이 보내도록 바꿨어요.
// apps/api-stub/server.mjs
ok(res, {
username,
profileImageUrl: seed(username, 64),
followerCount: FOLLOWER_COUNTS[username] ?? 0,
// 집계를 돌린 시각. 팔로워 수처럼 모아 센 값은 "언제 기준인지" 가 같이 와야 한다.
countedAt: new Date().toISOString(),
});
타입에도 한 칸 늘려주고요.
// apps/web-next/lib/api.ts
export type Profile = {
username: string;
profileImageUrl: string;
followerCount: number;
/** 이 숫자를 센 시각. 모아 센 값은 언제 기준인지가 같이 와야 한다. */
countedAt: string;
};
화면에 붙입니다.
// apps/web-next/app/[username]/page.tsx
<p className="mb-4 text-sm text-black/60">
게시물 {posts.length} · 팔로워 {profile.followerCount}
<span className="ml-2 text-black/40">
({new Date(profile.countedAt).toLocaleTimeString('ko-KR')} 기준)
</span>
</p>
100초 동안 한 번도 안 바뀝니다
빌드하고 띄운 뒤 3초 간격으로 열 번 새로고침해봤습니다.
요청 시각 화면에 뜬 집계 시각
10:06:52 -> 오전 10:05:51
10:06:57 -> 오전 10:05:51
10:07:01 -> 오전 10:05:51
... ...
10:07:30 -> 오전 10:05:51 100초 동안 한 번도 안 바뀜
API 서버는 부를 때마다 그 순간의 시각을 보내는데, 화면에는 10:05:51이 계속 떠서 부르지 않고 있다는 뜻입니다.
여기까지는 지난 시간에 우리가 원한 그대로지만, 문제는 이게 언제 풀리느냐를 우리가 모른다는 겁니다.
답은 응답 헤더에 이미 적혀 있습니다
터미널에서 헤더만 뽑아봅시다.
curl -s -D- -o /dev/null http://localhost:3000/jaehoon | grep x-nextjs
x-nextjs-stale-time: 300
x-nextjs-prerender: 1
x-nextjs-postponed: 1
300. 초 단위니까 5분인데, 우리는 이 숫자를 어디에도 적은 적이 없어요.
지난 시간 과제에서도 비슷한 것을 만났죠. FeedList에 'use cache'를 붙였더니 빌드 로그의 라우트 표에 Revalidate 15m과 Expire 1y라는 열이 새로 생겼습니다. 그때 "우리가 정한 적 없는 숫자" 라고만 하고 넘어갔어요.
정체를 밝히자면 굳힌 값에는 기본 수명이 자동으로 붙는데, 그 수명은 하나가 아니라 셋이에요.
수명이 왜 세 개인가
굳힌 값 하나에 시계가 세 개 붙는다
stale ──────── 5분 ─────────>
브라우저가 서버에 안 물어보고 그냥 쓰는 기간
revalidate ───────── 15분 ──────────>
이 시간이 지나면 서버가 다음 요청 때 다시 만든다
expire ─────────── (기본은 없음) ────────────>
여기까지 아무도 안 찾아오면 다음 사람은 새로 만들 때까지 기다린다
셋이 각각 다른 질문에 답합니다.
stale— 브라우저가 서버를 안 귀찮게 하는 기간입니다. 방금 본x-nextjs-stale-time: 300이 이 값이에요.revalidate— 서버가 값을 다시 만드는 주기입니다. 지나면 즉시 버리는 게 아니라, 다음 요청에게는 가진 것을 주고 뒤에서 새로 만듭니다.expire— 완전히 못 쓰게 되는 시점입니다. 이걸 넘기면 다음 사람이 새로 만들어질 때까지 기다려야 합니다.
아무것도 안 적으면 default라는 이름의 조합이 붙습니다. stale 5분 · revalidate 15분 · expire 없음. 방금 본 300초와 15m 이 정확히 이 값이에요.
🙋 학생 질문 — "revalidate가 지났는데도 옛날 값을 준다고요? 그럼 언제 새 값을 보나요"
바로 다음 요청에서 봅니다.
순서를 따라가 볼게요. 15분이 지난 뒤 A 씨가 들어옵니다. 서버는 가지고 있던 값을 A 씨에게 그대로 주고, 그와 동시에 새로 만들기 시작해요. A 씨는 기다리지 않는데, 잠시 뒤 B 씨가 들어오면 그때는 새 값이 준비돼 있죠.
정리하면 한 사람이 낡은 값을 받는 대신 아무도 기다리지 않는 방식인데, 이걸 뒤집으면 하루에 한 명 오는 화면에서는 그 한 명이 매번 낡은 값을 본다는 뜻이기도 해요. 트래픽이 적은 화면일수록 이 방식의 장점이 줄어듭니다.
expire는 그 극단을 막는 안전선입니다. 너무 오래 아무도 안 왔으면 낡은 값을 주지 말고 그냥 새로 만들어서 주라는 거예요. 그 대신 그 사람은 기다립니다.
💡 한 줄 정리
굳혀둔 값에는 우리가 안 정해도 기한이 붙어 있습니다. 그 기한은 하나가 아니라 셋이고, 각각 브라우저·서버·안전선을 맡아요. 지금 우리 팔로워 숫자에 붙어 있는 것은 누가 정했는지 모르는 default입니다.
Step 2: "수명을 우리 손에"
도구는 함수 하나입니다
cacheLife를 'use cache' 안에서 부르면 되는데, 지시어 바로 아래에 두면 읽기 편해요.
// apps/web-next/lib/api.ts
import { cacheLife } from 'next/cache';
⚠️ 규칙이 두 개 있습니다. 반드시 캐시 스코프 안에서 불러야 하고, 파일 맨 위(모듈 스코프)에서 부르면 에러가 나며, 한 번 호출에 한 번만 실행돼야 해요.
이름이 붙은 조합이 일곱 개 준비돼 있습니다.
| 이름 | 어떤 값에 | stale |
revalidate |
expire |
|---|---|---|---|---|
default |
안 적으면 이것 | 5분 | 15분 | 없음 |
seconds |
실시간에 가까운 값 | 30초 | 1초 | 1분 |
minutes |
자주 바뀌는 값 | 5분 | 1분 | 1시간 |
hours |
하루에 몇 번 바뀌는 값 | 5분 | 1시간 | 1일 |
days |
하루에 한 번 | 5분 | 1일 | 1주 |
weeks |
주 단위 | 5분 | 1주 | 30일 |
max |
거의 안 바뀌는 값 | 5분 | 30일 | 1년 |
stale이 대부분 5분으로 같다는 데 눈이 갈 거예요. 브라우저가 서버를 안 귀찮게 하는 기간은 값의 성격보다 사용자가 한 화면에 머무는 시간에 가깝기 때문입니다. 갈리는 것은 주로 revalidate예요.
눈으로 보려면 짧게 걸어봐야 합니다
여기서 문제가 하나 있습니다. hours를 걸면 한 시간을 기다려야 확인이 되므로, 확인용으로 잠깐 아주 짧은 값을 직접 적어봅니다. 이름 대신 숫자를 넣을 수도 있어요.
// apps/web-next/lib/api.ts (확인용 — 곧 되돌립니다)
export async function fetchProfile(username: string): Promise<Profile> {
'use cache';
cacheLife({ stale: 30, revalidate: 5, expire: 60 });
return get<Profile>(`/users/${encodeURIComponent(username)}`);
}
빌드하고 띄운 뒤 1초 간격으로 새로고침하면서 집계 시각이 언제 바뀌는지 봤습니다.
t=1s 오전 10:08:08
t=4s 오전 10:08:14 +6.7초
t=7s 오전 10:08:21 +6.7초
t=10s 오전 10:08:28 +6.7초
t=13s 오전 10:08:34 +6.7초
바뀝니다. Step 1에서 100초 동안 꿈쩍 안 하던 숫자가 이제 몇 초마다 갈려요.
5초라고 적었는데 왜 6.7초일까요
여기서 한 번 멈추고 갑시다. revalidate를 5로 적었는데 실제 주기는 6.7초예요. 1.7초가 어디서 붙었을까요.
다시 만드는 데 걸리는 시간입니다. 우리 프로필 화면은 태그 집계까지 하느라 한 번 그리는 데 1초 넘게 걸려서, 그 시간이 그대로 얹힙니다.
revalidate는 "정확히 5초마다 새 값"이라는 뜻이 아니라 "5초가 지난 뒤 들어온 요청부터 다시 만들기 시작한다"는 뜻이고, 실제 갱신 간격은 그보다 항상 깁니다.
우리 값을 정합니다
팔로워 수는 하루에도 몇 번 변하지만 초 단위로 정확할 이유는 없어서 hours가 맞아요.
// apps/web-next/lib/api.ts
export async function fetchProfile(username: string): Promise<Profile> {
'use cache';
// 팔로워 수는 하루에도 몇 번 변한다. 한 시간마다 다시 세면 충분하다.
cacheLife('hours');
return get<Profile>(`/users/${encodeURIComponent(username)}`);
}
findProfile에도 같이 걸어둡니다. 이쪽은 "이 사람이 있는가"를 보는 함수인데, 사람의 존재 여부는 팔로워 수보다도 덜 바뀌죠. 그래도 hours로 맞춥니다. 이유가 있어요.
// apps/web-next/lib/api.ts
export async function findProfile(username: string): Promise<Profile | null> {
'use cache';
// 사람이 있느냐 없느냐도 같은 주기로 본다 — 가입한 사람이 한 시간 안에는 보여야 한다.
cacheLife('hours');
// ...
}
⚠️ 없는 사람이라는 답도 굳습니다. 이 함수가 null을 돌려주면 그 null이 캐시에 들어가요. max를 걸었다면 오늘 가입한 사람의 프로필이 30일 동안 없는 사람으로 나옵니다. 오늘 과제에서 이 문제를 더 다뤄볼게요.
🙋 학생 질문 — "짧은 값을 걸었는데 아까 그 헤더는 여전히 300이에요. 제가 뭘 잘못한 건가요"
아무것도 안 틀렸습니다. 그 헤더는 다른 것의 값이에요.
Step 1에서 본 x-nextjs-stale-time은 화면 전체 껍데기의 stale입니다. 방금 우리가 고친 것은 fetchProfile이라는 함수 하나의 수명이고요. 둘은 서로 다른 층에 있어서, 함수 쪽을 30초로 바꿔도 껍데기 쪽 헤더는 그대로 300이 나옵니다.
Step 1에서 헤더와 default 값이 딱 맞아떨어졌던 것은 그때 둘 다 default 였기 때문이에요. 우연히 같았던 거라서, 함수 수명이 바뀌었는지 확인할 때는 헤더 말고 화면의 집계 시각을 보세요. 그게 이 Step에서 시각을 화면에 붙여둔 이유이기도 합니다.
💡 한 줄 정리
굳힌다는 결정에는 기한이 따라붙고, 안 적으면 default가 붙습니다. cacheLife는 그 기한을 호출한 자리에서 눈에 보이게 만드는 도구예요. 공식 문서도 모든 'use cache'마다 적어두라고 권합니다. 안 적으면 이 함수를 나중에 읽는 사람이 수명을 알 방법이 없거든요.
Step 3: "이름표를 붙인다"
시간으로는 못 푸는 자리
hours를 걸었으니 팔로워 수는 한 시간마다 갱신됩니다. 대부분의 경우 충분해요.
그런데 이런 순간이 있습니다. 사용자가 팔로우 버튼을 방금 눌렀어요. 그 사람은 숫자가 1 올라가는 것을 기대합니다. 한 시간 뒤에 올라가면 버튼이 고장 난 걸로 보이죠.
시간을 짧게 잡는 것으로는 안 풀립니다. 1초로 잡아도 그 1초 동안은 틀린 숫자가 뜨고, 대신 아무도 안 누르는 나머지 시간에 서버는 계속 헛일을 해요.
필요한 것은 "지금 이 값을 버려라"라고 지목하는 방법입니다. 그러려면 지목할 이름이 있어야 하고요.
이름표는 캐시 안에서 붙입니다
// apps/web-next/lib/api.ts
import { cacheLife, cacheTag } from 'next/cache';
export async function fetchProfile(username: string): Promise<Profile> {
'use cache';
// 팔로워 수는 하루에도 몇 번 변한다. 한 시간마다 다시 세면 충분하다.
cacheLife('hours');
// 이 칸에 이름표를 붙여둔다. 누군가 이 사람을 팔로우한 순간 이 이름으로 지목해 버릴 수 있게.
cacheTag('profile', `profile:${username}`);
return get<Profile>(`/users/${encodeURIComponent(username)}`);
}
이름을 두 개 붙였습니다. cacheTag는 한 번에 여러 개를 받아요.
캐시에 들어 있는 칸들
fetchProfile("jaehoon") -> 이름표: profile , profile:jaehoon
fetchProfile("minji") -> 이름표: profile , profile:minji
findProfile("jaehoon") -> 이름표: profile , profile:jaehoon
"profile:jaehoon" 을 버리면 -> 재훈이 것 두 칸만 사라진다
"profile" 을 버리면 -> 프로필 관련이 전부 사라진다
굵은 이름 하나와 가는 이름 하나를 같이 붙이는 것이 흔한 방식입니다. 평소에는 가는 이름으로 한 사람 것만 정확히 버리고, 프로필 화면 구조가 통째로 바뀌는 배포 같은 때는 굵은 이름으로 전부 버립니다.
오늘은 붙이는 데까지입니다
이름표를 붙였으면 부르는 쪽이 있어야 하는데, 오늘은 못 부릅니다.
버리라고 말하는 함수는 두 개입니다. 하나는 revalidateTag, 다른 하나는 updateTag인데, 이 둘은 부를 수 있는 자리가 정해져 있습니다. 서버에서 도는 동작 함수나 API 처리기 안에서만 불러야 하고, 우리 앱에는 아직 그런 것이 없어서 오늘은 이름표까지만 붙여둡니다. 다음 시간에 팔로우 버튼을 진짜로 만들면서, 그 버튼이 눌린 순간 이 이름을 부르게 될 거예요.
⚠️ 이름 규칙을 하나 정해두고 가면 좋습니다. profile:jaehoon처럼 무엇:누구 모양으로 통일하세요. 이름표는 붙이는 곳과 부르는 곳이 멀리 떨어져 있어서, 규칙이 없으면 반년 뒤에 오타 하나로 조용히 안 지워집니다.
🙋 학생 질문 — "이름표를 붙여두면 캐시가 더 무거워지지 않나요?"
거의 안 무거워집니다. 이름표는 짧은 글자일 뿐이라 값 자체에 비하면 없는 것과 같아요.
한계는 있어서 한 번에 붙일 수 있는 이름표는 128개까지고, 이름 하나는 256글자를 넘으면 안 됩니다. 넘기면 조용히 버려지면서 경고만 남아요. 실무에서 이 한계에 닿는 경우는 드물지만, 이름을 반복문으로 만들어 붙일 때는 조심할 만합니다.
더 실질적인 비용은 이름을 관리하는 사람 쪽입니다. 붙이는 곳과 부르는 곳이 멀리 떨어져 있어서, 오타 하나가 "지워질 줄 알았는데 안 지워짐"으로 조용히 남거든요. 그러니 규칙을 먼저 정하는 게 낫습니다.
💡 한 줄 정리
수명은 시간이 지나면 갈아 끼우고, 이름표는 일이 벌어지면 갈아 끼웁니다. 둘은 대체재가 아니라 짝이에요. 대부분의 값은 넉넉한 수명 + 정확한 이름표 조합이 답입니다.
Step 4: "서버가 쿠키를 읽는다"
지금 우리 앱은 누가 보고 있는지 모릅니다
머리말에 「글자 크기」 버튼이 있죠. 눌러보세요. 글자가 커집니다. 이제 새로고침을 해보세요.
원래대로 돌아옵니다.
당연해요. 그 상태는 useState로 브라우저 메모리에 들고 있었으니까요. 화면을 새로 그리면 사라집니다. C-3에서 Context를 만들 때부터 그랬는데, 그동안은 아무도 신경 안 썼어요.
이걸 고치려면 값을 어딘가에 적어둬야 하고, 서버가 화면을 그릴 때 그 값을 읽을 수 있어야 합니다. 그래야 첫 화면부터 큰 글자로 나옵니다.
브라우저와 서버가 요청마다 주고받는 작은 메모가 바로 쿠키입니다.
읽는 방법
import { cookies } from 'next/headers';
const jar = await cookies();
const large = jar.get('text-scale')?.value === 'large';
await가 붙어 있죠. 여기서 D-1의 숙제 하나가 풀립니다.
D-1에서 params를 받을 때도 await를 붙였습니다. 그때는 "지금은 그냥 붙이세요" 하고 넘어갔죠. 이유가 이겁니다. 이 값들은 요청이 도착해야만 알 수 있습니다. 빌드할 때 미리 그려두는 순간에는 아직 존재하지 않아요. await는 "지금은 없을 수도 있고, 생기면 그때 준다"는 표시입니다.
params, searchParams, cookies(), headers() — 넷 다 같은 부류인데, 요청이 있어야 아는 것들입니다.
❌ 먼저 틀린 방법을 봅시다
글자 크기는 화면 전체에 영향을 주니까, 가장 바깥 껍데기에서 읽으면 될 것 같죠.
// apps/web-next/app/layout.tsx (이렇게 하면 안 됩니다)
export default async function RootLayout({ children }: LayoutProps<'/'>) {
const large = (await cookies()).get('text-scale')?.value === 'large';
return (
<html lang="ko">
<body className="min-h-screen bg-white text-black antialiased">
{large ? <style>{`body { font-size: 1.125rem; }`}</style> : null}
{/* ... */}
빌드해봅니다.
Error: Route "/": Next.js encountered uncached or runtime data during prerendering.
Error: Route "/explore": Next.js encountered uncached or runtime data during prerendering.
Error: Route "/_not-found": Next.js encountered uncached or runtime data during prerendering.
종료 코드 1 · 에러 6건
앱에 있는 화면이 한꺼번에 무너졌습니다. 홈도, 탐색도, 404 화면까지도요. 쿠키는 건드리지도 않은 화면들인데요.
이유는 간단합니다. 가장 바깥 껍데기는 모든 화면이 쓰거든요. 거기서 요청 때만 아는 값을 읽으면, 그 껍데기를 쓰는 화면은 전부 미리 그릴 수 없게 됩니다. 한 줄이 앱 전체를 요청 시점으로 끌어내린 거예요.
요청 때만 아는 값을 어디서 읽느냐
❌ 바깥 껍데기에서 읽으면
┌────────────────────────┐
│ / /explore │
│ /[username] │
│ /_not-found │
└────────────────────────┘
└ 네 라우트가 한꺼번에 미리 그리기에서 빠진다 (에러 6건)
✅ 깊은 곳에서 읽으면
┌────────────────────────┐
│ / /explore │
│ /[username] │
│ /_not-found │
│ ┌─────────────────┐ │
│ │ TextScaleStyle │ │
│ └─────────────────┘ │
└────────────────────────┘
└ 나머지는 미리 그려진 채로 남고
안쪽 상자 하나만 요청 때 채워진다
✅ 조각 하나만 떼어냅니다
쿠키가 실제로 바꾸는 것이 무엇인지 다시 봅시다. 글자 크기 하나예요. 스타일 한 줄이니까, 그 한 줄만 따로 떼어서 요청이 온 뒤에 채우면 됩니다.
// apps/web-next/app/components/TextScaleStyle.tsx
import { cookies } from 'next/headers';
// 이 조각만 요청 때 정해진다. 쿠키는 요청이 와야만 알 수 있는 값이라
// 미리 그려둘 수 없다 — 그래서 이 하나만 따로 떼어 <Suspense> 안에 둔다.
export async function TextScaleStyle() {
const jar = await cookies();
const large = jar.get('text-scale')?.value === 'large';
if (!large) {
return null;
}
return <style>{`body { font-size: 1.125rem; }`}</style>;
}
껍데기에는 이 조각만 <Suspense>로 감싸 넣습니다.
// apps/web-next/app/layout.tsx
<body className="min-h-screen bg-white text-black antialiased">
{/* 쿠키를 읽는 조각 하나만 여기서 흘려보낸다. 나머지는 미리 그려진 채로 남는다. */}
<Suspense fallback={null}>
<TextScaleStyle />
</Suspense>
<Providers>
{/* ... */}
빌드하면 종료 코드 0, 에러 0건. 화면 내용은 전부 미리 그려진 채로 남고, 스타일 한 줄만 요청 때 따라옵니다.
상태를 브라우저에서 쿠키로 이사시킵니다
값이 서버에 있으니 브라우저가 따로 들고 있을 이유가 없어져서, Providers에서 상태를 걷어냅니다.
// apps/web-next/app/components/Providers.tsx
export function Providers({ children }: { children: ReactNode }) {
const router = useRouter();
// 상태를 여기서 들지 않는다. 쿠키에 적고, 서버에게 다시 그려달라고 한다.
function toggle() {
const large = document.cookie.includes('text-scale=large');
document.cookie = `text-scale=${large ? 'normal' : 'large'}; path=/; max-age=31536000`;
router.refresh();
}
return <TextScaleContext value={{ toggle }}>{children}</TextScaleContext>;
}
router.refresh()는 D-2에서 만난 useRouter의 다른 얼굴입니다. push()가 다른 주소로 옮기는 것이었다면, refresh()는 같은 주소를 서버에 다시 물어보는 것이에요. 쿠키를 바꿨으니 서버가 그린 결과도 달라지겠죠.
Context 자체는 그대로 뒀습니다. 통로는 남아 있고, 그 통로로 흐르는 것이 값에서 값을 바꾸는 방법으로 바뀐 것뿐이에요.
이제 버튼을 누르고 새로고침해보세요. 유지됩니다. 탭을 닫았다 열어도 그대로고요.
대가를 확인합시다
빌드 로그의 라우트 표를 지난 시간과 나란히 놓아봅니다.
지난 시간 끝 지금
◐ / ◐ /
○ /_not-found ◐ /_not-found
◐ /[username] ◐ /[username]
○ /explore ◐ /explore
○가 통째로 사라졌습니다. 완전히 정적이던 탐색 화면과 404 화면까지 ◐가 됐어요.
당연한 결과입니다. 모든 화면이 그 껍데기를 쓰고, 그 껍데기에 요청 때 채워지는 구멍이 하나 생겼으니까요. 화면 내용은 여전히 미리 그려져 있고 구멍은 스타일 한 줄뿐이지만, 완전히 정적인 화면은 이제 이 앱에 없습니다.
무엇을 잃은 건지 정확히 재봅시다. ○는 우리 서버를 안 거치고 답할 수 있다는 뜻이었어요. 빌드할 때 완성된 파일을 만들어두니 전송망이 그것만 들고 있다가 바로 내주면 됐죠. 이제는 어느 화면이든 우리 서버가 한 번은 손을 대야 합니다.
시간으로는 이렇게 나옵니다.
/explore 지난 시간(○) 지금(◐)
첫 바이트 5.3ms 5.8ms
전체 5.3ms 18ms
첫 글자가 나가는 시간은 거의 그대로고, 전체는 3배 남짓 늘었습니다. 절대값이 작아서 사람은 못 느끼지만 방향은 분명히 나빠졌어요.
다시 만드는 데 걸리는 시간입니다. 우리 프로필 화면은 태그 집계까지 하느라 한 번 그리는 데 1초 넘게 걸려서, 그 시간이 그대로 얹힙니다.
🙋 학생 질문 — "글자 크기를 머리말 안에서만 읽으면 탐색 화면은 ○로 남지 않았을까요?"
좋은 질문이고, 답은 "그렇게는 안 됩니다"입니다.
글자 크기는 화면 전체에 걸리는 설정이에요. 머리말 안에서만 읽으면 머리말 글자만 커집니다. 본문까지 커지게 하려면 본문을 감싸는 어딘가에서 읽어야 하고, 그 어딘가는 결국 모든 화면이 공유하는 껍데기예요.
정리하면 이건 우리가 코드를 잘못 짜서 생긴 비용이 아니라 "모든 화면에 걸리는 개인 설정"이라는 요구사항 자체의 가격입니다. 요구사항을 바꾸면(예: 글자 크기를 프로필 화면에만 적용) 가격도 바뀌고요.
바꿀 수 있는 것은 구멍의 크기입니다. 우리는 그걸 스타일 한 줄까지 줄였어요. 여기서 더 줄이려면 요구사항 쪽을 건드려야 합니다.
💡 한 줄 정리
요청 때만 아는 값은 어디서 읽느냐가 전부입니다. 같은 한 줄인데 껍데기 맨 위에서 읽으면 앱 전체가 무너지고, 조각으로 떼어내면 그 조각만 나중에 오는데, 아무리 잘 떼어내도 값은 치릅니다 — 완전히 정적인 화면이 하나도 안 남았어요.
Step 5: "캐시 안에서는 못 읽는다"
자연스러워 보이는 실수
쿠키를 읽을 줄 알게 됐으니 이런 생각이 듭니다. 프로필을 가져올 때도 보는 사람에 따라 뭔가 달라져야 하지 않을까?
fetchProfile 안에서 쿠키를 읽어봅시다.
// apps/web-next/lib/api.ts (실험 — 곧 되돌립니다)
export async function fetchProfile(username: string): Promise<Profile> {
'use cache';
cacheLife('hours');
cacheTag('profile', `profile:${username}`);
const jar = await cookies();
void jar.get('text-scale');
return get<Profile>(`/users/${encodeURIComponent(username)}`);
}
검사를 전부 돌려봅니다.
타입 검사 종료 코드 0
린트 종료 코드 0
빌드 종료 코드 0 · 에러 0건 · 라우트 표 무변경
셋 다 통과합니다. 아무도 뭐라고 안 해요.
그런데 화면이 깨져 있습니다
띄워서 프로필을 열어보면 이렇게 나옵니다.
@jaehoon
이 부분을 불러오지 못했어요
잠시 뒤 다시 시도해주세요. (기록 번호 3520140499@E831)
[ 다시 시도 ]
D-2에서 만든 오류 화면이 떴는데, 응답 상태 코드를 보면 200이에요. 서버는 "정상"이라고 답하면서 깨진 화면을 보내고 있습니다.
서버를 띄운 터미널을 보면 진짜 이유가 찍혀 있어요.
⨯ Error: Route /[username] used `cookies()` inside "use cache".
Accessing Dynamic data sources inside a cache scope is not supported.
If you need this data inside a cached function use `cookies()` outside of the
cached function and pass the required dynamic data in as an argument.
왜 금지인지 생각해봅시다
에러 메시지를 읽기 전에 스스로 답해보면 좋습니다. 캐시란 무엇이었죠?
한 번 만들어서 여러 사람이 나눠 쓰는 것입니다. 재훈이가 만든 결과를 민지가 받아 쓰는 거예요.
만약 그 결과 안에 재훈이의 쿠키가 섞여 들어가면 어떻게 되죠? 민지가 재훈이의 설정으로 그려진 화면을 받습니다. 로그인 정보였다면 훨씬 심각한 일이 벌어지고요.
Next는 이걸 아예 막아서, 캐시 안에서는 요청마다 다른 값을 못 읽습니다.
해법은 에러가 직접 말해줍니다
"use
cookies()outside of the cached function and pass the required dynamic data in as an argument"
밖에서 읽어서 인자로 넘기라는 겁니다.
❌ 캐시 안에서 읽기 ✅ 밖에서 읽어 인자로 넘기기
function 굳힌함수() { const 값 = await cookies()
'use cache' 굳힌함수(값)
const 값 = await cookies()
} function 굳힌함수(값) {
'use cache'
-> 누구 것인지 모르는 채 }
캐시에 섞여 들어감
-> 값이 캐시 키의 일부가 된다
재훈이 것과 민지 것이 갈린다
인자로 넘기면 지난 시간에 배운 규칙이 그대로 적용되는데, 인자가 캐시 칸을 가르는 열쇠가 되죠. 재훈이의 설정으로 만든 결과와 민지의 설정으로 만든 결과가 다른 칸에 들어갑니다. 섞이지 않아요.
이 실험은 되돌립니다. 우리 fetchProfile은 쿠키가 필요 없거든요. 지금 필요한 건 규칙 자체입니다.
🙋 학생 질문 — "그럼 사람마다 다른 값은 아예 캐시를 못 하나요? 로그인한 사람 정보 같은 거요"
할 수 있는 방법이 따로 있습니다. 'use cache: private'이라는 변종이에요.
이름 그대로 그 사람 것만 캐시하는데, 서버에 남기지 않고 그 사람의 브라우저에만 저장해서 남의 것을 받아볼 위험이 없습니다.
다만 대가가 있어요. 서버에 안 남으니 미리 그려두는 껍데기에는 들어갈 수 없습니다. 오늘 우리가 계속 넓히려고 애쓰는 그 껍데기요.
오늘은 이름만 알아두고 넘어갑시다. 인증을 붙이는 시간에 로그인한 사용자 정보를 다루면서 다시 만나게 될 거예요.
💡 한 줄 정리
타입 검사도 린트도 빌드도 통과하는데 런타임에서만 드러나는 결함이 있습니다. 도구가 통과시켜줬다는 것이 맞다는 뜻은 아니에요. 이런 종류는 상태 코드가 200이라 감시 도구도 잘 못 잡는데, 오늘 마지막 Step에서 같은 성질의 문제를 하나 더 만날 거예요.
Step 6: "머리말이 프로필에서만 막히는 이유"
미뤄둔 한 줄을 걷어내 봅니다
지난 시간에 프로필 껍데기에 이런 줄을 적어뒀죠.
// apps/web-next/app/[username]/layout.tsx
// 이 주소는 아직 정적 껍데기를 못 만든다 — 기다렸다 내보내는 것을 허용한다.
export const instant = false;
이 줄은 "이 화면은 미리 그리기 검사를 건너뛰어라"라는 뜻이었습니다. 문제를 푼 게 아니라 검사를 끈 거예요.
무엇을 가리고 있었는지 보려고 잠깐 걷어냅니다. 빌드해보면 종료 코드 1로 죽고, 에러가 정확히 두 개 뜹니다.
Error: Route "/[username]": Next.js encountered uncached or runtime data during prerendering.
at ProfileLayout (app/[username]/layout.tsx:11:24)
> 11 | const { username } = await params;
- [stream] <Suspense fallback={...}> 로 감싸기
- [cache] "use cache" 로 굳히기
- [block] export const instant = false
Error: Route "/[username]": Next.js encountered URL data `usePathname()` in a Client Component
outside of `<Suspense>`.
at HeaderNav (app/components/HeaderNav.tsx:17:31)
at RootLayout (app/layout.tsx:21:13)
digest: 'CLIENT_HOOK_DYNAMIC'
- [stream] <Suspense fallback={...}> 로 감싸기
- [block] export const instant = false
두 번째 것부터 봅시다.
갈래가 하나 없습니다
지난 시간에 만난 에러는 갈래가 셋이었어요. 흘려보내거나, 굳히거나, 미루거나.
이번 것은 둘뿐입니다. [cache]가 없어요.
왜 없을까요? 잠깐 생각해보고 넘어가면 좋습니다.
답은 이 에러가 어디서 났는지에 있습니다. 메시지를 다시 읽어보세요. in a Client Component라고 적혀 있죠. usePathname()은 브라우저에서 도는 코드입니다.
'use cache'는 서버 지시어예요. 서버에서 도는 함수의 결과를 굳히는 도구인데, 브라우저에서 도는 훅 호출에는 붙일 데가 없어서 Next는 이 에러에 [cache]를 아예 제안하지 않습니다. 안 되는 게 아니라 말이 안 되는 거예요.
⚠️ 이걸 "주소는 사람마다 다르니까 못 굳힌다"로 이해하면 안 됩니다. 바로 위 첫 번째 에러를 보세요. 그건 await params 때문에 났고 주소에서 온 값인데도 [cache]를 줍니다. Step 5에서 배웠듯이 사람마다 다른 값도 인자로 넘기면 칸이 갈려서 굳을 수 있어요. 갈린 것은 값의 성격이 아니라 그 코드가 서버에서 도느냐 브라우저에서 도느냐입니다.
같은 컴포넌트가 홈에선 통과했습니다
이상한 점이 있어요. 이 에러는 /[username]에서만 납니다. 빌드 로그 마지막 줄을 보면요.
> Export encountered errors on 1 path:
/[username]/page: /[username]
/와 /explore는 멀쩡히 통과했는데, HeaderNav는 모든 화면에 똑같이 들어 있는 컴포넌트인데요.
답은 주소를 빌드할 때 아느냐입니다.
빌드하는 시점에 Next 가 아는 것
/ -> 주소를 안다. usePathname() 이 뭘 줄지도 미리 안다 -> 통과
/explore -> 주소를 안다 -> 통과
/[username] -> 대괄호다. 누가 올지 모른다 -> 막힌다
폴더 이름에 대괄호가 있다는 것은 그 칸에 뭐가 들어올지 빌드할 때는 모른다는 뜻입니다. D-1에서 [username] 폴더를 만들 때 배운 그대로예요. 그때는 "주소가 변수가 된다" 정도로 이해했는데, 오늘 그 대가가 드러났습니다.
경계 하나로 풀립니다
해법은 [stream]입니다. 머리말을 <Suspense>로 감싸면 돼요.
// apps/web-next/app/layout.tsx
<header className="border-b border-black/10">
{/* 머리말은 지금 어느 주소에 와 있는지를 읽는다. 그건 요청이 와야 아는 값이라
미리 그릴 수 없는 주소가 있다 — 그 자리를 이 경계가 받아준다. */}
<Suspense fallback={<div className="mx-auto h-14 max-w-3xl" />}>
<HeaderNav />
</Suspense>
</header>
빌드하면 아까 그 에러가 사라지고 하나만 남습니다. await params 쪽이요.
감쌌는데 껍데기에 그대로 남아 있습니다
여기서 좋은 소식이 하나 있어요. <Suspense>로 감싸면 그 안쪽이 미리 안 그려질 것 같지만, 홈 화면의 미리 그려진 결과를 뜯어보면 머리말 글자가 그대로 들어 있습니다.
curl -s http://localhost:3000/ | grep -o "인스타그램 클론"
인스타그램 클론
<Suspense>는 "여기 안쪽은 미리 못 그릴 수도 있다"는 표시입니다. 미리 그릴 수 있으면 그냥 그려요. 홈은 주소를 아니까 그려지고, 프로필은 모르니까 안 그려집니다. 같은 코드가 화면에 따라 다르게 처리되는 거라서, 이 경계를 넣는 비용은 생각보다 쌉니다. 못 그리는 화면에서만 잠깐 빈 칸이 되고, 그릴 수 있는 화면은 손해가 없어요.
🙋 학생 질문 — "그럼 <Suspense>는 그냥 여기저기 다 씌워두면 안전한 건가요?"
빌드는 통과하겠지만 좋은 화면은 안 됩니다.
<Suspense>를 크게 씌울수록 그 안쪽은 통째로 나중에 옵니다. 극단적으로 페이지 전체를 감싸면 검사는 통과하는데 학생이 보는 건 매번 빈 화면이에요. 미리 그릴 수 있었던 것까지 같이 늦어집니다.
공식 문서도 이 점을 따로 짚습니다. 검사를 통과했다는 것과 기다리는 화면이 괜찮다는 것은 다른 이야기라고요. 좋은 기준은 진짜로 못 그리는 것만 감싸는 것입니다.
오늘 우리가 머리말을 감싼 것도 머리말 전체가 아니라 주소를 읽는 그 컴포넌트 하나였어요. Step 7에서는 이걸 한 번 더 좁힙니다.
💡 한 줄 정리
에러가 주는 갈래는 그 코드가 어디서 도는지에 따라 달라집니다. 브라우저에서 도는 것에는 서버 도구를 처방할 수 없고, 같은 컴포넌트라도 주소를 빌드할 때 아느냐에 따라 어떤 화면에서는 막히고 어떤 화면에서는 안 막힙니다.
Step 7: "껍데기를 넓힌다"
남은 하나
이제 에러가 하나 남았습니다. 프로필 껍데기의 첫 줄이요.
Error: Route "/[username]": Next.js encountered uncached or runtime data during prerendering.
at ProfileLayout (app/[username]/layout.tsx:11:24)
> 11 | const { username } = await params;
Next가 가리키는 것을 잘 보세요. findProfile이 아니라 await params입니다. 서버에 물어보는 것보다 앞서서, 주소에서 이름을 꺼내는 그 순간에 이미 막힌 거예요.
Step 4에서 정리한 그대로입니다. params는 요청이 와야 아는 값인데, 지금 우리 코드는 그걸 함수의 첫 줄에서 기다립니다. 껍데기 전체가 그 줄 하나 때문에 미리 못 그려집니다.
위에서 기다리면 전부 멈춥니다
지금 구조 — 껍데기가 맨 위에서 멈춘다
ProfileLayout (async)
└ await params ← 여기서 멈춘다
└ <main> 틀
└ 동그란 사진 자리
└ @이름
└ children
전부 요청을 기다린다.
바꿀 구조 — 멈추는 곳을 아래로 민다
ProfileLayout (async 아님)
├ <main> 틀 (미리 그려짐)
├ 동그란 사진 자리 (미리 그려짐)
├ <Suspense>
│ └ ProfileHeading (async)
│ └ await params ← 여기서만 멈춘다
└ children (미리 그려짐)
기다리는 것은 이름 한 줄뿐이다.
기다림을 없애는 게 아닙니다. 기다리는 범위를 좁히는 거예요. D-4에서 배운 것과 같은 이야기인데, 그때는 화면 안의 조각이었고 지금은 껍데기 자체입니다.
깊은 곳에서 기다립니다
// apps/web-next/app/[username]/layout.tsx
// 주소에서 이름을 꺼내 쓰는 일은 여기 안에서만 한다.
// 이 조각만 요청 때 채워지고, 바깥 껍데기는 미리 그려진 채로 남는다.
async function ProfileHeading({ params }: { params: LayoutProps<'/[username]'>['params'] }) {
const { username } = await params;
// 모르는 사람이면 여기서 멈춘다 — 게시물 0장으로 그리면 안 된다.
if ((await findProfile(username)) === null) {
notFound();
}
return <h1 className="text-xl font-bold">@{username}</h1>;
}
껍데기는 이제 기다릴 것이 없어졌습니다.
// apps/web-next/app/[username]/layout.tsx
// 이제 이 함수는 async 가 아니다. 기다릴 것이 없으니 통째로 미리 그려진다.
export default function ProfileLayout({ children, params }: LayoutProps<'/[username]'>) {
return (
<main className="mx-auto max-w-3xl p-6">
<div className="mb-6 flex items-center gap-4">
<div className="size-16 rounded-full bg-black/10" />
<Suspense fallback={<div className="h-7 w-40 animate-pulse rounded bg-black/5" />}>
<ProfileHeading params={params} />
</Suspense>
</div>
{children}
</main>
);
}
async가 사라진 것을 눈여겨보세요. 이 함수는 이제 아무것도 안 기다리고, params를 풀지 않은 채로 아래에 넘겨줍니다. 받는 쪽이 필요할 때 풀도록요.
D-4에서 Promise를 그대로 넘겨줬던 것과 같은 방식입니다. 그때는 데이터를 미리 출발시키기 위해서였고, 이번엔 기다림을 아래로 밀기 위해서예요.
부채를 갚습니다
// apps/web-next/app/[username]/layout.tsx
// export const instant = false; ← 지웁니다
빌드합니다.
종료 코드 0 · 에러 0건
Route (app)
┌ ◐ /
├ ◐ /_not-found
├ /[username]
│ └ ◐ /[username]
└ ◐ /explore
빌드하면 종료 코드 0, 에러 0건. 화면 내용은 전부 미리 그려진 채로 남고, 스타일 한 줄만 요청 때 따라옵니다.
🙋 학생 질문 — "껍데기에서 async를 뗐는데 그 안의 ProfileHeading은 여전히 async예요. 결국 기다리는 건 똑같지 않나요?"
기다리는 총 시간은 똑같습니다. 달라진 것은 기다리는 동안 학생에게 무엇을 보여줄 수 있느냐예요.
전에는 껍데기 함수 자체가 멈춰 있어서 그 아래 어떤 것도 못 그렸습니다. 화면 틀도, 동그란 사진 자리도, 게시물 뼈대도 전부 대기였죠.
지금은 껍데기가 안 멈추니까 그 틀이 먼저 나갑니다. 멈추는 것은 @이름 한 줄뿐이고, 그 자리에만 회색 막대가 떴다가 채워져요.
D-4에서 배운 그대로입니다. 기다림은 없앨 수 없고 어디서 기다릴지만 정할 수 있어요. 그때는 화면 안쪽 조각이었고, 오늘은 껍데기 자체를 그렇게 만든 겁니다.
💡 한 줄 정리
탈출구를 쓸 때 주석에 이유를 남겨두라고 했던 이유가 이겁니다. 그 주석이 없었으면 오늘 이 줄을 발견하고도 "지워도 되는 건가"를 몰랐을 거예요. 부채는 적어둬야 갚습니다.
Step 8: "404는 안 돌아온다"
확인해봅시다
지난 시간에 잃은 것이 있었죠. 없는 사람의 프로필을 열면 404여야 하는데 200이 나왔습니다.
이제 껍데기도 넓혔고 미리 그리기 검사도 통과했으니, 확인해봅시다.
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/zzzzz
200
안 돌아왔습니다.
화면은 멀쩡해요. "없는 주소예요"가 뜨고, 검색 엔진에게 이 페이지를 색인하지 말라는 표시도 붙어 있는데, 상태 코드만은 200입니다.
왜 안 되는지가 오늘의 마지막 이야기입니다
D-4에서 한 번 짚었던 법칙을 다시 꺼내옵니다. 상태 코드는 응답의 맨 앞에 나갑니다.
응답이 나가는 순서
1) 상태 코드와 헤더 ─────> 여기서 200 인지 404 인지 정해진다
2) 본문 앞부분(껍데기) ─────> 여기서부터는 되돌릴 수 없다
3) 본문 나머지 ─────> "이 사람 없네요" 를 여기서 알아도 늦었다
우리는 껍데기를 먼저 내보내기로 했습니다. 그게 오늘과 지난 시간에 한 일인데, 껍데기가 나가는 순간 1번은 이미 끝나 있습니다.
존재 확인은 어디서 하고 있죠? ProfileHeading 안이에요. <Suspense> 안쪽이고, 껍데기보다 뒤입니다.
공식 문서가 이 상황을 그대로 적어뒀습니다.
스트리밍할 때는 200이 반환된다. 응답 헤더가 이미 클라이언트로 갔기 때문에 상태 코드를 바꿀 수 없다. 404 상태가 필요하면 응답 본문이 스트리밍되기 전에 자원이 존재하는지 확인해야 한다.
껍데기를 넓힌 것이 원인이 아닙니다
여기서 오해하기 쉬운 게 있어요. "그럼 껍데기를 안 넓혔으면 404가 살아 있었을까?"
아닙니다. 지난 시간에 instant = false를 붙여둔 상태에서도 이미 200 이었어요. 그 줄은 검사를 끄는 것이지 응답 방식을 바꾸는 게 아니었으니까요.
문서가 더 정확하게 말해줍니다.
Cache Components에서는 모든 동적 라우트가 정적 껍데기를 먼저 흘려보낸다. 그러니 그 확인은
proxy에서 하라.
proxy라는 낯선 이름이 나왔죠. 화면을 그리기 전에, 요청이 우리 앱에 도착한 그 순간에 끼어드는 자리입니다. 거기서는 아직 아무것도 안 나갔으니 상태 코드를 정할 수 있어요.
그러면 지금 이 화면은 잘못된 걸까요
아닙니다. 나눠서 봐야 해요.
없는 사람의 프로필을 열었을 때
사용자가 보는 것 "없는 주소예요" + 피드로 돌아가기 지켜짐
검색 엔진이 받는 것 색인하지 말라는 표시 지켜짐
기계가 읽는 상태 코드 200 못 지킴
셋 중 둘은 지켜지고 있습니다. 검색 엔진은 이런 응답을 "실질적인 404"로 따로 분류해두긴 하지만, 색인 금지 표시가 붙어 있으면 색인은 하지 않아요. 사용자가 보는 화면도 멀쩡하고요.
못 지키는 것은 상태 코드로만 판단하는 기계입니다. 접속 기록을 세는 도구, 404 비율을 감시하는 대시보드, 상태 코드로 규정을 맞춰야 하는 곳들이요.
그러니 이건 "고쳐야 할 버그"가 아니라 "우리 서비스에 그게 필요한가"를 묻는 질문입니다. 필요하다면 확인하는 자리를 화면 밖으로 옮겨야 하고, 그 대가로 모든 프로필 요청마다 확인 한 번이 앞에 붙어요.
되돌아가는 길도 있긴 합니다
정직하게 하나 더 말해둘게요. 사실 404를 지금 당장 되찾는 방법이 하나 있습니다. 지난 시간에 켠 스위치를 도로 끄면 됩니다.
그렇게 하면 껍데기를 먼저 내보내지 않으니 상태 코드를 정할 수 있는데, 대신 지난 시간과 오늘 얻은 것을 전부 반납합니다. 피드를 흘려보내던 것도, 프로필을 굳혀둔 것도, 오늘 정한 수명도 다 같이요.
404 하나와 그 전부를 맞바꿀 것이냐는 질문이 되는 겁니다. 대부분의 서비스에서는 아니겠지만, 상태 코드가 규정인 곳이라면 진지하게 저울에 올릴 수도 있어요. 선택지를 모르고 안 하는 것과 알고 안 하는 것은 다릅니다.
🙋 학생 질문 — "화면은 404 인데 상태 코드가 200이면, 검색에 안 뜨는 게 확실한가요?"
색인은 안 되지만, "아무 일도 안 일어난다"는 아니에요.
색인을 막는 것은 상태 코드가 아니라 본문에 들어간 색인 금지 표시입니다. 그게 붙어 있는 한 검색 결과에는 안 나와요. 오늘 우리 응답에도 그 표시가 들어 있는 것을 확인했죠.
대신 검색 엔진 관리 도구에서는 이 주소가 "실질적인 404" 같은 이름으로 분류돼 보고됩니다. 사이트에 그런 주소가 몇 개 있는지가 기록으로 남는 거예요. 없는 사람 프로필 몇 개라면 신경 쓸 일이 아니지만, 수만 개가 이런 상태라면 사이트 품질 지표로 읽힐 수 있습니다.
그래서 규모가 커지면 결국 상태 코드를 되찾을 이유가 생깁니다. 지금 우리 앱은 아니고요.
💡 한 줄 정리
껍데기를 먼저 내보내기로 한 순간 상태 코드는 그 앞에서 이미 정해집니다. 구조를 아무리 잘 짜도 이 순서는 안 바뀌어요. 고칠 수 있는 것과 없는 것을 가르는 것이 오늘의 마지막 배움이고, 못 고치는 것은 자리를 옮겨야만 풀립니다.
마무리
오늘 배운 핵심 세 가지
💡 하나 — 굳힌다는 결정에는 기한이 따라옵니다. 안 적으면 기본값이 대신 붙어요. cacheLife는 그 기한을 코드에 드러내는 도구이고, 기한만으로는 못 푸는 순간이 있어서 cacheTag라는 이름표가 따로 있습니다. 시간이 지나서 갈아 끼우는 것과, 일이 벌어져서 갈아 끼우는 것은 다른 문제예요.
💡 둘 — 요청 때만 아는 값은 어디서 읽느냐가 전부입니다. 같은 await cookies() 한 줄인데, 가장 바깥에서 읽으면 앱의 모든 화면이 무너지고 조각 하나로 떼어내면 아무 일도 안 일어납니다. 캐시 안에서는 아예 못 읽는데, 캐시는 여러 사람이 나눠 쓰는 것이고 그 값은 나눠 쓸 수 없으니까요.
💡 셋 — 고칠 수 있는 것과 없는 것이 갈립니다. 오늘 우리는 미뤄둔 한 줄을 갚았지만 404는 못 되찾았습니다. 껍데기를 먼저 내보내기로 한 이상 상태 코드는 그 앞에서 정해져요. 구조를 아무리 잘 짜도 이 순서는 안 바뀝니다. 답은 화면 안이 아니라 화면 밖에 있습니다.
다음 시간 예고
오늘 두 번 같은 벽에 부딪혔습니다. 이름표를 붙였는데 부를 사람이 없었고, 존재 확인을 해야 하는데 놓을 자리가 없었어요.
둘 다 같은 것이 없어서입니다. 우리 앱은 아직 화면을 그리는 일만 합니다. 사용자가 무언가를 바꾸겠다고 보내온 요청은 아직 한 번도 받아본 적이 없어요. 댓글을 달거나 팔로우를 누르면 그 값이 서버에 어떻게 전달될까요?
다음 시간에 그걸 만드는데, 요청을 받을 수 있게 되는 순간 오늘 붙여둔 이름표가 처음으로 불리고, 오늘 못 되찾은 404도 다시 이야기할 수 있게 돼요.
한 가지 더. 팔로우 버튼을 누르면 숫자가 언제 올라가야 할까요? 서버가 답할 때까지 기다리는 게 맞을까요, 아니면 일단 올려놓고 잘못됐으면 되돌리는 게 맞을까요. C-7에서 브라우저 안에서 한 번 해봤던 그 고민인데요. 다음 시간엔 그 버튼이 서버까지 닿게 만드는 데까지 가고, 언제 숫자를 올릴지는 그다음 시간에 정합니다.
과제
[구현] 탐색 화면의 수명을 정하기
지난 시간 과제에서 /explore를 서버에서 가져오도록 바꾸고 세 갈래 중 하나를 골랐습니다. [cache]를 골랐다면 그때 수명을 안 정했을 거예요. 이제 정해줍시다.
/explore가 아직lib/posts.ts의 배열을 쓰고 있다면 먼저 서버에서 가져오게 바꾸세요.- 가져오는 함수를
'use cache'로 굳히고cacheLife와cacheTag를 붙이세요. - 어떤 프로필을 고를지 먼저 정하고 이유를 두세 문장 적으세요. 탐색 화면의 게시물이 몇 분 낡아도 괜찮은가가 기준입니다.
- 빌드 로그의 라우트 표를 보세요.
/explore줄에 열 두 개가 새로 생겼을 겁니다. 그 숫자가 여러분이 고른 프로필과 맞는지 확인하세요. - ⚠️ 빌드 실험 전에는 항상
rm -rf .next를 먼저 하세요.
[탐구] 표에 뜨는 수명은 누구 것인가
Step 2에서 fetchProfile에 cacheLife('hours')를 걸었는데 라우트 표에는 아무 열도 안 생겼습니다. 그런데 지난 시간 과제에서 FeedList에 'use cache'를 붙였을 때는 열이 생겼죠. 무슨 차이일까요.
app/components/FeedList.tsx의FeedList에'use cache'를 붙이고 빌드한 뒤, 홈 줄에 뜨는 두 숫자를 적어두세요.- 이번엔
cacheLife('hours')를 같이 붙이고 다시 빌드하세요. 숫자가 어떻게 바뀌나요? - 마지막으로
cacheLife('seconds')로 바꿔보세요. 예상하고 나서 확인하세요. 앞의 둘과 완전히 다른 일이 벌어집니다. - 왜
seconds만 그럴까요? Step 2의 프리셋 표에서seconds행만 가진 특징을 찾아보세요. - 확인이 끝나면 전부 되돌리고, 왜
FeedList에'use cache'를 붙이는 것이 답이 아닌지를 지난 시간 과제의 결론과 함께 한 문단으로 적어주세요.
[탐구] 없는 사람도 굳는다
Step 2에서 경고만 하고 넘어간 것이 있습니다. findProfile이 돌려주는 null도 캐시에 들어간다는 것이요.
findProfile의cacheLife('hours')를cacheLife('max')로 바꿔보세요. 빌드는 통과합니다.- 이 상태에서 오늘 새로 가입한 사람이 있다고 상상해보세요. 그 사람이 자기 프로필 주소를 친구에게 보냈습니다. 친구에게 무엇이 보일까요? 얼마 동안 그럴까요?
- 이제 고쳐봅시다.
cacheLife는 갈라지는 길마다 다르게 부를 수 있습니다. 사람을 못 찾았을 때와 찾았을 때 다른 수명을 주세요. - 힌트: 한 번 호출에
cacheLife가 한 번만 실행되면 됩니다.if안쪽과 바깥쪽에 각각 두는 것은 규칙 위반이 아니에요. - 고친 뒤 빌드가 통과하는지,
/jaehoon과 없는 주소가 둘 다 제대로 나오는지 확인하세요.
[탐구] 같은 규칙, 다른 시점
Step 5에서 fetchProfile 안에 await cookies()를 넣었더니 빌드는 통과하고 런타임에서 터졌습니다. 같은 짓을 다른 곳에서 해봅시다.
app/components/TextScaleStyle.tsx의 함수 첫 줄에'use cache'를 넣고 빌드하세요.- 결과를 먼저 예상하고 확인하세요. Step 5와 같을까요?
- 다릅니다. 무엇이 다른지 정확히 적고, 왜 한쪽은 빌드가 통과하고 한쪽은 안 통과하는지 설명해보세요.
- 힌트: 미리 그리는 동안 이 함수가 실제로 불리는가를 생각해보세요.
TextScaleStyle은 어디에 놓여 있고fetchProfile은 어디서 불리나요? - 확인이 끝나면 되돌려주세요.
생각해볼 주제
1. 그 기한은 누가 정하나
오늘 우리는 팔로워 수에 한 시간을 줬습니다. 코드로는 cacheLife('hours') 한 줄이지만, 그 한 줄을 정한 것은 코드 지식이 아니었어요.
여러분이 실제 팀에서 이 값을 정해야 한다면 누구에게 무엇을 물어보시겠습니까? "잘 모르겠으니 기본값 그대로 두자"는 안전해 보이는 선택인데, 오늘 본 것처럼 기본값도 결국 누군가 정한 숫자입니다. 우리 서비스를 모르는 사람이 정한 숫자를 그대로 쓰는 것이 왜 위험한지, 반대로 그게 합리적인 경우는 언제일지 생각해보세요.
2. 나눠 쓸 수 없는 값
오늘 두 번 같은 벽을 만났습니다. 쿠키도 주소도 캐시 안에서는 못 읽었어요. 이유는 하나였습니다. 캐시는 여러 사람이 나눠 쓰는 것인데 그 값들은 사람마다 다르니까요.
그런데 요즘 서비스는 대부분 사람마다 다른 화면을 보여줍니다. 그러면 캐시할 수 있는 게 점점 줄어드는 걸까요? 우리 앱에서도 쿠키 하나를 읽기 시작하자 완전히 정적인 화면이 하나도 안 남았습니다. 개인화와 캐시가 이렇게 부딪힐 때 어디서 선을 그으시겠습니까. 화면을 어떻게 쪼개면 둘 다 얻을 수 있을지도 함께 생각해보세요.
3. 못 고치는 것을 만났을 때
오늘 404를 못 되찾았습니다. 코드를 잘못 짜서가 아니라 껍데기를 먼저 내보내기로 한 선택의 필연적인 결과였어요. 프레임워크가 "이건 여기서 못 한다" 고 말한 겁니다.
이런 상황을 실무에서 만나면 어떻게 판단하시겠습니까? 프레임워크의 방식을 따르면서 다른 곳에서 푸는 길과, 프레임워크가 권하지 않는 방식으로 돌아가는 길이 있을 텐데요. 그 결정을 내리려면 무엇을 먼저 확인해야 할까요. 우리 경우라면 "상태 코드가 정말 필요한가"를 누구에게 물어봐야 할지도 생각해보세요.
✅ 예시 답안정답 보기
먼저 직접 풀어보신 뒤에 펼쳐보세요. 오늘 과제는 예상을 먼저 적고 확인하는 문제가 많습니다. 예상이 틀린 것은 감점이 아니에요. 예상 없이 실행부터 한 것이 감점입니다.
🎯 [과제 1 예시답안] 탐색 화면의 수명을 정하기 (25점)
채점 포인트
| 항목 | 배점 | 기준 |
|---|---|---|
| 서버에서 가져오도록 고침 | 5 | 파일 상수 대신 API를 부른다 |
'use cache' + cacheLife 적용 |
5 | 둘 다 붙였고 빌드가 통과한다 |
cacheTag 적용 |
5 | 이름표를 붙였고 이름 규칙이 일관적이다 |
| 프로필을 고른 이유를 적었다 | 5 | 탐색 화면 데이터의 성격으로 판단한 흔적이 있다 |
| 라우트 표의 두 숫자를 확인 | 5 | 고른 프로필의 값과 표의 값이 맞는지 대조했다 |
풀이 예시
가져오는 함수를 새로 만들고 굳힙니다.
// apps/web-next/lib/api.ts
export async function fetchAllPosts(): Promise<Post[]> {
'use cache';
cacheLife('minutes');
cacheTag('posts');
return get<Post[]>('/posts');
}
화면은 그 함수를 기다렸다 받으면 됩니다.
// apps/web-next/app/explore/page.tsx
import { fetchAllPosts } from '@/lib/api';
export default async function ExplorePage() {
const allPosts = await fetchAllPosts();
return (
<main className="mx-auto max-w-3xl p-6">
<h1 className="mb-4 text-xl font-bold">탐색</h1>
{/* ... 기존 격자 그대로 ... */}
</main>
);
}
export default async function으로 바뀐 것을 눈여겨보세요. 그런데도 빌드가 안 막히는데, 'use cache'로 굳혔으니 미리 그릴 수 있거든요. Step 7에서 프로필 껍데기가 async를 벗어야 했던 것과 대비되는 자리입니다. 기다리는 것이 문제가 아니라 요청 때만 알 수 있는 것을 기다리는 게 문제였어요.
빌드하면 라우트 표가 이렇게 나옵니다.
Route (app) Revalidate Expire
┌ ◐ /
├ ◐ /_not-found
├ /[username]
│ └ ◐ /[username]
└ ◐ /explore 1m 1h
minutes 프로필의 revalidate 1분과 expire 1시간이 그대로 찍혔는데, Step 2의 표와 대조해보면 정확히 맞아요.
프로필 선택 — 정답은 하나가 아닙니다
| 고른 것 | 판단 근거 | 평가 |
|---|---|---|
minutes |
탐색은 새 게시물이 보이는 곳이니 1분이면 충분히 신선하다 | 만점 |
hours |
탐색은 둘러보는 화면이라 최신성이 덜 중요하다 | 만점 |
seconds |
게시물은 실시간이어야 한다 | 감점 — 근거가 약하고 대가가 큽니다 |
max |
거의 안 바뀐다 | 감점 — 게시물은 계속 올라옵니다 |
minutes와 hours는 둘 다 만점입니다. 이유를 적었느냐가 점수예요.
자주 나오는 실수
cacheLife를 빼먹고'use cache'만 붙임 — 빌드는 통과하고 표에는15m 1y가 뜹니다. 우리가 정한 값이 아니에요. 표의 숫자가 프리셋 표의 어느 줄과도 안 맞으면 안 적었다는 뜻입니다.fetchPosts에 그냥'use cache'를 붙임 — 그러면 홈 피드까지 같이 굳습니다. 지난 시간에<Suspense>로 풀어놓은 것이 도로 굳어요. 탐색용 함수를 따로 만드는 게 맞습니다.- 이름표를
explore로 붙임 — 틀린 건 아니지만, 이 데이터의 정체는 "게시물 목록" 이지 "탐색 화면"이 아닙니다. 나중에 다른 화면이 같은 데이터를 쓰면 이름이 어긋나요.
💡 튜터의 한마디 — 이 과제에서 점수가 갈리는 칸은 채점표의 네 번째 줄입니다. minutes 든 hours 든 코드는 한 줄 차이인데, 왜 그걸 골랐는지를 못 적으면 다음에 다른 화면에서 또 처음부터 고민하게 돼요. 고른 이유를 cacheLife 옆 주석으로 남기는 습관을 들이세요. 반년 뒤에 이 값을 의심할 사람이 바로 본인입니다.
🎯 [과제 2 예시답안] 표에 뜨는 수명은 누구 것인가 (25점)
채점 포인트
| 항목 | 배점 | 기준 |
|---|---|---|
| 세 가지 경우를 모두 실행 | 5 | 없음 / hours / seconds 셋 다 빌드했다 |
| 표의 숫자를 정확히 기록 | 5 | 15m 1y · 1h 1d · (열 없음) 을 적었다 |
seconds 결과를 예상하고 확인 |
5 | 예상을 먼저 적었다 (틀려도 만점) |
seconds만 다른 이유 설명 |
5 | 프리셋 표에서 seconds 행의 특징을 찾았다 |
| 왜 답이 아닌지 서술 | 5 | 지난 시간 결론(피드가 도로 굳는다)과 연결했다 |
세 번 빌드한 결과
Revalidate Expire
FeedList 에 'use cache' 만 ◐ / 15m 1y
+ cacheLife('hours') ◐ / 1h 1d
+ cacheLife('seconds') ◐ / (열 없음)
hours를 걸었더니 표가 1h 1d로 바뀌는데, 프리셋 표의 hours 행(revalidate 1시간 · expire 1일)과 정확히 같아요. 표에 뜨는 두 숫자는 그 화면 전체의 수명이고, 그 화면에서 가장 바깥에 있는 캐시가 그 값을 정합니다.
Step 2에서 fetchProfile에 hours를 걸었을 때 아무 열도 안 생긴 이유가 이겁니다. fetchProfile은 화면 안쪽 조각의 수명일 뿐 화면 자체의 수명이 아니거든요.
seconds는 왜 열이 사라지나
프리셋 표에서 seconds 행만 가진 특징을 찾아봅시다.
| 프로필 | expire |
|---|---|
seconds |
1분 |
minutes |
1시간 |
hours |
1일 |
| 나머지 | 더 김 |
seconds만 expire가 5분보다 짧습니다.
수명이 이렇게 짧으면 미리 그려두는 것이 의미가 없어요. 빌드할 때 그려놨는데 배포되기도 전에 만료되니까, Next는 이런 조각을 미리 그리기에서 아예 빼버립니다. 요청이 올 때 채우는 구멍이 되는 거예요.
열이 사라진 것은 "수명이 없다"가 아니라 "이 화면에 미리 그려진 수명이라는 게 없다"는 뜻입니다.
같은 규칙이 걸리는 조건이 몇 개 더 있습니다. revalidate가 0 이거나, stale이 30초보다 짧을 때도 그래요. 뒤엣것은 이유가 재미있는데, 링크에 마우스를 올려 미리 받아둔 데이터가 클릭하기도 전에 만료되면 아무 소용이 없기 때문입니다.
그런데 이게 답이 아닙니다
지난 시간 과제에서 이미 결론이 나 있죠. FeedList에 'use cache'를 붙이면 홈이 ◐에서 ○로 돌아가고, 연습용 서버를 꺼도 게시물이 그대로 뜹니다. 지난 시간 Step 3에서 <Suspense>로 애써 풀어놓은 것이 도로 굳는 거예요.
수명을 붙이면 좀 나아 보일 뿐입니다. hours를 걸면 새 게시물이 최대 한 시간 늦게 보이고, seconds를 걸면 미리 그리기에서 빠지니 결국 지난 시간에 한 것과 같아지는데 코드만 복잡해져요.
피드는 굳히지 않는 것이 답입니다. 이 과제는 "표의 숫자가 어디서 오는가"를 보기 위한 실험이지 개선안이 아니에요.
자주 나오는 실수
1y를 보고 "1년마다 갱신"으로 읽음 —1y는expire열입니다. 갱신 주기는 앞의15m이에요. 세 값 중 표에 뜨는 것은 뒤의 둘뿐입니다.seconds로 열이 사라진 것을 오류로 봄 — 빌드는 종료 코드 0으로 통과합니다. 사라진 게 정상 동작이에요.- 되돌리는 것을 잊음 — 다음 시간 코드가 어긋납니다. 실험이 끝나면 원래대로 돌려두세요.
💡 튜터의 한마디 — 이 과제의 핵심은 "내가 정한 수명이 어디에 나타나는지"를 눈으로 확인하는 것입니다. Step 2에서 hours를 걸었는데 표에 아무것도 안 떴던 게 찜찜했다면, 그 찜찜함이 정확한 감각이었어요. 표는 화면 전체를 말하고 우리가 건 것은 함수 하나였으니까요. 도구가 보여주는 숫자가 무엇의 숫자인지 확인하는 습관이 이 과제의 진짜 학습 목표입니다.
🎯 [과제 3 예시답안] 없는 사람도 굳는다 (25점)
채점 포인트
| 항목 | 배점 | 기준 |
|---|---|---|
max로 바꾸고 빌드 확인 |
5 | 아무 에러도 안 난다는 것을 확인했다 |
| 문제 상황을 구체적으로 서술 | 5 | 새로 가입한 사람의 화면과 지속 기간을 적었다 |
| 지속 기간을 프리셋 표에서 확인 | 5 | max의 revalidate 30일을 근거로 들었다 |
| 조건부 수명으로 고침 | 5 | 갈라지는 길마다 다른 cacheLife를 불렀다 |
| 고친 뒤 동작 확인 | 5 | 빌드 통과 + 두 주소가 모두 제대로 나온다 |
문제 상황
max를 걸면 빌드는 아무 말 없이 통과합니다. 그런데 프리셋 표를 보면요.
| 프로필 | revalidate |
|---|---|
max |
30일 |
오늘 가입한 사람이 자기 프로필 주소를 친구에게 보냈다고 해봅시다. 그 주소가 이 앱에서 처음 열리는 순간이 하필 가입 직전이었다면, findProfile은 null을 받아 캐시에 넣고, 그 null은 30일 동안 유지돼요.
친구가 열면 "없는 주소예요"가 뜹니다. 당사자가 열어도 마찬가지고요. 회원가입은 정상적으로 됐고, 데이터베이스에도 잘 들어가 있고, API 서버도 제대로 답하는데, 화면만 한 달 동안 그 사람이 없다고 말합니다.
여기서 중요한 것은 이게 오류로 안 잡힌다는 점입니다. 빌드 통과, 상태 코드 정상, 에러 로그 없음. 문의가 들어와야 알 수 있어요.
고치기
cacheLife는 갈라지는 길마다 다르게 부를 수 있습니다. 한 번 호출에 한 번만 실행되면 되니까요.
// apps/web-next/lib/api.ts
export async function findProfile(username: string): Promise<Profile | null> {
'use cache';
cacheTag('profile', `profile:${username}`);
const response = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}`);
if (response.status === 404) {
// 아직 가입 안 했을 수도 있다 — 없다는 답은 짧게만 들고 있는다.
cacheLife('minutes');
return null;
}
// 있는 사람이 사라지는 일은 드물다.
cacheLife('days');
// ... 나머지는 그대로
}
if 안쪽과 바깥쪽에 하나씩 뒀습니다. 한 번 부를 때 둘 중 하나만 실행되니까 규칙을 안 어겨요.
이제 없다는 답은 1분마다 다시 확인하고, 있다는 답은 하루를 갑니다. 새로 가입한 사람은 1분 안에 보이고, 이미 있는 사람 조회는 하루에 한 번만 서버를 부릅니다.
이 방식이 왜 좋은가
한 함수 안에서도 결과에 따라 확신의 정도가 다릅니다.
"이 사람 있어요" -> 거의 안 뒤집힌다. 오래 들고 있어도 안전
"이 사람 없어요" -> 언제든 뒤집힐 수 있다. 짧게 들고 있어야 안전
이걸 하나의 수명으로 묶으면 둘 중 하나는 반드시 손해를 보는데, 길게 잡으면 새 사용자가 막히고 짧게 잡으면 멀쩡한 조회가 계속 서버를 부르죠.
같은 사고방식이 다른 데서도 쓰입니다. 아직 공개 전인 글, 품절된 상품, 처리 중인 주문처럼 "지금은 아니지만 곧 바뀔 수 있는" 상태는 전부 짧게 잡는 것이 맞아요.
자주 나오는 실수
if앞에cacheLife를 하나 두고if안에 또 둠 — 두 번 실행됩니다. 갈라진 길에 각각 하나씩 두세요.null을 안 굳히려고'use cache'를 통째로 뗌 — 그러면 프로필 화면이 매 요청마다 서버를 부릅니다. 문제는 캐시가 아니라 수명이었어요.- 원래대로
hours로만 되돌림 — 그것도 30일보다는 훨씬 낫지만, 새 사용자는 여전히 최대 한 시간 막힙니다. 갈라놓는 쪽이 정답입니다.
💡 튜터의 한마디 — 이 과제가 무서운 이유는 아무것도 안 터지기 때문입니다. 빌드 통과, 상태 코드 정상, 에러 로그 없음인데, 갓 가입한 사용자에게는 서비스가 통째로 없는 것처럼 보이죠. 실무에서 이런 종류는 CS 문의로 처음 발견되고, 그때쯤이면 이미 여러 명이 겪은 뒤입니다. "성공했을 때"만 생각하고 수명을 정하지 마세요. 실패한 답도 똑같이 캐시됩니다.
🎯 [과제 4 예시답안] 같은 규칙, 다른 시점 (25점)
채점 포인트
| 항목 | 배점 | 기준 |
|---|---|---|
| 결과를 먼저 예상 | 5 | 예상을 적고 실행했다 (틀려도 만점) |
| 빌드 결과를 정확히 기록 | 5 | 종료 코드 1 과 라우트 개수를 적었다 |
| Step 5와의 차이를 서술 | 5 | 한쪽은 빌드, 한쪽은 런타임이라는 것을 짚었다 |
| 차이의 이유 설명 | 5 | 미리 그리는 동안 실제로 불리느냐로 설명했다 |
| 되돌림 | 5 | 실험 후 원상 복구했다 |
결과
종료 코드 1 · Error 8건
Route / used `cookies()` inside "use cache"
Route /explore used `cookies()` inside "use cache"
Route /[username] used `cookies()` inside "use cache"
Route /_not-found used `cookies()` inside "use cache"
at TextScaleStyle (app/components/TextScaleStyle.tsx:8:28)
at RootLayout (app/layout.tsx:22:11)
빌드가 죽습니다. 그것도 화면 네 개 전부에서요. Step 5에서는 빌드가 멀쩡히 통과했는데 말이죠.
에러 메시지 자체는 한 글자도 안 다릅니다. 같은 규칙을 어긴 것이 맞아요. 다른 것은 언제 걸리느냐뿐입니다.
왜 시점이 다른가
두 함수가 언제 불리는지를 따라가 봅시다.
TextScaleStyle fetchProfile
root layout 안에 직접 놓여 있다 ProfilePage 가 부른다
│ │
빌드할 때 모든 화면을 미리 그린다 그 화면은 <Suspense> 안쪽이라
│ 미리 그리기에서 빠져 있다
이 함수가 실제로 실행된다 │
│ 빌드 때는 아예 안 불린다
그 자리에서 규칙 위반이 잡힌다 │
요청이 와야 처음 불린다
│
거기서 터진다
핵심은 미리 그리는 동안 이 코드가 실제로 도느냐입니다.
TextScaleStyle은 가장 바깥 껍데기에 직접 놓여 있어서 모든 화면을 미리 그릴 때마다 불립니다. 그러니 빌드가 규칙 위반을 직접 목격해요.
fetchProfile은 다릅니다. 프로필 화면 본문에서 불리는데, 그 본문은 이미 요청 때 채우는 구멍이라 빌드할 때는 실행조차 안 됩니다. 빌드가 못 본 거예요.
여기서 얻을 것
"빌드가 통과했다"는 "규칙을 안 어겼다"가 아니라 "빌드가 그 코드를 실행해봤는데 괜찮았다"는 뜻입니다.
실행 안 된 코드에 대해서는 빌드가 아무것도 보장하지 않고, 요청 때만 도는 코드는 갈수록 많아집니다. 오늘 우리가 껍데기를 넓히면서 만든 구멍들이 전부 그런 자리라서, 실무에서는 빌드 통과만으로 배포하지 않습니다. 실제로 띄워서 주요 화면을 한 번씩 열어보는 단계를 따로 둬요. 오늘 Step 5에서 우리가 한 것과 정확히 같은 일입니다.
자주 나오는 실수
- "Step 5는 통과했으니 캐시 안 쿠키가 어떤 경우엔 괜찮다" 고 결론 — 아닙니다. 규칙은 하나예요. 통과한 게 아니라 안 걸린 겁니다.
'use cache'를<Suspense>안으로 옮기면 될 거라고 생각 —TextScaleStyle은 이미<Suspense>안에 있습니다. 경계 문제가 아니라 캐시 안에서 쿠키를 읽었다는 것 자체가 문제예요.- 에러가 8건이라 "다른 문제 8개"로 셈 — 같은 문제 하나가 화면 네 개에 걸린 것입니다. 화면마다 위반 하나와 요약 한 줄이 붙어요.
💡 튜터의 한마디 — 두 실험을 나란히 놓으면 이런 표가 나옵니다.
| 어디에 넣었나 | 빌드 | 언제 드러나나 | |
|---|---|---|---|
| Step 5 | fetchProfile(페이지 안쪽) |
통과 | 요청이 와서 화면이 깨질 때 |
| 과제 4 | TextScaleStyle(껍데기 직속) |
죽음 | 빌드 중 |
같은 규칙, 같은 에러 문장, 다른 시점. 실무에서 무서운 쪽은 위입니다. 아래는 배포 전에 잡히지만 위는 사용자가 먼저 만나거든요. 그래서 빌드 통과를 배포 승인으로 쓰지 않고, 띄워서 주요 화면을 한 번씩 열어보는 단계를 따로 둡니다.
생각해볼 주제 예시답안
1. 그 기한은 누가 정하나
문제 상황 요약
cacheLife('hours') 한 줄은 코드로는 사소하지만, 그 안에는 "팔로워 수가 최대 한 시간 틀려도 된다"는 약속이 들어 있습니다. 이 약속을 개발자가 혼자 해도 되는 걸까요.
튜터의 가이드 및 해설
먼저 이 질문의 성격을 정확히 봅시다. 수명은 성능 설정처럼 보이지만 실제로는 서비스가 사용자에게 하는 약속입니다. "이 숫자는 실시간입니다"와 "이 숫자는 대략입니다"는 완전히 다른 제품이에요.
물어볼 대상은 이렇게 나뉩니다.
| 물어볼 사람 | 물어볼 것 |
|---|---|
| 기획·운영 | 이 값이 한 시간 늦어도 사용자가 납득하나요 |
| 데이터 | 실제로 얼마나 자주 바뀌나요 |
| 백엔드 | 굳히지 않으면 이 조회가 몇 배로 늘어나나요 |
| 법무·규정 | 정확한 시점이 기록으로 남아야 하는 값인가요 |
마지막 줄을 낯설게 보실 텐데, 돈이나 재고나 개인정보와 얽힌 화면에서는 이게 결정적입니다. "대략 맞는 숫자"가 허용 안 되는 영역이 실제로 있어요.
기본값 이야기로 넘어가 봅시다. 오늘 우리는 아무것도 안 적었는데 15분이 붙어 있는 것을 봤습니다. 그 15분은 우리 서비스를 한 번도 본 적 없는 사람이 정한 숫자예요.
그렇다고 기본값이 항상 나쁜 것은 아닙니다. 프레임워크가 정한 기본값은 대부분의 경우에 무난한 값으로 고른 거예요. 문제는 무난한 값이 우리 화면에도 무난한지 아무도 확인 안 했다는 데 있습니다.
그래서 판단 기준은 "기본값을 쓰느냐"가 아니라 "기본값이라는 것을 알고 쓰느냐"입니다. 공식 문서가 모든 'use cache'마다 수명을 적으라고 권하는 이유가 이거예요. cacheLife('default')라고 명시적으로 적어두면, 그건 기본값을 고른 것이지 몰랐던 것이 아닙니다.
실무에서는 이렇게 정리하면 좋습니다. 값이 틀렸을 때 누가 얼마나 다치는지를 먼저 보고, 그다음 얼마나 자주 바뀌는지와 얼마나 자주 읽히는지를 비교하고, 무엇을 골랐든 코드에 적어두세요. 반년 뒤에 이 값을 의심하게 될 사람에게 남기는 메모입니다.
🎯 면접관을 홀리는 핵심 멘트
"캐시 수명은 성능 설정이 아니라 서비스가 사용자에게 하는 약속이라고 봅니다. 그래서 저는 혼자 정하지 않고 기획과 백엔드에 각각 다른 질문을 던집니다. 그리고 기본값을 쓰기로 했더라도 반드시 코드에 명시합니다. 기본값을 고른 것과 기본값이 있는 줄도 몰랐던 것은 반년 뒤에 완전히 다른 문제가 되거든요."
2. 나눠 쓸 수 없는 값
문제 상황 요약
오늘 쿠키와 주소가 둘 다 캐시 안에서 막혔습니다. 이유는 하나였어요. 캐시는 여러 사람이 나눠 쓰는 것인데 그 값들은 사람마다 다르고, 쿠키 하나를 읽기 시작하자 우리 앱에서 완전히 정적인 화면이 사라졌습니다.
튜터의 가이드 및 해설
먼저 "개인화가 늘어나면 캐시할 게 없어진다"는 결론부터 의심해봅시다. 얼핏 맞는 말 같지만, 화면을 한 덩어리로 볼 때만 맞습니다.
인스타그램 프로필 화면을 뜯어보면 이렇게 갈립니다.
| 조각 | 누구에게나 같은가 |
|---|---|
| 게시물 목록·프로필 사진·팔로워 수 | 같다 |
| 팔로우 버튼이 「팔로우」인지 「팔로잉」인지 | 다르다 |
| 글자 크기·다크모드 | 다르다 |
개인화된 부분은 화면의 아주 작은 조각입니다. 나머지 대부분은 누가 봐도 같아요. 그러니 문제는 개인화의 양이 아니라 그것을 어디에 두느냐입니다.
오늘 우리가 한 것이 정확히 그 작업이었어요. 처음엔 쿠키를 가장 바깥에서 읽어서 화면 네 개를 한꺼번에 무너뜨렸습니다. 조각 하나로 떼어내니 아무 일도 안 일어났고요. 같은 개인화인데 위치만 바꿨습니다.
그럼 ○가 사라진 것은 손해일까요. 여기서 정직해야 합니다. 손해는 손해예요. 완전히 정적인 화면은 CDN이 통째로 들고 있다가 우리 서버를 안 거치고 답할 수 있는데, 그게 없어진 거니까요.
다만 대가를 정확히 재봐야 합니다. 우리 경우 요청 때 채워지는 것은 스타일 한 줄이고 나머지 내용은 여전히 미리 그려져 있습니다. 실측하면 /explore의 첫 바이트는 5.3ms → 5.8ms로 거의 그대로이고, 전체는 5.3ms → 18ms로 늘었어요. 절대값이 작아 사람은 못 느끼지만 방향은 분명히 나빠졌습니다.
그러니 정확히는 이렇습니다. 잃은 것의 본체는 "CDN이 통째로 답할 수 있음"이고, 체감 속도는 아직 멀쩡하지만 공짜는 아니었다는 것.
실무에서 선을 그을 때 저는 이 순서로 봅니다. 먼저 개인화가 첫 화면에 꼭 보여야 하는가를 묻습니다. 스크롤을 내려야 보이는 것이라면 나중에 채워도 아무도 모릅니다. 그다음 개인화된 조각을 얼마나 작게 만들 수 있는가를 봅니다. 화면 전체가 아니라 버튼 하나면 그 버튼만 떼면 되니까요.
🎯 면접관을 홀리는 핵심 멘트
"개인화와 캐시는 대립하는 게 아니라 입도의 문제라고 봅니다. 실제로 개인화된 부분은 화면의 몇 퍼센트뿐인데, 그걸 최상단에서 읽으면 화면 전체가 캐시 불가가 됩니다. 저는 개인화된 데이터를 읽는 지점을 최대한 깊은 컴포넌트로 밀어 넣고, 그 조각만 요청 시점에 채워지게 설계합니다."
3. 못 고치는 것을 만났을 때
문제 상황 요약
404 상태 코드는 구조를 아무리 잘 짜도 안 돌아왔습니다. 껍데기를 먼저 내보내기로 한 이상 상태 코드는 그 앞에서 정해지고, 그건 우리 코드의 문제가 아니라 순서의 문제였습니다.
튜터의 가이드 및 해설
첫 번째로 확인할 것은 "이게 정말 우리에게 필요한가"입니다.
오늘 정리했듯이 셋 중 둘은 이미 지켜지고 있어요. 사용자는 제대로 된 안내 화면을 보고, 검색 엔진은 색인하지 않습니다. 남은 것은 상태 코드로만 판단하는 기계들이에요.
그러니 이렇게 물어야 합니다. 우리 조직에 그 상태 코드를 읽는 기계가 실제로 있는가?
| 있다면 | 없다면 |
|---|---|
| 404 비율을 감시하는 대시보드 | 아무도 안 보는 숫자를 위해 |
| 규정상 상태 코드를 남겨야 하는 요구 | 모든 요청에 확인 한 번을 추가하는 셈 |
| 외부 파트너가 상태 코드로 분기 |
없는데도 "원칙적으로 맞으니까"로 고치는 것은 비용만 내고 아무도 안 쓰는 정확성입니다. 오늘 배운 대로, 이걸 고치려면 모든 프로필 요청 앞에 존재 확인이 하나씩 붙어요. 그 비용은 매일 나갑니다.
두 번째로 확인할 것은 프레임워크의 권고가 무엇이냐입니다.
오늘 우리가 만난 것은 막다른 길이 아니었습니다. 문서가 "이건 여기서 못 한다" 고 말하면서 동시에 어디서 하라고 알려줬어요. 이건 좋은 신호입니다. 설계자가 이 상황을 알고 있고 답을 준비해뒀다는 뜻이니까요.
진짜로 조심해야 할 것은 문서가 아무 말도 안 해주는 경우입니다. 그때 프레임워크의 방식을 우회하기 시작하면, 그 우회는 다음 버전에서 깨질 확률이 높아요.
세 번째는 우회를 택했을 때의 기록입니다.
만약 권고를 안 따르기로 했다면, 그 이유를 코드에 남겨야 합니다. 오늘 우리가 지난 시간의 instant = false를 걷어낼 수 있었던 것은 그 옆에 왜 달았는지가 적혀 있었기 때문이에요. 주석이 없었으면 그 줄은 아무도 못 건드리는 채로 몇 년을 갔을 겁니다.
🎯 면접관을 홀리는 핵심 멘트
"프레임워크가 못 하게 막는 것을 만나면 저는 먼저 '우리에게 그게 정말 필요한가'를 확인합니다. 필요 없는데 원칙 때문에 고치는 것이 제일 비싸거든요. 그다음 문서가 대안을 제시했는지를 봅니다. 대안이 있으면 설계자가 이 상황을 알고 있다는 뜻이라 그 길을 따르고, 대안 없이 우회해야 한다면 그 결정과 이유를 반드시 코드에 남깁니다. 다음 사람이 지워도 되는지 판단할 수 있어야 하니까요."