티스토리 뷰

Webpack과 Vite 동작 원리

번들러는 여러 모듈의 의존 관계와 실행 순서를 관리하고, 각 파일의 스코프를 분리해 변수 충돌을 막는다. TypeScript나 JSX처럼 브라우저가 바로 실행할 수 없는 코드도 JavaScript로 변환한다.

Webpack 동작 원리

Webpack은 다음 순서로 동작한다.

  1. Entry를 기준으로 분석을 시작한다.
  2. import, require를 따라가며 전체 의존성 그래프를 만든다.
  3. 로더와 플러그인으로 각 모듈을 변환한다.
  4. 모듈 코드와 실행 런타임을 하나의 번들로 출력한다.

1. Entry와 번들 요청

개발 서버는 / 요청에 HTML을 반환한다. HTML에 포함된 <script>를 통해 브라우저가 다시 번들 파일을 요청한다.

export function createDevServerResponse({
  url,
  entryFilePath,
  rootDir,
  bundleUrl,
}: CreateDevServerResponseOptions): DevServerResponse {
  if (url === "/") {
    return {
      statusCode: 200,
      contentType: "text/html",
      body: createHtml(bundleUrl),
    };
  }

  if (url === bundleUrl) {
    const entryId = toModuleId(path.resolve(entryFilePath), rootDir);
    const graph = createGraph(entryFilePath, { rootDir });

    return {
      statusCode: 200,
      contentType: "text/javascript",
      body: createBundle(graph, { entryId }),
    };
  }

  return {
    statusCode: 404,
    contentType: "text/plain",
    body: `Not found: ${url}`,
  };
}

/bundle.js 요청이 들어오면 Entry부터 의존성 그래프를 만들고 번들을 생성한다.

2. 의존성 그래프

의존성 그래프는 어떤 모듈이 어떤 모듈을 불러오는지 나타낸 구조다.

// main.tsx
import App from "./App";
import { formatDate } from "./utils/date";

// App.tsx
import Header from "./Header";
import { formatDate } from "./utils/date";
main.tsx
├── App.tsx
│   ├── Header.tsx
│   └── utils/date.ts
└── utils/date.ts

각 모듈은 노드가 되고, importrequire 관계는 간선이 된다.

export type ModuleGraphNode = {
  id: string;
  filePath: string;
  code: string;
  dependencies: Record<string, string>;
};
  • id: 번들 내부에서 사용할 모듈 식별자
  • filePath: 실제 파일 경로
  • code: 소스 코드
  • dependencies: import 경로와 모듈 ID의 관계

의존성 찾기

function toModuleId(filePath: string, context: GraphContext): string {
  return path
    .relative(context.rootDir, filePath)
    .split(path.sep)
    .join("/");
}

function findDependencies(
  filePath: string,
  code: string,
  context: GraphContext,
): Record<string, string> {
  const dependencies: Record<string, string> = {};
  const dirname = path.dirname(filePath);

  for (const match of code.matchAll(IMPORT_RE)) {
    const request = match[1];
    const dependencyFilePath = path.resolve(dirname, request);

    dependencies[request] = toModuleId(dependencyFilePath, context);
  }

  return dependencies;
}

그래프 생성

function collectModule(
  filePath: string,
  graph: ModuleGraphNode[],
  visited: Set<string>,
  context: GraphContext,
): void {
  if (visited.has(filePath)) return;

  visited.add(filePath);

  const code = fs.readFileSync(filePath, "utf8");
  const dependencies = findDependencies(filePath, code, context);

  graph.push({
    id: toModuleId(filePath, context),
    filePath,
    code,
    dependencies,
  });

  for (const dependencyId of Object.values(dependencies)) {
    collectModule(
      path.resolve(context.rootDir, dependencyId),
      graph,
      visited,
      context,
    );
  }
}

export function createGraph(
  entryFilePath: string,
  options: CreateGraphOptions = {},
): ModuleGraphNode[] {
  const graph: ModuleGraphNode[] = [];
  const visited = new Set<string>();
  const context: GraphContext = {
    rootDir: options.rootDir ?? process.cwd(),
  };

  collectModule(path.resolve(entryFilePath), graph, visited, context);

  return graph;
}

Entry부터 시작해 의존성을 재귀적으로 방문한다. visited는 같은 모듈을 여러 번 처리하는 것을 막는다.

3. 모듈 변환

각 모듈은 다음 형태로 저장한다.

moduleId: [
  factory,
  dependencies,
];
  • factory: 모듈 코드를 실행하는 함수
  • dependencies: import 경로와 모듈 ID의 관계

각 파일을 함수로 감싸면 파일 내부 변수가 함수 스코프에 들어가기 때문에 다른 모듈의 변수와 충돌하지 않는다.

