junyeokk
Blog
Tooling·2024. 09. 26

Vite Path Alias

프로젝트 규모가 커지면 import 경로가 길어진다. 컴포넌트 안에서 다른 모듈을 가져올 때 이런 코드가 생긴다.

typescript
import { render } from "../../../core/render";
import { Header } from "../../components/Header";
import { store } from "../../../store/index";

../../../가 세 번 반복되는 순간부터 코드를 읽기 싫어진다. 파일을 다른 폴더로 옮기면 상대 경로가 전부 깨지고, 새로 합류한 팀원은 ../가 어디를 가리키는지 머릿속으로 역추적해야 한다.

Path alias는 이 문제를 해결한다. 특정 디렉토리에 별칭을 지정해서, 어디서든 동일한 경로로 import할 수 있게 만든다.

typescript
import { render } from "@core/render";
import { Header } from "@components/Header";
import { store } from "@/store/index";

파일이 어느 깊이에 있든 경로가 동일하다. 폴더 구조를 바꿔도 alias 설정만 수정하면 된다.


Vite에서 설정하기

Vite는 내부적으로 Rollup을 번들러로 사용하고, 개발 서버에서는 ESBuild를 사용한다. resolve.alias 옵션은 이 두 환경 모두에 적용되어서, 개발 중에도 프로덕션 빌드에서도 동일한 경로를 사용할 수 있다.

기본 형태

vite.config.ts에서 resolve.alias를 설정한다.

typescript
import { defineConfig } from "vite";

export default defineConfig({
  resolve: {
    alias: [
      { find: "@core", replacement: "/src/core" },
      { find: "@components", replacement: "/src/components" },
      { find: "@", replacement: "/src" },
    ],
  },
});

find는 import 문에서 매칭할 문자열이고, replacement는 실제 디렉토리 경로다. /src로 시작하는 절대 경로를 사용하면 프로젝트 루트 기준으로 해석된다.

객체 형태 vs 배열 형태

alias는 두 가지 형태로 작성할 수 있다.

typescript
// 객체 형태
resolve: {
  alias: {
    "@core": "/src/core",
    "@components": "/src/components",
    "@": "/src",
  },
}

// 배열 형태
resolve: {
  alias: [
    { find: "@core", replacement: "/src/core" },
    { find: "@components", replacement: "/src/components" },
    { find: "@", replacement: "/src" },
  ],
}

객체 형태가 간결하지만, 배열 형태를 권장한다. 이유는 매칭 순서 때문이다.

객체의 키 순서는 JavaScript 엔진에 의존하기 때문에 보장되지 않는 경우가 있다. 반면 배열은 작성 순서대로 매칭을 시도한다. @core@가 동시에 존재할 때, 배열이면 @core가 먼저 매칭되어 의도한 대로 동작한다. 객체이면 @가 먼저 매칭되어 @core/render/src/core/render가 아니라 /src/core를 기준으로 잘못 해석될 수 있다.

path.resolve 사용하기

/src/core 같은 문자열 경로 대신, path.resolve로 절대 경로를 생성하는 방식이 더 안전하다.

typescript
import { defineConfig } from "vite";
import path from "path";

export default defineConfig({
  resolve: {
    alias: [
      { find: "@core", replacement: path.resolve(__dirname, "src/core") },
      { find: "@components", replacement: path.resolve(__dirname, "src/components") },
      { find: "@", replacement: path.resolve(__dirname, "src") },
    ],
  },
});

path.resolve(__dirname, "src/core")는 현재 설정 파일이 위치한 디렉토리를 기준으로 절대 경로를 만든다. OS에 따라 경로 구분자가 다를 수 있는 문제를 path.resolve가 처리해주기 때문에 크로스 플랫폼 호환성이 더 좋다.

/src/core처럼 문자열로 직접 쓰면 Vite가 프로젝트 루트 기준으로 해석하기 때문에 대부분의 경우 문제없이 동작한다. 하지만 monorepo 환경이나 설정 파일이 프로젝트 루트가 아닌 곳에 있을 때는 path.resolve가 필수다.

정규식으로 패턴 매칭

배열 형태에서 find에 정규식을 사용할 수 있다. 복잡한 매칭 규칙이 필요할 때 유용하다.

typescript
resolve: {
  alias: [
    { find: /^@lib\/(.*)/, replacement: "/src/lib/$1" },
  ],
}

@lib/utils/src/lib/utils로 변환된다. 캡처 그룹 $1을 활용하면 하위 경로까지 매핑할 수 있다. 하지만 일반적인 프로젝트에서는 문자열 매칭으로 충분하고, 정규식은 오히려 가독성을 떨어뜨릴 수 있다.


TypeScript와 함께 사용하기

Vite에서 alias를 설정하면 번들러는 경로를 올바르게 해석한다. 하지만 TypeScript 컴파일러는 Vite 설정을 모른다. 그래서 에디터에서 빨간 줄이 그어지고, Cannot find module '@core/render' 같은 타입 에러가 발생한다.

이 문제를 해결하려면 tsconfig.json에도 동일한 경로 매핑을 설정해야 한다.

