
"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 전략을 정리해봤습니다.

데이터를 수정했는데 페이지에 계속 예전 값이 나오는 유령 같은 현상. Next.js 13+의 강력한(그리고 사악한) 캐싱 메커니즘을 4계층으로 분석하고, React Query와의 차이점, 그리고 실제 디버깅 전략을 공유합니다.

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)이라서 구형 설정에선 에러로 인식될 수 있습니다.