Engineering note · Node

Vite에서 하나의 코드베이스를 서비스별 앱으로 나누기: 엔트리와 라우터 분리

2026년 04월 09일이현수수정 2026.07.13
태그vue3vitebuildtree-shakingrollup

여러 앱이 한 Vite 프로젝트에 섞여 있을 때, 빌드 대상별 엔트리와 라우터를 분리해 필요한 모듈만 번들에 포함하는 방법을 정리한다.

기존 프로젝트에서는 하나의 저장소로 여러 앱을 빌드하고 있었다. 하나의 결과물로 관리하다가 앱별로 빌드를 분리하게 되었다.

문제는 라우터가 모두를 참조하는 구조였다

기존에는 main.ts 하나에서 공통 앱과 전체 라우터를 등록했다. 특정 서비스에서 접근하지 않는 화면이라도 전체 라우트 배열에 포함되어 있었고, 그 라우트가 참조하는 컴포넌트와 의존성도 번들 그래프에 남았다.

Vite는 기본적으로 프로젝트 루트의 index.html을 빌드 진입점으로 사용하고, HTML의 모듈 스크립트도 모듈 그래프의 일부로 처리한다. 따라서 index.html이 가리키는 시작 파일과 그 파일이 가져오는 라우터를 서비스별로 좁히는 것이 먼저였다. Vite의 HTML 진입점 설명프로덕션 빌드 문서를 기준으로 보면, 별도의 JavaScript 입력을 직접 만들기보다 HTML이 가리키는 모듈을 바꾸는 방법을 선택했다.

서비스별 엔트리에서 전용 라우터만 등록하기

각 서비스에 main.{appType}.ts를 만들고, 공통 초기화는 유지하되 해당 서비스의 라우터 생성 함수만 가져오도록 분리했다. 아래의 service-a는 실제 서비스 이름을 대신한 예시다.

ts
// src/main.service-a.ts
import app from "./app";
import { createServiceARouter } from "./router/service-a-entry";
import "@/assets/scss/main.scss";
import "@/assets/style.css";

const router = createServiceARouter();

app.use(router).mount("#app");

이 파일은 공통 스타일과 앱 인스턴스는 그대로 사용하지만, createServiceARouter만 호출한다. 전용 라우터도 다른 서비스의 라우트 배열을 가져오지 않게 구성한다.

ts
// src/router/service-a-entry.ts
import { createRouter, createWebHistory } from "vue-router";
import { serviceARoutes } from "./service-a-routes";

export function createServiceARouter() {
  return createRouter({
    history: createWebHistory(),
    routes: serviceARoutes,
  });
}

중요한 건 service-a-routes에서 필요한 화면만 정적으로 참조하도록 만들어야 다른 서비스의 라우터와 그 하위 컴포넌트가 이 엔트리의 의존성 그래프에 들어오지 않는다. 트리 쉐이킹은 사용하지 않는 코드를 자동으로 전부 제거하는 것보다, 번들러가 필요한 것만 참조하도록 할 때 효과가 난다.

index.html의 모듈 스크립트를 빌드 대상에 맞춰 바꾸기

Vite에서 index.html은 단순한 정적 파일이 아니라 변환 대상이다. transformIndexHtml 훅은 HTML 진입점의 내용을 바꿀 수 있고, order: "pre"를 주면 기본 HTML 변환 전에 적용할 수 있다. 자세한 반환 형식과 실행 순서는 Vite Plugin API의 transformIndexHtml 문서에서 확인할 수 있다.

처음에는 빌드가 끝난 뒤 HTML을 복사하고 수정하는 훅도 사용했다. 하지만 결과물이 만들어진 뒤에 다시 파일을 조작하면, 출력 경로와 다른 HTML 플러그인의 처리 순서를 함께 관리해야 했다. 최종적으로는 HTML을 처리하는 단계에서 모듈 스크립트 경로만 바꾸는 방식으로 결정했다.

ts
// build/entry-switcher.ts
import type { Plugin } from "vite";

export function entrySwitcherPlugin(appType?: string): Plugin {
  return {
    name: "entry-switcher",
    transformIndexHtml: {
      order: "pre",
      handler(html) {
        if (!appType) {
          return html;
        }

        return html.replace(
          "/src/main.ts",
          `/src/main.${appType}.ts`,
        );
      },
    },
  };
}

이 플러그인은 index.html/src/main.ts/src/main.service-a.ts처럼 바꾼다. 목적이 되는 파일만 잘 바뀌도록 구성해야 한다. 엉뚱한 문자열이 함께 바뀌지 않도록 치환 대상을 명확하게 지정한다.

플러그인은 HTML 내용을 읽거나 치환하는 다른 플러그인보다 앞에 둔다. 빌드 대상은 설정에서 받아 출력 폴더도 나눴다.

ts
// vite.config.ts
import { defineConfig } from "vite";
import { entrySwitcherPlugin } from "./build/entry-switcher";

export default defineConfig(({ mode }) => {
  const appType = mode.startsWith("service-")
    ? mode.replace("service-", "")
    : undefined;

  return {
    plugins: [
      entrySwitcherPlugin(appType),
      // HTML을 후속 처리하는 플러그인
    ],
    build: {
      outDir: appType ? `dist/${appType}` : "dist",
    },
  };
});

이 방식은 기본 index.html 진입점은 유지한다. 앱별로 여러 HTML 파일을 운영하는 편이 더 명확한 경우에는 Vite의 멀티 페이지 애플리케이션 구성을 선택할 수도 있다. 다만 같은 HTML 템플릿을 공유하고 진입 모듈만 달라지는 상황에서는 변환 훅 하나로 관리할 수 있었다.

측정값은 서비스별 배포물 크기로 비교했다

분리 전후의 효과는 브라우저 성능 지표가 아니라, 생성해 배포한 압축 아티팩트의 크기로 확인했다. 즉 요청 수, 초기 렌더링 시간, 실제 전송 크기가 같은 비율로 줄었다는 뜻은 아니다.

빌드 대상아티팩트 크기
분리 전 통합 빌드29.6 MB
서비스 A29.0 MB
서비스 B23.9 MB
서비스 C23.6 MB

모든 기능을 포함하는 서비스 A는 감소 폭이 작았고, 제한된 화면만 사용하는 서비스 B와 C는 더 작은 결과물이 나왔다. 크기가 생각보다 크게 줄어들지는 않았다.

크기만 비교하면 공통 의존성이 중복되어 결국 전체로는 크기가 늘어난다. 그래서 서비스별 배포가 필요한지, 공통 청크를 공유할 수 있는지, 각 서비스의 실제 초기 로딩에서 어떤 청크가 내려오는지도 함께 확인해야 한다.

좋아요와 댓글

댓글 남기기

댓글 0개

댓글을 불러오는 중입니다.