function transformModule(module: ModuleGraphNode): string {
  const exportedNames: string[] = [];
  let code = module.code;

  code = code.replace(
    /import\s+(.+?)\s+from\s+["'](.+?)["'];?/g,
    (_statement, importClause: string, request: string) => {
      return `const ${importClause.trim()} = __bundle_require__(
        __bundle_dependencies__[${JSON.stringify(request)}]
      );`;
    },
  );

  code = code.replace(
    /export\s+(const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g,
    (_statement, declarationKind: string, name: string) => {
      exportedNames.push(name);
      return `${declarationKind} ${name} =`;
    },
  );

  code = code.replace(
    /export\s+function\s+([A-Za-z_$][\w$]*)\s*\(/g,
    (_statement, name: string) => {
      exportedNames.push(name);
      return `function ${name}(`;
    },
  );

  const exportAssignments = exportedNames
    .map((name) => `__bundle_exports__.${name} = ${name};`)
    .join("\n");

  return `${code}\n${exportAssignments}`;
}

변환 과정에서는 import를 번들 런타임의 require 호출로 바꾸고, 외부로 내보낼 값은 exports 객체에 넣는다.

이 코드는 원리를 보기 위한 단순 구현이다. 실제 번들러는 정규식이 아니라 AST를 사용한다.

4. 번들 런타임

export function createBundle(
  graph: ModuleGraphNode[],
  options: CreateBundleOptions,
): string {
  const modules = graph
    .map((module) => {
      return `${JSON.stringify(module.id)}: [
        function(
          __bundle_require__,
          __bundle_exports__,
          __bundle_dependencies__
        ) {
          ${transformModule(module)}
        },
        ${JSON.stringify(module.dependencies)}
      ]`;
    })
    .join(",\n");

  return `(function(modules) {
    const cache = {};

    function __bundle_require__(moduleId) {
      if (cache[moduleId]) {
        return cache[moduleId];
      }

      const moduleRecord = modules[moduleId];

      if (!moduleRecord) {
        throw new Error("Cannot find module: " + moduleId);
      }

      const [factory, dependencies] = moduleRecord;
      const exports = {};

      cache[moduleId] = exports;
      factory(__bundle_require__, exports, dependencies);

      return exports;
    }

    __bundle_require__(${JSON.stringify(options.entryId)});
  })({
    ${modules}
  });`;
}

런타임은 모듈 ID로 factory 함수를 찾아 실행하고, 결과를 exports로 반환한다. 이미 실행한 모듈은 캐시에서 가져온다. 마지막에는 Entry 모듈을 실행한다.

Webpack에서는 브라우저가 원본 import 관계를 직접 처리하지 않는다. 번들러가 만든 런타임이 모듈 로딩과 실행 순서를 관리한다.


Vite는 무엇이 다른가

Vite 개발 서버는 시작할 때 전체 애플리케이션을 하나의 번들로 만들지 않는다.

브라우저가 요청한 파일을 그때 변환해 반환하고, 모듈 연결은 브라우저의 ESM 로더가 처리한다.

function createDevServerResponse({
  url,
  appRoot,
  entryUrl,
  moduleGraph,
}: CreateDevServerResponseOptions): DevServerResponse {
  const requestUrl = new URL(url, "http://mini-vite.local");

  if (requestUrl.pathname === "/") {
    return {
      statusCode: 200,
      contentType: "text/html",
      body: createHtml(entryUrl),
    };
  }

  const filePath = path.resolve(
    appRoot,
    requestUrl.pathname.slice(1),
  );

  if (!fs.existsSync(filePath)) {
    return {
      statusCode: 404,
      contentType: "text/plain",
      body: `Not found: ${requestUrl.pathname}`,
    };
  }

  if (filePath.endsWith(".js")) {
    const transformed = transformModule({ filePath, appRoot });

    moduleGraph?.updateModule({
      url: transformed.url,
      filePath,
      importedUrls: transformed.importedUrls,
    });

    return {
      statusCode: 200,
      contentType: "text/javascript",
      body: transformed.code,
    };
  }
}

현재 예제는 JavaScript만 처리하지만, 실제 Vite는 플러그인을 통해 TypeScript, JSX, CSS, 에셋 등을 변환한다.

Webpack 방식

function moduleFactory(
  __bundle_require__,
  __bundle_exports__,
  __bundle_dependencies__,
) {
  const { message } = __bundle_require__(
    __bundle_dependencies__["./message.js"],
  );

  console.log(message);
}

모듈을 factory 함수로 변환하고 자체 런타임으로 실행한다.

Vite 방식

import { message } from "./message.js";

console.log(message);

개발 환경에서 ESM 형태를 유지한다. 브라우저는 import 경로를 보고 필요한 모듈을 서버에 다시 요청한다.

Vite의 모듈 그래프

Vite도 HMR과 캐시 무효화를 위해 모듈 그래프를 관리한다.

{
  url: "/src/App.js",

  importedModules: [
    "/src/Header.js",
    "/src/AdminScreen.js",
  ],

  importers: [
    "/src/main.js",
  ],

  transformResult: {
    code: "import Header from '/src/Header.js';",
  },
}
  • importedModules: 현재 모듈이 import하는 모듈
  • importers: 현재 모듈을 import하는 상위 모듈
  • transformResult: 브라우저에 반환할 변환 결과

파일이 변경되면 importers를 따라가며 영향을 받는 상위 모듈을 찾는다. 이 정보가 HMR과 캐시 무효화에 사용된다.

정리

Webpack

  1. Entry부터 전체 의존성 그래프를 만든다.
  2. 각 모듈을 factory 함수로 변환한다.
  3. 자체 모듈 런타임과 함께 번들을 생성한다.
  4. 브라우저에서는 번들 런타임이 모듈을 실행한다.

Vite 개발 서버

  1. 브라우저가 Entry 모듈을 ESM으로 요청한다.
  2. 서버가 요청받은 파일을 변환해 반환한다.
  3. 브라우저의 ESM 로더가 import를 해석한다.
  4. 필요한 다음 모듈을 다시 서버에 요청한다.

핵심 차이는 모듈 로딩과 실행을 담당하는 주체다.

  • Webpack: 번들러가 생성한 런타임
  • Vite 개발 환경: 브라우저의 ESM 로더

'프론트엔드' 카테고리의 다른 글

1. 번들러에 대해서  (1) 2026.07.19
React 상태관리 패턴(MVC, MVVM, flux패턴)  (0) 2024.01.04
[React] suspense를 써야하는 이유  (1) 2023.12.30