03. manifest.json과 빌드 해시
03. manifest.json과 빌드 해시
이 문서에서 배우는 것
- 빌드 시 파일명에 해시가 붙는 이유 (브라우저 캐시 무효화)
- manifest.json의 역할: "원본 파일명 → 빌드된 파일명" 매핑
- manifest를 읽어서 HTML에 삽입하는 코드 분석
- manifest.json의 실제 내용
- "빌드 도구와 서버 사이의 계약서"라는 비유
PM 요청 (김도연)
김도연 PM: 준혁님, Vite로 빌드한 결과물을 보니까 entry-client-D6usOwnO.js 이런 이상한 파일명이 나오더라고요. 저는 entry-client.js로 나올 줄 알았는데... 이 알 수 없는 문자열은 뭔가요? 그리고 test.js에서 manifest.json을 읽고 있던데, 이건 또 뭐예요?
이준혁 시니어: 아, 그거! 빌드 해시와 manifest라는 개념인데, SSR에서 정말 중요한 부분이야. 이걸 모르면 프로덕션에서 고생해.
시니어 멘토링 (이준혁)
문제: 브라우저 캐시
이준혁: 먼저 왜 이런 복잡한 파일명이 필요한지부터 이해해야 해. 문제의 핵심은 브라우저 캐시야.
김도연: 캐시가 왜 문제가 되나요?
이준혁: 시나리오를 하나 보자.
시나리오: 캐시 문제
[1일차]
사용자가 사이트 방문
→ 브라우저가 entry-client.js 다운로드 (100KB)
→ 브라우저 캐시에 저장: "entry-client.js → 이 파일"
[2일차]
개발자가 버그 수정 → entry-client.js 내용 변경
→ 서버에 새 파일 배포
[3일차]
사용자가 다시 방문
→ 브라우저: "entry-client.js? 캐시에 있네. 다운로드 안 할래"
→ 사용자는 버그가 수정된 새 코드를 받지 못함!이준혁: 브라우저는 같은 파일명이면 "이미 다운로드했으니까 다시 안 받아도 돼"라고 판단해. 이게 보통은 성능에 좋지만, 코드가 바뀌었을 때는 문제가 돼.
해결: 빌드 해시 (Content Hash)
이준혁: 해결책은 간단해. 파일 내용이 바뀌면 파일명도 바꿔버리는 거야.
빌드 해시 = 파일 내용을 기반으로 생성한 고유 문자열
파일 내용: console.log("hello") → 해시: a1b2c3d4
파일 내용: console.log("world") → 해시: e5f6g7h8
같은 내용이면 항상 같은 해시, 다른 내용이면 항상 다른 해시해시를 적용한 파일명:
빌드 1회차: entry-client-a1b2c3d4.js
빌드 2회차: entry-client-a1b2c3d4.js ← 코드 안 바뀜, 같은 해시
빌드 3회차: entry-client-e5f6g7h8.js ← 코드 수정됨, 다른 해시!김도연: 아! 파일명이 다르니까 브라우저가 "새 파일이네, 다운로드해야지"라고 인식하는 거군요!
이준혁: 정확해. 이제 캐시 문제가 완벽하게 해결돼.
해시 적용 후 시나리오:
[1일차]
브라우저: entry-client-a1b2c3d4.js 다운로드 → 캐시 저장
[2일차]
개발자: 코드 수정 → 새 빌드 → entry-client-e5f6g7h8.js 생성
[3일차]
HTML의 <script src>가 새 해시 파일을 가리킴
브라우저: "entry-client-e5f6g7h8.js? 캐시에 없네. 다운로드하자!"
→ 사용자는 항상 최신 코드를 받음!
+ 코드가 안 바뀐 파일은? 해시가 같으니까 캐시 그대로 사용
→ 불필요한 다운로드 없음!새로운 문제: 서버가 파일명을 모른다
이준혁: 근데 여기서 새로운 문제가 생겨.
이전 (해시 없을 때):
server.js에서 이렇게 쓰면 됐음:
<script src="/public/entry-client.js"></script>
→ 파일명이 항상 같으니까 하드코딩 가능
이후 (해시 있을 때):
빌드할 때마다 파일명이 바뀜:
entry-client-a1b2c3d4.js (이번 빌드)
entry-client-e5f6g7h8.js (다음 빌드)
entry-client-x9y0z1w2.js (그다음 빌드)
server.js에 뭐라고 써야 하지?
<script src="/public/entry-client-???.js"></script>김도연: 빌드할 때마다 server.js를 수동으로 수정해야 하는 건가요?
이준혁: 그건 미친 짓이지. 그래서 manifest.json이 필요한 거야.
manifest.json: 빌드 도구와 서버 사이의 계약서
이준혁: manifest.json은 "원본 파일명 → 빌드된 파일명"을 매핑하는 파일이야. 빌드 도구가 자동으로 생성해.
A003 프로젝트의 실제 manifest.json:
{
"entry-client.jsx": {
"file": "assets/entry-client-D6usOwnO.js",
"name": "entry-client",
"src": "entry-client.jsx",
"isEntry": true
}
}이준혁: 이걸 읽으면:
"entry-client.jsx"라는 원본 파일이
→ "assets/entry-client-D6usOwnO.js"라는 이름으로 빌드되었다김도연: 아, 원본 파일명은 안 바뀌니까, 서버는 항상 "entry-client.jsx"라는 키로 찾으면 되는 거군요!
이준혁: 바로 그거야. 이게 "계약서"인 이유를 비유로 설명하면:
비유: 빌드 도구와 서버 사이의 계약서
빌드 도구 (Vite):
"나는 entry-client.jsx를 빌드해서 어딘가에 저장할 거야.
파일명은 내용에 따라 매번 달라질 수 있어.
대신 manifest.json에 '어떤 이름으로 저장했는지' 써놓을게."
서버 (Express):
"알겠어. 나는 manifest.json을 읽어서
실제 파일명을 알아낸 다음 HTML에 넣을게."
manifest.json = 이 약속을 문서화한 계약서코드 분석: manifest 읽기 → clientBundle 추출 → HTML 삽입
이준혁: A003 프로젝트의 test.js에서 이 과정이 정확히 구현되어 있어.
// test.js (A003 프로젝트 — Vite 기반 서버)
import express from 'express';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
// 빌드된 정적 파일 서빙
app.use(express.static(path.join(__dirname, 'dist/client')));
// entry-server.js (서버 빌드 결과물) 로드
const { render } = await import('./dist/server/entry-server.js');
// ★ manifest.json 읽기
const manifest = JSON.parse(
fs.readFileSync(
path.join(__dirname, 'dist/client/.vite/manifest.json'),
'utf-8'
)
);
// ★ 원본 파일명으로 빌드된 파일명 조회
const clientBundle = manifest['entry-client.jsx'].file;
// clientBundle = "assets/entry-client-D6usOwnO.js"
app.get('/', (req, res) => {
const appHtml = render();
res.send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>React SSR - Home</title>
</head>
<body>
<div id="root">${appHtml}</div>
<!-- ★ manifest에서 가져온 파일명 사용 -->
<script type="module" src="/${clientBundle}"></script>
</body>
</html>`);
});이준혁: 단계별로 보면:
1. manifest.json 파일을 읽는다
fs.readFileSync('dist/client/.vite/manifest.json')
2. "entry-client.jsx" 키로 빌드된 파일명을 찾는다
manifest['entry-client.jsx'].file
→ "assets/entry-client-D6usOwnO.js"
3. HTML의 <script src>에 넣는다
<script src="/assets/entry-client-D6usOwnO.js"></script>
이제 빌드할 때마다 파일명이 바뀌어도
manifest.json만 읽으면 서버가 자동으로 정확한 파일을 참조할 수 있다!manifest.json이 더 복잡해지는 경우
이준혁: 우리 프로젝트는 진입점이 하나지만, 실제 프로덕션에서는 이런 식이야.
{
"entry-client.jsx": {
"file": "assets/entry-client-D6usOwnO.js",
"name": "entry-client",
"src": "entry-client.jsx",
"isEntry": true,
"css": ["assets/entry-client-a1b2c3d4.css"],
"imports": ["_vendor-e5f6g7h8.js"]
},
"pages/About.jsx": {
"file": "assets/About-x9y0z1w2.js",
"name": "About",
"src": "pages/About.jsx",
"isDynamicEntry": true
},
"_vendor-e5f6g7h8.js": {
"file": "assets/vendor-e5f6g7h8.js"
}
}manifest.json 각 필드:
"file" : 빌드된 JS 파일 경로
"name" : 원본 이름
"src" : 원본 파일 경로
"isEntry" : 진입점 여부 (script 태그로 로드해야 함)
"isDynamicEntry" : 동적 import로 로드되는 파일 (코드 스플리팅)
"css" : 이 모듈에 필요한 CSS 파일들
"imports" : 이 모듈이 의존하는 공유 청크들이준혁: CSS 파일도 해시가 붙고, 공유 라이브러리(vendor)도 해시가 붙어. 이 모든 걸 manifest가 추적해주는 거야.
esbuild (시스템 A)는 왜 manifest가 없는가
김도연: 잠깐, build.js(esbuild)에서는 manifest를 안 쓰지 않나요?
이준혁: 좋은 관찰이야! 맞아. esbuild 방식에서는 해시를 안 붙였어.
// build.js (esbuild 기반 — 해시 없음)
await esbuild.build({
entryPoints: ['./client/react-entry.jsx'],
outfile: './public/react-bundle.js', // ← 파일명 고정!
// ...
});// server.js (esbuild 기반 — 하드코딩)
res.send(`
<script src="/public/react-bundle.js"></script>
`);
// ← 항상 같은 파일명이니까 하드코딩 가능esbuild 방식 (시스템 A):
파일명 고정 → manifest 불필요 → 서버에서 하드코딩
✅ 단순함
❌ 캐시 무효화 안 됨 (프로덕션 부적합)
Vite 방식 (시스템 B):
파일명에 해시 → manifest 필요 → 서버에서 manifest 읽기
✅ 캐시 무효화 완벽
✅ 프로덕션 준비 완료
❌ 약간 더 복잡이준혁: 프로덕션에서는 반드시 해시 + manifest 조합을 써야 해. esbuild도 해시를 지원하긴 하지만, Vite가 이걸 자동으로 해주니까 더 편해.
전체 흐름 다이어그램
빌드 시 (npm run build):
entry-client.jsx ──→ [Vite 빌드] ──→ assets/entry-client-D6usOwnO.js
│
└──→ .vite/manifest.json
{
"entry-client.jsx": {
"file": "assets/entry-client-D6usOwnO.js"
}
}
서버 시작 시 (node test.js):
서버 ──→ manifest.json 읽기
──→ clientBundle = "assets/entry-client-D6usOwnO.js"
요청 처리 시 (GET /):
서버 ──→ renderToString(HomePage) ──→ HTML 문자열
──→ HTML 템플릿에 appHtml + clientBundle 삽입
──→ 응답:
<div id="root">...앱 HTML...</div>
<script src="/assets/entry-client-D6usOwnO.js"></script>
브라우저:
HTML 수신 ──→ 화면 렌더링 (죽은 HTML)
──→ entry-client-D6usOwnO.js 다운로드
──→ hydrateRoot() 실행
──→ 앱이 "살아남" (이벤트 동작)핵심 정리
-
빌드 해시는 파일 내용 기반의 고유 문자열이다. 파일이 바뀌면 해시도 바뀌어 브라우저 캐시를 자동으로 무효화한다.
-
manifest.json은 "원본 파일명 → 빌드된 파일명" 매핑 파일이다. 빌드 도구가 자동으로 생성한다.
-
서버는 manifest.json을 읽어서 정확한 빌드 파일명을 HTML에 삽입한다.
manifest['entry-client.jsx'].file로 조회. -
manifest.json은 **"빌드 도구와 서버 사이의 계약서"**다. 빌드 도구는 파일명을 기록하고, 서버는 그 기록을 참조한다.
-
esbuild 방식(시스템 A)은 해시 없이 파일명을 하드코딩한다. 단순하지만 프로덕션에서 캐시 문제가 발생한다. Vite 방식(시스템 B)은 해시 + manifest로 이를 해결한다.