"분명히 npm install 했단 말이에요!"
동료가 짠 유틸리티 라이브러리를 제 프로젝트에 설치했습니다.
npm install @team/utils
그리고 코드를 짰습니다.
import { formatDate } from '@team/utils';
VSCode는 조용합니다. 빨간 줄도 없습니다.
그런데 npm run start를 하자마자 에러가 터집니다.
Error: Cannot find module '@team/utils'
code: 'MODULE_NOT_FOUND'
"아니, node_modules 폴더 안에 파일이 버젓이 있는데 왜 못 찾아?"
rm -rf node_modules 하고 다시 설치해도 똑같습니다. 귀신이 곡할 노릇입니다.
처음엔 뭐가 이해가 안 갔나? (파일이 있으면 되는 거 아냐?)
저는 "파일이 존재하면 import 할 수 있다"고 생각했습니다.
node_modules/@team/utils/index.js 파일이 있으면 당연히 불러와져야 하는 거 아닌가?
하지만 Node.js의 모듈 시스템(특히 ESM)은 생각보다 깐깐했습니다. 단순히 파일이 있다고 로드해주는 게 아니라, "이 패키지가 이 파일을 밖으로 내보내겠다고 허락했는가?"를 확인합니다.
그리고 TypeScript 설정(moduleResolution)에 따라 파일을 찾는 방법이 완전히 달라진다는 걸 몰랐습니다.
어떤 포인트에서 이해가 됐나? (레스토랑 메뉴판 비유)
이걸 "레스토랑 메뉴판"에 비유하니 이해가 됐습니다.
- Package: 레스토랑 주방입니다. 안에 온갖 식재료(파일)가 있습니다.
- package.json exports: 메뉴판입니다. "우리 식당은 파스타랑 리조또만 팝니다"라고 적혀 있습니다.
- Developer: 손님입니다.
제가 주방(node_modules)에 들어가서 냉장고를 열어보니 생고기(내부 모듈)가 보입니다.
그래서 "생고기 주세요(import ... from 'package/src/internal')!"라고 외칩니다.
하지만 웨이터(Node.js)는 "손님, 그건 메뉴판(exports)에 없어서 못 드립니다"라고 거절하는 겁니다.
파일이 실재하더라도, 메뉴판(exports)에 적혀있지 않으면 없는 취급을 당하는 것이죠.
해결 과정 - 범인 찾기 (3가지 시나리오)
1. package.json exports 필드 확인
최신 라이브러리들은 exports 필드를 적극적으로 씁니다.
// node_modules/@team/utils/package.json
{
"name": "@team/utils",
"exports": {
".": "./dist/index.js",
"./date": "./dist/date.js"
// "./string"은 없음!
}
}
만약 제가 import ... from '@team/utils/string'을 하려 한다면?
MODULE_NOT_FOUND가 뜹니다. 파일이 있어도 안 됩니다.
이럴 땐 라이브러리 제작자에게 부탁해서 exports에 추가해달라고 하거나, 공식 entry point(.)만 써야 합니다.
2. TypeScript 설정 (moduleResolution)
tsconfig.json을 확인해 보세요.
{
"compilerOptions": {
"moduleResolution": "Node", // 옛날 방식
// "moduleResolution": "Bundler" // 최신 방식 (Vite, Next.js)
}
}
만약 Node (Classic) 모드라면 package.json의 exports 필드를 제대로 해석하지 못할 수 있습니다.
최신 프레임워크를 쓴다면 Bundler로 바꾸는 게 정신 건강에 좋습니다.
3. 확장자(.js) 생략 문제
Node.js 환경에서 ESM("type": "module")을 쓸 때는 확장자를 생략하면 안 됩니다.
// CommonJS 시절엔 이게 됨
import { add } from './math';
// ESM에선 에러 발생!
// Error: Cannot find module '.../math'
반드시 명시해야 합니다:
import { add } from './math.js';
VSCode는 확장자 없이도 에러를 안 낼 수 있습니다. (TypeScript가 알아서 찾아주니까) 하지만 실행하는 Node.js는 "난 그딴 거 모른다"라며 뻗어버립니다. (지도와 택시 기사 비유 기억나시죠?)
깊이 파고들기 - 모노레포에서의 유령 의존성 (Phantom Dependency)
pnpm이나 Yarn Berry(PnP)를 쓰면 더 황당한 에러를 만납니다.
A 패키지에서 lodash를 쓰고 있습니다.
B 패키지에서 A를 import 합니다.
B 패키지 코드에서 무심코 lodash를 import 합니다. (B의 package.json엔 lodash가 없는데!)
npm/yarn classic은 node_modules 구조가 평평(Flat)해서 이게 우연히 작동했습니다(Hoisting).
하지만 pnpm은 엄격하게 격리시킵니다.
그래서 "로컬(npm)에선 되는데, CI(pnpm)에서만 터지는" 끔찍한 일이 발생합니다.
해결책: "쓰는 건 무조건 명시한다."
남의 패키지(A)가 쓴다고 해서 내가(B) 몰래 갖다 쓰면 안 됩니다. B/package.json에도 lodash를 추가하세요.
Application: CI에서 잡아내기
"내 컴퓨터에선 되는데요?"를 방지하려면, CI에서 엄격하게 검사해야 합니다.
# 의존성 정합성 검사 (Yarn Berry)
yarn constraints
# 혹은 depcheck 도구 사용
npx depcheck
depcheck는 package.json에 없는데 코드에서 import 하거나,
반대로 package.json에는 있는데 안 쓰는 패키지를 귀신같이 찾아줍니다.
"내 컴퓨터에선 되는데?" (tsconfig paths의 함정) 한 걸음 더
우리는 흔히 @/components/... 같은 별칭(Alias)을 씁니다.
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
VSCode와 TypeScript 컴파일러(tsc)는 이걸 아주 잘 이해합니다.
하지만 tsc로 컴파일된 결과물(.js)에는 이 별칭이 그대로(@/...) 남아있습니다.
Node.js는 @/가 무슨 뜻인지 전혀 모릅니다. 그래서 실행 시 터집니다.
"Cannot find module '@/components/Button'..."
해결책
- tsc-alias: 컴파일 후 경로를 상대 경로(
../../src/)로 바꿔주는 도구 사용. - ts-node / tsx: 개발 환경에선 TS를 직접 실행해서 해결.
- Subpath Imports: Node.js 네이티브 기능인
package.json의imports필드 사용 (가장 추천하는 모던 방식).
// package.json
{
"imports": {
"#src/*": "./src/*"
}
}
8. Case Study: 모노레포의 지옥 (Symlink와 실경로)
최근 겪은 모노레포 이슈입니다.
packages/ui를 apps/web에서 쓰고 싶었습니다.
상황
VSCode가 자동으로 임포트를 해줬습니다.
import { Button } from '../../packages/ui/src/Button';
로컬에선 잘 됩니다. 하지만 Docker 빌드에서 터집니다.
이유는 Docker 컨텍스트가 apps/web만 복사했기 때문에, 상위 폴더(../../packages)는 존재하지 않았던 거죠.
해결 - 워크스페이스 패키지 이름 사용
절대로 상대 경로로 모노레포 패키지를 넘나들면 안 됩니다.
package.json에 정의된 패키지 이름(@myorg/ui)을 써야 합니다.
// ✅ Good
import { Button } from '@myorg/ui';
// ❌ Bad (Local Only)
import { Button } from '../../packages/ui';
이를 강제하기 위해 eslint-plugin-import의 no-relative-packages 규칙을 켰습니다.
이를 강제하기 위해 eslint-plugin-import의 no-relative-packages 규칙을 켰습니다.
9. Architecture: Barrel Files (index.ts)의 배신
흔히 index.ts에 모든 걸 다 모아서 내보냅니다 (Barrel Pattern).
import { A, B, C } from './utils';
하지만 이게 프로젝트가 커지면 "순환 참조(Circular Dependency)"와 "Tree Shaking 실패"의 원흉이 됩니다.
Node.js는 index.ts를 읽을 때 그 안에 있는 모든 파일을 다 메모리에 올립니다.
나는 A만 필요한데, index.ts 때문에 Z까지 다 로딩하다가 Z가 다시 A를 참조하면?
"ReferenceError: Cannot access 'A' before initialization"이 터집니다.
해결책:
Barrel File을 남용하지 마세요. 필요한 파일만 직접 import 하는 게 가장 안전하고 빠릅니다.
VSCode 설정에서 Auto Import 방식을 설정할 수 있습니다.
types condition in exports 제대로 이해하기
package.json의 exports에서 놓치기 쉬운 게 타입 정의입니다.
TS 4.7+부터는 types 조건이 가장 위에 있어야 합니다.
"exports": {
".": {
"types": "./dist/index.d.ts", // 👈 반드시 제일 먼저!
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
순서가 바뀌면 TS가 타입을 못 찾고 Could not find a declaration file 에러를 뱉습니다.
JSON은 순서가 없다지만, exports 필드만큼은 순서가 중요합니다.
11. FAQ: 자주 묻는 질문
Q: npm install 했는데도 계속 모듈을 못 찾겠대요.
A: VSCode를 껐다 켜보세요 (Reload Window). TS 서버가 캐시를 들고 있어서 그럴 때가 많습니다. 그래도 안 되면 node_modules 지우고 npm ci (Clean Install) 하세요.
Q: @types/패키지명을 꼭 설치해야 하나요?
A: 해당 패키지가 TS로 만들어졌다면 필요 없습니다. 하지만 JS로 만들어졌는데 d.ts 파일(정의 파일)이 없다면 설치해야 합니다. 안 그러면 "Could not find a declaration file for module..." 에러가 뜹니다.
Q: import 문에서 .js를 붙여도 VSCode가 파일 못 찾는다고 빨간 줄 그어요.
A: tsconfig.json에서 "moduleResolution": "NodeNext" 또는 "Bundler"로 설정되어 있는지 확인하세요. 확장자 붙이는 건 최신 표준(ESM)이라서 구형 설정에선 에러로 인식될 수 있습니다.