background blurbackground mobile blur

1/1/1970

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

안녕하세요! 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도 바뀌게 됩니다. 결국 main.js의 실제 로직은 전혀 바뀌지 않았는데도 새로운 해시를 받게 되는 거죠.

이 현상이 의존성 그래프 전체로 퍼져 나갑니다. 유틸리티 함수 하나만 바꿔도 갑자기 js 파일의 절반이 새 해시를 받게 됩니다. 제 경우엔 useBackgroundMusic.ts에서 글자 하나 바꿨더니 500개가 넘는 파일이 다시 해싱되었어요.

실제로 미치는 영향이 컸습니다. 저희는 새 버전을 Cloudflare Pages에 배포할 때, 약간 구버전 클라이언트를 쓰는 사용자들도 자기 버전을 그대로 실행할 수 있도록 과거 빌드의 자산 8개 버전을 함께 번들링합니다. 그런데 Cloudflare Pages에는 20,000개 파일 제한이 있고, 얼마 전 i18n 작업으로 만들어지는 파일 수가 폭증하면서 이 한계에 부딪히기 시작했습니다.

연쇄 해시 문제를 해결하면, 이제 대부분의 파일은 더 이상 바뀔 일이 없으므로 한도에 걸리지 않고 훨씬 더 많은 과거 빌드를 보관할 수 있게 됩니다. 또한 구버전 빌드를 사용 중인 사용자가 에러를 만날 가능성도 줄어듭니다. 요청하는 파일이 (이제 바뀌지 않았기 때문에) 우리에게 그대로 남아 있을 가능성이 훨씬 더 높아지니까요.

다른 대안들은 왜 안 됐을까?

처음 이 문제를 풀려고 했을 때 몇 가지 접근법을 검토했습니다. 어느 것도 딱 맞지는 않았어요.

빌드 후 스크립트

가장 먼저 떠오른 생각은, 빌드 이후에 모든 import 경로를 정규화하고 파일을 다시 해싱한 뒤 참조를 업데이트하는 스크립트를 작성하는 것이었습니다. 단순해 보였어요. 정규식으로 해시 파일명을 안정적인 이름으로 바꾸고, 해시를 다시 계산하면 되니까요.

하지만 "하이젠버그(Heisenbug)"와 캐시 오염 우려 때문에 이 방법은 접었습니다. 과거 빌드를 Cloudflare Pages에 저장한다고 해도, 캐시 불일치의 위험을 감수할 가치는 없었습니다. 빌드 후 파일을 수정하는 스크립트는 프로덕션에서만 나타나는 미묘한 버그를 만들 수 있고, 그걸 디버깅하는 건 악몽이 될 거예요.

Vite manualChunks

또 다른 옵션은 Vite의 manualChunks 설정을 사용해서 안정적인 코드(node_modules 같은)와 불안정한 코드(비즈니스 로직)를 분리하는 것이었습니다. 벤더 코드는 덜 자주 바뀌니까 연쇄 해시도 줄어들지 않을까 하는 발상이었죠.

이건 사실 근본 문제를 해결하는 게 아니라 완화하는 것에 불과합니다. 비즈니스 로직 청크 안에서는 여전히 연쇄 해시가 발생하니까요. 저는 문제를 살짝 덜 나쁘게 만드는 게 아니라 핵심 원인을 해결하는 방법을 원했습니다.

Import Maps: 현대적인 해결책

Import Maps는 브라우저에 내장된 기능(구형 브라우저용 폴리필 지원 포함)으로, 모듈 식별자와 파일 경로를 분리해 줍니다. 파일 A가 "./button-abc123.js"를 import하는 대신, 그냥 "button"을 import합니다. 브라우저는 import map을 사용해서 "button"을 실제 해시 파일명으로 해석하죠.

제가 원하던 게 바로 이거였습니다. 파일 A의 내용은 동일하게 유지되고(항상 "button"을 import하니까요), 따라서 해시도 그대로 유지됩니다. 오직 import map과 실제로 바뀐 파일만 새 해시를 받게 됩니다. 이걸 위한 좋은 플러그인을 아무도 안 만들었다는 게 좀 충격이었어요!

Vite 플러그인 만들기

다음과 같은 일을 해주는 Vite 플러그인을 만들기로 했습니다:

  1. 모든 상대 경로 import를 안정적인 모듈 식별자로 변환
  2. 그 식별자들을 실제 해시 파일명에 매핑하는 import map 생성
  3. import map을 HTML에 주입

이 플러그인은 GitHub에서 사용할 수 있습니다: @foony/vite-plugin-import-map

처음 접근법

generateBundle 훅을 사용하는 Vite 플러그인부터 시작했습니다. 첫 시도는 정규식으로 import 경로를 찾아 바꾸는 방식이었어요. 코딩하기 쉬웠고 저희 작은 팀 Foony에서는 작동했지만, 너무 부서지기 쉬웠고 변환되지 말아야 할 것까지 변환되는 거짓 양성(false-positive) 문제가 있는 플러그인이라면 절대 통하지 않을 방식이었습니다.

정규식 접근에는 명백한 문제들이 있었습니다. 코드 안의 어떤 문자열이 우연히 파일명처럼 생겼다면? 동적 import는? export 구문은? 다른 사람들도 쓸 수 있는 플러그인을 만들려면 더 견고한 해결책이 필요했습니다.

AST 파싱

모든 import 구문을 찾으려면 JavaScript 코드를 제대로 파싱해야 했습니다. 처음 시도한 건 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")
  • 이름 있는 재내보내기: export { x } from "./file.js" (처음에 이걸 놓쳤어요!)
  • 전체 재내보내기: export * from "./file.js"

