background blurbackground mobile blur

1/1/1970

Import Maps로 연쇄 해시 변경 문제를 해결한 방법

Howdy! 이 문제를 겪은 지는 5년이 훌쩍 넘었는데, 이제는 더는 눈 감고 넘길 수 없는 지경이 돼서 드디어 제대로 붙잡고 해결해 보기로 했어요. 파일 하나에서 글자 한 글자만 바꿔도, 빌드 안에 있는 JavaScript 파일 절반의 파일명이 전부 새로운 해시로 바뀌어 버리더라고요. 실제 내용은 그대로인데도 말이죠. 이 때문에 캐시가 쓸데없이 깨지고, 빌드 사이에 뭘 진짜로 바꿨는지 추적하기도 거의 불가능해졌고, 최악인 건 Cloudflare Pages의 파일 개수 제한 때문에 빌드가 그냥 터져 버리기도 했다는 거예요.

아래에서는 이 문제가 정확히 뭐였는지, 기존 해결책들이 왜 저에게는 안 맞았는지, 그리고 Import Maps를 이용해서 커스텀 Vite 플러그인을 어떻게 만들어 이 문제를 깔끔하게 해결했는지 얘기해 볼게요.

문제: 줄줄이 바뀌는 해시

Vite는 프로덕션 빌드에서 콘텐츠 기반 해싱을 사용해요. 앱을 빌드하면, 각 JavaScript 파일은 자기 내용에 따라 파일명 뒤에 해시가 붙습니다. 예를 들어 button.tsxbutton-abc12345.js로 컴파일되었다가 내용이 바뀌면 button-def45678.js 같은 식으로 달라지는 거죠. 이렇게 하면 캐시를 잘 깨줄 수 있어서 파일이 바뀌면 사용자들이 바로 새 파일을 받게 돼요.

문제는 A 파일이 B 파일을 import할 때 시작됩니다. 예를 들어 이런 코드가 있다고 해볼게요:

// main.js
import { Button } from "./button-abc12345.js";

button.tsx 내용이 바뀌면 Vite는 button-def45678.js를 새로 만들어 줍니다. 그런데 이제 main.js 안의 "./button-abc12345.js" 문자열이 틀려 버렸죠. 그래서 main.js도 변경된 걸로 인식되고, 실제 로직은 그대로인데도 새 해시를 받게 됩니다.

이게 의존성 그래프 전체로 줄줄이 번져요. 유틸 함수 하나만 살짝 바꿔도 js 파일 절반이 전부 새 해시를 받는 상황이 되는 거죠. 제 경우에는 useBackgroundMusic.ts에서 글자 한 글자만 바꿨는데 500개가 넘는 파일이 다시 해시를 찍어 냈어요.

실제 서비스에서의 영향도 꽤 컸어요. 저희는 Cloudflare Pages에 배포할 때, 예전 빌드 자산을 8버전까지 묶어서 보관합니다. 그래야 살짝 오래된 클라이언트 버전을 쓰고 있는 사용자들도, 새 버전을 배포했을 때 자기 버전을 그대로 돌릴 수 있거든요. 그런데 Cloudflare Pages에는 파일 20,000개 제한이 있는데, 이전에 했던 i18n 개편 이후에 생성되는 파일 수가 폭발적으로 늘어나면서 이 제한을 계속 건드리게 됐어요.

연쇄 해시 문제를 해결하면, 이제 대부분의 파일은 더 이상 쓸데없이 바뀌지 않기 때문에 예전 빌드를 훨씬 많이 쌓아 둘 수 있어요. 그러면 오래된 빌드를 쓰고 있는 사용자가 에러를 만날 확률도 줄어듭니다. 지금은 바뀌지 않는 파일을 요청하게 될 확률이 훨씬 높아졌거든요.

왜 [다른 해결책들]은 안 썼을까?

