
절대 경로 import가 안 될 때: 원인부터 모노레포 설정까지
TypeScript/JavaScript에서 절대 경로 import 설정이 안 될 때의 원인을 '지도와 택시 기사' 비유로 설명합니다. CJS vs ESM 역사적 배경과 모노레포 설정, 팀 컨벤션까지 총정리.

TypeScript/JavaScript에서 절대 경로 import 설정이 안 될 때의 원인을 '지도와 택시 기사' 비유로 설명합니다. CJS vs ESM 역사적 배경과 모노레포 설정, 팀 컨벤션까지 총정리.
버튼을 눌렀는데 부모 DIV까지 클릭되는 현상. 이벤트는 물방울처럼 위로 올라갑니다(Bubbling). 반대로 내려오는 캡처링(Capturing)도 있죠.

any를 쓰면 타입스크립트를 쓰는 의미가 없습니다. 제네릭(Generics)을 통해 유연하면서도 타입 안전성(Type Safety)을 모두 챙기는 방법을 정리합니다. infer, keyof 등 고급 기법 포함.

함수가 선언될 때의 렉시컬 환경(Lexical Environment)을 기억하는 현상. React Hooks의 원리이자 정보 은닉의 핵심 키.

부모에서 전달한 props가 undefined로 나와서 앱이 크래시되는 문제 해결

