3 / 3

03. manifest.json과 빌드 해시

예상 시간: 5분

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() 실행
            ──→ 앱이 "살아남" (이벤트 동작)

핵심 정리

  1. 빌드 해시는 파일 내용 기반의 고유 문자열이다. 파일이 바뀌면 해시도 바뀌어 브라우저 캐시를 자동으로 무효화한다.

  2. manifest.json은 "원본 파일명 → 빌드된 파일명" 매핑 파일이다. 빌드 도구가 자동으로 생성한다.

  3. 서버는 manifest.json을 읽어서 정확한 빌드 파일명을 HTML에 삽입한다. manifest['entry-client.jsx'].file로 조회.

  4. manifest.json은 **"빌드 도구와 서버 사이의 계약서"**다. 빌드 도구는 파일명을 기록하고, 서버는 그 기록을 참조한다.

  5. esbuild 방식(시스템 A)은 해시 없이 파일명을 하드코딩한다. 단순하지만 프로덕션에서 캐시 문제가 발생한다. Vite 방식(시스템 B)은 해시 + manifest로 이를 해결한다.