처음 이 문제를 제대로 해결해 보려고 했을 때, 몇 가지 방법을 고민해 봤어요. 그런데 딱 마음에 드는 게 없었습니다.

Post-build 스크립트

제일 먼저 떠올린 건 빌드가 끝난 뒤에 돌리는 스크립트를 만드는 거였어요. 이 스크립트가 모든 import 경로를 정규화하고, 파일을 다시 해시하고, 참조도 업데이트해 주는 거죠. 겉으로 보면 꽤 단순해 보였어요. 정규식으로 해시가 붙은 파일명을 안정적인 이름으로 치환하고, 다시 해시를 계산해 주면 되니까요.

하지만 이 방법은 "하이젠버그(Heisenbug)" 같은 애매한 버그랑 캐시 오염(cache poisoning) 때문에 포기했어요. 예전 빌드들을 Cloudflare Pages에 쌓아 두고 쓰고 있긴 하지만, 캐시가 뒤틀리는 리스크를 감수할 만큼 가치가 있지는 않았습니다. 빌드가 끝난 다음에 파일을 수정하는 스크립트는, 로컬이나 스테이징에서는 안 나타나다가 프로덕션에서만 갑자기 드러나는 미묘한 버그를 만들기 딱 좋아요. 그런 거 디버깅하는 건 진짜 악몽이죠.

Vite manualChunks

또 다른 선택지는 Vite의 manualChunks 설정을 쓰는 거였어요. 안정적인 코드(예: node_modules)와 자주 바뀌는 비즈니스 로직을 따로 떼어내는 방식이죠. 벤더 코드는 자주 안 바뀌니까 해시가 줄줄이 바뀌는 걸 좀 줄일 수 있으리라는 계산이었습니다.

하지만 이건 근본적인 해결책이 아니라 그냥 완화책에 가깝더라고요. 비즈니스 로직이 들어 있는 청크 안에서는 여전히 해시가 줄줄이 바뀝니다. 저는 "덜 나쁜" 정도가 아니라, 문제의 뿌리 자체를 해결하고 싶었어요.

Import Maps: 요즘식 답안

Import Maps는 브라우저에서 기본으로 지원하는 기능이에요. (구형 브라우저는 폴리필로 커버 가능) 이 기능은 모듈 specifier를 실제 파일 경로랑 분리해 줍니다. A 파일이 "./button-abc123.js" 같은 파일 경로를 직접 import하는 대신 "button" 같은 이름을 import하게 만들고, 브라우저는 Import Map을 보고 "button"을 실제 해시가 붙은 파일명으로 찾아가요.

이게 제가 딱 필요로 했던 기능이었습니다. A 파일의 내용은 항상 "button"을 import하고, 이 문자열은 변하지 않아요. 그러니까 A 파일의 해시도 그대로입니다. 바뀐 파일이랑 Import Map만 새 해시를 받게 되는 거죠. 도대체 왜 이걸 해주는 플러그인이 아직 제대로 안 나와 있지? 싶을 정도였어요.

구현 여정

그래서 저는 이런 일을 해주는 Vite 플러그인을 만들기로 했어요:

  1. 모든 상대 import를 안정적인 모듈 specifier로 변환하기
  2. 이 specifier를 실제 해시가 붙은 파일명으로 매핑해 주는 Import Map 만들기
  3. 만들어진 Import Map을 HTML에 주입하기

플러그인은 이제 GitHub에 올라가 있어요: @foony/vite-plugin-import-map

첫 시도

처음에는 Vite의 generateBundle 훅을 쓰는 플러그인으로 시작했어요. 첫 번째 버전은 정규식을 써서 import 경로를 찾아 바꾸는 방식이었습니다. 코드짜기는 쉬웠고, 우리 작은 팀 Foony 내부에서 쓰기에는 그럭저럭 잘 돌아갔어요. 하지만 매우 취약했고, 여러 사람이 쓸 플러그인으로는 도저히 내놓을 수 없는 수준이었죠. 잘못 매치된 문자열이 덩달아 바뀌어 버릴 수 있었거든요.