처음 프로그래밍을 배우고 토이 프로젝트를 만들 때는 파일 구조가 단순했기 때문에 import 경로에 대해 크게 고민하지 않았습니다. 모든 파일이 한두 단계 깊이의 폴더에 있었으니까요. 하지만 실제에 가까운 규모의 프로젝트를 혼자서 클론 코딩해보기 시작하면서 문제가 터졌습니다.
컴포넌트를 재사용하기 위해 분리하고, 유틸리티 함수를 모아두고, 훅을 따로 관리하다 보니 폴더 구조가 점점 깊어졌습니다. 그러다 보니 어느 순간 제 코드 상단은 이런 ../../.. 의 향연이 되어버렸습니다.
import { Button } from '../../../components/ui/Button';
import { useAuth } from '../../../../hooks/useAuth';
import { formatDate } from '../../../utils/date';
import { UserType } from '../../../../types/user';
이걸 보면서 "이건 좀 아닌데..."라는 생각이 들었습니다. 파일을 다른 폴더로 옮기기라도 하면 저 경로들을 일일이 다 수정해야 했으니까요. 리팩토링이 고통 그 자체가 되었습니다. 게다가 ../../가 3개인지 4개인지 세고 있는 제 자신을 발견했을 때, "이걸 해결하는 방법이 분명히 있을 것이다"라고 확신했습니다.
그래서 찾아보니 '절대 경로(Absolute Path)' 또는 'Path Alias'라는 것이 있더군요. @/components/Button처럼 깔끔하게 쓸 수 있다는 걸 보고 바로 적용해보기로 했습니다.
인터넷 검색 결과는 대부분 "tsconfig.json에 paths 설정을 추가하면 된다"라고 나와 있었습니다. "생각보다 간단하네?"라고 생각하며 바로 적용했습니다.
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
설정을 저장하고 VSCode를 보니, 빨간 줄이 사라지고 자동 완성도 기가 막히게 잘 동작했습니다. ../../.. 지옥에서 탈출했다는 기쁨에 신나서 코드를 수정하고 저장했죠.
Error: Cannot find module '@/components/ui/Button'
Require stack:
- /Users/me/project/src/pages/index.js
이 상황이 도저히 이해가 안 갔습니다. "아니, VSCode에서는 멀쩡하잖아? Ctrl+Click 하면 파일로 이동도 잘 되는데? 왜 실행할 때만 모른다고 하는 거야?"
제가 가진 오개념은 이것이었습니다: "TypeScript 컴파일러가 알아서 경로를 JS가 이해할 수 있게 바꿔주겠지."
하지만 현실은 그렇지 않았습니다. 에디터에서는 행복했지만, 실제 런타임 환경(Node.js나 브라우저)은 @/가 도대체 어디를 가리키는지 전혀 모르고 있었습니다. 마치 내비게이션에 "우리 집"이라고 검색했는데, 내비게이션이 "그게 어딘데?"라고 반문하는 꼴이었습니다.
이 문제를 해결하기 위해 며칠을 삽질하다가, 'TypeScript'와 '런타임(또는 번들러)'의 역할이 완전히 분리되어 있다는 사실을 깨달았을 때 비로소 모든 의문이 풀렸습니다. 이걸 "지도(Map)"와 "택시 기사(Driver)"의 관계로 비유해보니 머릿속에 확 들어왔습니다.
TypeScript (지도):
tsconfig.json에 paths를 설정하는 건, 지도에 "여기서 '우리 집'이라고 하면 '서울시 강남구...'를 말하는 거야"라고 메모해두는 것과 같습니다.@/components가 어딘지 알고 에러를 안 냅니다.Runtime/Bundler (택시 기사):
graph LR
subgraph "Design Time (VSCode)"
A[Source Code] --> B[TypeScript Compiler]
B --> C{tsconfig.json}
C -- paths 설정 확인 --> B
B -- OK --> D[Type Check Pass]
end
subgraph "Runtime / Build Time"
E[Bundler / Node.js] --> F{Config File}
F -- alias 설정 확인 --> E
E --> G[File Resolution]
G -- OK --> H[Execution / Bundle]
end
style C fill:#f9f,stroke:#333
style F fill:#9f9,stroke:#333
왜 이런 복잡한 일이 벌어질까요? 자바스크립트의 역사 때문입니다.
오랫동안 자바스크립트엔 '모듈'이라는 개념이 없었습니다. 그러다 Node.js가 나오면서 require()라는 CommonJS(CJS) 방식을 썼습니다.
나중에 브라우저 표준으로 import라는 ES Modules(ESM) 방식이 나왔습니다.
이 두 세계가 충돌하면서, "파일을 찾는 방법(Module Resolution Strategy)"이 꼬이기 시작했습니다.
TypeScript는 이 둘을 모두 지원해야 하므로, "네가 쓰는 환경이 CJS냐 ESM이냐에 따라 경로 해석을 다르게 하겠다"는 복잡한 설정을 가지게 된 것입니다.
요즘 표준인 Vite는 vite.config.ts가 기사님(번들러)의 매뉴얼입니다.
가장 추천하는 방식은 vite-tsconfig-paths 플러그인을 사용하는 것입니다.
// vite.config.ts
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()], // 이거 하나면 끝!
});
이러면 tsconfig.json의 paths 설정을 Vite가 자동으로 읽어갑니다. 실제로는 무조건 이걸 씁니다.
Next.js는 정말 똑똑한 기사님입니다. tsconfig.json에 설정만 되어 있으면, Next.js 내부의 Webpack 설정이 알아서 그걸 읽어서 적용해줍니다. Next.js에서는 tsconfig.json만 건드리면 됩니다. 꿀이죠.
여기서 가장 많이 헤맸습니다. Node.js는 브라우저 번들러 같은 게 기본적으로 없습니다. TypeScript를 tsc로 컴파일하면 JS 파일이 생성되는데, import 경로는 변환되지 않고 그대로 남습니다.
가장 깔끔한 해결책은 tsc-alias를 쓰는 것입니다. 빌드(컴파일) 후, JS 파일 내의 @/ 경로를 상대 경로(../../)로 싹 바꿔줍니다.
// package.json scripts
"build": "tsc && tsc-alias"
실제로 규모가 커지면 모노레포를 쓰게 됩니다. 이때는 상황이 조금 더 복잡해집니다.
예를 들어 packages/ui에 있는 버튼을 apps/web에서 가져다 쓰고 싶을 때입니다.
// apps/web/src/App.tsx
import { Button } from '@repo/ui/Button'; // 이렇게 쓰고 싶음
이럴 때는 루트 tsconfig.json과 각 패키지의 package.json을 잘 맞춰줘야 합니다.
특히 package.json의 exports 필드가 중요합니다. "기사님"이 패키지를 찾을 때 이 필드를 보고 진입점을 찾기 때문이죠.
// packages/ui/package.json
{
"name": "@repo/ui",
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
}
}
모노레포에서는 단순히 paths만 맞춘다고 되는 게 아니라, 심볼릭 링크(Symlink)와 패키지 매니저(pnpm, yarn berry)의 동작 방식까지 이해해야 합니다.
모든 설정을 다 한 것 같은데도 빨간 줄이 뜨거나 에러가 난다면? 제가 겪은 삽질 리스트를 확인해보세요.
tsconfig.json을 수정하고 나면 TS 서버가 바로 인식을 못할 때가 많습니다. Cmd + Shift + P -> TypeScript: Restart TS Server를 실행하거나 창을 껐다 켜세요.paths 설정은 반드시 baseUrl 설정과 함께 가야 합니다. baseUrl: "."을 꼭 확인하세요..storybook/main.js의 webpackFinal 옵션이나 jest.config.js의 moduleNameMapper를 따로 설정해줘야 합니다. "기사님이 여러 명"이라는 사실을 잊지 마세요.여러 사람이 같이 일할 때는 규칙이 필요합니다. 저희 팀이 쓰는 규칙을 공유합니다.
@ 하나만 쓰자: ~, #, `# 절대 경로 import가 안 될 때 - 원인부터 모노레포 설정까지처음 프로그래밍을 배우고 토이 프로젝트를 만들 때는 파일 구조가 단순했기 때문에 import 경로에 대해 크게 고민하지 않았습니다. 모든 파일이 한두 단계 깊이의 폴더에 있었으니까요. 하지만 실제에 가까운 규모의 프로젝트를 혼자서 클론 코딩해보기 시작하면서 문제가 터졌습니다.
컴포넌트를 재사용하기 위해 분리하고, 유틸리티 함수를 모아두고, 훅을 따로 관리하다 보니 폴더 구조가 점점 깊어졌습니다. 그러다 보니 어느 순간 제 코드 상단은 이런 ../../.. 의 향연이 되어버렸습니다.
import { Button } from '../../../components/ui/Button';
import { useAuth } from '../../../../hooks/useAuth';
import { formatDate } from '../../../utils/date';
import { UserType } from '../../../../types/user';
이걸 보면서 "이건 좀 아닌데..."라는 생각이 들었습니다. 파일을 다른 폴더로 옮기기라도 하면 저 경로들을 일일이 다 수정해야 했으니까요. 리팩토링이 고통 그 자체가 되었습니다. 게다가 ../../가 3개인지 4개인지 세고 있는 제 자신을 발견했을 때, "이걸 해결하는 방법이 분명히 있을 것이다"라고 확신했습니다.
그래서 찾아보니 '절대 경로(Absolute Path)' 또는 'Path Alias'라는 것이 있더군요. @/components/Button처럼 깔끔하게 쓸 수 있다는 걸 보고 바로 적용해보기로 했습니다.
인터넷 검색 결과는 대부분 "tsconfig.json에 paths 설정을 추가하면 된다"라고 나와 있었습니다. "생각보다 간단하네?"라고 생각하며 바로 적용했습니다.
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
설정을 저장하고 VSCode를 보니, 빨간 줄이 사라지고 자동 완성도 기가 막히게 잘 동작했습니다. ../../.. 지옥에서 탈출했다는 기쁨에 신나서 코드를 수정하고 저장했죠.
Error: Cannot find module '@/components/ui/Button'
Require stack:
- /Users/me/project/src/pages/index.js
이 상황이 도저히 이해가 안 갔습니다. "아니, VSCode에서는 멀쩡하잖아? Ctrl+Click 하면 파일로 이동도 잘 되는데? 왜 실행할 때만 모른다고 하는 거야?"
제가 가진 오개념은 이것이었습니다: "TypeScript 컴파일러가 알아서 경로를 JS가 이해할 수 있게 바꿔주겠지."
하지만 현실은 그렇지 않았습니다. 에디터에서는 행복했지만, 실제 런타임 환경(Node.js나 브라우저)은 @/가 도대체 어디를 가리키는지 전혀 모르고 있었습니다. 마치 내비게이션에 "우리 집"이라고 검색했는데, 내비게이션이 "그게 어딘데?"라고 반문하는 꼴이었습니다.
이 문제를 해결하기 위해 며칠을 삽질하다가, 'TypeScript'와 '런타임(또는 번들러)'의 역할이 완전히 분리되어 있다는 사실을 깨달았을 때 비로소 모든 의문이 풀렸습니다. 이걸 "지도(Map)"와 "택시 기사(Driver)"의 관계로 비유해보니 머릿속에 확 들어왔습니다.
TypeScript (지도):
tsconfig.json에 paths를 설정하는 건, 지도에 "여기서 '우리 집'이라고 하면 '서울시 강남구...'를 말하는 거야"라고 메모해두는 것과 같습니다.@/components가 어딘지 알고 에러를 안 냅니다.Runtime/Bundler (택시 기사):
graph LR
subgraph "Design Time (VSCode)"
A[Source Code] --> B[TypeScript Compiler]
B --> C{tsconfig.json}
C -- paths 설정 확인 --> B
B -- OK --> D[Type Check Pass]
end
subgraph "Runtime / Build Time"
E[Bundler / Node.js] --> F{Config File}
F -- alias 설정 확인 --> E
E --> G[File Resolution]
G -- OK --> H[Execution / Bundle]
end
style C fill:#f9f,stroke:#333
style F fill:#9f9,stroke:#333
왜 이런 복잡한 일이 벌어질까요? 자바스크립트의 역사 때문입니다.
오랫동안 자바스크립트엔 '모듈'이라는 개념이 없었습니다. 그러다 Node.js가 나오면서 require()라는 CommonJS(CJS) 방식을 썼습니다.
나중에 브라우저 표준으로 import라는 ES Modules(ESM) 방식이 나왔습니다.
이 두 세계가 충돌하면서, "파일을 찾는 방법(Module Resolution Strategy)"이 꼬이기 시작했습니다.
TypeScript는 이 둘을 모두 지원해야 하므로, "네가 쓰는 환경이 CJS냐 ESM이냐에 따라 경로 해석을 다르게 하겠다"는 복잡한 설정을 가지게 된 것입니다.
요즘 표준인 Vite는 vite.config.ts가 기사님(번들러)의 매뉴얼입니다.
가장 추천하는 방식은 vite-tsconfig-paths 플러그인을 사용하는 것입니다.
// vite.config.ts
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()], // 이거 하나면 끝!
});
이러면 tsconfig.json의 paths 설정을 Vite가 자동으로 읽어갑니다. 실제로는 무조건 이걸 씁니다.
Next.js는 정말 똑똑한 기사님입니다. tsconfig.json에 설정만 되어 있으면, Next.js 내부의 Webpack 설정이 알아서 그걸 읽어서 적용해줍니다. Next.js에서는 tsconfig.json만 건드리면 됩니다. 꿀이죠.
여기서 가장 많이 헤맸습니다. Node.js는 브라우저 번들러 같은 게 기본적으로 없습니다. TypeScript를 tsc로 컴파일하면 JS 파일이 생성되는데, import 경로는 변환되지 않고 그대로 남습니다.
가장 깔끔한 해결책은 tsc-alias를 쓰는 것입니다. 빌드(컴파일) 후, JS 파일 내의 @/ 경로를 상대 경로(../../)로 싹 바꿔줍니다.
// package.json scripts
"build": "tsc && tsc-alias"
실제로 규모가 커지면 모노레포를 쓰게 됩니다. 이때는 상황이 조금 더 복잡해집니다.
예를 들어 packages/ui에 있는 버튼을 apps/web에서 가져다 쓰고 싶을 때입니다.
// apps/web/src/App.tsx
import { Button } from '@repo/ui/Button'; // 이렇게 쓰고 싶음
이럴 때는 루트 tsconfig.json과 각 패키지의 package.json을 잘 맞춰줘야 합니다.
특히 package.json의 exports 필드가 중요합니다. "기사님"이 패키지를 찾을 때 이 필드를 보고 진입점을 찾기 때문이죠.
// packages/ui/package.json
{
"name": "@repo/ui",
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
}
}
모노레포에서는 단순히 paths만 맞춘다고 되는 게 아니라, 심볼릭 링크(Symlink)와 패키지 매니저(pnpm, yarn berry)의 동작 방식까지 이해해야 합니다.
모든 설정을 다 한 것 같은데도 빨간 줄이 뜨거나 에러가 난다면? 제가 겪은 삽질 리스트를 확인해보세요.
tsconfig.json을 수정하고 나면 TS 서버가 바로 인식을 못할 때가 많습니다. Cmd + Shift + P -> TypeScript: Restart TS Server를 실행하거나 창을 껐다 켜세요.paths 설정은 반드시 baseUrl 설정과 함께 가야 합니다. baseUrl: "."을 꼭 확인하세요..storybook/main.js의 webpackFinal 옵션이나 jest.config.js의 moduleNameMapper를 따로 설정해줘야 합니다. "기사님이 여러 명"이라는 사실을 잊지 마세요.여러 사람이 같이 일할 때는 규칙이 필요합니다. 저희 팀이 쓰는 규칙을 공유합니다.
@ 하나만 쓰자: ~, #, 등을 섞어 쓰면 헷갈립니다. 가장 대중적인 @/로 통일하세요.@/components/common/Button 까지는 괜찮지만, @/components/pages/home/sections/features/Item 처럼 너무 깊어지면 paths 설정이 꼬입니다. 모듈 구조를 평평하게(Flat) 유지하세요.import { Button } from '@/components/Button/Button' 대신, index.ts를 활용해 import { Button } from '@/components'로 깔끔하게 만드세요."내비게이션(Runtime)에 주소를 입력하지 않고 지도(TypeScript)에만 표시해두면, 운전기사님은 길을 찾을 수 없다."
절대 경로 설정이 안 될 때는 이 문장을 떠올리세요. 에디터(TS)를 위한 설정과 실행기(Runtime/Bundler)를 위한 설정이 짝을 맞춰야 한다는 점만 기억하면, 더 이상 Cannot find module 에러가 무섭지 않을 것입니다.