
"Cannot find module" 에러가 또 떴습니다 (패키지 매니저의 배신)
`npm install`을 했는데 모듈을 못 찾는다고 합니다. 로컬에선 되는데 CI에서만 터지는 이유와 `package.json`의 `exports`, 그리고 TypeScript 설정까지 완벽 분석.

`npm install`을 했는데 모듈을 못 찾는다고 합니다. 로컬에선 되는데 CI에서만 터지는 이유와 `package.json`의 `exports`, 그리고 TypeScript 설정까지 완벽 분석.
분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

안드로이드는 Xcode보다 낫다고요? Gradle 지옥에 빠져보면 그 말이 쏙 들어갈 겁니다. minSdkVersion 충돌, Multidex 에러, Namespace 변경(Gradle 8.0), JDK 버전 문제, 그리고 의존성 트리 분석까지 완벽하게 해결해 봅니다.

서버에서 잘 오던 데이터가 갑자기 앱을 죽입니다. 'type Null is not a subtype of type String' 에러의 원인과, 안전한 JSON 파싱을 위한 Null Safety 전략을 정리해봤습니다.

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

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)에 따라 파일을 찾는 방법이 완전히 달라진다는 걸 몰랐습니다.
이걸 "레스토랑 메뉴판"에 비유하니 이해가 됐습니다.
제가 주방(node_modules)에 들어가서 냉장고를 열어보니 생고기(내부 모듈)가 보입니다.
그래서 "생고기 주세요(import ... from 'package/src/internal')!"라고 외칩니다.
하지만 웨이터(Node.js)는 "손님, 그건 메뉴판(exports)에 없어서 못 드립니다"라고 거절하는 겁니다.
파일이 실재하더라도, 메뉴판(exports)에 적혀있지 않으면 없는 취급을 당하는 것이죠.
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(.)만 써야 합니다.
moduleResolution)tsconfig.json을 확인해 보세요.
{
"compilerOptions": {
"moduleResolution": "Node", // 옛날 방식
// "moduleResolution": "Bundler" // 최신 방식 (Vite, Next.js)
}
}
만약 Node (Classic) 모드라면 package.json의 exports 필드를 제대로 해석하지 못할 수 있습니다.
최신 프레임워크를 쓴다면 Bundler로 바꾸는 게 정신 건강에 좋습니다.
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는 "난 그딴 거 모른다"라며 뻗어버립니다. (지도와 택시 기사 비유 기억나시죠?)
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를 추가하세요.
"내 컴퓨터에선 되는데요?"를 방지하려면, CI에서 엄격하게 검사해야 합니다.
# 의존성 정합성 검사 (Yarn Berry)
yarn constraints
# 혹은 depcheck 도구 사용
npx depcheck
depcheck는 package.json에 없는데 코드에서 import 하거나,
반대로 package.json에는 있는데 안 쓰는 패키지를 귀신같이 찾아줍니다.
우리는 흔히 @/components/... 같은 별칭(Alias)을 씁니다.
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
VSCode와 TypeScript 컴파일러(tsc)는 이걸 아주 잘 이해합니다.
하지만 tsc로 컴파일된 결과물(.js)에는 이 별칭이 그대로(@/...) 남아있습니다.
Node.js는 @/가 무슨 뜻인지 전혀 모릅니다. 그래서 실행 시 터집니다.
"Cannot find module '@/components/Button'..."
../../src/)로 바꿔주는 도구 사용.package.json의 imports 필드 사용 (가장 추천하는 모던 방식).// package.json
{
"imports": {
"#src/*": "./src/*"
}
}
최근 겪은 모노레포 이슈입니다.
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 규칙을 켰습니다.
흔히 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 필드만큼은 순서가 중요합니다.
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)이라서 구형 설정에선 에러로 인식될 수 있습니다.