정규식 접근 방식은 눈에 보이는 문제들이 있었어요. 코드 안에 있는 일반 문자열이 우연히 파일명처럼 생겼다면 어떻게 할까요? 동적 import는요? export 문은요? 다른 사람들도 쓰는 플러그인을 만들 생각이라면, 이보다 훨씬 튼튼한 방법이 필요했습니다.

AST 파싱

그래서 JavaScript 코드를 제대로 파싱해서 모든 import 문을 정확히 찾아야 했어요. 처음 써본 건 ES 모듈 파싱 전용 라이브러리인 es-module-lexer였습니다. 목적에 딱 맞는 도구였죠. 그런데 이게 Vite의 모듈 분석 단계에서 네이티브 패닉을 일으키더라고요. asm.js 빌드를 써봐도 패닉은 막을 수 없었습니다.

결국 Acorn으로 정착했어요. 빠르고, 가볍고, 순수 JavaScript로 작성된 파서입니다. 여기에 AST 순회를 위한 acorn-walk를 붙여 쓰니, 네이티브 의존성 문제 없이 제가 원하는 걸 전부 할 수 있더라고요.

해결해야 했던 핵심 과제들

온갖 종류의 import 처리하기

import는 모양새도 다양하고, AST에서 다르게 취급됩니다. 제가 처리해야 했던 것들은:

  • 정적 import: import x from "./file.js"
  • 동적 import: import("./file.js")
  • 이름을 지정한 re-export: export { x } from "./file.js" (이건 처음에 놓쳤어요!)
  • 전부 다시 export: export * from "./file.js"

re-export 케이스는 특히 까다로웠어요. 어느 날 보니까 어떤 파일만 변환이 안 되고 있더라고요. 코드를 보니 export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" 같은 게 있었는데, 플러그인이 이걸 완전히 무시하고 있었던 거죠. 제가 ImportDeclaration이랑 ImportExpression 노드만 보고 있었기 때문이었습니다.

지금은 이렇게 전부 처리하고 있어요:

walk(ast, {
  ImportDeclaration(node: any) {
    // 정적 import: import x from "spec"
    const specifier = node.source.value;
    // ... 변환 로직
  },
  ExportNamedDeclaration(node: any) {
    // 소스가 있는 이름 기반 export: export { x, y } from "spec"
    if (!node.source?.value) return;
    // ... 변환 로직
  },
  ExportAllDeclaration(node: any) {
    // 전부 export: export * from "spec"
    if (!node.source?.value) return;
    // ... 변환 로직
  },
  ImportExpression(node: any) {
    // 동적 import: import("spec")
    // ... 변환 로직
  },
});

결정론적인 충돌 해결

서로 다른 디렉터리에 index.tsx 같은 이름의 파일이 여러 개 있을 때, 이걸 어떻게 구분할지가 문제였어요. 전부 "index"라는 specifier로 쓸 수는 없으니까요.

제가 선택한 방법은 이거예요. 충돌이 나면 원래 소스 경로와 베이스 이름을 합쳐서 해시를 만들어 버립니다. 예를 들면 src/client/games/chess/index.tsx:index 같은 문자열을 해싱해서 index-abc123 같은 specifier를 만드는 식이에요. 이렇게 하면 같은 파일은 빌드마다 항상 똑같은 모듈 specifier를 쓰게 되고, 같은 이름을 가진 다른 파일이 추가되거나 빠져도 흔들리지 않아요.

여기서 저는 주로 chunk.facadeModuleId(엔트리 포인트)를 식별자로 쓰고, 그게 없으면 chunk.moduleIds[0]을 fallback으로 사용합니다. 이렇게 하면 해싱에 쓸 안정적인 소스 경로를 얻을 수 있어요.

소스맵 체인 유지하기