json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@core/*": ["src/core/*"],
      "@components/*": ["src/components/*"],
      "@/*": ["src/*"]
    }
  }
}

주의할 점이 몇 가지 있다.

baseUrl이 필수다. pathsbaseUrl을 기준으로 해석되기 때문에, baseUrl이 없으면 paths 설정이 무시된다. .으로 설정하면 tsconfig.json이 위치한 디렉토리가 기준이 된다.

와일드카드 패턴이 다르다. Vite에서는 @core만 써도 하위 경로까지 매핑되지만, TypeScript에서는 반드시 @core/*src/core/*처럼 와일드카드를 명시해야 한다. 와일드카드 없이 @core만 쓰면 정확히 @core라는 모듈만 매핑되고, @core/render 같은 하위 경로는 해석되지 않는다.

Vite 설정과 항상 동기화해야 한다. Vite alias를 추가하고 tsconfig paths를 안 바꾸면 빌드는 되지만 에디터에서 에러가 나고, 반대로 paths만 추가하고 Vite alias를 안 바꾸면 에디터는 정상이지만 빌드가 실패한다. 두 설정을 항상 함께 관리해야 한다.


Vite vs Webpack 비교

Webpack에서도 path alias를 설정할 수 있지만, 방식이 다르다.

javascript
// webpack.config.js
const path = require("path");

module.exports = {
  resolve: {
    alias: {
      "@core": path.resolve(__dirname, "src/core"),
      "@components": path.resolve(__dirname, "src/components"),
    },
  },
};

Webpack은 resolve.alias를 객체 형태로만 받는다. Vite처럼 배열 형태나 정규식 매칭은 지원하지 않는다. 대신 resolve.alias$를 붙여서 정확한 매칭을 지정할 수 있다.

javascript
alias: {
  "utils$": path.resolve(__dirname, "src/my-utils.js"),
}

import "utils"는 매칭되지만 import "utils/something"은 매칭되지 않는다.

Vite의 장점은 설정이 단순하고, path.resolve 없이 문자열 경로만으로도 동작한다는 점이다. 그리고 배열 형태로 매칭 순서를 명시적으로 제어할 수 있어서, 여러 alias가 겹치는 상황에서도 예측 가능하게 동작한다.


자주 하는 실수

1. alias 순서 문제

typescript
// 잘못된 순서
alias: [
  { find: "@", replacement: "/src" },
  { find: "@core", replacement: "/src/core" },
]

@가 먼저 매칭되기 때문에 @core/render/src/core/render가 아니라 /src/core/render로 해석된다. 이 경우에는 우연히 동일한 결과가 나올 수 있지만, 항상 더 구체적인 alias를 앞에 배치해야 안전하다.

typescript
// 올바른 순서
alias: [
  { find: "@core", replacement: "/src/core" },
  { find: "@components", replacement: "/src/components" },
  { find: "@", replacement: "/src" },
]

2. tsconfig paths를 안 쓰는 경우

Vite alias만 설정하고 TypeScript paths를 빼먹으면, 빌드는 정상적으로 되지만 에디터가 모듈을 찾지 못해서 자동완성이 안 되고, 타입 체크에서 에러가 발생한다. CI에서 tsc --noEmit을 사용하면 타입 검사에서 빌드가 실패하게 된다.

3. baseUrl 누락

tsconfig.json에서 paths만 추가하고 baseUrl을 안 쓰면 설정이 조용히 무시된다. 에러 메시지도 없어서 왜 안 되는지 찾기 어렵다.

4. 절대 경로와 상대 경로 혼용

alias를 설정해놓고도 습관적으로 ../를 쓰는 경우가 있다. 팀에서 alias를 도입했다면 린트 규칙으로 상대 경로 사용을 제한하는 것이 좋다. ESLint의 no-restricted-imports 규칙을 활용하면 된다.

json
{
  "rules": {
    "no-restricted-imports": ["error", {
      "patterns": ["../*"]
    }]
  }
}

실무 팁

alias 네이밍 컨벤션

@ 접두사를 사용하는 것이 관례다. npm 패키지의 스코프(@types, @vue)와 충돌할 수 있지만, Vite는 node_modules보다 alias를 먼저 매칭하기 때문에 실제로는 문제가 되지 않는다. 다만 혼란을 피하기 위해 ~# 같은 접두사를 사용하는 프로젝트도 있다.

typescript
// @ 접두사 (가장 일반적)
import { render } from "@core/render";

// ~ 접두사 (npm 스코프와 충돌 방지)
import { render } from "~core/render";

// # 접두사 (Node.js subpath imports와 호환)
import { render } from "#core/render";

적절한 alias 개수

alias를 너무 많이 만들면 오히려 혼란스러워진다. @core, @components, @utils 정도의 상위 디렉토리만 alias로 만들고, 하위 구조는 상대 경로를 사용하는 것이 균형 잡힌 방식이다. 모든 폴더에 alias를 만들면 import { x } from "@a/b" 같은 코드에서 @a가 어떤 폴더인지 기억하기 어려워진다.

monorepo에서의 활용

monorepo 환경에서는 패키지 간 참조를 alias로 설정할 수 있다.

typescript
alias: [
  { find: "@shared", replacement: path.resolve(__dirname, "../shared/src") },
  { find: "@ui", replacement: path.resolve(__dirname, "../ui/src") },
]

이 경우 path.resolve가 필수다. 상대 경로의 기준이 프로젝트 루트가 아니라 설정 파일 위치이기 때문에, 문자열 경로만으로는 다른 패키지 디렉토리를 정확히 가리킬 수 없다.


정리

  • resolve.alias 배열 형태로 구체적인 alias를 앞에 배치하고, tsconfig.jsonpaths와 항상 동기화한다.
  • path.resolve(__dirname, ...)을 쓰면 monorepo나 크로스 플랫폼에서도 안전하게 동작한다.
  • alias는 상위 디렉토리 3~5개 정도로 제한하고, 린트 규칙으로 상대 경로 사용을 통제하면 팀 전체가 일관되게 유지할 수 있다.

관련 문서