넥스트는 되는데 node 스크립트만 @/ 별칭을 못 찾으면 tsconfig paths가 런타임 경로를 안 바꿉니다. TypeScript paths, Next.js 번들러, Node.js 타입 스트리핑, package.json imports, 모듈 해석, 1인 개발자 크론과 깃허브 액션 기준. 2026년 9월 TypeScript, Node, Next 공식 문서.
넥스트 화면은 되는데 node scripts/만 @/를 못 찾으면, 타입스크립트 paths가 런타임 경로를 바꿔 주지 않아서입니다. 별칭은 편집기와 번들러가 읽고, 노드는 읽지 않습니다.
혼자 넥스트를 돌리다가 발행 스크립트나 크론에서 Cannot find module @/lib/...가 뜨는 일, 한 번은 만납니다. 제가 쓰는 사이트도 페이지는 초록인데 스크립트만 그 줄로 죽습니다.
tsc는 경로를 다시 쓰지 않고, 노드 타입 스트리핑도 tsconfig를 무시합니다. 런타임에서 쓸 이름은 package.json의 샵 임포트로 옮기거나 상대 경로로 되돌리면 됩니다. 근거는 타입스크립트 paths와 노드 경로 별칭 문서에 있습니다.
넥스트는 되고 노드 스크립트만 깨지는 이유
번들러가 별칭을 바꿔 주고, 노드 프로세스는 그대로 둡니다. @/lib/posts는 패키지 이름이 아니라 tsconfig에 적어 둔 별칭입니다. 넥스트는 tsconfig의 paths와 baseUrl을 읽어 그 문자열을 실제 파일로 바꿉니다. 그래서 next dev와 next build에서는 화면이 나옵니다.
같은 한 줄을 node scripts/publish.js로 실행하면 노드는 @라는 패키지를 찾습니다. 디스크에 그 이름의 폴더가 없으니 ERR_MODULE_NOT_FOUND 또는 Cannot find module이 납니다. 패키지를 못 깐 게 아니라, 별칭을 런타임이 모르는 겁니다.
타입스크립트 공식 문서도 paths는 컴파일 결과의 import 경로를 바꾸지 않는다고 적습니다. 다른 도구가 런타임이나 번들에서 그 매핑을 쓴다는 전제입니다. 넥스트가 그 도구이고, 맨 노드 실행은 그 도구가 아닙니다. 패키지 이름 탐색은 Cannot find module 칸이고, 여기는 별칭 문자열만 봅니다.
먼저 기억할 것 | 앱이 초록이고 스크립트만 빨간 줄이면 node_modules를 지울 자리가 아닙니다. 에러에 찍힌 지정자가 @/나 커스텀 접두어로 시작하면 tsconfig paths를 누가 해석했는지부터 보세요.
같은 @/ 문자열이라도 넥스트와 노드가 읽는 칸이 다르다
편집기, 번들러, 노드가 읽는 칸
같은 import 한 줄을 세 도구가 따로 읽습니다. 한 칸만 고치면 다른 칸이 깨지니, 누가 성공했는지를 표로 가릅니다.
누가
무엇을 읽나
실패하면
편집기, tsc
tsconfig paths, baseUrl
빨간 물결, 타입은 맞는데 실행은 죽음
넥스트 번들러
같은 tsconfig를 빌드에 반영
페이지, 앱 라우트 import가 깨짐
노드 런타임
package.json imports, node_modules
스크립트와 크론만 Cannot find module
테스트 러너
자체 alias 설정
테스트는 되고 node -e만 실패
한국에서 1인으로 넥스트를 돌리면 페이지는 넥스트가 묶고, 발행과 페이지뷰, 사이트맵은 node scripts/...가 따로 돕니다. 그 두 줄이 같은 @/를 쓰면 로컬 next dev는 되고 새벽 크론만 죽습니다. CSS 파일명 대소문자는 맥과 리눅스 CI 칸이고, 여기는 지정자 해석만 봅니다.
넥스트 설치 문서도 paths와 baseUrl을 앱 import 별칭으로 안내합니다. 그 안내의 범위는 넥스트가 묶는 파일입니다. 스크립트 엔트리는 그 범위 밖입니다.
같은 별칭, 실행기만 다름
// 페이지. 넥스트가 @/ 를 실제 경로로 바꿈
import { loadPosts } from '@/data/posts'
// 스크립트. 노드는 @ 패키지를 찾다가 실패
// node scripts/publish.js
import { loadPosts } from '@/data/posts'
// Error: Cannot find package '@/data/posts'
타입 스트리핑을 켜도 별칭은 그대로다
노드는 타입만 지울 뿐 tsconfig paths는 적용하지 않습니다. 최근 노드는 .ts를 바로 실행할 수 있습니다. 타입 문법을 공백으로 지우고 돌리며, tsconfig.json은 읽지 않습니다. 그래서 paths, 최신 문법 변환처럼 tsconfig에 매달린 기능은 의도적으로 빠집니다.
공식 문서의 경로 별칭 절은 tsconfig paths가 변환되지 않아 에러가 난다고 적습니다. 가장 가까운 대체는 package.json의 서브패스 임포트이고, 이름은 #으로 시작해야 합니다. node --experimental-strip-types를 붙이거나 기본 스트리핑을 켜도 @/는 파일로 바뀌지 않습니다.
enum이나 네임스페이스처럼 지울 수 없는 문법 에러는 다른 글입니다. 별칭 실패는 문법이 아니라 지정자입니다. 타입이 지워진 뒤에도 from '@/lib/foo' 문자열이 남으면 노드는 그걸 패키지로 봅니다. 문법 쪽은 타입 스트리핑과 이레이저블 신택스를 보세요.
함정 | .ts가 실행된다고 해서 넥스트와 같은 모듈 해석이 따라오는 건 아닙니다. 타입만 빠진 자바스크립트가 돌고, 경로 규칙은 노드 그대로입니다.
타입 스트리핑은 tsconfig를 무시하므로 paths 매핑이 런타임에 없다
런타임 별칭은 샵 임포트로 옮긴다
샵으로 시작하는 이름만 노드가 패키지 안에서 바꿔 줍니다. package.json의 imports 필드는 그 패키지 내부 지정자용입니다. 키는 항상 #으로 시작해야 바깥 패키지 이름과 섞이지 않습니다. 값은 ./로 시작하는 상대 경로입니다.
앱 파일은 넥스트가 이해하는 @/를 그대로 두고, node로 직접 여는 스크립트만 #lib/...를 쓰거나 상대 경로로 적으면 역할이 갈립니다. 한 파일을 앱과 스크립트가 같이 불러야 하면 그 파일 안에서는 상대 경로나 샵 임포트만 쓰는 편이 안전합니다. 노드가 해석하지 못하는 @/를 공유 모듈에 넣으면 스크립트가 다시 죽습니다.
전체 타입스크립트 지원이 필요하면 노드 문서가 예시로 드는 것처럼 개발 의존성에 tsx를 넣고 node --import=tsx로 열 수 있습니다. 그건 tsconfig를 읽는 도구를 한 겹 얹는 선택입니다. 크론에 도구를 더 얹기 부담이면 샵 임포트나 상대 경로가 더 얇습니다.
작업 폴더가 루트여도 별칭 문자열은 그대로입니다. cwd 실수는 ENOENT나 패키지 모듈 없음과 겹쳐 보입니다. 에러 지정자가 @/lib/foo처럼 별칭이면 폴더를 고쳐도 같은 줄이 납니다. 맥 터미널에서 next dev만 쓰다가 액션의 node scripts/foo.js에서 처음 드러나는 패턴입니다.
증상
먼저 볼 것
고칠 칸
로컬 next는 되고 액션 node만 실패
에러 지정자가 @/ 인가
스크립트 import를 # 또는 상대로
홈에서 크론이 죽음
process.cwd()와 지정자
cwd를 저장소 루트로. 별칭은 별개
테스트는 되고 node -e만 실패
러너 alias 설정
테스트 통과를 런타임 증거로 쓰지 말 것
CI만 대소문자 실패
파일명과 import 철자
별칭 글이 아님. CSS 모듈 대소문자 칸
제가 쓰는 발행 스크립트도 처음엔 페이지와 같은 @/를 넣었다가, 깃허브 액션이 아니라 로컬 스케줄러에서 먼저 죽었습니다. 화면 확인만 하고 스크립트를 안 돌리면 배포 전날 밤에야 보입니다. 카카오 콜백 검증처럼 페이지와 별도로 도는 한 줄이 있으면, 그 파일을 저장소 루트에서 한 번 실행해 보는 게 배포보다 앞입니다. package.json의 scripts로 한 번 감싸 두면 작업 폴더는 루트로 맞춰지고, 그다음에도 별칭이면 imports를 고칩니다.
빌드 스크립트가 로컬은 되고 CI만 빨간 줄인 경우는 ELIFECYCLE과 자리가 다릅니다. 그쪽은 종료 코드와 엔진, 환경 변수입니다. 지정자에 @/가 남아 있으면 이 글의 칸입니다.
페이지가 초록이어도 스크립트 엔트리를 한 번 실행해 봐야 별칭 실패가 보인다
상대 경로로 되돌리는 게 맞을까
스크립트 한두 파일이면 상대 경로가 더 짧습니다. ../data/posts.js는 도구가 없어도 노드가 바로 엽니다. ESM이면 확장자를 빼먹지 마세요. 파일이 늘고 폴더가 깊어지면 샵 임포트가 이깁니다. 앱의 @/를 스크립트에 복사하지 않는 게 먼저입니다.
공유 유틸을 페이지와 스크립트가 같이 쓰면, 그 유틸 안에서는 @/를 쓰지 않습니다. 유틸이 다시 @/lib/db를 부르면 스크립트 진입점이 샵이어도 한 단계 안에서 깨집니다. 경계를 스크립트 엔트리만이 아니라, 노드가 여는 그래프 전체로 보면 됩니다.
피어 충돌로 설치가 멈춘 줄은 ERESOLVE입니다. 별칭 실패는 이미 있는 파일을 다른 이름으로 부르는 줄이라 설치를 반복해도 안 풀립니다.
스크립트에서 상대 경로로 열기
// scripts/publish.js (ESM, 확장자 필수)
import { loadPosts } from '../data/posts.js'
// 확인
node --check scripts/publish.js
node scripts/publish.js