코드를 변환할 때는 소스맵 체인을 깨뜨리지 않는 것도 중요해요. 기존 소스맵은 원래 TypeScript 소스에서 Babel, 난독화/최적화 등을 거쳐 현재 코드까지 매핑해 줍니다. 제가 한 번 더 변환을 걸면 체인에 단계가 하나 더 생기니까, 이 연결을 그대로 유지해 줘야 해요.

여기에는 MagicString을 사용해서 제 변환 과정을 추적하고 새 소스맵을 만든 다음, 기존 소스맵과 합쳐 줍니다. 이때 기존 소스맵의 sourcessourcesContent 배열은 그대로 보존해서, 전체 체인:

원본 소스 → (기존 소스맵) → 변환된 코드

를 유지해요.

const existingMap = typeof chunk.map === 'string' ? JSON.parse(chunk.map) : chunk.map;
const newMap = magicString.generateMap({
  source: fileName,
  file: newFileName,
  includeContent: true,
  hires: true,
});

// 머지: 새 맵의 mappings를 쓰되, 원래 sources는 유지
chunk.map = {
  ...newMap,
  sources: existingMap.sources || newMap.sources,
  sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
  file: newFileName,
};

변환된 콘텐츠로 다시 해시하기

파일 내용이 안정적이어야 해요. 그래서 먼저 import를 변환해서(기존 Vite의 해시가 붙은 import를 제가 만든 안정적인 specifier로 교체) 그 상태를 기준으로 해시를 계산합니다. 이때 해시 계산에서는 소스맵 주석은 제거해요. 소스맵 주석 안에는 예전 파일명이 들어 있을 수 있거든요.

그다음 새 해시를 계산해서 파일 이름을 업데이트하고, Import Map 엔트리도 같이 업데이트합니다.

최종 구현

이 플러그인은 네 단계에 걸쳐 동작합니다:

  1. 카운트 패스: 같은 베이스 이름을 공유하는 파일 수를 세서 이름 충돌이 나는지 확인
  2. 맵 패스: 청크 매핑(해시된 파일명 → 모듈 specifier)과 초기 Import Map 생성
  3. 변환 패스: 코드 안의 import 경로를 다시 쓰고, 해시 재계산, 소스맵 업데이트
  4. 이름 변경 패스: 번들 파일명을 새로 정하고 Import Map 확정

핵심 변환 로직은 대략 이런 느낌이에요:

import {simple as walk} from 'acorn-walk';

// 코드를 파싱해서 AST 얻기
const ast = Parser.parse(chunk.code, {
  ecmaVersion: 'latest',
  sourceType: 'module',
  locations: true,
});

const importsToTransform: Array<{start: number; end: number; replacement: string}> = [];

// AST를 순회하면서 모든 import/export 찾기
walk(ast, {
  ImportDeclaration(node: any) {
    const specifier = node.source.value;
    const filename = specifier.split('/').pop()!;
    const moduleSpec = chunkMapping.get(filename);
    
    if (moduleSpec) {
      importsToTransform.push({
        start: node.source.start + 1, // 시작 따옴표 건너뛰기
        end: node.source.end - 1,     // 끝 따옴표 제외
        replacement: moduleSpec,
      });
    }
  },
  // ... 다른 노드 타입도 여기서 처리
});

// 위치가 꼬이지 않도록 뒤에서부터 적용
importsToTransform.sort((a, b) => b.start - a.start);
for (const transform of importsToTransform) {
  magicString.overwrite(transform.start, transform.end, transform.replacement);
}

Import Map을 HTML에 주입할 때는 정규식으로 HTML을 뒤틀지 않고, Vite가 제공하는 태그 주입 API를 씁니다:

transformIndexHtml() {
  return {
    tags: [
      {
        tag: 'script',
        attrs: {type: 'importmap'},
        children: JSON.stringify(importMap, null, 2),
        injectTo: 'head-prepend',
      },
    ],
  };
}

