| 서론
안녕하세요, 팡일입니다.
프론트엔드 프로젝트를 진행하다 보면 Vite의 resolve 옵션을 자주 마주하게 됩니다. 처음에는 단순히 alias 설정 정도로만 사용하는 경우가 많지만, 실제로는 모듈 경로 해석, 의존성 중복 방지, 환경별 entry 선택 등 프로젝트의 구조와 성능에 직접적인 영향을 주는 중요한 설정들이 포함되어 있습니다.
특히 React 프로젝트나 모노레포 환경에서는 resolve 관련 옵션을 제대로 이해하지 못하면, 예상하지 못한 에러나 번들 문제를 겪는 경우도 많습니다.
이번 글에서는 resolve.alias를 시작으로 dedupe, conditions, mainFields, extensions, preserveSymlinks, tsconfigPaths까지, Vite의 모듈 해석과 관련된 핵심 옵션들을 하나씩 정리해보려고 합니다.
단순히 설정 방법을 나열하는 것이 아니라, “왜 필요한지”, “어떤 상황에서 문제가 되는지”까지 함께 이해하는 데 초점을 맞춰보겠습니다.
| resolve.alias
- Type:Record<string, string> | Array<{ find: string | RegExp, replacement: string }>
resolve.alias는 import 경로를 다른 경로로 치환(replace)해주는 옵션입니다, 쉽게 말해, 복잡한 경로를 짧고 직관적인 별칭(alias)으로 바꿔주는 기능입니다.
React 프로젝트에서 자주 사용하는 @/components 같은 경로도 이 설정을 통해 만들어집니다.
1) 작동 흐름
resolve.alias는 import 경로를 우리가 정의한 규칙에 따라 다른 경로로 바꿔주는 역할을 합니다.
즉, 코드에서 작성한 경로를 실제 파일 경로로 변환해주는 일종의 “경로 매핑 규칙”이라고 이해하면 쉽습니다.
(1) 설정
: 여기서는"@"라는 별칭을/src 디렉토리로 매핑하고 있습니다.
export default defineConfig({
resolve: {
alias: {
"@": "/src",
},
},
});
(2) 사용
: 개발자는 /src/components/Button 대신 @/components/Button처럼 짧은 경로를 사용할 수 있습니다.
import Button from "@/components/Button";
(3) 실제 변환
: Vite 내부에서는 위와 같이 실제 경로로 변환되어 처리됩니다.
import Button from "/src/components/Button";
즉, alias는 단순히 “짧게 쓰는 문법”이 아니라, 개발자가 작성한 import 경로를 실제 파일 시스템 경로로 치환해주는 역할을 합니다.
2) 객체 방식 (기본)
가장 일반적으로 사용하는 방식은 객체 형태입니다.
resolve: {
alias: {
utils: "/src/utils",
"old-lib": "/src/new-lib",
},
}
- key는 “사용할 이름”, value는 “실제 경로”입니다.
- 실제 사용 : import helper from "utils/helper";
- 내부 변환 : import helper from "/src/utils/helper";
이 방식은 단순하고 직관적이기 때문에 대부분의 프로젝트에서 기본적으로 사용됩니다.
3) 배열 방식 (고급)
조금 더 복잡한 규칙이 필요한 경우에는 배열 형태를 사용할 수 있습니다.
resolve: {
alias: [
{ find: "utils", replacement: "/src/utils" },
{ find: /^@components\/(.*)/, replacement: "/src/components/$1" },
],
}
이 방식의 핵심은 RegExp(정규식)을 사용할 수 있다는 점입니다.
- $1은 정규식에서 캡처한 값을 의미하며, 동적인 경로 치환이 가능해집니다.
- 즉, 배열 방식은 단순 alias를 넘어서 패턴 기반 경로 매핑이 필요할 때 사용하는 고급 기능입니다.
(1) 예시
{ find: /^@components\/(.*)/, replacement: "/src/components/$1" }
위의 규칙에 의해서, 아래와 같이 변경되어 적용됩니다.
// 사용
import Button from "@components/Button";
// 변경
import Button from "/src/components/Button";
4) 핵심 특징
- import / require 경로를 자동으로 치환해준다
- 여러 alias를 동시에 정의할 수 있다
- 정의된 순서대로 적용되기 때문에 순서가 중요하다
- 깊은 상대 경로(../../../) 문제를 해결할 수 있다
(1) 유용한 해결 예시
: 특히 React 프로젝트에서 다음과 같은 문제를 해결하는 데 매우 유용합니다. 아래와 같이 사용하여, 가독성과 유지보수성이 크게 개선됩니다.
// ❌ 기존 방식
import Button from "../../../components/Button";
// ✅ alias 사용
import Button from "@/components/Button";
5) 주의사항
- 파일 시스템 경로는 반드시 절대 경로로 사용하는 것이 안전합니다
- (/src/... 또는 path.resolve 사용 권장)
- 상대 경로(./, ../)는 Vite가 해석하지 않고, 문자열 그대로 치환되기 때문에 의도와 다르게 동작할 수 있습니다
| resolve.dedupe
- Type: string[]
resolve.dedupe는 동일한 라이브러리가 여러 번 설치된 경우, 항상 하나의 버전만 사용하도록 강제하는 옵션입니다.
1) 왜 필요한가?
일반적인 프로젝트에서는 큰 문제가 없지만, 모노레포나 패키지 링크 환경에서는 다음과 같은 상황이 자주 발생합니다.
node_modules/
├── react (루트)
└── some-package/
└── node_modules/
└── react (별도 설치)
즉, React가 두 번 설치되는 상황입니다. 이 상태에서 코드가 실행되면, 아래와 같이 서로 다른 React 인스턴스가 로드됩니다.
react (A 패키지)
react (B 패키지)
이게 왜 문제냐면, React는 내부적으로 상태와 context를 공유해야 하는데, 서로 다른 인스턴스는 완전히 다른 라이브러리처럼 동작합니다
그래서 대표적으로 아래 오류가 발생합니다:
Invalid hook call
Hooks can only be called inside the body of a function component
즉, “코드는 맞는데 React가 두 개라서 터지는 상황”이 됩니다.
2) 설정
resolve: {
dedupe: ["react", "react-dom"],
}
이렇게 설정하면 Vite는 다음과 같이 동작합니다:
- 어떤 패키지에서 import 하든
- 항상 루트(node_modules)의 react만 사용하도록 강제
결과적으로는 아래와 같이 동작합니다.
❌ 중복 로딩
react (A), react (B)
✅ dedupe 적용 후
react (A) 하나만 사용
3) 핵심 특징
- 중복 의존성 문제 해결
- React hook 오류 방지 (가장 중요)
- 동일 라이브러리를 하나로 강제 통일
- 모노레포 / pnpm / yarn workspace 환경에서 특히 유용
| resolve.conditions
- Type: string[]
resolve.conditions는 패키지의 조건부 export(export conditions)를 어떻게 해석할지 결정하는 옵션입니다.
즉, 하나의 패키지가 여러 개의 빌드 파일을 가지고 있을 때, 현재 환경에 맞는 파일을 선택하기 위한 조건 기준을 정의하는 설정입니다.
1) 개념 이해
최근 npm 패키지들은 단일 파일만 제공하지 않고, 환경에 따라 다른 파일을 제공하는 경우가 많습니다.
예를 들어 package.json의 exports는 다음과 같이 구성될 수 있습니다.
{
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.js"
}
}
}
여기서 의미는 다음과 같습니다
- import → ES Module 환경
- require → CommonJS 환경
즉, 하나의 패키지라도 사용 환경에 따라 다른 파일을 선택해서 사용하도록 설계된 구조입니다.
2) 작동 방식
resolve: {
conditions: ["browser", "development"],
}
이 설정은 다음과 같은 의미를 가집니다:
- Vite가 패키지를 해석할 때
- "browser", "development" 조건을 기준으로
- 해당 조건에 맞는 파일을 우선 선택
3) 실제 동작 흐름
{
"exports": {
".": {
"browser": "./index.browser.js",
"development": "./index.dev.js",
"production": "./index.prod.js",
"default": "./index.js"
}
}
}
예를 들어 패키지가 다음과 같이 구성되어 있고, 조건이 "conditions: ["browser", "development"]"와 같다면, Vite는 다음 순서로 탐색합니다.
1. browser → 있으면 선택
2. 없으면 development → 선택
3. 없으면 default → fallback
즉, conditions 배열 순서가 우선순위를 결정합니다.
4) 왜 중요한가?
이 옵션은 단순히 “파일 선택”을 넘어서 번들 결과와 실행 환경을 바꾸는 핵심 요소입니다.
예를 들어 아래와 같이 어떤 조건을 선택하느냐에 따라 성능, 디버깅, 번들 크기까지 달라질 수 있습니다.
- development → 디버깅 코드 포함
- production → 최적화된 코드
- browser → 브라우저 전용 코드
5) 핵심 특징
- 패키지의 조건부 export 해석 기준 설정
- 환경별 entry 파일 선택 가능
- 조건 순서에 따라 우선순위 결정
- development / production 자동 치환 지원
- CSS / Sass 등 스타일 import에도 적용
| resolve.mainFields
- Type: string[]
resolve.mainFields는 패키지의 진입 파일(entry point)을 어떤 순서로 찾을지 결정하는 옵션입니다.
즉, 우리가 import react from "react"처럼 패키지를 import했을 때, Vite가 해당 패키지의 어떤 파일을 실제로 사용할지 선택하는 기준이라고 이해하면 됩니다.
1) 기본 동작
일반적인 npm 패키지의 package.json을 보면 다음과 같은 필드들이 존재합니다.
{
"main": "index.js",
"module": "index.esm.js"
}
여기서 의미는 다음과 같습니다
- main: CommonJS 방식 (Node.js 중심)
- module: ES Module 방식 (브라우저 / 번들러 친화적)
문제는, 둘 다 존재할 때 어떤 파일을 사용할지 결정해야 한다는 점입니다.
- index.js를 쓸 수도 있고
- index.esm.js를 쓸 수도 있음
이 선택을 Vite가 자동으로 해주는데, 그 기준이 바로 resolve.mainFields입니다.
2) 설정
resolve: {
mainFields: ["browser", "module", "main"],
}
위 설정은 다음과 같은 의미입니다
- browser 필드가 있으면 가장 먼저 사용
- 없으면 module 사용
- 그것도 없으면 main 사용
즉, 왼쪽부터 우선순위가 높은 탐색 순서입니다.
3) 실제 동작 흐름
{
"browser": "index.browser.js",
"module": "index.esm.js",
"main": "index.js"
}
예를 들어 어떤 패키지가 위와 같다면, Vite는 다음 순서로 찾습니다:
1. browser → 있음 → 사용
2. (여기서 종료)
만약 browser가 없다면, 아래와 같이 찾습니다.
1. browser → 없음
2. module → 있음 → 사용
4) 왜 중요한가?
이 옵션은 단순한 설정 같지만, 실제로는 번들 결과와 성능에 직접적인 영향을 줍니다.
- module (ESM)을 사용하면, tree-shaking이 가능하고, 번들 크기 감소됩니다.
- main (CJS)을 사용하면, tree-shaking이 어렵고, 번들 커질 가능성 있습니다.
그래서 보통은 ["browser", "module", "main"]와 같이 이런 순서로 두어서 브라우저 최적화 → ESM → fallback(CJS) 순으로 처리합니다.
5) 핵심 특징
- 패키지 entry 파일 선택 우선순위 결정
- ESM(module) 우선 사용 가능 → 번들 최적화 유리
- 브라우저 전용 빌드(browser) 우선 적용 가능
- 번들 크기와 성능에 영향
| resolve.extensions
- Type: string[]
resolve.extensions는 import 시 확장자를 생략했을 때 Vite가 어떤 파일을 순서대로 찾을지 결정하는 옵션입니다.
즉, 우리가 파일 경로에서 .tsx, .js 같은 확장자를 생략했을 때, Vite가 어떤 규칙으로 실제 파일을 찾아가는지 정의하는 기준입니다.
1) 기본 동작
보통 우리는 다음과 같이 import를 작성합니다.
import App from "./App";
이때 실제로는 "./App"이라는 파일이 존재하지 않기 때문에, Vite는 내부적으로 확장자를 붙여가며 파일을 탐색합니다.
예를 들어 기본 설정 기준으로는 다음과 같은 순서로 찾습니다:
./App.mjs
./App.js
./App.ts
./App.jsx
./App.tsx
./App.json
즉, 확장자를 자동으로 보완해서 파일을 찾아주는 기능입니다.
2) 설정
resolve: {
extensions: [".ts", ".tsx", ".js"],
}
이렇게 설정하면 탐색 순서가 다음과 같이 바뀝니다.
./App.ts
./App.tsx
./App.js
즉, 앞에 있는 확장자일수록 우선순위가 높습니다.
3) 왜 필요한가?
이 옵션을 사용하면 다음과 같은 장점이 있습니다.
- import 코드가 깔끔해짐
- 파일 확장자에 대한 의존도가 줄어듦
- 팀 내에서 일관된 import 스타일 유지 가능
(1) 예시
: 아래와 같이, 코드 가독성이 훨씬 좋아집니다.
// ❌ 확장자 명시
import App from "./App.tsx";
// ✅ 확장자 생략
import App from "./App";
4) 핵심 특징
- 확장자 생략 가능
- 설정된 순서대로 자동 탐색
- 우선순위 기반으로 파일 선택
- import 문을 더 간결하게 유지 가능
5) 주의사항
이 옵션은 편리하지만, 몇 가지 주의할 점이 있습니다.
(1) 커스텀 확장자는 생략 비추천
: .vue, .css 같은 파일은 생략하면, IDE가 파일을 제대로 인식하지 못할 수 있고, 타입 추론이 깨질 수 있습니다.
// ❌ 비추천
import Comp from "./Comp"; // Comp.vue
// ✅ 권장
import Comp from "./Comp.vue";
(2) 자동완성 / 타입 지원 문제
: 확장자를 생략하면 일부 IDE에서는 경로 자동완성 제한이 되거나, 타입 추론 오류가 일어날 수 있습니다.
(3) 모호한 파일 충돌 가능성
: App.ts이나 App.js와 같이, 이런 경우 어떤 파일이 선택될지 extensions 순서에 따라 달라지기 때문에 의도와 다르게 동작할 수 있습니다.
| resolve.preserveSymlinks
- Type: boolean
- Default: false
이 옵션은 심볼릭 링크(symlink)를 어떻게 해석할지 결정합니다.
즉, Vite가 모듈을 가져올 때 “링크된 경로를 기준으로 볼지, 실제 파일 위치를 기준으로 볼지”를 선택하는 설정입니다.
1) 기본 동작 (false)
기본값인 false에서는 Vite가 symlink를 따라가서 실제 파일 경로(real path) 기준으로 모듈을 처리합니다.
(1) 예시 구조
node_modules/
└── my-lib -> ../../packages/my-lib (symlink)
이때 Vite는 다음과 같이 해석합니다:
my-lib → ../../packages/my-lib (실제 경로)
즉, symlink를 무시하고, “진짜 파일이 있는 위치”를 기준으로 동일한 모듈인지 판단합니다.
(2) 장점
- 중복 모듈 문제 방지 (특히 React)
- 동일 라이브러리를 하나로 인식
- 일반적인 프로젝트에서는 가장 안전한 방식
2) true 설정
resolve: {
preserveSymlinks: true,
}
위와 같이, 이 옵션을 활성화하면, 아래와 같이, 실제 경로가 아니라 “링크된 경로 자체를 기준으로 모듈을 판단”하게 됩니다.
my-lib → node_modules/my-lib (symlink 경로 유지)
결과적으로, 같은 라이브러리라도, symlink 경로가 다르면, 서로 다른 모듈로 인식될 수 있습니다.
3) 언제 필요한가?
이 옵션은 일반 프로젝트에서는 거의 사용할 일이 없지만, 다음과 같은 환경에서는 필요해질 수 있습니다.
(1) 모노레포 환경 (pnpm, yarn workspace)
- 패키지가 symlink로 연결됨
- 실제 경로 기준으로 보면 의도와 다르게 동작할 수 있음
이때 symlink 기준으로 유지해야 할 경우 사용됩니다.
(2) 패키지 개발 환경 (yarn link / pnpm link)
: A 프로젝트 → B 패키지 링크와 같이, 개발 중인 패키지를 로컬에서 연결해서 사용할 때, 경로 기준을 유지하기 위해 필요할 수 있습니다.
(3) 특정 번들/의존성 문제 해결
: 일부 라이브러리는 경로 기준이 중요하며, real path 기준으로 처리하면 깨지는 경우 존재할 수 있습니다.
4) 핵심 특징
- symlink를 real path로 변환할지 여부 결정
- false: 실제 파일 기준 (기본, 안전)
- true: 링크 경로 기준 유지
- 모듈 중복 인식 여부에 직접 영향
| resolve.tsconfigPaths
- Type: boolean
- Default: false
이 옵션은 tsconfig.json에 정의된 paths 설정을 Vite에서도 자동으로 적용해주는 옵션입니다.
즉, TypeScript에서 설정한 경로 별칭(alias)을 Vite에서도 그대로 인식하도록 연결해주는 역할을 합니다.
1) 왜 필요한가?
TypeScript에서는 다음과 같이 경로 별칭을 자주 설정합니다.
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/*": ["src/*"]
}
}
}
위의 설정 덕분에 아래와 같이 TS 코드에서는 이렇게 간결하게 작성할 수 있습니다.
import Button from "@/components/Button";
하지만 문제는 TypeScript는 이 경로를 이해하지만, Vite는 기본적으로 이 설정을 모릅니다
TS: 정상
Vite: 경로 못 찾음 (에러)
그래서 위와 같은 문제가 발행할 수 있습니다.
2) Vite 설정
resolve: {
tsconfigPaths: true,
}
이렇게 설정하면, Vite가 tsconfig.json을 읽어서 paths 설정을 자동으로 적용합니다
3) 작동 결과
import Button from "@/components/Button";
위의 코드를 내부적으로 아래와 같이 해석됩니다.
import Button from "/src/components/Button";
즉, 따로 alias를 설정하지 않아도 tsconfig 기준으로 자동으로 경로가 매핑됩니다.
4) 핵심 특징
- tsconfig.json의 paths를 자동으로 반영
- Vite와 TypeScript의 경로 해석 방식 통일
- alias 설정 중복 제거 가능
- 설정 관리 포인트를 하나로 줄일 수 있음
5) alias와의 차이
// 방법 1 (기존)
resolve: {
alias: {
"@": "/src",
},
}
// 방법 2 (tsconfigPaths 사용)
resolve: {
tsconfigPaths: true,
}
(1) 차이점
- alias: Vite 전용 설정
- tsconfigPaths: TS 설정을 그대로 재사용
(2) 실무에서는 보통 아래와 같이 선택하는 경우가 많습니다.
- TS 중심 프로젝트 → tsconfigPaths
- JS 또는 단순 프로젝트 → alias
6) 주의사항
이 옵션은 기본 내장 기능이 아니라, vite-tsconfig-paths 플러그인을 사용하는 방식이 일반적입니다
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
});
따라서 단순히 tsconfigPaths: true만 넣는 것이 아니라 플러그인을 사용하는 방식이 더 정확한 접근입니다.
| 결론
이번 글에서는 Vite의 resolve 옵션들을 중심으로, 모듈 경로가 어떻게 해석되는지에 대해 살펴봤습니다.
resolve.alias를 통해 import 경로를 간결하게 만들 수 있고, dedupe를 통해 중복 의존성 문제를 해결할 수 있으며, mainFields나 conditions를 통해 어떤 빌드 파일을 사용할지 결정할 수 있습니다. 또한 extensions를 통해 import 방식을 단순화하고, preserveSymlinks와 tsconfigPaths를 통해 모노레포나 TypeScript 환경에서도 안정적인 구조를 유지할 수 있습니다.
이러한 옵션들은 각각 독립적으로 보이지만, 결국 하나의 공통된 목적을 가지고 있습니다. 바로 “프로젝트에서 모듈을 어떻게 찾고, 어떻게 사용할지 명확하게 정의하는 것”입니다. 작은 프로젝트에서는 크게 체감되지 않을 수 있지만, 프로젝트 규모가 커지고 구조가 복잡해질수록 resolve 설정의 중요성은 점점 커지게 됩니다.
이번 정리를 통해 Vite의 모듈 해석 방식에 대한 이해를 한 단계 더 높이고, 필요에 맞는 설정을 스스로 설계할 수 있는 기반이 되셨으면 좋겠습니다.