D-3: 서버 vs 클라이언트 컴포넌트 ① — 경계를 긋는다
목차 49
질문 하나로 시작할게요.
지금 우리 앱에는 컴포넌트가 아홉 개 있습니다. 피드, 탐색, 프로필, 바깥 껍데기, 프로필 껍데기, 머리말, 검색칸, 없는 주소, 터진 화면.
이 중에 브라우저에서 도는 건 몇 개일까요?
안녕하세요, 홍순구입니다. 아마 "아홉 개 다요"라고 답하셨을 거예요. React로 만들었고 React는 브라우저에서 도는 것이니까, 당연히 전부 브라우저에서 돈다. 순수 자바스크립트만 하다 넘어왔다면 이보다 자연스러운 답이 없습니다.
답은 셋입니다.
나머지 여섯은 브라우저에 자바스크립트가 아예 안 갔어요. 우리가 지난 시간에 그렇게 만들어놓고도 몰랐던 겁니다.
셋이 무엇인지는 사실 이미 아십니다. 지난 시간에 파일 세 개(머리말, 검색칸, 에러 화면)에 'use client' 한 줄을 붙였는데, 그때마다 "이게 무엇을 가르는지는 다음에"라고 미뤄뒀습니다.
오늘 그 한 줄을 정면으로 다룹니다.
💡 오늘 수업의 핵심 — "브라우저로 보낼 것을 우리가 고릅니다"
지금까지 여러분이 만든 React 앱은 전부 브라우저에서 돌았습니다. index.html이 거의 비어 있고, 자바스크립트가 도착해야 화면이 그려졌죠. C-10에서 "검색 엔진이 흰 종이를 본다" 고 했던 그 구조입니다.
Next는 반대라서, 아무 표시도 안 하면 서버에서 돕니다. 브라우저에서 돌게 하려면 그렇게 하겠다고 따로 말해야 하는데, 그러다 보니 무엇을 서버에 두고 무엇을 브라우저로 보낼지 새로운 결정이 하나 생깁니다. 이 선을 어디에 긋느냐로 사용자가 받는 바이트가 달라지고, 심지어 화면에 안 보이는 데이터까지 딸려 갈 수 있습니다.
오늘은 그 선을 긋는 법만 다룹니다. 서버가 데이터를 직접 가져오는 이야기는 다음 시간이에요.
🎯 학습 목표
- 지금까지 만든 컴포넌트가 어디서 돌고 있었는지 직접 확인합니다.
'use client'한 줄이 파일 하나가 아니라 어디까지 영향을 주는지 실험으로 알아냅니다.- 경계 밖으로는 자바스크립트가 안 간다는 것을 바이트로 잽니다.
- 좋아요 버튼만 브라우저로 내려보내고, 그 값이 얼마인지 확인합니다.
- 경계를 건널 수 있는 값과 없는 값을 가릅니다.
오늘의 여정
1. 이미 서버에서 돌고 있었다 심어서 확인한다
2. 'use client' 가 긋는 선 파일 하나가 아니라 그 아래 전부
3. 경계 밖으로는 JS 가 안 간다 실제로 받는 바이트를 잰다
4. 좋아요만 브라우저로 경계를 잎에 둔다
5. 경계를 건너는 값 함수는 못 건넌다
Step 1: "이미 서버에서 돌고 있었습니다"
셋과 여섯을 갈라봅시다
app/ 아래를 훑어보면 이렇습니다.
app/layout.tsx 표시 없음
app/page.tsx 표시 없음
app/explore/page.tsx 표시 없음
app/[username]/layout.tsx 표시 없음
app/[username]/page.tsx 표시 없음
app/not-found.tsx 표시 없음
app/[username]/error.tsx ← 'use client'
app/components/HeaderNav.tsx ← 'use client'
app/components/UserSearch.tsx ← 'use client'
표시가 붙은 셋에는 공통점이 있는데, 전부 훅(usePathname, useRouter, useState)을 쓰고 에러 화면은 버튼을 눌러야 하니 onClick이 필요하죠.
나머지 여섯에는 훅도 없고 onClick도 없습니다. 그냥 데이터를 받아 화면 모양을 만들어 돌려줄 뿐이라, 그 여섯은 어디서 도는 걸까요? 말로 설명하는 것보다 심어서 보는 게 빠릅니다.
심어서 확인해봅시다
app/page.tsx에 줄 하나만 넣어보세요.
// apps/web-next/app/page.tsx
import { feedPosts } from '@/lib/posts';
export default function HomePage() {
console.log('[서버] 피드를 그립니다');
return (
<main className="mx-auto max-w-xl p-6">
<h1 className="mb-4 text-xl font-bold">피드</h1>
프로필에도 하나 넣습니다. 이쪽은 누구의 프로필인지도 같이 찍어볼게요.
// apps/web-next/app/[username]/page.tsx
export default async function ProfilePage({ params }: PageProps<'/[username]'>) {
const { username } = await params;
console.log('[서버] 프로필을 그립니다:', username);
const posts = postsByUsername(username);
그리고 빌드합니다.
npm run build
빌드 로그를 잘 보세요. 이런 줄이 지나갑니다.
✓ Compiled successfully in 281ms
[서버] 피드를 그립니다
브라우저를 열지도 않았는데 벌써 찍혔어요. HomePage가 빌드하는 동안 한 번 돌았다는 뜻입니다.
요청할 때마다 도는 것도 있습니다
이번엔 서버를 띄우고 주소를 몇 개 두드려봅시다.
npm run start
/jaehoon, /minji, 그리고 / 순서로 들어가보세요. 그리고 브라우저가 아니라 서버를 띄운 터미널을 봅니다.
[서버] 프로필을 그립니다: jaehoon
[서버] 프로필을 그립니다: minji
두 줄만 찍혔습니다. 프로필은 들어갈 때마다 찍히는데, 피드는 아무리 들어가도 다시 안 찍혀요. 빌드 때 이미 한 번 돌아서 결과를 만들어뒀기 때문입니다.
이 갈림은 사실 지난 시간에 이미 보셨어요. 빌드 표의 동그라미와 물결 표시요.
○ / 빌드 때 미리 그려둔다
ƒ /[username] 요청이 올 때 그린다
동그라미는 미리 그려두는 것, 물결은 요청을 받고 그리는 것. 우리가 심은 로그가 정확히 그 표대로 찍혔습니다.
그런데 브라우저에는 한 글자도 안 갔습니다
여기가 오늘의 출발점입니다. 브라우저 개발자 도구의 콘솔을 열어보세요.
비어 있습니다.
더 확인해볼까요. 응답으로 받은 HTML 안에도, 내려받은 자바스크립트 파일 안에도 그 문구가 없습니다.
[서버] 프로필을 그립니다 가 어디에 있나
서버 터미널 → 찍힌다
브라우저 콘솔 → 아무것도 없음
응답 HTML 안 → 0 건
내려받은 JS 안 → 0 개 파일
console.log는 코드입니다. 그 코드가 브라우저로 갔다면 어떤 형태로든 자바스크립트 파일 안에 남아 있어야 해요. 그런데 없습니다.
컴포넌트가 브라우저로 안 간 겁니다. 서버에서 실행돼서, 그 결과인 글자만 브라우저로 갔어요.
⚠️ 확인이 끝났으면 심어둔 console.log 두 줄은 지워주세요. 앞으로 계속 쓸 코드가 아닙니다.
그래서 이름이 서버 컴포넌트입니다
이게 서버 컴포넌트(Server Component)입니다. 서버에서 실행되고, 브라우저에는 실행 결과만 보내는 컴포넌트인데, Next에서는 이게 기본값이라 아무 표시도 안 하면 서버 컴포넌트가 돼요. 우리가 여섯 개를 서버 컴포넌트로 만든 게 아니라, 아무것도 안 했더니 서버 컴포넌트가 된 겁니다.
순수 자바스크립트에서 React로 넘어오신 분들에게 이건 꽤 큰 재배선이에요. "React 컴포넌트 = 브라우저에서 도는 것"이라는 전제가 여기서 깨집니다. C-1부터 C-10까지 만든 SPA에서는 그 전제가 맞았어요. 그때는 브라우저 말고 코드가 돌 곳이 없었으니까요.
Next에는 돌 곳이 두 군데입니다. 그래서 고를 수 있게 됐고, 고를 수 있게 되니 고르는 법을 알아야 합니다.
💡 한 줄 정리
아무 표시도 안 한 컴포넌트는 서버에서 돌고 브라우저에는 결과만 갑니다. 지금까지 만든 아홉 개 중 여섯이 그랬어요.
🙋 학생 질문 — "튜터님, 서버에서 돈다면 사람이 들어올 때마다 매번 도는 건가요?"
아니요. 방금 실험에서 갈렸죠.
프로필(/jaehoon)은 들어갈 때마다 찍혔지만, 피드(/)는 빌드할 때 한 번 찍히고 그 뒤로는 아무리 들어가도 안 찍혔습니다.
피드는 누가 보든 같은 화면이라 Next가 미리 그려서 저장해뒀다가, 요청이 오면 그 결과를 그냥 꺼내 줍니다. 반대로 프로필은 주소의 이름에 따라 화면이 달라지니 미리 그려둘 수가 없어서, 요청을 받고 나서 그립니다.
"서버에서 돈다"와 "요청마다 돈다"는 다른 말이에요. 무엇이 어느 쪽으로 갈리는지, 그리고 우리가 그걸 어떻게 정하는지는 캐싱을 다루는 시간에 제대로 봅니다. 오늘은 둘 다 브라우저가 아니라 서버에서 돈다는 것만 챙기면 충분합니다.
🙋 학생 질문 — "서버에서 돌면 화면이 아예 안 움직이는 것 아닌가요?"
지금 피드 화면은 실제로 아무 반응이 없어요. 좋아요 숫자는 글자일 뿐이고 눌러도 아무 일이 안 일어납니다.
그런데 머리말은 다릅니다. 검색칸에 글자를 치면 입력이 되고, 이동을 누르면 프로필로 갑니다. 지금 어느 화면인지에 따라 글자 굵기도 바뀌고요.
그게 'use client'를 붙인 셋이 하는 일이에요. 화면 전체가 아니라 그 부분만 브라우저에서 돌고 있습니다.
한 화면 안에 서버에서 온 부분과 브라우저에서 도는 부분이 섞여 있는 거예요. 그 둘이 어디서 갈리는지가 바로 다음 Step입니다.
Step 2: "'use client'가 긋는 선"
세 파일에 붙어 있는데, 하나는 이상합니다
지시어가 붙은 셋을 다시 봅시다.
app/[username]/error.tsx 'use client'
app/components/HeaderNav.tsx 'use client'
app/components/UserSearch.tsx 'use client'
앞의 둘은 이유가 분명해요. 에러 화면은 버튼을 눌러야 하고, 머리말은 usePathname을 쓰는데, 세 번째는 좀 이상합니다. UserSearch는 아무 데서도 직접 안 불리고, HeaderNav가 불러다 씁니다.
// apps/web-next/app/components/HeaderNav.tsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { UserSearch } from './UserSearch';
여기서 질문이 하나 생깁니다. HeaderNav가 이미 브라우저에서 도는데, 거기서 불러 쓰는 UserSearch에 그 한 줄이 또 필요할까요?
지워보면 압니다.
실험 A — 검색칸에서만 지워본다
app/components/UserSearch.tsx의 'use client'; 한 줄만 지우고 빌드해보세요. HeaderNav는 그대로 둡니다.
npm run build
✓ Compiled successfully in 237ms
아무 일도 안 일어납니다. 화면도 그대로고, 검색칸에 글자를 쳐도 잘 되고, 이동 버튼도 멀쩡해요.
이상하죠. UserSearch는 useRouter와 useState를 쓰는데, 브라우저에서 돌겠다는 표시가 없는데도 잘 돕니다.
실험 B — 머리말에서도 지워본다
이번엔 HeaderNav의 'use client';도 지웁니다. 이제 두 파일 다 표시가 없어요.
Error: Turbopack build failed with 3 errors:
Error: You're importing a module that depends on `usePathname` into a
React Server Component module. This API is only available in Client
Components. To fix, mark the file (or its parent) with the
`"use client"` directive.
Error: You're importing a module that depends on `useRouter` into a
React Server Component module. ...
Error: You're importing a module that depends on `useState` into a
React Server Component module. ...
빌드가 실패했는데, 에러가 세 개라는 걸 보세요.
usePathname— 이건HeaderNav것입니다.useRouter— 이건UserSearch것이에요.useState— 이것도UserSearch것입니다.
우리는 방금 HeaderNav에서 한 줄을 지웠을 뿐인데, UserSearch가 같이 무너졌습니다.
실험 A의 답이 여기 있습니다
두 실험을 나란히 놓으면 답이 나옵니다.
실험 A HeaderNav 'use client' 있음
UserSearch 없음 → 둘 다 멀쩡
실험 B HeaderNav 없음
UserSearch 없음 → 둘 다 터짐 (에러 3건)
UserSearch의 운명이 자기 파일이 아니라 HeaderNav에 달려 있었으니, 'use client'는 그 파일 하나에만 붙는 표시가 아니라 그 파일에서 시작해서, 거기서 불러다 쓰는 것들까지 전부 브라우저 쪽으로 끌고 가는 셈입니다.
app/layout.tsx 서버
│
└─ HeaderNav.tsx 'use client' ←── 선은 여기서 그어진다
│
└─ UserSearch.tsx ←── 표시가 없어도 딸려 들어온다
Next의 에러 문구가 이미 그 이야기를 하고 있었는데, 다시 읽어보세요.
To fix, mark the file (or its parent) with the
"use client"directive.
그 파일에 붙이거나, 부모에 붙이거나. 부모에 붙이면 자식은 안 붙여도 된다는 뜻입니다.
그럼 왜 검색칸에도 붙여뒀을까요
지워도 되는데 왜 붙어 있을까요. 실무에서 셋 다 붙여두는 이유가 있습니다.
UserSearch는 지금 HeaderNav 안에서만 쓰이지만, 나중에 다른 데서 쓸 수도 있어요. 그때 그 다른 곳이 서버 컴포넌트라면 그 순간 터지는데, 파일 하나만 보고는 "이게 안전한가"를 알 수 없는 겁니다.
훅을 쓰는 파일에는 그냥 붙여두세요. 중복이 있어도 아무 손해가 없고, 파일 하나만 봐도 어디서 도는지 알 수 있습니다.
⚠️ 실험이 끝났으면 두 파일의 'use client';를 원래대로 되돌려주세요. 빌드가 다시 통과해야 합니다.
💡 한 줄 정리
'use client'는 파일 하나가 아니라 거기서부터 불러다 쓰는 것 전부에 걸리기 때문에, 이 한 줄을 어디에 두느냐가 곧 "무엇을 브라우저로 보낼 것인가"의 답이 됩니다.
🙋 학생 질문 — "그럼 맨 위 layout.tsx에 한 번 붙이면 편하지 않나요?"
그렇게 하면 어떻게 되는지는 과제로 남겨뒀습니다. 재보시면 결과가 예상과 다를 거예요.
힌트만 드리면, 방금 배운 규칙을 글자 그대로 읽으셔야 합니다. 선은 import를 따라 내려갑니다. 그런데 app/layout.tsx를 열어보면 페이지를 import 하는 곳이 없어요. 페이지는 children으로 들어옵니다.
그 차이가 무엇을 만드는지는 직접 재보시고, 오늘은 확실한 것부터 챙깁시다. 어디에 붙이든 그 파일이 import 하는 것들은 전부 따라갑니다.
한 화면을 통째로 브라우저로 보내면 얼마나 손해인지, 다음 Step에서 바이트로 잽니다.
🙋 학생 질문 — "표시를 붙인 컴포넌트는 서버에서 아예 안 도나요?"
아니요, 여기가 헷갈리기 쉬운 곳입니다.
'use client'는 "서버에서 돌지 마"가 아니라 "브라우저에서도 돌아야 해"에 가까워요. Next는 이 컴포넌트도 서버에서 한 번 그려서 HTML에 담아 보냅니다. 그래야 자바스크립트가 도착하기 전에도 화면에 뭔가 보이니까요.
그다음 브라우저에서 자바스크립트가 도착하면, 이미 그려져 있는 그 HTML에 이벤트를 다시 붙입니다. 이 과정을 하이드레이션(hydration)이라고 부르는데, 마른 HTML에 물을 붓는다는 뜻이라서, 정확한 대비는 "서버에서 도느냐 브라우저에서 도느냐"가 아니라 "브라우저까지 코드가 가느냐 안 가느냐"입니다. Step 4에서 이 하이드레이션의 흔적을 HTML에서 직접 찾아볼 거예요.
Step 3: "경계 밖으로는 자바스크립트가 안 갑니다"
손해가 얼마인지 재봅시다
앞 Step 끝에서 한 화면을 통째로 브라우저로 보내면 손해라고 했지만, 얼마나 손해인지는 안 말했습니다.
느낌으로 말하면 안 됩니다. 재봅시다.
그런데 재는 법부터 정해야 합니다
⚠️ 검색해서 나오는 옛 자료들은 대부분 이렇게 가르칩니다. "next build를 하면 라우트 표에 First Load JS 열이 나오니 그걸 보라."
Next 16에서는 그 열이 없습니다. 직접 확인해보세요.
Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /[username]
└ ○ /explore
주소와 동그라미/물결 표시뿐이고, 숫자가 아예 안 나옵니다. 그래서 우리는 브라우저가 실제로 받는 것을 직접 세겠습니다. 어차피 그게 우리가 알고 싶은 값이라, 서버가 보낸 HTML에서 자바스크립트 파일 목록을 뽑고 그 파일들의 크기를 더하면 됩니다.
서버를 띄워두고 이렇게 하세요.
PORT=3000
ROUTE=/explore
curl -s "http://localhost:$PORT$ROUTE" \
| grep -oE 'src="/_next/static/chunks/[^"]+"' \
| sed 's/src="//; s/"//' | sort -u \
| xargs -I{} curl -s -o /dev/null -w '%{size_download}\n' "http://localhost:$PORT{}" \
| paste -sd+ - | bc
⚠️ 재기 전에 그 포트를 우리 앱이 잡고 있는지 확인하세요. 다른 프로그램이 이미 3000을 쓰고 있으면 npm run start는 조용히 죽고, 위 명령은 이미 돌고 있던 남의 앱을 재게 됩니다. 숫자는 멀쩡하게 나오는데 우리 앱 값이 아니라서, 서버를 띄운 터미널에 EADDRINUSE가 없는지 먼저 보세요.
기준선
지금 상태에서 세 주소를 재면 이렇습니다.
| 주소 | 자바스크립트 파일 | 받는 바이트 |
|---|---|---|
/ |
7개 | 575,819 B |
/explore |
7개 | 575,819 B |
/jaehoon |
6개 | 551,574 B |
숫자가 꽤 크지만, 이 중 대부분은 React와 Next 자체라서 어떤 앱을 만들어도 깔리는 바닥이에요. 우리가 정하는 건 이 바닥이 아니라 그 위에 얼마를 더 얹느냐이므로, 절대값 말고 차이를 봐야 합니다.
탐색 화면을 통째로 브라우저로 보내봅니다
app/explore/page.tsx 맨 위에 딱 한 줄을 넣습니다.
// apps/web-next/app/explore/page.tsx
'use client';
import { allPosts } from '@/lib/posts';
export default function ExplorePage() {
다시 빌드하고 재보세요.
/explore |
이전 | 이후 | 차이 |
|---|---|---|---|
| 자바스크립트 파일 | 7개 | 8개 | +1 |
| 받는 바이트 | 575,819 B | 577,822 B | +2,003 B |
| 서버가 보낸 HTML | 10,772 B | 8,371 B | −2,401 B |
한 줄 넣었더니 2 KB가 늘었는데, /는 한 바이트도 안 변했어요. 선이 그 화면에만 그어졌으니까요.
그런데 늘어난 2 KB의 정체가 문제입니다
새로 생긴 자바스크립트 파일 안에서 이 문장을 찾아보세요.
grep -l "한강 노을이 미쳤다" .next/static/chunks/*.js
나옵니다. 게시물 데이터가 자바스크립트 파일 안에 통째로 실려 있어요.
바꾸기 전에 같은 명령을 돌리면 한 건도 안 나옵니다. 서버가 읽고 서버가 그렸으니 브라우저는 그 데이터를 볼 일이 없었거든요.
여기서 한 번 더 보셔야 할 게 있습니다. 탐색 화면은 content를 화면에 안 그립니다.
// apps/web-next/app/explore/page.tsx
<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>
이름과 좋아요 수만 씁니다. 글 내용은 안 써요.
그런데도 브라우저까지 갔습니다. allPosts 배열이 통째로 경계를 건넜기 때문이에요. 우리가 그중 두 필드만 쓴다는 건 아무도 모릅니다.
서버 컴포넌트일 때
allPosts ──→ 서버가 읽고 그린다 ──→ HTML 에 결과 글자만
JS 에는 0 건
'use client' 일 때
allPosts ──→ JS 파일에 통째로 실린다 ──→ 브라우저가 받아서 그린다
안 쓰는 글 내용까지 함께
지금은 게시물이 열 장이라 2 KB 지만, 실제 서비스라면 어떨지, 그리고 그 데이터에 사용자 이메일이나 전화번호 같은 게 섞여 있었다면 어땠을지 생각해보세요.
경계를 넓게 그으면 보내려던 것보다 많이 보내게 됩니다.
HTML은 왜 줄었을까요
표에서 HTML이 2,401 B 줄어든 걸 보셨을 거예요. 데이터가 사라진 게 아니라 옮겨간 겁니다.
서버 컴포넌트일 때는 서버가 그린 결과가 HTML에 실립니다. 클라이언트 컴포넌트가 되면 그 부분이 "이 파일을 받아서 브라우저가 그려라"라는 짧은 표시로 바뀌고, 대신 데이터와 코드가 자바스크립트 쪽으로 갑니다.
HTML에서 2.4 KB를 덜고 자바스크립트로 2 KB를 옮긴 셈이에요. 그런데 이 교환은 손해입니다. HTML은 받는 즉시 화면이 되지만, 자바스크립트는 받고 → 실행하고 → 그려야 화면이 되거든요.
⚠️ 확인이 끝났으면 'use client'; 한 줄을 지워 원래대로 되돌려주세요.
💡 한 줄 정리
경계 밖에 있는 컴포넌트는 코드도 데이터도 브라우저로 안 갑니다. 경계를 넓게 그으면 화면에 안 쓰는 데이터까지 딸려 갑니다.
🙋 학생 질문 — "575 KB 나 받는다니 너무 큰 것 아닌가요?"
두 가지를 갈라 보셔야 해요.
첫째, 그 숫자는 압축을 풀었을 때 크기입니다. 우리가 쓴 방법은 파일을 그대로 받아서 세거든요. 브라우저는 압축된 상태로 받기 때문에 개발자 도구 네트워크 탭에는 훨씬 작은 숫자가 뜹니다. 같은 파일을 다르게 센 것뿐이에요.
둘째, 지금은 연습용이라 최적화를 하나도 안 했습니다. 이미지도, 글꼴도, 코드 쪼개기도요. 그건 배포를 다루는 시간에 따로 봅니다.
오늘 중요한 건 절대값이 아니라 차이예요. 한 줄을 어디에 넣느냐로 2,003 B가 왔다 갔다 한다는 것.
🙋 학생 질문 — "그럼 'use client'는 최대한 안 쓰는 게 좋은 건가요?"
"안 쓴다"가 아니라 "필요한 곳까지만 내린다"가 맞습니다.
상호작용이 필요하면 브라우저 코드가 있어야 해요. 그건 피할 수 없습니다. 문제는 필요한 범위보다 넓게 긋는 것이에요.
방금 실험이 딱 그 경우였습니다. 탐색 화면에는 사실 상호작용이 하나도 없는데 페이지 전체를 브라우저로 보냈으니까요.
반대로 진짜 상호작용이 필요할 때 어떻게 해야 하는지가 다음 Step입니다. 좋아요 버튼을 만들 건데, 화면 전체가 아니라 버튼 하나만 내려보낼 거예요.
Step 4: "좋아요만 브라우저로"
지금 좋아요는 글자입니다
피드를 보면 좋아요 숫자가 있는데, 눌러도 아무 일이 안 일어납니다.
// apps/web-next/app/page.tsx
<p className="mt-2 text-sm text-black/60">좋아요 {post.likeCount}</p>
<p> 니까요. B-2에서 만들었던 그 토글을 여기서 되살릴 차례입니다.
누르면 상태가 바뀌어야 하니 useState가 필요하고, useState는 훅이니 브라우저에서 돌아야 합니다. 여기까진 정해져 있어요.
정하지 않은 건 어디에 선을 그을 것인가입니다. 피드 페이지 전체를 브라우저로 보낼 수도 있고, 버튼만 보낼 수도 있어요. Step 3에서 전자가 어떤 값을 치르는지 봤으니 후자로 갑니다.
버튼만 따로 떼어냅니다
// apps/web-next/app/components/LikeButton.tsx
'use client';
import { useState } from 'react';
// 이 화면에서 브라우저가 맡는 일은 이것 하나다.
// 카드의 나머지(사람 이름·글·격자)는 서버가 그려서 글자로 보낸다.
export function LikeButton({ likeCount, liked }: { likeCount: number; liked: boolean }) {
const [pressed, setPressed] = useState(liked);
const [count, setCount] = useState(likeCount);
function toggle() {
setPressed(!pressed);
setCount(pressed ? count - 1 : count + 1);
}
return (
<button
type="button"
aria-pressed={pressed}
onClick={toggle}
className="mt-2 text-sm text-black/60"
>
{pressed ? '♥' : '♡'} 좋아요 {count}
</button>
);
}
B-2에서 쓰던 것과 거의 같죠. 달라진 건 맨 위 한 줄뿐입니다.
aria-pressed를 붙인 이유는 E-7에서 다룬 그대로예요. 하트 모양만 바뀌고 글자는 그대로라, 눌렸는지 아닌지를 낭독기가 알 방법이 이것뿐입니다.
피드에서도 이 컴포넌트로 갈아 끼웁니다.
// apps/web-next/app/page.tsx
import { LikeButton } from '@/app/components/LikeButton';
import { feedPosts } from '@/lib/posts';
// 이 컴포넌트는 서버에서만 돈다. 브라우저로는 결과 글자만 간다.
export default function HomePage() {
return (
<main className="mx-auto max-w-xl p-6">
<h1 className="mb-4 text-xl font-bold">피드</h1>
<ul className="space-y-4">
{feedPosts.map((post) => (
<li key={post.id} className="rounded border border-black/10 p-4">
<p className="font-semibold">@{post.username}</p>
<p className="mt-1">{post.content}</p>
<LikeButton likeCount={post.likeCount} liked={post.liked} />
</li>
))}
</ul>
</main>
);
}
HomePage에는 'use client'가 없습니다. 여전히 서버 컴포넌트예요. 서버 컴포넌트가 클라이언트 컴포넌트를 불러다 쓰는 건 아무 문제가 없습니다.
HomePage 서버 데이터를 읽고 카드를 그린다
└─ LikeButton 브라우저 ← 선은 여기서만 그어진다
값이 얼마나 붙었나
재봅시다.
| 주소 | 이전 | 이후 | 차이 |
|---|---|---|---|
/ |
575,819 B | 576,272 B | +453 B |
/explore |
575,819 B | 575,819 B | 0 B |
/jaehoon |
551,574 B | 551,574 B | 0 B |
453 바이트. 그리고 다른 두 화면은 한 바이트도 안 늘었습니다.
Step 3의 실험과 나란히 놓아보세요.
| 페이지 통째로 | 버튼 하나만 | |
|---|---|---|
| 늘어난 자바스크립트 | +2,003 B | +453 B |
| 화면에 안 쓰는 데이터 유출 | 있음 | 없음 |
| 얻은 상호작용 | 없음 | 좋아요 토글 |
Step 3은 2 KB를 내고 아무 상호작용도 못 얻었습니다. Step 4는 그 4분의 1도 안 되는 값으로 실제 기능을 얻었어요.
같은 앱, 같은 빌드입니다. 달라진 건 선을 어디에 그었느냐 뿐이에요.
그런데 버튼은 이미 HTML 안에 있습니다
앞 Step의 🙋 에서 클라이언트 컴포넌트도 서버가 먼저 그린다고 했죠. 확인해봅시다.
curl -s http://localhost:3000/ | grep -o '<button' | wc -l
curl -s http://localhost:3000/ | grep -o 'aria-pressed' | wc -l
<button 3개
aria-pressed 2개
♡ 2개
aria-pressed가 2개인 건 피드에 게시물이 두 장이기 때문이고, <button>이 3개인 건 머리말의 이동 버튼이 하나 더 있어서입니다.
자바스크립트가 도착하기 전에 이미 하트가 그려져 있습니다. 서버가 LikeButton을 한 번 실행해서 그 결과를 HTML에 담아 보냈어요.
그다음 자바스크립트가 도착하면 그 하트에 onClick을 붙이는데, 그때부터 눌립니다. 이게 하이드레이션이고, 'use client'는 "서버에서 빼라"가 아니라 "브라우저에도 보내라"라는 뜻이에요. 서버는 어차피 한 번 그려요.
💡 한 줄 정리
상호작용이 필요하면 그 부분만 떼어 브라우저로 보냅니다. 같은 기능이라도 선을 잎에 두면 4분의 1 값으로 끝나고, 다른 화면은 영향을 안 받습니다.
🙋 학생 질문 — "좋아요를 눌렀는데 새로고침하면 돌아가요."
맞습니다. 지금은 브라우저 안에서만 숫자가 바뀌어요. 서버는 우리가 눌렀다는 걸 모릅니다.
useState는 그 화면이 살아 있는 동안만 값을 들고 있으니까요. 새로고침하면 서버가 원래 데이터로 다시 그려주고, 그 데이터에는 우리가 누른 기록이 없습니다.
진짜로 서버에 반영하려면 서버로 요청을 보내야 하는데, Next에는 그걸 위한 방법이 따로 있어요. 그건 Server Actions를 다루는 시간에 봅니다. 오늘은 선을 어디에 긋느냐만 챙기면 됩니다.
🙋 학생 질문 — "그럼 클라이언트 컴포넌트 안에 서버 컴포넌트를 넣을 수도 있나요?"
좋은 질문이고, 답이 조금 까다롭습니다.
앞에서 배운 대로 'use client'가 붙은 파일이 불러다 쓰는(import) 것은 전부 브라우저 쪽으로 딸려 갑니다. 그러니 클라이언트 컴포넌트 파일 안에서 import 해서 쓰면 그건 서버 컴포넌트로 남을 수 없어요.
그런데 빠져나갈 구멍이 하나 있습니다. import가 아니라 children으로 받으면 딸려 가지 않아요. 서버가 미리 그려서 결과만 건네주는 방식입니다.
이 구멍이 실제로 왜 필요한지는 다음 시간에 봅니다. 서버에서 데이터를 직접 가져오기 시작하면 그때부터 절실해지거든요.
Step 5: "경계를 건너는 값"
아무거나 내려보내도 될까요
방금 서버에서 브라우저로 값을 두 개 내려보냈습니다.
// apps/web-next/app/page.tsx
<LikeButton likeCount={post.likeCount} liked={post.liked} />
숫자 하나, 참/거짓 하나. 잘 됐죠.
그렇다면 아무거나 내려보내도 될까요? 지금까지 React를 쓰면서 props에 뭘 넣든 문제된 적이 없었을 겁니다. 함수도 넣고, 객체도 넣고요.
여기서는 다릅니다.
함수를 내려봅시다
좋아요를 누를 때 부모가 뭔가 하고 싶다고 해봅시다. 흔한 패턴이죠. 콜백을 내려주면 됩니다.
// apps/web-next/app/page.tsx
<LikeButton
likeCount={post.likeCount}
liked={post.liked}
onLike={() => console.log('좋아요')}
/>
LikeButton 쪽도 받아서 부르게 고칩니다.
// apps/web-next/app/components/LikeButton.tsx
export function LikeButton({ likeCount, liked, onLike }: { likeCount: number; liked: boolean; onLike: () => void }) {
타입 검사부터 돌려보세요.
npx tsc --noEmit
통과합니다. 에러 0건이에요. 타입만 보면 아무 문제가 없습니다.
빌드해봅니다.
npm run build
Error occurred prerendering page "/"
Error: Event handlers cannot be passed to Client Component props.
{likeCount: 1240, liked: false, onLike: function onLike}
^^^^^^^^^^^^^^^
If you need interactivity, consider converting part of this to a
Client Component.
빌드가 멈췄는데, 화살표가 어느 prop 때문인지 정확히 짚어줘요.
타입이 통과했는데 왜 막힐까요
이 과목에서 비슷한 일을 여러 번 겪었습니다. A-6에서 "번들러는 타입체크를 안 한다"를 봤고, C-4에서 "테스트가 전부 초록인데 타입은 8건을 잡았다"를 봤죠.
이번엔 방향이 반대입니다. 타입은 통과하는데 실제로 막힙니다.
이유는 경계의 정체에 있어요. 서버에서 그린 결과는 브라우저로 갈 때 글자로 바뀌어서 갑니다. 네트워크를 건너야 하니까요. 1240은 글자로 쓸 수 있고 false도 쓸 수 있는데, 함수는 글자로 쓸 수가 없습니다.
서버 네트워크 브라우저
likeCount: 1240 ──→ "1240" ──→ 1240 ✅
liked: false ──→ "false" ──→ false ✅
onLike: () => {} ──→ ??? ❌
함수는 코드예요. 코드를 글자로 적어 보낸다 해도, 그 함수가 붙잡고 있던 바깥 값들은 같이 못 갑니다. 그래서 아예 막습니다.
타입 시스템은 이 사정을 모릅니다. TypeScript에게 () => void는 그냥 정상적인 타입이거든요. 경계는 타입이 아는 개념이 아닙니다.
그럼 JSON으로 바꿀 수 있는 것만 되나요
여기서 많이들 이렇게 외우는데, 틀립니다.
Date 객체를 내려보세요.
// apps/web-next/app/page.tsx
<LikeButton likeCount={post.likeCount} liked={post.liked} at={new Date(post.createdAt)} />
LikeButton에서 연도를 찍어봅니다.
{pressed ? '♥' : '♡'} 좋아요 {count} · {at.getFullYear()}
빌드가 통과합니다. 화면에 연도가 그대로 찍혀요.
JSON.stringify를 거치면 Date는 문자열이 되고 돌아올 때 Date로 안 살아나는데, 여기서는 살아나요. React는 자기만의 방식으로 값을 실어 나르고, 그 방식은 JSON보다 조금 더 똑똑합니다.
그러니 기준을 이렇게 잡으세요.
건널 수 있다 숫자 · 문자열 · 참거짓 · 배열 · 평범한 객체 · Date · null
못 건넌다 함수 · 클래스로 만든 인스턴스 · 화면 요소가 아닌 것
외우기보다, 막히면 빌드가 알려준다는 걸 기억하시는 게 낫습니다. 화살표까지 그려서 알려주니까요.
그럼 클릭은 어떻게 처리하나요
onClick은 어떻게 되는 거냐고 물으실 텐데, 우리 LikeButton을 다시 보세요.
// apps/web-next/app/components/LikeButton.tsx
function toggle() {
setPressed(!pressed);
setCount(pressed ? count - 1 : count + 1);
}
toggle은 LikeButton 파일 안에서 만들어졌습니다. 경계를 건넌 적이 없어요. 이미 브라우저 쪽에 있는 코드가 브라우저 쪽에서 함수를 만들어 쓰는 겁니다.
막히는 건 서버가 만든 함수를 브라우저로 보내려 할 때뿐이에요.
⚠️ 확인이 끝났으면 onLike와 at을 지워 원래대로 되돌려주세요. 빌드가 다시 통과해야 합니다.
💡 한 줄 정리
경계를 건너는 값은 글자로 옮겨 적을 수 있어야 합니다. 함수는 못 건너고, 타입 검사는 그걸 안 막아줍니다. 막아주는 건 빌드입니다.
🙋 학생 질문 — "그럼 부모에서 자식 버튼의 클릭을 처리하고 싶으면 어떻게 하나요?"
두 가지 길이 있습니다.
하나는 부모도 브라우저로 내리는 것인데, 그러면 부모가 만든 함수도 브라우저 쪽 코드라 건넬 게 없는 대신 Step 3에서 본 값을 치릅니다.
다른 하나는 필요한 상태를 자식 안으로 내리는 것으로, 우리가 방금 한 게 이쪽이에요. 부모는 숫자만 건네고, 누른 뒤에 무엇을 할지는 자식이 스스로 정합니다.
C-4에서 상태를 어디에 둘지 정하는 규칙을 세웠었죠. 여기서도 같은 질문인데, 판이 하나 더 늘어서 서버냐 브라우저냐가 먼저 갈리고, 그다음에 어디에 담을지를 정합니다.
세 번째 길도 사실 있는데, 그건 서버 쪽 함수를 실행하는 완전히 다른 방법이라 Server Actions 시간에 따로 봅니다.
마무리
오늘 시작은 질문 하나였습니다. 아홉 개 중 브라우저에서 도는 게 몇 개냐.
답이 셋이라는 것보다 중요한 건, 그 셋을 우리가 골랐다는 사실입니다. 지난 시간에는 훅을 쓰려니 어쩔 수 없이 붙였던 한 줄이었는데, 오늘 보니 그게 선을 긋는 행위였고, 그 선의 위치가 숫자로 나왔습니다.
같은 기능, 다른 선
페이지 통째로 브라우저 +2,003 B 상호작용 0개 데이터 유출 있음
버튼 하나만 브라우저 +453 B 좋아요 토글 유출 없음
프레임워크가 좋은 선을 자동으로 골라주지 않습니다. 우리가 고릅니다.
오늘 배운 핵심 세 가지
💡 하나 — Next에서는 아무것도 안 하면 서버입니다. 브라우저에서 돌게 하려면 그렇게 하겠다고 따로 말해야 해요. C-1부터 만든 SPA와 정반대입니다.
💡 둘 — 'use client'는 파일 하나가 아니라 그 아래 전부에 걸립니다. 검색칸에서 지웠을 땐 아무 일도 없었지만 머리말에서 지우니 검색칸까지 무너졌죠. 선은 그 파일에서 시작해 불러다 쓰는 것들을 따라 내려갑니다.
💡 셋 — 경계를 건너는 값은 글자로 옮겨 적을 수 있어야 합니다. 함수는 못 건너고, 타입 검사는 그걸 안 막아줍니다. 막아주는 건 빌드예요.
다음 시간 예고
오늘 우리는 선을 그었습니다. 그런데 그 선의 서버 쪽에서 무엇을 할 수 있는지는 아직 안 봤어요.
지금까지 데이터는 전부 lib/posts.ts에 적어둔 상수였습니다. 진짜 서비스라면 어딘가에 물어봐서 가져와야 하죠. C-6에서 그걸 하려고 무엇이 필요했는지 떠올려보세요. 훅을 부르고, 로딩 상태를 만들고, 에러 상태를 만들고, 언제 다시 부를지 정하고요.
서버 컴포넌트에서는 그게 전부 필요 없습니다. 함수 안에서 그냥 기다렸다 받으면 됩니다.
지난 시간에 세 번에 한 번만 성공하는 흉내 서버를 만들어뒀는데, 그걸 진짜로 바꿉니다.
기다린다는 말이 나온 김에 하나 더요. 서버가 데이터를 기다리는 동안 사용자는 뭘 보고 있어야 할까요? 흰 화면일까요? 그 답이 파일 이름 하나로 정해집니다. D-1에서 파일 규칙 표를 볼 때 이름만 부르고 넘어간 것이 있었는데, 다음 시간에 그걸 씁니다.
과제
[구현] 탐색 화면에도 좋아요를
탐색 화면의 격자에도 좋아요 버튼을 붙여보세요. Step 4에서 만든 LikeButton을 그대로 쓰면 됩니다.
- 붙이기 전에 예상해보세요.
/explore의 자바스크립트가 몇 바이트 늘까요? 피드는 게시물이 두 장이었고 탐색은 열 장입니다. 다섯 배 늘까요? - 예상을 적어두고 나서 재보세요.
/와/jaehoon도 함께 재서, 안 건드린 화면이 정말 안 변했는지 확인하세요.
[구현] 머리말의 경계를 더 아래로 내리기
지금 HeaderNav는 파일 전체가 브라우저로 갑니다. 그런데 그 안을 뜯어보면 브라우저가 꼭 필요한 부분은 일부예요.
HeaderNav안에서 정말 브라우저가 필요한 부분이 어디인지 먼저 표시해보세요.- 그 부분만 별도 컴포넌트로 떼어내고,
HeaderNav자신은 서버 컴포넌트로 되돌려보세요. - 되돌린 뒤 빌드가 통과하는지, 화면이 그대로인지 확인하세요.
- 세 주소의 자바스크립트를 다시 재서 값이 얼마나 줄었는지 적어보세요. ⚠️ 줄지 않았다면 그것도 결과입니다. 왜 안 줄었을지 생각해보세요.
[탐구] 무엇이 경계를 건너나
Step 5에서 함수는 못 건너고 Date는 건넌다는 걸 봤습니다. 나머지도 직접 확인해서 표를 채워보세요.
- 배열 · 평범한 객체 ·
null·undefined·Map·Set· 정규식 · 클래스로 만든 인스턴스 - 각각을
LikeButton에 prop으로 내려보고 빌드해보세요. - 막히는 것은 에러 메시지도 함께 적으세요. 함수가 막혔을 때와 같은 메시지인가요, 다른 메시지인가요?
- 통과한 것들은 원래 모습 그대로 도착할까요?
Map을 받아서.get()을,Date를 받아서.getFullYear()를 화면에 찍어보세요. 메서드가 동작한다면 그건 무슨 뜻일까요? - ⚠️ 빌드가 통과했는지는 종료 코드로 확인하세요. 화면에
Compiled successfully가 지나가도 그 뒤에서 막히는 경우가 있습니다.
[탐구] 가장 나쁜 선을 그어보기
app/layout.tsx 맨 위에 'use client'를 붙이면 어떻게 될까요.
- 먼저 예상해보세요. 빌드가 될까요, 안 될까요? 된다면 자바스크립트가 얼마나 늘까요?
- 실제로 붙여보고 확인하세요. ⚠️ 에러가 난다면 그 에러가 무엇을 말하는지 읽어보세요. 오늘 배운 것으로 설명이 되나요?
- 확인이 끝나면 반드시 되돌려주세요.
생각해볼 주제
1. 선을 어디에 그을지 정하는 기준
오늘 우리는 좋아요 버튼 하나만 브라우저로 내려보냈고, 결과만 놓고 보면 잘한 선택처럼 보이지만, 항상 잎에 두는 게 정답일까요? 버튼이 스무 개인 화면이라면 작은 클라이언트 컴포넌트가 스무 개 생깁니다. 그게 부모 하나를 클라이언트로 만드는 것보다 반드시 나을까요?
여러분이 팀에 들어가서 "이 화면은 어디에 선을 그어야 하나요"라는 질문을 받았다고 해봅시다. 무엇을 보고 판단하겠으며, 그 판단이 옳았는지는 나중에 무엇으로 확인할 수 있을까요?
2. 안 보이는 데이터가 새는 문제
Step 3에서 탐색 화면을 브라우저로 보냈더니, 화면에 그리지도 않는 글 내용이 자바스크립트 파일에 실려 갔습니다.
우리 데이터는 공개된 게시물이라 별일 아니었어요. 그런데 그 배열에 사용자 이메일이나 전화번호가 섞여 있었다면 어떨까요.
이런 일은 조용히 일어납니다. 화면은 멀쩡하고, 빌드도 통과하고, 아무도 경고해주지 않아요. 어떻게 하면 이런 유출을 미리 막을 수 있을지 생각해보세요. 코드를 짜는 습관으로 막을 수 있을까요, 아니면 자동으로 잡아주는 장치가 필요할까요?
3. 그럼 SPA는 이제 안 쓰나요
우리는 C-1부터 C-10까지 브라우저에서 전부 도는 앱을 만들었고, 오늘 그 전제가 깨졌습니다.
그렇다면 앞으로는 모든 화면을 서버 컴포넌트로 만드는 게 맞을까요? 오늘 배운 대로라면 브라우저로 가는 코드가 적을수록 좋아 보입니다.
그런데 서버에서 그린다는 건 서버가 있어야 한다는 뜻이기도 합니다. 우리가 만든 SPA는 파일 몇 개만 올려두면 돌아갔죠. 어떤 종류의 서비스가 어느 쪽에 어울릴지, 그리고 그 선택을 되돌리려면 무엇을 치러야 할지 생각해보세요.
✅ 예시 답안정답 보기
먼저 직접 풀어보신 뒤에 펼쳐보세요. 특히 과제는 예상을 적어두고 재보는 순서가 중요합니다. 오늘 과제 넷 중 셋은 예상이 빗나가는 쪽이 정답이에요.
🎯 [과제 1 예시답안] 탐색 화면에도 좋아요를 (25점)
채점 포인트
| 항목 | 배점 | 기준 |
|---|---|---|
| 예상을 먼저 적었는가 | 5 | 재기 전에 숫자를 적어둔 기록이 있다 |
LikeButton을 그대로 재사용 |
5 | 새로 만들지 않고 가져다 썼다 |
| 세 주소를 모두 측정 | 10 | /explore 뿐 아니라 /·/jaehoon도 쟀다 |
| 결과를 설명 | 5 | 왜 다섯 배가 아닌지 말할 수 있다 |
풀이 예시
// apps/web-next/app/explore/page.tsx
import { LikeButton } from '@/app/components/LikeButton';
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) => (
<li key={post.id} className="aspect-square rounded bg-black/5 p-3 text-sm">
<p className="font-semibold">@{post.username}</p>
<LikeButton likeCount={post.likeCount} liked={post.liked} />
</li>
))}
</ul>
</main>
);
}
측정 결과입니다.
| 주소 | 이전 | 이후 | 차이 |
|---|---|---|---|
/explore |
575,819 B | 576,272 B | +453 B |
/ |
576,272 B | 576,272 B | 0 B |
/jaehoon |
551,574 B | 551,574 B | 0 B |
자주 나오는 실수
"게시물이 다섯 배니까 자바스크립트도 다섯 배"라고 예상하는 것.
실제로는 피드에 붙였을 때와 정확히 같은 453 바이트입니다. 하트는 화면에 열 개가 그려지는데도요.
브라우저로 가는 건 LikeButton 함수 하나이기 때문입니다. 그걸 몇 번 부르든 코드는 하나예요. 열 번 그려진 결과는 서버가 만들어 HTML에 담아 보냈고, 브라우저는 그 HTML에 이벤트만 붙입니다.
한 가지 더 눈여겨보실 게 있는데, 이제 /와 /explore가 같은 값(576,272 B)이 됐습니다. 둘 다 같은 LikeButton 파일을 받으니까요. 한 번 받은 파일은 다른 화면에서 다시 안 받습니다.
💡 튜터의 한마디
이 과제의 진짜 답은 "컴포넌트를 몇 번 쓰느냐는 번들 크기와 무관하다"입니다.
Step 3에서 잰 +2,003 B와 비교해보세요. 그쪽은 데이터가 갔고 이쪽은 코드가 갔어요. 데이터는 늘어나면 같이 늘어나지만 코드는 안 늘어나기 때문에, 경계를 그을 때 "이 안에 데이터가 얼마나 들어가나"를 봐야 하는 거예요.
🎯 [과제 2 예시답안] 머리말의 경계를 더 아래로 내리기 (25점)
채점 포인트
| 항목 | 배점 | 기준 |
|---|---|---|
| 브라우저가 필요한 부분을 정확히 식별 | 8 | 활성 표시와 검색칸 둘을 짚었다 |
떼어낸 뒤 HeaderNav가 서버 컴포넌트 |
7 | 'use client'가 사라졌고 빌드가 통과한다 |
| 화면·동작이 그대로인지 확인 | 5 | 활성 표시와 검색 이동이 여전히 된다 |
| 결과를 정직하게 해석 | 5 | 적게 줄었다는 사실을 그대로 적고 이유를 말한다 |
풀이 예시
HeaderNav 안에서 브라우저가 꼭 필요한 건 둘입니다. 지금 어디인지 알아야 하는 링크의 활성 표시, 그리고 입력을 받아야 하는 검색칸. 로고는 그냥 글자예요.
활성 표시 부분만 떼어냅니다.
// apps/web-next/app/components/NavLinks.tsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const items = [
{ href: '/', label: '홈' },
{ href: '/explore', label: '탐색' },
] as const;
export function NavLinks() {
const pathname = usePathname();
return (
<>
{items.map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? 'page' : undefined}
className={active ? 'font-semibold text-black' : 'text-black/50'}
>
{item.label}
</Link>
);
})}
</>
);
}
이렇게 하면 HeaderNav에서 지시어가 사라집니다.
// apps/web-next/app/components/HeaderNav.tsx
import { NavLinks } from './NavLinks';
import { UserSearch } from './UserSearch';
// 이 컴포넌트 자체는 서버에서 돈다. 브라우저가 필요한 두 조각만 안에 둔다.
export function HeaderNav() {
return (
<nav className="mx-auto flex max-w-3xl items-center gap-4 p-4 text-sm">
<span className="font-bold">인스타그램 클론</span>
<NavLinks />
<UserSearch />
</nav>
);
}
빌드도 타입 검사도 통과하고, 화면은 완전히 그대로입니다. 활성 표시도 되고 검색 이동도 되는데, 측정 결과는 이렇습니다.
| 주소 | 이전 | 이후 | 차이 |
|---|---|---|---|
/ |
576,272 B | 576,184 B | −88 B |
/explore |
575,819 B | 575,731 B | −88 B |
/jaehoon |
551,574 B | 551,574 B | 0 B |
88 바이트. 파일을 새로 만들고 구조를 바꿨는데 이만큼입니다.
자주 나오는 실수
"안 줄었으니 내가 잘못한 것"이라고 생각하고 되돌리는 것.
제대로 하신 게 맞습니다. 적게 줄어든 게 결과예요.
이유는 간단합니다. HeaderNav의 무게가 거의 전부 진짜로 브라우저가 필요한 부분이었기 때문이에요. 서버로 되돌린 건 로고 <span> 하나와 <nav> 껍데기뿐입니다. usePathname을 쓰는 링크도, useState와 useRouter를 쓰는 검색칸도 여전히 브라우저로 갑니다.
Step 3의 탐색 화면과 비교해보세요. 거기서는 상호작용이 하나도 없는 화면을 통째로 보내고 있었기 때문에 2 KB를 줄일 수 있었지만, 여기는 애초에 낭비가 거의 없었습니다.
💡 튜터의 한마디
경계를 아래로 내리는 게 항상 이득은 아닙니다. 이득의 크기는 "지금 경계 안에 브라우저가 필요 없는 것이 얼마나 들어 있나"로 정해져요.
그러니 순서가 중요합니다. 구조부터 바꾸지 말고, 재보고 나서 바꾸세요. C-8에서 성능을 다룰 때 세운 원칙 그대로입니다. 측정 없이 손대면 88 바이트를 위해 파일 하나와 복잡도를 늘리게 됩니다.
물론 이 경우에도 얻은 게 있긴 해요. 이제 HeaderNav 파일만 봐도 "여기는 서버, 저 둘은 브라우저"가 눈에 보입니다. 바이트가 아니라 읽기 쉬움을 산 겁니다. 그것도 값어치이긴 하지만, 88 바이트를 근거로 내세우면 안 됩니다.
🎯 [과제 3 예시답안] 무엇이 경계를 건너나 (25점)
채점 포인트
| 항목 | 배점 | 기준 |
|---|---|---|
| 여덟 가지를 모두 실험 | 10 | 하나씩 따로 확인했다 |
| 판정을 종료 코드로 | 5 | Compiled successfully만 보고 판단하지 않았다 |
| 에러 메시지가 두 종류임을 발견 | 5 | 함수와 나머지의 메시지가 다르다 |
| 통과한 것이 원형대로 오는지 확인 | 5 | 메서드를 실제로 불러봤다 |
풀이 예시
| 값 | 결과 |
|---|---|
배열 [1, 2, 3] |
✅ |
평범한 객체 { a: 1 } |
✅ |
null |
✅ |
undefined |
✅ |
Date |
✅ |
Map |
✅ |
Set |
✅ |
정규식 /ab+c/ |
❌ |
| 클래스로 만든 인스턴스 | ❌ |
| 함수 | ❌ |
막히는 것들의 메시지는 두 종류입니다.
함수일 때는 이렇게 나옵니다.
Error: Event handlers cannot be passed to Client Component props.
정규식과 클래스 인스턴스는 다른 메시지예요.
Error: Only plain objects, and a few built-ins, can be passed to
Client Components from Server Components. Classes or null prototypes
are not supported.
"평범한 객체와 몇 가지 내장 타입만 건널 수 있다" — 이 문장이 규칙 전체를 담고 있습니다. Date·Map·Set이 통과하는 이유가 여기 있어요. 그 셋이 바로 몇 가지 내장 타입에 해당하고, 통과한 것들은 원형 그대로 도착합니다. 확인해보세요.
// apps/web-next/app/components/LikeButton.tsx (실험용)
[when={when.getFullYear()}] [tags={String(tags.get('여행'))}] [ids={String(ids.has(2))}]
[when=2026] [tags=7] [ids=true]
Date는 getFullYear()가 돌고, Map은 get()이 돌고, Set은 has()가 돕니다. 껍데기만 흉내 낸 객체가 아니라 진짜 그 타입이에요.
JSON.stringify를 거쳤다면 셋 다 죽었을 겁니다. Date는 문자열이 되고 Map과 Set은 {}가 되니까요. React는 자기만의 방식으로 값을 실어 나르고, 그 방식은 JSON보다 넓습니다.
자주 나오는 실수
Compiled successfully를 보고 통과로 판정하는 것.
빌드는 두 단계로 진행됩니다. 먼저 코드를 변환하고(Compiled successfully), 그다음 화면을 미리 그려보는데, 경계 검사는 두 번째 단계에서 일어나요. 그래서 화면에는 이런 순서로 지나갑니다.
✓ Compiled successfully in 233ms
...
Error occurred prerendering page "/"
Error: Only plain objects, and a few built-ins, can be passed to ...
앞줄만 보고 "통과"로 적으면 표가 통째로 틀립니다. 실제로 클래스 인스턴스를 통과로 적었다가 나중에 뒤집히는 경우가 많아요.
npm run build를 돌린 뒤 echo $?로 확인하세요. 0이면 통과, 아니면 실패입니다.
💡 튜터의 한마디
이 표를 외우실 필요는 없습니다. 실무에서 정규식을 prop으로 내리는 일은 거의 없고, 함수는 막히면 바로 알게 되니까요.
챙기실 건 두 가지예요.
첫째, 판정 기준은 "JSON으로 되나"가 아닙니다. 그렇게 외우면 Date·Map·Set에서 틀립니다. 정확한 기준은 에러 메시지가 말해주는 그대로 "평범한 객체와 몇 가지 내장 타입"입니다.
둘째, 틀렸을 때 알려주는 건 타입이 아니라 빌드입니다. Step 5에서 봤듯 타입 검사는 함수를 통과시켰어요. 이 과목에서 A-6·C-4·C-7을 지나며 "도구마다 보는 게 다르다"를 반복해서 만났는데, 여기가 또 한 자국입니다.
🎯 [과제 4 예시답안] 가장 나쁜 선을 그어보기 (25점)
채점 포인트
| 항목 | 배점 | 기준 |
|---|---|---|
| 예상을 먼저 적었는가 | 5 | 재기 전 기록이 있다 |
| 첫 번째 에러의 정체를 파악 | 8 | metadata 때문이라는 것을 읽어냈다 |
| 그 뒤에도 끝까지 확인 | 7 | 막혔다고 멈추지 않고 원인을 걷어낸 뒤 다시 쟀다 |
| 결과를 오늘 배운 것으로 설명 | 5 | children을 언급할 수 있다 |
풀이 예시
app/layout.tsx 맨 위에 'use client';를 넣고 빌드하면 먼저 이 에러부터 만납니다.
Error: You are attempting to export "metadata" from a component marked
with "use client", which is disallowed. "metadata" must be resolved on
the server before the page component is rendered.
번들 크기를 재보려 했는데 잴 기회조차 없습니다. metadata는 서버에서 정해져야 하는 값이라 클라이언트 컴포넌트에서 내보낼 수 없거든요. 검색 엔진이나 링크 미리보기가 읽어가는 값인데, 브라우저가 자바스크립트를 실행한 뒤에야 정해진다면 이미 늦습니다.
여기서 멈추면 절반만 푼 겁니다. metadata를 잠시 걷어내고 다시 빌드해보세요.
✓ Compiled successfully
이번엔 통과하는데, 재보면 이렇습니다.
| 주소 | 이전 | 이후 | 차이 |
|---|---|---|---|
/ |
576,272 B | 576,513 B | +241 B |
/explore |
575,819 B | 576,060 B | +241 B |
/jaehoon |
551,574 B | 551,574 B | 0 B |
241 바이트. 앱에서 가장 바깥 껍데기를 브라우저로 보냈는데 이것뿐이에요.
두 가지를 더 확인해보시면 확실해집니다.
grep -l "한강 노을이 미쳤다" .next/static/chunks/*.js # 0건
curl -s http://localhost:3000/ | grep -c "한강 노을이 미쳤다" # 1건
게시물 데이터는 자바스크립트로 안 갔고, 소스 보기에는 글자가 그대로 남아 있습니다. 피드도 탐색도 프로필도 여전히 서버 컴포넌트예요.
자주 나오는 실수
"맨 위에 붙였으니 그 아래 전부가 브라우저로 간다" 고 예상하는 것.
Step 2에서 배운 규칙을 다시 정확히 읽어보세요. 선은 import를 따라 내려갑니다.
app/layout.tsx를 열어보면 페이지를 import 하는 곳이 없습니다.
// apps/web-next/app/layout.tsx
import type { Metadata } from 'next';
import { HeaderNav } from './components/HeaderNav';
import './globals.css';
export default function RootLayout({ children }: LayoutProps<'/'>) {
페이지는 children으로 들어옵니다. 서버가 먼저 그려서 결과를 건네주는 방식이라, 클라이언트 쪽으로 딸려 갈 것이 없어요.
늘어난 241 바이트는 layout 자신의 코드일 뿐입니다. HeaderNav는 이미 브라우저에 있었으니 새로 늘 것도 없었고요.
💡 튜터의 한마디
이 과제가 오늘 배운 것 중 가장 헷갈리는 곳을 정확히 찌릅니다.
'use client'를 "여기 아래로는 전부 브라우저"라고 외우면 여기서 틀려요. 정확한 규칙은 "이 파일과 이 파일이 import 하는 것들"입니다. import 냐 children 이냐가 갈림길인데, 이 차이가 우연이 아니라 일부러 만들어둔 탈출구입니다. 이게 없으면 바깥 껍데기 하나만 상호작용이 필요해도 앱 전체가 브라우저로 끌려가니까요.
다음 시간에 이 구멍을 제대로 씁니다. 서버에서 데이터를 직접 가져오기 시작하면, 데이터를 쥔 서버 컴포넌트를 상호작용이 필요한 껍데기 안에 넣어야 하는 상황이 실제로 생기거든요.
생각해볼 주제
🤔 [생각해볼 주제 1] 선을 어디에 그을지 정하는 기준
문제 상황 요약
오늘 좋아요 버튼 하나만 브라우저로 내려보냈고 결과가 좋았습니다. 그런데 "항상 잎에 두라"가 규칙이 될 수 있을까요? 버튼이 스무 개인 화면이라면요?
튜터의 가이드 및 해설
먼저 "잎에 두라"가 왜 통했는지부터 정확히 봅시다.
Step 4에서 이득이 났던 이유는 잎이라서가 아닙니다. 그 선 바깥에 브라우저가 필요 없는 것이 많이 남아 있었기 때문이에요. 피드 카드의 이름·글·격자가 전부 서버에 남았죠.
반대로 과제 2에서는 같은 방식으로 경계를 내렸는데 88 바이트밖에 못 줄였습니다. 경계 안에 이미 낭비가 거의 없었으니까요.
그러니 기준은 "잎이냐 아니냐"가 아니라 "이 선 안에 브라우저가 필요 없는 것이 얼마나 들어 있나"입니다.
버튼이 스무 개면 어떻게 될까요.
과제 1이 답을 줍니다. 컴포넌트를 몇 번 쓰든 코드는 한 번만 갑니다. 스무 개가 같은 LikeButton 이라면 여전히 453 바이트예요.
문제가 되는 건 서로 다른 스무 종류일 때입니다. 그때는 파일이 스무 개 가고, 관리할 경계도 스무 개가 됩니다. 이 지점에서 부모 하나를 클라이언트로 만드는 게 나을 수 있지만, 그 판단은 바이트가 아니라 읽고 고치기 쉬운가로 하는 겁니다.
실무에서 팀에 답할 때는 이렇게 나누세요.
1. 이 화면에서 상호작용이 정말 필요한 곳을 먼저 표시한다
2. 그 표시들이 흩어져 있나, 한 덩어리인가 본다
3. 흩어져 있으면 각각 잎으로 · 한 덩어리면 그 덩어리째로
4. 어느 쪽이든 바꾸기 전과 후를 재서 기록한다
판단이 옳았는지는 나중에 무엇으로 확인할까요.
번들 크기만으로는 부족합니다. 그건 원인이지 결과가 아니에요. 실제로 봐야 하는 건 사용자가 화면을 쓸 수 있게 되기까지의 시간입니다. 자바스크립트를 적게 보내는 이유가 결국 그것이니까요.
이 지표들을 어떻게 재고 무엇을 목표로 삼는지는 프로덕션 과정에서 따로 다룹니다. 오늘 챙기실 건 "바이트는 중간 지표다"라는 감각이에요.
🎯 면접관을 홀리는 핵심 멘트
"경계는 잎에 두는 게 원칙이라기보다, 경계 안에 브라우저가 필요 없는 것이 얼마나 남는지로 정합니다. 저희 프로젝트에서 같은 방식으로 두 군데를 고쳤는데 한쪽은 2 KB가 줄고 다른 쪽은 88 바이트가 줄었어요. 구조가 아니라 그 안에 든 것이 이득을 정하더군요. 그래서 지금은 구조를 바꾸기 전에 먼저 재고, 바꾼 뒤에 다시 재서 기록으로 남깁니다."
🤔 [생각해볼 주제 2] 안 보이는 데이터가 새는 문제
문제 상황 요약
Step 3에서 탐색 화면을 브라우저로 보냈더니, 화면에 그리지도 않는 글 내용이 자바스크립트 파일에 실려 갔습니다. 그 배열에 이메일이나 전화번호가 있었다면요?
튜터의 가이드 및 해설
이 사고가 무서운 이유는 아무 신호가 없다는 것입니다.
화면은 정상이고, 빌드는 통과하고, 타입 검사도 조용합니다. 리뷰에서도 잘 안 보여요. 'use client' 한 줄이 추가된 것뿐이니까요.
그런데 실제로 일어나는 일은 이겁니다. 서버에서만 알아야 할 값이 브라우저로 넘어가서, 개발자 도구를 열 줄 아는 누구나 볼 수 있게 됩니다.
막는 방법은 크게 셋인데, 값이 다릅니다.
첫째, 습관으로 막기. 경계를 건널 때 객체를 통째로 넘기지 말고 필요한 것만 뽑아 넘기는 겁니다.
❌ <PostCard post={post} />
✅ <LikeButton likeCount={post.likeCount} liked={post.liked} />
우리 LikeButton이 이렇게 되어 있죠. 숫자 하나와 참거짓 하나만 받습니다. 이러면 실수해도 새는 양이 그 두 개로 제한돼요.
이게 가장 값싸고 효과가 크지만, 사람이 기억해야 하는 방법이라 언젠가 뚫립니다.
둘째, 도구로 막기. 서버에서만 쓸 코드에 표시를 달아두면, 그것이 클라이언트 쪽으로 딸려 들어가는 순간 빌드가 실패합니다. server-only라는 이름의 표시가 있어요.
주의할 건 이게 코드가 새는 것을 막지 데이터가 새는 것을 막지는 않는다는 점입니다. props로 넘긴 값에는 안 걸려요. 그래서 첫 번째를 대체하지 못하고 보완합니다.
셋째, 애초에 안 가져오기. 화면이 이름과 좋아요 수만 쓴다면 서버에서 가져올 때부터 그 둘만 가져오는 겁니다. 없는 데이터는 샐 수가 없어요.
이건 서버가 데이터를 직접 가져오기 시작하는 다음 시간부터 실제로 선택지가 됩니다.
그럼 무엇을 먼저 해야 할까요.
순서는 셋째 → 첫째 → 둘째입니다. 안 가져오는 게 가장 확실하고, 그다음이 좁게 넘기는 습관, 마지막이 자동 검사예요.
거꾸로 하는 팀이 많습니다. 도구부터 붙이고 안심하는데, 도구는 props를 안 보기 때문에 정작 가장 흔한 사고를 못 잡습니다.
🎯 면접관을 홀리는 핵심 멘트
"서버 컴포넌트에서 클라이언트로 넘길 때는 객체를 통째로 넘기지 않는 것을 규칙으로 둡니다. 화면에 안 그리는 필드까지 번들에 실려 가거든요. 저희가 실제로 재봤는데, 열 장짜리 목록을 통째로 넘겼더니 화면에 쓰지도 않는 본문이 자바스크립트 안에 그대로 들어가 있었습니다. 빌드도 타입 검사도 아무 말을 안 해줘서, 습관과 리뷰로 막을 수밖에 없다고 판단했어요."
🤔 [생각해볼 주제 3] 그럼 SPA는 이제 안 쓰나요
문제 상황 요약
C-1부터 C-10까지 브라우저에서 전부 도는 앱을 만들었는데, 오늘 그 전제가 깨졌습니다. 앞으로는 전부 서버 컴포넌트로 만드는 게 맞을까요?
튜터의 가이드 및 해설
"브라우저로 가는 코드가 적을수록 좋다"는 명제부터 의심해봅시다.
오늘 우리가 잰 건 바이트였지만, 바이트가 유일한 값이 아니에요. 서버 컴포넌트를 쓰면 화면이 바뀔 때마다 서버에 물어봐야 합니다. SPA에서는 이미 받아둔 자바스크립트로 브라우저가 즉시 그렸죠.
자바스크립트를 덜 보내는 대신 왕복을 더 하는 교환인 셈인데, 사용자가 인터넷이 느린 곳에 있다면 이 교환이 손해일 수도 있어요.
더 큰 차이는 "서버가 필요하다"는 것입니다.
여러분이 만든 SPA는 빌드하면 파일 몇 개가 나오고, 그걸 아무 데나 올려두면 돌았습니다. 서버가 죽을 일도, 서버 비용도, 서버 운영도 없었어요.
Next 앱은 다릅니다. 화면을 그려줄 서버가 계속 살아 있어야 합니다. 사람이 몰리면 그 서버를 늘려야 하고요.
이렇게 갈립니다.
서버 렌더링이 어울리는 것
처음 보는 사람이 많이 들어온다 첫 화면이 빨라야 한다
검색 엔진에 걸려야 한다 HTML 에 글자가 있어야 한다
서버에만 있는 데이터를 쓴다 비밀 값을 안 내보내도 된다
브라우저 렌더링이 어울리는 것
로그인한 사람이 오래 머문다 첫 화면보다 이후 반응이 중요
검색 엔진과 무관하다 사내 도구·대시보드
서버를 두고 싶지 않다 올려두면 끝나는 배포
인스타그램은 앞쪽입니다. 처음 보는 사람이 프로필 링크를 타고 들어오고, 그 화면이 검색에 걸려야 하니까요. C-10에서 "검색 엔진이 흰 종이를 본다" 고 했던 게 바로 그 문제였습니다.
되돌리려면 무엇을 치를까요.
한번 서버 컴포넌트로 짜면 되돌리기가 꽤 아픕니다. 데이터를 가져오는 방식이 컴포넌트 안에 들어가 있어서, 브라우저 렌더링으로 옮기려면 그걸 전부 훅으로 다시 짜야 하거든요. 반대 방향도 마찬가지라서, 이 선택은 프로젝트 초반에, 되돌리기 어렵다는 걸 알고 해야 합니다. 나중에 고치면 된다고 미룰 종류가 아니에요.
마지막으로, 둘 중 하나를 고르는 문제가 아니라는 점도 기억하세요.
오늘 우리가 한 게 정확히 그거였습니다. 한 앱 안에서 피드는 서버가 그리고 좋아요 버튼은 브라우저가 그렸어요. "전부 서버"도 "전부 브라우저"도 아닙니다.
🎯 면접관을 홀리는 핵심 멘트
"서버 컴포넌트가 SPA를 대체한다기보다, 첫 화면과 이후 반응 중 무엇이 중요한 서비스인가로 갈린다고 봅니다. 처음 오는 사용자가 많고 검색 유입이 중요하면 서버 쪽이고, 로그인한 사용자가 오래 머무는 도구라면 브라우저 쪽이 낫습니다. 다만 이 선택은 데이터를 가져오는 방식이 컴포넌트 안에 박히기 때문에 나중에 되돌리기가 비쌉니다. 그래서 저는 초반에 이 질문부터 팀과 맞추는 편입니다."