재내보내기 케이스가 특히 까다로웠습니다. 변환되지 않은 파일을 발견하기 전까지 알아채지 못했어요. 코드에 export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js"가 있었는데, 제 플러그인은 ImportDeclarationImportExpression 노드만 보고 있어서 이걸 완전히 무시하고 있었던 거죠.

지금은 모두 이렇게 처리합니다:

walk(ast, {
  ImportDeclaration(node: any) {
    // Static imports: import x from "spec"
    const specifier = node.source.value;
    // ... transform logic
  },
  ExportNamedDeclaration(node: any) {
    // Named exports with source: export { x, y } from "spec"
    if (!node.source?.value) return;
    // ... transform logic
  },
  ExportAllDeclaration(node: any) {
    // Export all: export * from "spec"
    if (!node.source?.value) return;
    // ... transform logic
  },
  ImportExpression(node: any) {
    // Dynamic imports: import("spec")
    // ... transform logic
  },
});

결정론적 충돌 해결

여러 파일이 같은 베이스 이름을 가질 때(서로 다른 디렉터리에 있는 여러 index.tsx 파일처럼), 그것들을 구분해야 합니다. 모두에게 그냥 "index"라고 쓸 수는 없죠.

제 해결책은: 충돌이 있을 경우, 원본 소스 경로와 베이스 이름을 합쳐서 해싱하는 겁니다. 예를 들어 src/client/games/chess/index.tsx:index는 해싱되어 index-abc123 같은 식별자가 됩니다. 이렇게 하면 같은 이름의 다른 파일이 추가되거나 제거되어도, 같은 파일은 빌드마다 항상 같은 모듈 식별자를 갖게 됩니다.

기본 식별자로는 chunk.facadeModuleId(엔트리 포인트)를 사용하고, 그게 없으면 chunk.moduleIds[0]로 폴백합니다. 이렇게 결정론적 해싱을 위한 안정적인 소스 경로를 얻습니다.

소스맵 체이닝

코드를 변환하면 소스맵 체인이 끊깁니다. 기존 소스맵은 원본 TypeScript 소스에서 Babel과 minification을 거쳐 현재 코드까지 매핑되어 있죠. 제 변환은 그 위에 또 한 층을 더하므로, 그 체인을 보존해야 합니다.

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,
});

// Merge: use new map's mappings but preserve original sources
chunk.map = {
  ...newMap,
  sources: existingMap.sources || newMap.sources,
  sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
  file: newFileName,
};

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

안정적인 파일 콘텐츠가 필요합니다. 이를 위해 import를 변환하고(Vite의 해시된 import를 안정적인 import로 교체), 해시 계산 시에는 소스맵 주석을 제외합니다(이전 파일명을 참조하니까요).

그 후 새 해시를 계산하고 파일명과 import map 항목을 모두 업데이트합니다.

최종 구현

플러그인은 4단계 전략을 사용합니다:

  1. 카운트 단계: 각 베이스 이름을 공유하는 파일 수를 세어 이름 충돌을 감지
  2. 매핑 단계: 청크 매핑(해시 파일명 → 모듈 식별자)과 초기 import map을 생성
  3. 변환 단계: 코드 안의 import 경로를 다시 작성하고, 해시를 재계산하고, 소스맵을 업데이트
  4. 이름 변경 단계: 번들 파일명을 업데이트하고 import map을 확정

핵심 변환 로직은 다음과 같습니다:

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

// Parse the code to get an AST
const ast = Parser.parse(chunk.code, {
  ecmaVersion: 'latest',
  sourceType: 'module',
  locations: true,
});

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

// Traverse the AST to find all imports/exports
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, // +1 to skip opening quote
        end: node.source.end - 1,     // -1 to skip closing quote
        replacement: moduleSpec,
      });
    }
  },
  // ... handle other node types
});

// Apply transformations in reverse order to preserve positions
importsToTransform.sort((a, b) => b.start - a.start);
for (const transform of importsToTransform) {
  magicString.overwrite(transform.start, transform.end, transform.replacement);
}

import map을 HTML에 주입할 때는 정규식 조작 대신 Vite의 태그 주입 API를 사용합니다:

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

HTML 태그를 정규식으로 매칭하려는 것보다 훨씬 안정적입니다.

숫자로 보기

이 플러그인이 어떤 일을 하는지 감을 잡을 수 있도록:

  • 빌드당 ~1,000개 이상의 JavaScript 파일 처리
  • 빌드 시간 23초 추가 (감수할 만한 트레이드오프)
  • 불필요한 해시 변경 ~99% 감소 (이제 대부분의 파일은 실제 내용이 바뀔 때만 바뀝니다)
  • 플러그인 코드 ~340줄 (주석과 에러 처리 포함)

지금까지 마주한 모든 엣지 케이스를 플러그인이 잘 처리하고 있고, 빌드 과정도 훨씬 예측 가능해졌습니다.

배운 점

AST 파싱이 왜 필수인지

번들된 코드에 정규식을 쓰는 건 위험합니다. 코드 안의 어떤 문자열이 우연히 파일명처럼 생겼다면, 정규식은 그것까지 다시 써버립니다. 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가 이런 기능을 네이티브로 지원해 줄지도요.

(업데이트: Foony 빌드에서 플러그인을 적용해 봤더니 일부 사용자에게서 예상치 못한 문제가 생겨서 일단 비활성화해 두었습니다. 나중에 다시 살펴볼 거예요. 아마도요. 그래도 여전히 멋진 해결책이라고 생각합니다.)

8 Ball Pool online multiplayer billiards icon