- Published on
Node 22 TypeScript 실행 - strip-types 트러블슈팅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버·배치 스크립트를 TypeScript로 작성해도 “빌드 없이 바로 실행”하고 싶은 순간이 자주 옵니다. Node 22에는 TypeScript 파일을 로딩할 때 타입 문법을 제거해 실행하는 실험 옵션인 --experimental-strip-types가 들어왔고, 간단한 유틸이나 내부 도구는 ts-node 없이도 돌릴 수 있게 됐습니다.
다만 이 기능은 “TypeScript 컴파일러”가 아니라 “타입 구문만 제거하는 로더”에 가깝습니다. 그래서 기존에 ts-node나 tsx에서 잘 돌던 코드가 Node 22에선 갑자기 깨지기도 합니다. 이 글은 실제로 자주 마주치는 오류 패턴을 원인별로 분류하고, 재현 가능한 예제와 해결책을 제공합니다.
참고로 Node 22에서 ESM 전환 과정에서 require()가 막히는 문제와도 자주 엮입니다. ESM 관련 배경이 필요하면 Node 22에서 require() 막힘? ESM 전환 실전도 같이 보면 전체 그림이 빠르게 잡힙니다.
--experimental-strip-types는 무엇을 해주고, 무엇을 안 해주나
핵심은 다음 한 줄입니다.
- 해주는 것: TypeScript의 타입 문법(예:
: string,interface,type,as const의 타입 컨텍스트 등)을 제거해서 런타임이 파싱할 수 있게 함 - 안 해주는 것:
tsc가 하는 트랜스파일링 전반(예:paths별칭 해석,emitDecoratorMetadata,const enum인라인,namespace변환, 레거시 데코레이터 변환 등)
즉, “대부분의 순수 ESM TypeScript 스크립트”는 잘 되지만, “컴파일러 기능에 기대던 프로젝트”는 트러블슈팅이 필요합니다.
빠른 실행 예시
아래는 가장 단순한 형태입니다.
node --experimental-strip-types ./src/index.ts
패키지 매니저 스크립트로는 이렇게 고정해두는 편이 안전합니다.
{
"scripts": {
"dev:node": "node --experimental-strip-types ./src/index.ts"
}
}
트러블슈팅 1) require is not defined / Cannot use import statement outside a module
증상
require is not defined in ES module scopeCannot use import statement outside a module
원인
TypeScript 실행 자체보다 모듈 시스템(ESM vs CJS) 충돌입니다. Node 22에서 TypeScript를 바로 실행하면, 파일 확장자와 package.json의 type 설정에 따라 ESM으로 해석될 가능성이 큽니다. 그 상태에서 CJS 문법(require, module.exports)을 쓰면 바로 깨집니다.
해결
해결책 A: 프로젝트를 ESM으로 정리
package.json에 다음을 명시합니다.
{
"type": "module"
}
그리고 CJS 문법을 ESM으로 바꿉니다.
// before
const fs = require("node:fs");
module.exports = { read };
// after
import fs from "node:fs";
export function read() {}
ESM 전환 체크리스트는 위에서 언급한 내부 글(Node 22에서 require() 막힘? ESM 전환 실전)이 실무적으로 도움이 됩니다.
해결책 B: CJS로 유지하고 싶다면
CJS로 유지하려면 TypeScript를 “그대로 실행”하기보다, 안전하게 tsc로 빌드하거나 tsx 같은 런타임을 쓰는 편이 낫습니다. --experimental-strip-types는 ESM 친화적인 흐름을 전제하는 경우가 많습니다.
트러블슈팅 2) 확장자 문제: .ts에서 상대 import가 깨짐
증상
ERR_MODULE_NOT_FOUND가 뜨면서 로컬 파일을 못 찾음- 특히
import "./foo"형태가 깨짐
원인
ESM에서 Node는 기본적으로 확장자 없는 상대 경로 import를 엄격하게 다룹니다. TypeScript 생태계에서는 번들러/트랜스파일러가 확장자 문제를 흡수해주는 경우가 많았는데, Node가 직접 로딩하면 그 관성이 깨집니다.
해결
해결책 A: 상대 import에 확장자를 명시
프로젝트 정책을 “Node ESM 규칙에 맞추기”로 잡는다면 가장 확실합니다.
// index.ts
import { hello } from "./hello.ts";
console.log(hello());
// hello.ts
export const hello = () => "hi";
해결책 B: 배포/운영은 빌드 산출물로
개발 편의는 strip-types로 가져가되, 운영은 tsc 결과물(보통 .js)을 실행하는 전략이 안전합니다.
트러블슈팅 3) tsconfig의 paths 별칭이 먹지 않음
증상
import x from "@/lib/x"같은 별칭이 런타임에서 해석되지 않음ERR_MODULE_NOT_FOUND로 이어짐
원인
paths는 TypeScript 컴파일러가 해석하는 기능입니다. Node의 strip-types는 타입만 제거할 뿐, 모듈 해석을 tsc처럼 해주지 않습니다.
해결
해결책 A: 런타임에서 별칭을 쓰지 않기
Node 직접 실행이 목표라면, 실행 경로에선 상대 경로 또는 패키지 경로로 정리합니다.
해결책 B: 빌드 단계에서 별칭을 상대 경로로 변환
tsc 빌드 후 별칭을 치환하는 도구(예: tsc-alias)를 쓰는 방식이 일반적입니다.
{
"scripts": {
"build": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
"start": "node ./dist/index.js"
}
}
트러블슈팅 4) enum, namespace, 레거시 데코레이터에 의존한 코드
증상
- 파싱은 되는데 런타임에서 동작이 달라짐
- 또는 문법 에러/참조 에러가 발생
원인
이 영역은 “타입 제거”가 아니라 “코드 변환”이 필요합니다. 예를 들어 namespace는 런타임 변환이 필요하고, 데코레이터는 TS 설정과 변환 방식에 따라 결과가 달라집니다.
해결
namespace는 모듈로 바꾸기- 데코레이터는 Node 직접 실행을 목표로 한다면 실험 기능에 의존하지 말고
tsc빌드로 고정 enum도 런타임 객체가 필요하므로 동작 자체는 가능하지만, 빌드/번들러와 조합에 따라 결과가 달라질 수 있어 “직접 실행 스크립트” 영역에서는as const객체로 대체하는 편이 예측 가능
예시로 enum을 상수 객체로 바꾸면 런타임 의존성이 명확해집니다.
export const Role = {
Admin: "admin",
User: "user"
} as const;
export type Role = (typeof Role)[keyof typeof Role];
트러블슈팅 5) 타입만 지웠는데도 실패하는 케이스: satisfies와 타입 좁히기 착시
증상
- “컴파일 단계에서만 안전한” 패턴이 런타임에선 아무 보호가 없어 장애로 이어짐
- 예:
satisfies로 타입을 맞춰놨다고 믿었는데 실제 데이터는 깨져 있음
원인
strip-types는 타입을 제거합니다. 즉, 타입 시스템이 제공하는 안전장치는 런타임에 남지 않습니다. satisfies는 특히 “타입 체크만” 하고 런타임 코드를 바꾸지 않기 때문에, 입력 데이터 검증을 따로 하지 않으면 운영에서 그대로 터질 수 있습니다.
이 주제는 TypeScript 5.7의 satisfies 관련 함정과도 이어집니다. 타입 좁히기가 기대와 다르게 동작하는 사례는 TS 5.7 - satisfies로 타입 좁히기 실패 해결에서 더 깊게 다룹니다.
해결
런타임 검증이 필요한 경계(환경변수, 외부 API 응답, 파일 입력)는 스키마 검증을 붙입니다.
import { z } from "zod";
const EnvSchema = z.object({
PORT: z.coerce.number().int().min(1).max(65535)
});
const env = EnvSchema.parse(process.env);
console.log(env.PORT);
트러블슈팅 6) ERR_UNKNOWN_FILE_EXTENSION 또는 로더 충돌
증상
.ts를 인식 못한다는 오류- 또는 기존에
--loader를 쓰던 프로젝트에서 충돌
원인
Node 실행 옵션 조합이 꼬였을 가능성이 큽니다. 예를 들어 기존에 ts-node/esm 로더를 쓰고 있었는데 strip-types까지 얹으면 로더 체인이 예상과 달라집니다.
해결
- “한 가지 방식만” 선택합니다.
- Node 내장 실험 기능을 쓸 거면
--experimental-strip-types만 - 생태계 런타임을 쓸 거면
tsx또는ts-node만
- Node 내장 실험 기능을 쓸 거면
- 문제를 최소 재현으로 줄여서,
node --experimental-strip-types ./a.ts단독 실행부터 확인합니다.
권장 운영 전략: 개발은 즉시 실행, 배포는 빌드 고정
--experimental-strip-types는 개발 생산성을 올리기 좋지만, 실험 기능인 만큼 팀/CI/배포에서 100퍼센트 예측 가능한 동작을 기대하긴 어렵습니다. 실무에서 자주 쓰는 타협안은 다음입니다.
- 로컬 개발 도구/간단한 스크립트:
node --experimental-strip-types - 운영 배포:
tsc로 빌드 후node dist/*.js
예시 package.json:
{
"scripts": {
"dev": "node --experimental-strip-types ./src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node ./dist/index.js"
}
}
체크리스트: 문제를 빨리 좁히는 순서
package.json의type이module인지 확인 (ESM인지 CJS인지부터 확정)- 실행 파일과 import 대상 파일의 확장자/상대 경로 규칙 점검
paths별칭 사용 여부 확인 (런타임에서 깨지면 빌드 전략으로 전환)- 데코레이터/namespace/특수 변환 기능 의존 여부 확인
- 타입 안전을 런타임 안전으로 착각한 경계 입력(환경변수, API 응답) 검증 추가
마무리
Node 22의 --experimental-strip-types는 “TypeScript를 Node가 직접 실행한다”는 꿈에 가까워졌지만, 실제로는 “타입만 제거하는 최소 기능”이라서 모듈 시스템과 런타임 해석 규칙을 정면으로 마주치게 됩니다. 위의 원인 분류대로 접근하면 대부분의 장애는 빠르게 정리됩니다.
특히 ESM/CJS 충돌이 가장 흔한 1순위이므로, Node 22 환경에서의 모듈 전환이 아직 정리되지 않았다면 Node 22에서 require() 막힘? ESM 전환 실전을 먼저 정독한 뒤 strip-types를 적용하는 것을 권합니다.