이 방식이 <script> 태그를 정규식으로 억지로 찾는 것보다 훨씬 믿을 만해요.

숫자로 보는 효과

플러그인이 실제로 어떤 일을 하고 있는지 감을 드리자면:

  • 빌드마다 처리되는 JavaScript 파일: 약 1,000개+
  • 빌드 시간 증가: 약 2~3초 (충분히 감당 가능한 수준)
  • 의미 없는 해시 변경 감소: 약 99% (이제 대부분의 파일은 실제 내용이 바뀔 때만 해시가 바뀜)
  • 플러그인 코드 길이: 약 340줄 (주석과 에러 처리 포함)

지금까지 제가 만난 엣지 케이스는 전부 처리하고 있고, 빌드 과정도 훨씬 예측 가능해졌어요.

배운 점들

왜 AST 파싱이 필수인지

번들 코드에 정규식을 쓰는 건 꽤 위험한 짓입니다. 코드 안의 평범한 문자열이 우연히 파일명처럼 생겼다면, 정규식은 그걸 import 경로라고 착각하고 바꿔 버릴 수 있어요. AST 파싱을 하면 실제 import/export 문만 정확히 골라서 변환할 수 있습니다.

왜 es-module-lexer 대신 Acorn을 썼는지

es-module-lexer가 더 빠르고 목적에도 잘 맞는 도구이긴 해요. 하지만 네이티브 패닉 문제 때문에 Vite 플러그인에서는 도저히 쓸 수가 없었습니다. Acorn은 순수 JavaScript라 네이티브 의존성 걱정이 없어요. 언젠가는 속도를 더 끌어올리려고 es-module-lexer를 다시 시도해 보고 싶지만, 지금으로서는 Acorn만으로도 충분히 잘 돌아갑니다.

왜 다른 방법 말고 Import Maps인지

Import Maps는 웹 표준이고, 이미 브라우저에서 네이티브로 지원하고 있는 기능이에요. 이 문제를 푸는 "정석" 같은 답이라고 볼 수 있죠. 폴리필(es-module-shims)을 쓰면 Safari 16.4 미만 같은 구형 브라우저도 무난하게 커버할 수 있고, 전체 구조도 깔끔하고 유지보수하기 좋습니다.

마무리

이 Import Maps 플러그인은 Vite 빌드에서 연쇄 해시 변경을 깔끔하게 막아 줍니다. 이제 파일들은 자기 실제 내용이 바뀔 때만 새 해시를 받지, 의존성이 바뀌었다고 줄줄이 딸려 바뀌지 않아요. 빌드는 더 예측 가능해지고, 캐시를 괜히 깨트리는 일도 줄어들고, Cloudflare Pages의 파일 개수 제한도 한결 여유로워졌습니다.

구조도 단순하고, 유지보수도 쉽고, 최신 웹 표준을 그대로 활용하는 해법이에요. 문제를 충분히 깊게 이해하고 나면, 가장 "올바른" 해답이 동시에 가장 단순한 해답이기도 하다는 걸 잘 보여주는 예 같아요.

플러그인은 오픈 소스로 GitHub에 올라가 있습니다: @foony/vite-plugin-import-map. npm install @foony/vite-plugin-import-map으로 설치해서 본인의 Vite 프로젝트에서도 바로 써볼 수 있어요.

앞으로는 네이티브 패닉 문제가 해결되면 es-module-lexer로 최적화를 시도해 본다든지, 더 복잡한 import 패턴도 지원하는 방향으로 확장할 수도 있겠죠. 하지만 지금 상태만으로도, 제가 필요로 하던 일은 딱 정확하게 해 주고 있습니다.

언젠가 Vite에 이런 기능이 아예 기본으로 들어갈지도 모르죠. 그날이 오면 더 멋질 것 같아요.

8 Ball Pool online multiplayer billiards icon