D-1: Next.js 16 입문 — 파일이 화면이 된다
목차 47
우리가 만든 인스타그램을 브라우저에서 열어보세요. 피드가 뜨고, 좋아요가 눌리고, 쪽지가 실시간으로 도착하는데, 여덟 주 동안 쌓아 올린 것이 거기 다 있어요.
이제 그 화면에서 오른쪽 버튼을 누르고 페이지 소스 보기를 눌러봅니다.
<body class="bg-canvas text-ink">
<div id="root"></div>
</body>
이게 전부입니다.
한강 노을도, 좋아요 1240도, 우리가 만든 어떤 글자도 거기 없는데, 빈 상자 하나뿐입니다.
안녕하세요, 홍순구입니다. 지난 시간 마지막에 이 이야기를 꺼냈으니까, 오늘부터 그 빈 상자를 채웁니다.
💡 오늘 수업의 핵심 — "주소를 어디에 적었느냐가 전부를 바꿉니다"
지난 시간까지 우리는 주소를 배열에 적었는데, routes.ts를 열면 path: 'explore' 같은 줄이 늘어서 있었죠.
오늘부터는 주소를 폴더 이름으로 적으면서 배열이 사라져요.
처음엔 그냥 취향 차이처럼 보이는데, 이 하나가 바뀌면서 화면이 서버에서 먼저 그려지고 타입이 저절로 생기고 오타를 컴파일러가 잡아주는 것까지 줄줄이 따라 바뀌어요.
같은 인스타그램인데 바닥이 달라지는 겁니다.
🎯 학습 목표
- Next.js 앱을 만들고, 지금까지 쓰던 Vite 앱과 무엇이 같고 다른지 짚습니다.
- 폴더를 만들면 주소가 생기는 규칙을 익히고, 라우트 배열과 대조합니다.
- 브라우저가 받는 HTML을 직접 열어보고, 오프닝에서 본 빈 상자가 어떻게 채워지는지 확인합니다.
- 모든 주소가 물려받는 가장 바깥 껍데기를 세웁니다.
- 대괄호로 자리를 비워둔 칸을 만들고, 그 칸이 타입까지 만들어내는 것을 확인합니다.
- 특정 구간에만 씌워지는 껍데기를 한 겹 더 얹습니다.
오늘의 여정
1. 앱을 만든다 create-next-app · 번들러 설정이 없다
2. 폴더가 곧 주소다 배열이 사라진다
3. 서버가 이미 보냈다 빈 상자 vs 글자가 든 HTML
4. 가장 바깥 껍데기 html·body 를 우리가 쓴다
5. 자리를 비워둔 칸 폴더가 타입을 만든다
6. 껍데기 안에 껍데기 구간마다 다른 껍데기
Step 1: "앱을 만든다"
지금 있는 것을 버리지 않습니다
지금까지 만든 SPA는 지우지 않으니 먼저 안심하고 가죠.
우리 저장소는 앱을 여러 개 담을 수 있는 구조라서, apps/ 아래에 있던 web-spa 옆에 web-next를 하나 더 만듭니다.
instagram-clone-frontend/
└── apps/
├── api-stub/ ← 연습용 API 서버 (그대로)
├── web-spa/ ← 지금까지 만든 것 (그대로)
└── web-next/ ← 오늘 만들 것
두 앱은 나중에 같은 API를 보는데, 한쪽을 만드는 동안 다른 쪽은 멀쩡히 돌아가요.
명령 한 줄
apps/ 폴더로 가서 이렇게 칩니다.
npx create-next-app@latest web-next
물어보는 것이 몇 개 있는데, 우리는 이렇게 답합니다.
| 질문 | 우리 답 | 왜 |
|---|---|---|
| TypeScript | 예 | 이 과목은 처음부터 타입을 답니다 |
| Tailwind CSS | 예 | 스타일링은 이미 배운 것을 그대로 씁니다 |
| ESLint | 예 | 규칙 검사도 그대로 |
| App Router | 예 | 오늘 배우는 것이 이것입니다 |
src/ 폴더 |
아니오 | 폴더 한 겹을 줄여 주소와 폴더를 바로 붙여 봅니다 |
| import 별칭 | @/* |
@/lib/posts처럼 짧게 씁니다 |
⚠️ Node 버전을 먼저 확인하세요. Next 16은 Node 20.9 이상을 요구합니다. node --version으로 확인하고, 낮으면 올리고 오셔야 해요.
무엇이 깔렸는지 봅니다
package.json을 열어봅니다.
{
"dependencies": {
"next": "16.3.1",
"react": "19.2.8",
"react-dom": "19.2.8"
}
}
React 19.2.8이고 SPA에서 쓰던 것과 같은 버전이라, React를 새로 배우는 게 아니에요. 컴포넌트도, useState도, 그동안 익힌 것이 그대로 갑니다.
새로 들어온 건 next 하나입니다.
없는 것이 더 눈에 띕니다
scripts를 보세요.
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
}
}
SPA 쪽 vite.config.ts 같은 파일이 없습니다. 번들러를 고르고 플러그인을 붙이던 그 설정이요.
Next 16은 Turbopack이라는 번들러를 기본으로 씁니다. 개발 서버든 빌드든 따로 켜는 플래그가 없어서, next dev 라고만 쓰면 그게 Turbopack입니다.
🙋 학생 질문 — "튜터님, 검색해 보니 --turbopack을 붙이라던데요?"
Next 15까지의 이야기인데, 그때는 Turbopack이 실험 단계라 플래그로 켜야 했어요.
16 부터는 그게 기본이 되면서 플래그가 필요 없어졌습니다. 오히려 반대 방향의 탈출구가 생겨서, 옛 번들러로 돌아가고 싶으면 next dev --webpack이라고 씁니다.
옛 글을 보고 플래그를 붙여도 당장 안 터지긴 하지만, 붙일 이유가 없어졌다는 것만 알아두세요.
띄워봅니다
npm run dev
http://localhost:3000을 열면 Next 기본 화면이 뜨는데, 여기까지 왔으면 준비가 끝났어요.
다음으로 넘어가기 전에 app/ 폴더만 잠깐 보고 갑시다. 파일이 세 개 있습니다.
app/
├── layout.tsx ← 껍데기 (Step 4)
├── page.tsx ← 화면 (Step 2)
└── globals.css ← 스타일
이 두 이름이 오늘의 주인공입니다.
Step 2: "폴더 이름이 곧 주소"
지난 시간에는 어떻게 했더라
C-1에서 주소를 하나 늘리려면 무엇을 했는지 떠올려보면, routes.ts를 열어서 배열에 한 줄을 넣었죠.
// apps/web-spa/src/routes/routes.ts — C-1 에서 세운 방식 (앞 세 줄만 발췌)
children: [
{ index: true, Component: HomePage },
{ path: 'signup', Component: SignUpPage },
{ path: 'explore', Component: ExplorePage },
// ... 상세·쪽지·없는 주소가 아래로 이어진다
]
주소와 컴포넌트를 손으로 짝지어 적었고, 그 파일을 라우터에게 건넸어요.
이번에는 짝을 안 지어줍니다
Next에서는 이 배열이 없는 대신, 규칙이 하나 있어요.
app/ 아래 폴더가 주소 한 칸이 되고, 그 폴더 안의 page.tsx가 그 주소의 화면이 된다.
말로 하면 어려우니 그림으로 봅시다.
app/page.tsx → /
app/explore/page.tsx → /explore
app/settings/alarm/page.tsx → /settings/alarm
└───────┴────┘
이 두 칸이 폴더 이름
폴더 이름이 그대로 주소 칸이 되고, 등록하는 곳이 따로 없어요.
홈부터 만듭니다
app/page.tsx를 우리 피드로 바꿉니다.
// apps/web-next/app/page.tsx
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>
<p className="mt-2 text-sm text-black/60">좋아요 {post.likeCount}</p>
</li>
))}
</ul>
</main>
);
}
export default가 붙어 있는 것을 눈여겨보세요. Next는 이 파일에서 기본 내보내기 하나를 찾아 화면으로 쓰는데, 이름은 자유예요.
데이터는 lib/posts.ts에 적어뒀는데, SPA 쪽 feed.ts와 같은 열 장이에요. 아직 서버에서 가져오지 않고 파일에 적힌 값을 그대로 쓰고, 진짜로 가져오는 건 다음다음 시간입니다.
탐색 화면을 늘려봅니다
이제 주소를 하나 더 만들어볼까요. 등록할 곳이 없으니까 폴더를 만드는 것이 곧 등록입니다.
// 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) => (
<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>
</main>
);
}
app/explore/page.tsx를 저장하는 순간 /explore가 생기는데, 다른 파일은 한 글자도 안 고쳤어요.
주소창에 localhost:3000/explore를 쳐보면, 열 장이 격자로 뜹니다.
정말 그런지 빌드가 알려줍니다
npm run build를 돌리면 마지막에 주소 목록이 나옵니다.
Route (app)
┌ ○ /
├ ○ /_not-found
└ ○ /explore
우리가 만든 두 개가 있고, 만든 적 없는 /_not-found가 하나 더 있죠. Next가 기본으로 준비해두는 자리인데, 이건 다음 시간에 우리 것으로 바꿉니다.
이름이 곧 역할인 파일들
page.tsx 말고도 app/ 안에서 특별 대접을 받는 이름이 몇 개 있는데, 오늘 다 쓰지는 않지만 지도는 미리 보고 가죠.
| 파일 이름 | 역할 | 언제 |
|---|---|---|
page.tsx |
그 주소의 화면 | 오늘 Step 2 |
layout.tsx |
그 구간을 감싸는 껍데기 | 오늘 Step 4·6 |
not-found.tsx |
없는 주소일 때 | 다음 시간 |
error.tsx |
터졌을 때 | 다음 시간 |
loading.tsx |
기다리는 동안 | 데이터를 서버에서 가져오는 시간에 |
route.ts |
화면이 아니라 API | 서버 액션을 다루는 시간에 |
⚠️ 이 이름들은 약속된 철자입니다. Page.tsx처럼 대문자로 쓰면 Next가 그 파일을 화면으로 안 보는데, 에러도 안 나고 빌드도 통과해요.
더 고약한 건 그다음인데, 오늘 Step 5까지 만들고 나면 그 상태에서 /explore를 열었을 때 에러 대신 멀쩡해 보이는 다른 화면이 뜹니다. 왜 그런지는 과제에서 직접 확인해보세요.
Step 3: "서버가 이미 글자를 보냈다"
오프닝의 빈 상자로 돌아갑니다
맨 처음에 SPA의 소스 보기를 열었더니 <div id="root"></div> 하나뿐이었죠.
왜 그럴까요? 순서를 그려보면 이렇습니다.
SPA 에서 /explore 를 열면
1. 브라우저가 서버에 index.html 을 달라고 한다
2. 서버가 준다 ─── 그 안엔 빈 상자뿐
3. 브라우저가 자바스크립트를 내려받는다
4. 자바스크립트가 돌면서 상자를 채운다
└─ 글자는 여기서 처음 생긴다
글자가 생기는 시점이 4번입니다. 서버는 글자를 보낸 적이 없고, 보낸 건 "이 코드를 받아서 네가 그려라"라는 지시뿐이라서, 자바스크립트를 못 돌리거나 안 돌리는 방문자에게는 끝까지 빈 상자입니다.
Next는 어디서 그리나
같은 걸 Next로 해보죠, 빌드하고 띄운 뒤 브라우저가 받은 HTML을 그대로 들여다봤어요.
받은 HTML 안에서 오늘 한강 노을이 미쳤다를 찾아봤습니다.
| SPA가 보낸 HTML | Next가 보낸 HTML | |
|---|---|---|
| 크기 | 1,487 바이트 | 7,713 바이트 |
오늘 한강 노을이 미쳤다 |
없음 | 있음 |
Next 쪽에는 게시물 글이 HTML 안에 이미 들어 있는데, 브라우저가 자바스크립트를 한 줄도 안 돌린 시점이에요.
서버가 화면을 먼저 그려서 보낸 겁니다.
Next 에서 / 를 열면
1. 브라우저가 서버에 / 를 달라고 한다
2. 서버가 화면을 그린다 ← 여기가 새로 생긴 단계
3. 서버가 준다 ─── 글자가 든 HTML
4. 브라우저가 자바스크립트를 받아 이어붙인다
3번에서 이미 읽을 것이 있는데, 4번은 버튼을 누를 수 있게 만드는 단계고요.
직접 해보세요
여러분 화면에서도 확인해봅시다. npm run build 하고 npm run start로 띄운 뒤, 소스 보기를 열고 오늘 한강 노을이 미쳤다를 찾아보세요.
⚠️ 찾을 때 주의할 게 하나 있습니다. 좋아요 1240이나 @jaehoon으로는 안 찾아집니다.
React가 서버에서 그릴 때, 붙어 있는 글자와 값 사이에 표시를 하나 끼워 넣거든요.
화면에 보이는 것 HTML 안에 실제로 있는 것
─────────────────────────────────────────────
@jaehoon → @<!-- -->jaehoon
좋아요 1240 → 좋아요 <!-- -->1240
오늘 한강 노을이… → 오늘 한강 노을이… ← 이건 안 끼어든다
{post.content}처럼 하나가 통째로 만든 글자는 그대로 있어서 문장으로 찾아야 하는데, 이걸 모르면 "안 나오는데요?" 하고 멀쩡한 걸 의심하게 됩니다.
🙋 학생 질문 — "그럼 지금까지 만든 SPA는 잘못 만든 건가요?"
아닙니다. 둘은 잘하는 일이 달라요.
브라우저에서 다 처리하는 방식은 한 번 뜨고 나면 화면 전환이 아주 매끄러운데, 사내 관리 도구처럼 로그인한 사람만 쓰는 화면, 검색 엔진이 볼 일이 없는 화면에는 여전히 좋은 선택이에요.
반대로 검색으로 사람이 들어와야 하는 화면, 링크를 공유했을 때 미리보기가 떠야 하는 화면은 서버가 먼저 그려주는 쪽이 유리한데, 인스타그램 게시물 페이지가 딱 그렇죠.
오늘 배우는 건 "이게 정답"이 아니라 "이런 선택지가 있다"인데, 무엇을 언제 고를지는 앞으로 계속 나와요.
Step 4: "가장 바깥 껍데기"
머리말을 어디에 둘까
지금 홈과 탐색은 각자 따로 노는데, 두 화면 다 위쪽에 머리말이 있으면 좋겠지만, 양쪽에 복사해 붙이면 나중에 고칠 때 두 군데를 고쳐야 하죠.
C-1에서 이 문제를 이미 풀어봤는데, Layout 컴포넌트를 만들고 자식 자리에 <Outlet />을 뒀었죠.
Next에도 같은 것이 있는데, 이름이 정해져 있을 뿐이에요. layout.tsx입니다.
스캐폴드가 준 것을 우리 것으로
app/layout.tsx를 열어 이렇게 고칩니다.
// apps/web-next/app/layout.tsx
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: '인스타그램 클론',
description: 'Next.js App Router 로 다시 짓는 인스타그램',
};
export default function RootLayout({ children }: LayoutProps<'/'>) {
return (
<html lang="ko">
<body className="min-h-screen bg-white text-black antialiased">
<header className="border-b border-black/10">
<nav className="mx-auto flex max-w-3xl gap-4 p-4 text-sm">
<span className="font-bold">인스타그램 클론</span>
<span className="text-black/50">홈</span>
<span className="text-black/50">탐색</span>
</nav>
</header>
{children}
</body>
</html>
);
}
볼 것이 세 개 있습니다.
하나 — html과 body를 우리가 씁니다
SPA에서는 index.html이라는 파일이 따로 있었는데, 거기에 <html>과 <body>가 있었고요.
Next에는 그 파일이 없는데, 가장 바깥 layout.tsx가 그 역할을 하기 때문에 여기서는 <html>과 <body>를 직접 써야 해요. 빠뜨리면 빌드가 막습니다.
둘 — children이 <Outlet /> 자리입니다
SPA — C-1 에서 지금
────────────────────────────────────────────────────
<Layout> app/layout.tsx
머리말 머리말
<Outlet /> {children}
</Layout>
둘 다 자식이 끼는 자리다. 다른 건 그 자식을 누가 정하느냐다.
하는 일은 같은데, 다른 건 자식을 누가 정하느냐예요. C-1에서는 라우트 배열의 children이 정했고, 지금은 폴더 구조가 정합니다.
셋 — 이 타입은 어디서 왔을까
LayoutProps<'/'>를 보고, 파일 맨 위를 다시 보세요.
LayoutProps를 가져오는 import가 없습니다.
어디서도 안 가져왔는데 쓰고 있지만, 오타도 아니고 빌드도 통과합니다. 이 타입의 정체는 다음 Step에서 밝힙니다. 지금은 "가져온 적 없는 타입이 있다"는 것만 기억해두세요.
머리말이 안 눌립니다
화면을 보면 머리말이 잘 붙어 있지만, "홈"과 "탐색"을 눌러도 아무 일이 안 일어나요.
일부러 그렇게 뒀는데, 지금은 <span>이거든요. 누르면 움직이게 만드는 건 다음 시간의 첫 순서입니다.
Step 5: "자리를 비워둔 칸"
사람이 만 명이면 폴더도 만 개일까
프로필 화면을 만들 차례입니다. /jaehoon, /minji, /seungwoo…
폴더가 곧 주소라면, 사람마다 폴더를 하나씩 만들어야 할까요? 사람이 만 명이면 폴더가 만 개?
당연히 아닙니다. C-2에서 path: 'p/:postId'처럼 콜론으로 자리를 비워뒀던 것 기억하시죠. 폴더에도 같은 것이 있는데, 그게 대괄호예요.
C-2 에서 { path: 'p/:postId', ... } ← 콜론
지금 app/[username]/page.tsx ← 대괄호
만들어봅니다
app/[username]/page.tsx를 만드는데, 폴더 이름에 대괄호를 그대로 넣으세요.
// apps/web-next/app/[username]/page.tsx
import { postsByUsername } from '@/lib/posts';
export default async function ProfilePage({ params }: PageProps<'/[username]'>) {
const { username } = await params;
const posts = postsByUsername(username);
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>{post.content}</p>
<p className="mt-1 text-black/60">좋아요 {post.likeCount}</p>
</li>
))}
</ul>
);
}
/jaehoon을 열면 jaehoon 님 게시물 세 장이, /minji를 열면 minji 님 게시물 세 장이 뜹니다. 폴더는 하나뿐인데요.
두 가지가 눈에 걸릴 겁니다. async와 await요.
params는 값이 아니라 나중에 도착할 값이기 때문에 기다렸다 받아요. 왜 그렇게 만들었는지는 캐싱을 다루는 시간에 제대로 나오는데, 지금은 "대괄호 값은 await로 받는다"로 외워두셔도 됩니다.
이제 아까 그 타입 이야기
PageProps<'/[username]'>도 가져온 적이 없는데, Step 4의 LayoutProps와 같은 식구입니다.
정체는 이렇습니다. Next 가 우리 폴더를 읽고 타입을 만들어냅니다.
빌드를 돌린 뒤 .next/types/routes.d.ts라는 파일을 열어보면 이런 줄이 있어요.
type AppRoutes = "/" | "/[username]" | "/explore"
interface ParamMap {
"/": {}
"/[username]": { "username": string; }
"/explore": {}
}
우리가 만든 주소들이 그대로 타입이 됐습니다. [username] 폴더를 만들자 ParamMap에 { username: string }이 정확히 박혔고요.
Step 2에서 폴더를 하나 늘렸을 때 이 줄이 어떻게 변했는지 비교해보면 이렇습니다.
app/page.tsx 만 있을 때
type AppRoutes = "/"
explore 폴더를 만든 뒤
type AppRoutes = "/" | "/explore"
[username] 폴더를 만든 뒤
type AppRoutes = "/" | "/[username]" | "/explore"
폴더가 주소가 된다는 말이 비유가 아니었던 겁니다. 폴더가 타입까지 만듭니다.
그래서 진짜 막아주나
생성된다는 건 알겠는데, 그게 실제로 도움이 되냐는 건 다른 문제죠. 세 가지로 일부러 틀리게 해봤습니다.
없는 주소를 적으면
PageProps<'/profile/[username]'>
→ Type '"/profile/[username]"' does not satisfy the constraint 'AppRoutes'.
칸 이름을 틀리면
const { userName } = await params;
→ Property 'userName' does not exist on type '{ username: string; }'.
await를 빠뜨리면
const { username } = params;
→ Property 'username' does not exist on type 'Promise<{ username: string; }>'.
셋 다 컴파일에서 막히는데, 특히 마지막 게 반갑죠. await를 빠뜨리면 조용히 undefined가 되는 게 아니라, 기다려야 한다는 사실을 타입이 직접 말해줍니다.
C-2와 나란히 놓아봅니다
C-2에서 이 값들의 타입이 어땠는지 떠올려봅시다.
| 받는 값의 타입 | 틀리면 | |
|---|---|---|
C-2 useParams() |
string | undefined |
조심해서 쓰면 됨 |
C-2 useLoaderData() |
any |
틀려도 통과시킴 |
지금 params |
Promise<{ username: string }> |
세 가지가 다 막힘 |
라이브러리 하나가 부실하고 하나가 훌륭해서가 아닙니다.
C-1의 라우터는 주소를 배열 안의 문자열로 받았어요. 그 문자열은 프로그램이 돌아야 알 수 있어서, 컴파일하는 시점엔 알 수가 없어요.
Next는 주소가 폴더인데, 폴더는 컴파일 전에도 거기 그대로 있어서 읽어서 타입을 만들 수 있는 겁니다.
차이를 만든 건 도구의 성능이 아니라 주소를 어디에 적었느냐입니다.
🙋 학생 질문 — "저장소를 새로 받아서 타입 검사를 돌렸더니 에러가 나요"
Cannot find name 'LayoutProps'라고 나오죠? 정상인데, 이 Step을 읽었으면 이유도 짐작하실 거예요.
그 타입은 손으로 쓴 게 아니라 Next가 폴더를 읽고 만들어내는 것이어서, Next 를 한 번도 안 돌린 상태에서는 그 타입이 아직 존재하지 않아요.
npm install 만으로는 안 생기는데, npm run dev 나 npm run build를 한 번 돌리면 그때 만들어지고, 그 뒤로는 타입 검사가 통과합니다.
새 팀원이 저장소를 받자마자 "타입 에러가 잔뜩 나는데요?" 하면 이걸 알려주면 됩니다.
Step 6: "껍데기 안에 껍데기"
프로필에만 필요한 것
프로필 화면을 보니 허전한데, 인스타그램 프로필이면 위에 프로필 사진과 이름, 게시물 수가 있어야 하죠.
그런데 그건 프로필 주소에만 필요한 것이라서, 탐색 화면에 남의 프로필 사진이 뜨면 안 되잖아요.
가장 바깥 layout.tsx에 넣으면 모든 화면에 뜨는데, 그렇다고 page.tsx 안에 넣으면 나중에 프로필 아래에 화면이 늘어날 때 또 복사해야 하고요.
Next의 답은 이렇습니다. 껍데기를 그 구간에 하나 더 둔다.
구간마다 껍데기
layout.tsx는 app/ 맨 위에만 놓을 수 있는 게 아니라 아무 폴더에나 놓을 수 있어서, 그 폴더 아래 주소들만 그 껍데기를 씁니다.
// apps/web-next/app/[username]/layout.tsx
import { postsByUsername } from '@/lib/posts';
export default async function ProfileLayout({ children, params }: LayoutProps<'/[username]'>) {
const { username } = await params;
const posts = postsByUsername(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" />
<div>
<h1 className="text-xl font-bold">@{username}</h1>
<p className="mt-1 text-sm text-black/60">게시물 {posts.length}</p>
</div>
</div>
{children}
</main>
);
}
LayoutProps<'/[username]'>를 보세요. Step 4의 것은 <'/'>였는데, 껍데기도 자기가 어느 구간을 맡는지 타입에 적힙니다.
겹쳐서 그려집니다
/jaehoon을 열면 이렇게 됩니다.
app/layout.tsx 머리말 (인스타그램 클론 · 홈 · 탐색)
└ app/[username]/layout.tsx 프로필 사진 · @jaehoon · 게시물 3
└ app/[username]/page.tsx 게시물 격자
바깥 껍데기가 사라지고 안쪽으로 바뀌는 게 아닙니다. 바깥은 그대로 있고 그 안에 한 겹이 더 생겨요.
폴더가 깊어지는 만큼 껍데기도 겹칩니다.
정말 프로필에만 씌워졌을까
확인해봤는데, 두 주소가 보낸 HTML에서 각 부분이 몇 번 나오는지 세어봤어요.
/jaehoon |
/explore |
|
|---|---|---|
| 바깥 머리말 | 1 | 1 |
| 프로필 껍데기의 "게시물" | 2 | 0 |
바깥 머리말은 양쪽 다 있고, 프로필 껍데기는 프로필 주소에만 있는데, 의도한 대로예요.
(/jaehoon 쪽이 2인 건 화면용 HTML과 브라우저가 이어받을 때 쓰는 데이터에 각각 한 번씩 들어 있어서입니다. 그 둘의 정체는 앞으로 다룹니다.)
🙋 학생 질문 — "이 껍데기, 화면을 옮겨도 안 다시 그려진다고 들었는데요?"
맞습니다. 그게 이 구조의 가장 큰 값어치인데, 프로필 안에서 화면을 옮겨도 프로필 머리 부분은 그대로 있고 스크롤 위치나 입력하던 값도 안 날아가는데, 오늘은 그걸 보여드릴 수가 없습니다.
지금 우리 머리말은 눌리지 않는데, 주소창에 직접 쳐서 옮기면 브라우저가 문서를 통째로 다시 받아오면서 껍데기도 당연히 처음부터 다시 그려져요. 안 다시 그려지는 걸 보려면 화면을 안 새로 받고 옮기는 방법이 먼저 있어야 합니다.
그게 다음 시간 첫 순서인데, 오늘은 "겹쳐서 그려진다"는 구조까지 다루고, 다음 시간에 "옮겨도 안 다시 그려진다"를 직접 재봅니다.
마무리
오늘 배열 하나를 지우고, 그 자리에 폴더를 뒀어요.
SPA 로 만들 때 (A~C) 오늘부터
────────────────────────────────────────────────────────────────────
routes.ts 배열에 등록 app/ 아래 폴더를 만든다
<Outlet /> {children}
index.html 에 html·body layout.tsx 에 html·body
:postId [username]
useParams() → string | undefined params → { username: string }
브라우저가 그린다 서버가 그려서 보낸다
오른쪽 줄들이 전부 하나에서 나왔습니다. 주소를 폴더로 적었다는 것.
지금 우리 앱에는 피드·탐색·프로필, 이렇게 화면이 셋 있고 껍데기도 둘 겹쳐 있는데, 서버가 글자를 채워서 보내니 소스 보기에도 내용이 있어요.
머리말의 "홈"과 "탐색"은 아직 눌리지 않는 글자라서, 셋 사이를 오갈 수가 없고 주소창에 직접 쳐서 다녀야 합니다.
다음 시간엔 그 사이를 잇는데, 이으면 재밌는 일이 생겨요. 오늘 만든 껍데기가 화면을 옮겨도 안 다시 그려진다는 것을 그때 직접 재볼 수 있게 되고, 없는 주소로 갔을 때와 화면이 터졌을 때 무엇을 보여줄지도 그때 정합니다.
과제
[구현] 사람 이름을 눌러 프로필로
지금 피드와 탐색에는 @jaehoon 같은 이름이 떠 있지만 눌리지 않는데, 눌러서 그 사람 프로필로 가게 만들어보세요.
- 아직
<Link>를 안 배웠으니 평범한<a href="...">로 만듭니다. - 만들고 나서 눌러보고, 주소창 왼쪽의 새로고침 표시가 어떻게 되는지 관찰해보세요.
- 개발자 도구 네트워크 탭을 열어두고 눌러보세요. 요청이 몇 건 오가나요?
- 그 관찰을 적어두세요. 다음 시간에 같은 자리를
<Link>로 바꾸고 숫자를 비교합니다.
[구현] 게시물 상세 주소를 폴더로
SPA에는 /p/:postId라는 주소가 있었는데, 같은 것을 Next에 만들어보세요.
- 폴더 구조를 어떻게 짜야
/p/7이 되는지 먼저 종이에 그려보세요. - 없는 번호(
/p/999)로 들어가면 지금은 어떻게 되나요? 화면에 무엇이 뜨는지 적어두세요. - ⚠️ 대괄호 칸의 값은 항상 글자입니다. 숫자로 쓰려면 한 번 바꿔야 해요.
[탐구] 규칙을 어겨보기
Step 2에서 파일 이름은 약속된 철자라고 했는데, 정말 그런지 확인해보세요.
app/explore/page.tsx를app/explore/Page.tsx로 바꿔봅니다./explore를 열면 무엇이 뜨나요? 에러가 나나요, 아니면 다른 게 뜨나요?- 빌드를 돌리면 주소 목록에서
/explore가 어떻게 되나요? - 확인이 끝나면 되돌리세요.
[탐구] 생성되는 타입 들여다보기
Step 5에서 본 .next/types/routes.d.ts를 직접 열어보세요.
- 폴더를 하나 더 만들고 빌드한 뒤, 그 파일이 어떻게 달라지는지 비교해보세요.
AppRoutes말고 다른 줄도 있는데,LayoutRoutes는 무엇이 들어 있고AppRoutes와 왜 다른가요?- 이 파일을 손으로 고치면 어떻게 되는지, 고쳐보고 빌드해보세요.
생각해볼 주제
1. 등록하는 곳이 없다는 것의 뒷면
오늘 라우트 배열이 사라져서, 주소를 늘리려고 파일을 열 일이 없어졌죠.
그런데 배열에는 배열의 장점이 있었는데, 그 파일 하나만 열면 이 앱에 어떤 주소가 있는지 한눈에 보였어요.
지금은 그걸 어떻게 알 수 있을까요? 그리고 주소가 수백 개인 앱에서 이 차이는 어느 쪽으로 기울까요? 팀에 새로 온 사람 입장에서도 생각해보세요.
2. 폴더 이름을 바꾸면 무슨 일이 생기나
app/explore를 app/discover로 바꾸면 주소가 /discover가 되는데, 파일 하나 안 고치고요.
편해 보이는데, 이미 서비스가 돌아가고 있다면 어떨까요? 사람들이 저장해둔 링크, 검색 엔진이 알고 있는 주소, 다른 사이트에 걸린 링크가 전부 /explore를 가리키고 있습니다.
폴더 이름 하나를 바꾸는 일이 왜 가벼운 일이 아닌지 정리하고, 그럼에도 바꿔야 한다면 무엇을 함께 해야 할지 생각해보세요.
3. 서버가 그린다는 것의 값
Step 3에서 Next가 보낸 HTML이 SPA 것보다 다섯 배쯤 컸는데, 서버는 화면을 그리느라 일을 더 했고요.
공짜가 아니라는 뜻인데, 그런데도 그렇게 하는 이유가 있죠.
어떤 화면에서 이 교환이 이득일까요? 반대로 손해인 화면은 어떤 것일까요? 우리 인스타그램 안에서 각각 예를 하나씩 찾아보고, 왜 그렇게 갈리는지 적어보세요.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 사람 이름을 눌러 프로필로 (25점)
채점 포인트
| 항목 | 배점 | 설명 |
|---|---|---|
| 링크가 올바른 주소를 가리킨다 | 8 | /jaehoon처럼 사람 이름이 주소 칸에 들어간다 |
| 눌렀을 때 프로필로 간다 | 5 | 실제로 이동하고 그 사람 게시물이 뜬다 |
| 새로고침이 일어나는 것을 관찰했다 | 7 | 탭이 다시 뜨는 것을 말로 설명할 수 있다 |
| 요청 수를 세어보고 적어뒀다 | 5 | 숫자와 함께 "무엇이 다시 왔는지" 를 적었다 |
풀이 예시
이름을 <a>로 감싸면 됩니다.
// apps/web-next/app/page.tsx
<p className="font-semibold">
<a href={`/${post.username}`}>@{post.username}</a>
</p>
주소를 만들 때 /${post.username}처럼 앞에 빗금을 붙여야 하는데, 안 붙이면 지금 있는 주소 뒤에 이어 붙습니다.
관찰한 것
눌러보면 프로필로 가는데, 잘 동작하지만 화면이 한 번 하얗게 깜빡입니다.
탭의 새로고침 표시를 보세요. 주소창 왼쪽 아이콘이 잠깐 X로 바뀌었다 돌아오는데, 브라우저가 문서를 통째로 다시 받고 있다는 뜻이에요.
개발자 도구 네트워크 탭을 열고 눌러보면 목록이 새로 채워지는데, 빌드본 기준으로 최소 이만큼이 다시 옵니다.
| 무엇 | 건수 |
|---|---|
| 문서(HTML) | 1 |
| 스타일 | 1 |
| 미리 받기 | 1 |
| 스크립트 | 4 |
| 합계 | 7 |
여러분 화면의 숫자는 이것과 다를 수 있는데, 브라우저가 저장해둔 것을 재활용하면 줄고 개발 서버로 재면 늘어나지만, 중요한 건 정확한 숫자가 아니라 0이 아니라는 것입니다.
우리가 이미 갖고 있던 자바스크립트를 버리고 똑같은 것을 다시 받으면서, React도 처음부터 다시 시작합니다.
이 숫자를 적어두면, 다음 시간에 같은 자리를 다른 물건으로 바꾸고 나서 다시 셀 겁니다.
자주 나오는 실수
- 빗금을 안 붙임 —
href={post.username}이라고 쓰면/explore에서 눌렀을 때/explore/jaehoon으로 가는데, 그런 주소는 없으니 엉뚱한 화면이 떠요. <a>안에 또<a>— 카드 전체를 링크로 감싸놓고 이름도 링크로 감싸면 겹치는데, HTML 규칙 위반이라 브라우저가 알아서 쪼개버려요.- "원래 이런 거 아닌가요?" — 이 깜빡임은 정상이 아니라 아직 안 배운 것이 있다는 신호입니다. 그냥 넘기지 말고 숫자를 적어두세요.
🎯 [과제 2 예시답안] 게시물 상세 주소를 폴더로 (30점)
채점 포인트
| 항목 | 배점 | 설명 |
|---|---|---|
| 폴더 구조가 맞다 | 10 | app/p/[postId]/page.tsx |
| 값을 제대로 꺼낸다 | 8 | await params로 받는다 |
| 글자를 숫자로 바꿨다 | 7 | Number() 없이 비교하면 아무것도 안 찾아진다 |
| 없는 번호일 때를 확인했다 | 5 | 200 이 나온다는 것을 알아냈다 |
풀이 예시
주소가 /p/7이 되려면 폴더가 두 겹이어야 하는데, p는 고정된 글자니까 그냥 폴더고, 번호 자리만 대괄호예요.
app/
└── p/
└── [postId]/
└── page.tsx → /p/7 · /p/42 · /p/999
// apps/web-next/app/p/[postId]/page.tsx
import { allPosts } from '@/lib/posts';
export default async function PostDetailPage({ params }: PageProps<'/p/[postId]'>) {
const { postId } = await params;
const post = allPosts.find((it) => it.id === Number(postId));
return (
<main className="mx-auto max-w-xl p-6">
<h1 className="text-xl font-bold">{post ? post.content : '없는 게시물'}</h1>
</main>
);
}
대괄호 칸은 언제나 글자입니다
Number(postId)를 빼면 어떻게 될까요? it.id === postId는 숫자와 글자를 비교하는 것이기 때문에 영원히 거짓이라서, 게시물이 있어도 "없는 게시물"이 떠요.
주소는 글자로 오는 게 당연한데, 주소창에 친 것은 다 글자니까요. 화면에 찍어보면 확실합니다.
/p/7 에서 typeof postId → string
⚠️ 여기서 타입 검사는 도와주지 않는데, postId는 진짜로 string이고, it.id === Number(postId)도 it.id === postId도 문법상 틀린 게 없어요. 컴파일러가 막아주는 것과 안 막아주는 것의 경계가 여기 있습니다.
없는 번호로 가면
/p/999를 열어보면, 예상과 다를 겁니다.
| 주소 | 상태 코드 | 화면 |
|---|---|---|
/p/7 |
200 | 주말 등산 브이로그 |
/p/999 |
200 | 없는 게시물 |
/p/abc |
200 | 없는 게시물 |
404가 아니라 200입니다.
당연한 이야기인데, 서버 입장에서 /p/[postId]라는 주소는 있습니다. 그 안에서 우리가 데이터를 못 찾았을 뿐이어서, 주소가 없는 것과 데이터가 없는 것은 다른 이야기입니다.
검색 엔진이 이 화면을 보면 "정상적인 페이지"로 여기고 목록에 넣는데, 없는 게시물 페이지가 검색에 뜨는 거죠.
이걸 제대로 404로 만드는 방법은 다음 시간에 다룹니다.
자주 나오는 실수
app/p/postId/로 만듦 — 대괄호를 빼면 그냥 글자라서,/p/postId라는 주소만 생겨요.app/[p]/[postId]/—p까지 대괄호로 감싸면/jaehoon/7같은 주소도 다 걸립니다. 고정된 글자는 고정으로 두세요.await를 빼먹음 — 다행히 타입 검사가 잡아주는데, Step 5에서 본 그 메시지가 나와요.
🎯 [과제 3 예시답안] 규칙을 어겨보기 (20점)
채점 포인트
| 항목 | 배점 | 설명 |
|---|---|---|
| 에러가 안 난다는 것을 확인했다 | 5 | 빌드가 그냥 통과한다 |
| 주소 목록에서 사라진 것을 봤다 | 5 | /explore가 표에서 빠진다 |
| 화면에 무엇이 떴는지 정확히 적었다 | 10 | "안 뜬다" 가 아니라 다른 화면이 뜬다 |
풀이 예시
app/explore/page.tsx를 Page.tsx로 바꾸고 빌드하면, 먼저 이게 눈에 띕니다.
바꾸기 전 바꾼 뒤
┌ ○ / ┌ ○ /
├ ○ /_not-found ├ ○ /_not-found
├ ƒ /[username] └ ƒ /[username]
└ ○ /explore ← /explore 가 없어졌다
에러는 한 줄도 안 나고 빌드는 성공이라고 말하지만, /explore만 조용히 사라집니다.
그런데 진짜는 여기부터
이제 /explore를 열어보세요. "페이지를 찾을 수 없습니다"가 뜰 것 같죠?
/explore → 상태 코드 200
@explore
게시물 0
프로필 화면이 뜹니다. explore라는 이름의 사람 프로필로요.
왜 그럴까요. /explore를 맡던 폴더가 자격을 잃었으니까, 남은 주소들 중에 이 한 칸짜리 주소를 받을 수 있는 것을 Next가 찾습니다. 그게 [username]인데, 비워둔 칸이니까 explore라는 글자도 받아줍니다.
그래서 화면에는 "@explore, 게시물 0"이 뜨는데, 에러도 아니고 404도 아니고, 말이 되는 것처럼 생긴 화면이요.
이게 대문자 오타가 무서운 이유인데, 빨간 글씨가 뜨면 오히려 다행이지만, 여기서는 아무도 안 알려주고 화면은 "explore 님은 게시물이 없구나"처럼 보입니다.
짝지어 기억해두면 좋은 것
이 실험은 두 가지를 한꺼번에 보여줍니다.
- 파일 이름은 약속된 철자다. 대소문자까지.
- 고정된 주소와 비워둔 칸이 겹칠 때는 고정된 쪽이 먼저 잡는데, 고정된 쪽이 없어지면 비워둔 칸이 받는다.
두 번째가 오늘 안 배운 것이라 더 놀라웠을 겁니다.
자주 나오는 실수
- 확인을 안 하고 넘어감 — 주소 목록에서 사라진 것만 보고 "안 뜨는구나" 하고 끝내면 절반만 본 겁니다. 꼭 열어보세요.
- 되돌리기를 잊음 — 다음 시간 실습이
/explore를 쓰니, 반드시page.tsx로 돌려놓으세요. - 맥에서는 안 바뀌는 것처럼 보임 — 맥의 기본 파일 시스템은 대소문자를 구분 안 해서, 이름만 바꾸면 git이 못 알아챌 때가 있습니다. 그래도 Next는 구분하니 화면은 바뀝니다.
🎯 [과제 4 예시답안] 생성되는 타입 들여다보기 (25점)
채점 포인트
| 항목 | 배점 | 설명 |
|---|---|---|
| 폴더 추가 전후를 비교했다 | 8 | 유니온이 늘어난 것을 보여준다 |
LayoutRoutes의 정체를 설명했다 |
10 | AppRoutes와 왜 다른지 근거를 댄다 |
| 손으로 고치면 어떻게 되는지 확인했다 | 7 | 다음 빌드에 지워진다 |
풀이 예시
.next/types/routes.d.ts를 열면 이렇게 생겼습니다.
type AppRoutes = "/" | "/[username]" | "/explore"
type LayoutRoutes = "/" | "/[username]"
LayoutRoutes는 왜 더 짧을까
둘을 나란히 놓고 무엇이 빠졌는지 봅시다.
AppRoutes / /[username] /explore
LayoutRoutes / /[username]
└─ 여기만 없다
/explore만 빠져 있습니다.
우리 폴더를 보면 답이 나오는데, app/과 app/[username]/에는 layout.tsx가 있고, app/explore/에는 page.tsx만 있습니다.
AppRoutes= 화면이 있는 주소 전부LayoutRoutes= 껍데기가 있는 주소만
그래서 LayoutProps<'/explore'>라고 쓰면 타입 에러가 나는데, 그런 껍데기가 없으니까요. app/explore/layout.tsx를 만들면 그때 LayoutRoutes에 들어옵니다.
이름이 비슷해서 헷갈리지만, 세는 대상이 다릅니다.
손으로 고치면
없는 주소를 하나 끼워 넣어봅시다.
type AppRoutes = "/" | "/[username]" | "/explore" | "/i-made-this-up"
저장하면 잠깐은 통하는데, 그 주소를 PageProps에 넣어도 에러가 안 나요.
그런데 빌드를 한 번 돌리면요.
type AppRoutes = "/" | "/[username]" | "/explore"
지워집니다. 우리가 쓴 줄은 흔적도 없어요.
이 파일 맨 위에 이렇게 적혀 있거든요.
// This file is generated automatically by Next.js
// Do not edit this file manually
그리고 .gitignore에 /.next/가 들어 있어서, 저장소에 올라가지도 않아요.
정리하면 이 파일은 결과물이지 원본이 아닌데, 원본은 우리 폴더 구조고요. 주소를 늘리고 싶으면 타입을 고치는 게 아니라 폴더를 만들어야 합니다.
💡 오늘 배운 것이 여기서 한 바퀴 도는데, 폴더가 주소를 만들고 주소가 타입을 만들지만, 방향이 한쪽이기 때문에 거꾸로는 안 가요.
자주 나오는 실수
- 파일을 못 찾음 — 빌드나 개발 서버를 한 번도 안 돌렸으면
.next폴더 자체가 없습니다. 먼저 돌리세요. - 고친 게 반영됐다고 착각 — 편집기가 잠깐 통과시켜주는 것을 보고 "되네?" 하기 쉽습니다. 꼭 빌드까지 돌려서 확인하세요.
LayoutRoutes를 "layout 파일 목록"으로 읽음 — 파일 목록이 아니라 주소 목록인데,app/[username]/layout.tsx하나가/[username]이라는 주소 하나에 대응해요.
생각해볼 주제
🤔 [생각해볼 주제 1] 등록하는 곳이 없다는 것의 뒷면
문제 상황 요약
라우트 배열이 사라져서 주소를 늘릴 때 파일을 열 일이 없어졌는데, 그 배열은 이 앱의 주소 목록이기도 했습니다. 그 목록은 이제 어디에 있을까요?
튜터의 가이드 및 해설
먼저 사실 확인부터 해보면, 목록이 사라진 게 아니라 모양이 바뀌었습니다.
예전 목록 routes.ts 파일 하나 — 눈으로 읽는다
지금 목록 폴더 구조 그 자체 — 훑어야 보인다
그리고 npm run build 가 표로 뽑아준다
빌드가 찍어주는 그 표가 사실상 목록이지만, 파일을 열어 보는 것과 명령을 돌려 보는 것은 체감이 다릅니다.
주소가 수백 개인 앱에서는 어느 쪽이 나을까요. 양쪽 다 근거가 있습니다.
배열이 나은 점. 한 곳만 보면 전부 보이는데, 검색도 쉽고 주소마다 붙은 설정(권한, 미리 불러오기)을 한눈에 비교할 수 있어요. 코드 리뷰에서 "주소가 하나 늘었다"를 발견하기도 쉽습니다.
폴더가 나은 점. 배열은 커질수록 실제 파일과 어긋나기 시작합니다. 컴포넌트를 지웠는데 배열에 줄이 남거나, 배열에 등록을 안 해서 안 뜨는 화면이 생겨요. 둘을 손으로 맞추는 일이 계속 생깁니다. 폴더 방식에는 그 어긋남이 원리적으로 없습니다. 폴더가 곧 목록이니까요.
그리고 규모가 커질수록 사람은 어차피 전체 목록을 한눈에 못 봅니다. 500줄짜리 배열은 "한눈에 보인다"는 장점을 이미 잃었어요. 그때는 관련된 것끼리 폴더로 모여 있는 쪽이 실제로 더 잘 찾힙니다.
새로 온 사람 입장에서 정리하면 이렇습니다. "이 앱에 무슨 주소가 있나"는 배열이 유리하고, "이 화면 코드가 어디 있나"는 폴더가 압도적으로 유리합니다. 뒤엣것을 하는 시간이 훨씬 많죠.
🎯 면접관을 홀리는 핵심 멘트
"라우트 배열은 목록과 실제 파일이 어긋날 수 있는 구조입니다. 등록을 빠뜨리거나 지운 컴포넌트가 배열에 남는 일이 실제로 생기죠. 파일 기반 라우팅은 목록 자체가 파일 구조라서 그 어긋남이 원리적으로 없습니다. 대신 전체 조망을 잃는데, 그건 빌드가 뽑아주는 라우트 표로 메웁니다."
🤔 [생각해볼 주제 2] 폴더 이름을 바꾸면 무슨 일이 생기나
문제 상황 요약
app/explore를 app/discover로 바꾸면 주소가 바뀝니다. 코드는 한 줄도 안 고쳤는데요. 이미 서비스 중이라면 이게 왜 위험할까요?
튜터의 가이드 및 해설
폴더 이름 하나를 바꾸는 것이 주소를 바꾸는 것과 같아졌는데, 편한 만큼 위험도 같이 왔어요.
주소가 바뀌면 그 주소를 알고 있던 모든 것이 깨집니다.
사람이 저장해둔 즐겨찾기 → 404
검색 엔진이 갖고 있는 주소 → 404 (그리고 순위가 날아간다)
다른 사이트에 걸린 링크 → 404
우리 앱 안에서 /explore 를 가리키던 곳
카카오톡에 공유해둔 링크
특히 검색 순위는 하루아침에 회복이 안 됩니다. 그 주소가 쌓아온 신뢰를 새 주소가 물려받지 못하거든요.
그럼에도 바꿔야 한다면 옛 주소를 없애는 게 아니라 새 주소로 안내해야 합니다. 옛 주소로 온 사람을 새 주소로 넘겨주고, 그것이 임시가 아니라 영구적인 이동이라고 알려주는 것이죠. 검색 엔진은 그 신호를 보고 순위를 옮겨줍니다.
여기서 더 중요한 이야기가 하나 있습니다. 주소는 사용자와 한 약속입니다.
폴더 이름은 우리 사정이에요. 코드가 읽기 좋아지려고 바꾸고 싶을 수 있지만, 그 사정이 바깥으로 새어 나가면 안 됩니다. 폴더 기반 라우팅은 이 둘을 붙여놨기 때문에, "안쪽 정리"가 "바깥 약속 파기"가 되기 쉽습니다.
그래서 실무에서는 주소가 될 폴더 이름을 처음에 신중하게 정하고, 그 뒤로는 리팩토링 대상에서 빼둡니다. 안쪽 이름과 바깥 주소를 떼어놓는 장치도 있는데, 그건 뒤에서 다룹니다.
🎯 면접관을 홀리는 핵심 멘트
"파일 기반 라우팅에서 폴더 이름은 내부 구현이 아니라 공개 API입니다. 사용자 즐겨찾기, 검색 색인, 외부 링크가 전부 그 이름에 걸려 있으니까요. 그래서 저는 주소가 되는 폴더는 리팩토링 대상에서 빼두고, 꼭 바꿔야 하면 영구 이동으로 안내해서 쌓아둔 신뢰를 넘깁니다."
🤔 [생각해볼 주제 3] 서버가 그린다는 것의 값
문제 상황 요약
Next가 보낸 HTML이 SPA 것보다 다섯 배 컸습니다. 서버는 화면을 그리느라 일을 더 했고요. 그런데도 그렇게 하는 이유가 뭘까요?
튜터의 가이드 및 해설
먼저 무엇을 주고 무엇을 받는지 정리합시다.
내는 것 서버가 매 요청마다 화면을 그린다 (서버 일이 늘어난다)
보내는 HTML 이 커진다 (1,487 → 7,713 바이트)
받는 것 자바스크립트 없이도 읽을 수 있는 글자
검색 엔진이 내용을 본다
링크 공유했을 때 미리보기가 뜬다
첫 글자가 화면에 뜨는 시점이 빨라진다
갈림길은 "누가 이 화면을 처음 여는가"입니다.
이득인 화면. 우리 인스타그램에서는 게시물 상세가 대표적입니다. 링크를 카톡으로 공유하면 미리보기가 떠야 하고, 검색으로 들어오는 사람도 있어요. 그 사람들은 우리 앱을 처음 여는 사람이라 손에 아무것도 없는데, 서버가 채워 보내주면 바로 읽혀요. 프로필도 같습니다.
손해인 화면. 쪽지함이 그렇습니다. 로그인한 사람만 들어오고, 검색 엔진이 볼 일이 없고, 내용이 실시간으로 계속 바뀝니다. 서버가 힘들여 그려서 보내도 그 화면은 1초 뒤에 다른 내용으로 바뀌어요. 게다가 남의 쪽지를 미리 그려둘 수도 없으니까 캐싱도 안 됩니다.
기준을 한 줄로 만들면 이렇습니다. 처음 오는 사람에게 보여줄 것이 있으면 서버가 그리고, 이미 들어와 있는 사람이 계속 쓰는 화면이면 브라우저에 맡긴다.
그리고 이게 오늘 배운 것의 진짜 값어치인데 — 둘 중 하나를 고르는 게 아닙니다. 화면마다 다르게 정할 수 있어요. 같은 앱 안에서 게시물 상세는 서버가 그리고 쪽지함은 브라우저가 그리게 할 수 있습니다. 어디까지가 서버 몫이고 어디부터가 브라우저 몫인지 나누는 이야기가 앞으로 이어집니다.
🎯 면접관을 홀리는 핵심 멘트
"서버 렌더링은 공짜가 아니라 교환입니다. 서버 연산과 전송량을 내주고 첫 화면 도달 시간과 검색 노출을 받죠. 그래서 앱 단위로 정하지 않고 화면 단위로 정합니다. 게시물 상세처럼 외부에서 처음 들어오는 화면은 서버가 그리고, 쪽지함처럼 로그인한 사람이 계속 쓰는 화면은 브라우저에 맡기는 식으로요."