node app.ts — ts-node 없이 TypeScript 직접 실행하기: Node.js 22 타입 스트리핑 실전 가이드
기존 프로젝트에서 package.json의 devDependencies에 있던 ts-node를 지우고, 스크립트를 ts-node src/index.ts에서 node src/index.ts로 바꿔봤습니다. 추가 설정 없이 그냥 실행됐습니다. 이 글은 그 경험을 바탕으로, Node.js 22의 네이티브 TypeScript 실행이 정확히 어떻게 동작하는지, 어떤 프로젝트에 적합한지, 실제 마이그레이션 시 어떤 함정이 있는지를 정리한 것입니다.
Node.js 22.18.0부터 별도 플래그 없이 .ts 파일을 직접 실행할 수 있습니다. ts-node도, tsx도, 컴파일 단계도 필요 없습니다. 내부적으로 amaro라는 라이브러리가 TypeScript 타입 구문을 공백으로 치환(strip)한 뒤 순수 JavaScript처럼 실행합니다.
핵심 개념
타입 스트리핑: 어떻게 동작하는가
원리는 단순합니다. TypeScript의 타입 관련 구문을 공백으로 치환하고 나머지를 그대로 실행합니다. 변환이 아니라 제거입니다. 줄 번호가 원본 .ts 파일과 완전히 동일하게 유지됩니다.
타입을 삭제가 아닌 공백 치환으로 처리하는 이유: 줄/열 번호를 원본과 동일하게 유지하기 위해서입니다. 소스맵 없이도 스택 트레이스가
.ts파일 위치와 정확히 일치합니다.
내부 엔진은 amaro — Node.js 팀이 공식적으로 관리하는 래퍼 라이브러리입니다. 실제 파싱과 변환은 Rust로 만들어진 SWC의 WebAssembly 빌드(@swc/wasm-typescript)가 담당합니다. SWC WASM 빌드를 쓰는 이유는 네이티브 바이너리 없이 Node.js 코어에 포함시킬 수 있기 때문입니다(tsx가 esbuild를 채택한 것과 같은 맥락의 선택입니다). amaro 1.0이 2025년에 안정화되면서 Node.js 공식 문서에서도 프로덕션 고려 대상으로 언급하기 시작했습니다.
버전별 진화 타임라인
이 기능은 단계별로 조심스럽게 안정화됐습니다. 22.x와 23.x는 병렬 릴리즈 라인으로, 선형 업그레이드 경로가 아닙니다.
Node.js 22 LTS 라인
| 버전 | 변경 내용 |
|---|---|
| 22.6.0 (2024년 8월) | --experimental-strip-types 플래그 최초 도입 |
| 22.7.0 (2024년 8월) | --experimental-transform-types 추가 (enum/namespace 변환 지원) |
| 22.18.0 (2025년) | 플래그 없이 .ts 직접 실행 가능 (flag-free 안정화) |
홀수 버전(비 LTS) 및 이후 LTS
| 버전 | 변경 내용 |
|---|---|
| 23.x (홀수, 비 LTS) | strip-types가 실험적 플래그에서 해제 |
| 24 (현 LTS) | 타입 스트리핑을 기본 동작으로 채택 |
지금 신규 프로젝트를 시작한다면 node app.ts는 이미 실험적 기능이 아닙니다.
지원되는 문법과 지원되지 않는 문법
"지울 수 있는(erasable) 문법"만 기본 모드에서 지원됩니다.
지원됩니다 — 공백으로 치환 가능:
- 타입 어노테이션 (
: string,: number) interface,type별칭- 제네릭 (
<T>) import type/export type
기본 모드에서 지원되지 않습니다 — 런타임 코드를 생성하기 때문:
// ❌ ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX 발생
enum Direction { Up, Down, Left, Right }
// ❌ 마찬가지
namespace Utils { export function helper() {} }
// ❌ NestJS에서 흔히 쓰는 파라미터 프로퍼티
constructor(private readonly name: string) {}
// ❌ experimentalDecorators
@Injectable()
class MyService {}enum이 안 되는 이유는 공백으로 치환하면 런타임에서 Direction이 정의되지 않은 변수가 되기 때문입니다. "지우기"만으로는 올바른 JavaScript를 만들 수 없고, 실제 JavaScript 코드를 생성해야 합니다.
--experimental-transform-types를 쓰면 되지 않나요?
--experimental-transform-types 플래그를 사용하면 enum과 namespace를 변환할 수 있습니다. 하지만 이 방식은 두 가지 이유로 기본 권장 경로가 아닙니다.
첫째, 이름 그대로 여전히 실험적입니다. 동작이 바뀌거나 제거될 수 있습니다. 둘째, 팀 내 모든 개발 환경과 CI에 플래그를 명시적으로 추가해야 하는 운영 부담이 생깁니다. 따라서 실전에서는 enum을 as const 패턴으로 전환하거나, 해당 구문이 많다면 아직 tsx를 유지하는 쪽을 권장합니다.
TC39 표준 데코레이터(현재 표준화 진행 중)는 별개 논의이지만, 기존 NestJS 코드베이스의 experimentalDecorators 기준으로는 아직 네이티브 실행이 불가합니다.
실전 적용
마이그레이션 여부 판단하기
무조건 올려야 하는 건 아닙니다. 현재 프로젝트 상황에 따라 판단이 달라집니다.
기본 실행
Node.js 22.18+ 환경이라면 아무 설정 없이:
# 그냥 됩니다
node app.ts
# 개발 시 파일 감시 재실행
node --watch src/index.ts
# 22.6.0 ~ 22.17.x 구버전이라면
node --experimental-strip-types app.tspackage.json 설정
{
"type": "module",
"engines": {
"node": ">=22.18.0"
},
"scripts": {
"dev": "node --watch src/index.ts",
"typecheck": "tsc --noEmit",
"build": "tsc"
}
}typecheck를 따로 분리한 게 보이시나요? 네이티브 실행은 타입 체크를 하지 않습니다. 실행은 되는데 타입 오류가 있어도 그냥 넘어갑니다. tsc --noEmit을 CI에 반드시 별도로 넣어야 합니다. engines 필드를 명시해두면 팀 내 Node.js 버전 불일치로 인한 혼란을 사전에 방지할 수 있습니다.
tsconfig.json 설정
네이티브 실행과 잘 맞는 tsconfig 조합입니다:
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"moduleResolution": "nodenext",
"erasableSyntaxOnly": true,
"rewriteRelativeImportExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true
}
}세 가지 옵션이 특히 중요합니다:
erasableSyntaxOnly(TypeScript 5.8+): enum, namespace, 파라미터 프로퍼티를 쓰면tsc단계에서 에러를 발생시킵니다. 린팅이 아닌 컴파일러 에러이므로 IDE에서도 즉시 빨간 줄이 생깁니다. 팀 전체가 "이 프로젝트는 네이티브 실행 가능한 문법만 쓴다"는 제약을 코드베이스 수준에서 공유하게 됩니다.rewriteRelativeImportExtensions(TypeScript 5.7+): 소스에서./foo.ts로 임포트하면 빌드 출력에서./foo.js로 자동 변환합니다.noEmit: true: 이 tsconfig는 타입 체크 전용입니다. 실제 빌드 출력은 별도 tsconfig 또는 esbuild/swc로 처리합니다.
ESM 임포트 경로 문제
"type": "module" 프로젝트에서 Node.js ESM은 확장자를 명시적으로 요구합니다.
// ❌ CJS 스타일 — ESM에서 동작 안 함
import { foo } from './foo'
// ✅ 개발 시: .ts 확장자 명시 (네이티브 실행 환경)
import { foo } from './foo.ts'
// ✅ 빌드 출력 기준으로 .js 확장자 명시
import { foo } from './foo.js'두 방식 모두 유효하지만 상황이 다릅니다. 개발 중 네이티브 실행 환경에서는 .ts로 참조하는 게 직관적입니다. .js로 쓰는 방식은 TypeScript 공식 권장 패턴으로 "빌드되면 .js가 될 파일을 미리 .js로 참조한다"는 논리이며, rewriteRelativeImportExtensions를 쓰면 .ts로 써도 빌드 시 자동으로 변환됩니다.
경로 별칭 해결
@/utils/helper 같은 tsconfig paths 별칭을 쓰고 있다면, 네이티브 실행은 이를 해석하지 않습니다. --import 플래그로 커스텀 resolver를 등록하는 방법이 있습니다:
node --import ./register-paths.mjs src/index.ts// register-paths.mjs
import { register } from 'node:module'
import { pathToFileURL } from 'node:url'
register('some-resolver', pathToFileURL('./'))다만 이 방식은 설정 복잡도가 올라갑니다. @/ 별칭을 수십 파일에서 광범위하게 쓰고 있다면 전환 비용이 생각보다 큽니다. 이 경우 상대 경로로 전환하거나 아직 tsx를 유지하는 쪽이 더 현실적입니다.
enum 마이그레이션
문자열 enum이라면 as const 패턴으로 전환하는 게 가장 현실적이며, 타입 레벨 동작이 거의 동일합니다:
// ❌ 네이티브 실행 불가 (기본 모드)
enum Direction {
Up = 'UP',
Down = 'DOWN',
Left = 'LEFT',
Right = 'RIGHT',
}
// ✅ 동일하게 동작하는 대체 패턴
const Direction = {
Up: 'UP',
Down: 'DOWN',
Left: 'LEFT',
Right: 'RIGHT',
} as const
type Direction = typeof Direction[keyof typeof Direction]
// 사용법은 동일
function move(dir: Direction) {
// ...
}
move(Direction.Up)단, 숫자형 enum은 주의가 필요합니다. 숫자형 enum은 역방향 매핑(Direction[0] === 'Up')을 지원하지만 as const 객체에는 없습니다. 숫자형 enum을 역방향 매핑과 함께 쓰고 있다면 단순 치환이 아니라 로직 변경이 필요합니다.
이 방향의 전환은 TypeScript 팀의 장기 방향성(isolatedModules 호환성 강화)과도 일치합니다.
전체 개발 워크플로
개발 단계에서는 네이티브 타입 스트리핑으로 빠르게 실행하고, 타입 체크는 CI에서 tsc --noEmit으로 별도 처리하고, 프로덕션 빌드는 esbuild나 tsc를 그대로 사용하는 구조입니다. 기존 빌드 파이프라인을 건드릴 필요가 없습니다.
장단점 분석
비교 한눈에 보기
| 항목 | 네이티브 실행 | tsx | ts-node |
|---|---|---|---|
| 콜드 스타트 | 가장 빠름 | 빠름 | 느림 |
| 추가 의존성 | 없음 | esbuild | 여러 개 |
| enum 지원 | ❌ 기본 모드 | ✅ | ✅ |
| 데코레이터 지원 | ❌ | ✅ | ✅ |
| 경로 별칭 지원 | ❌ | ✅ | ✅ 플러그인 |
| 타입 체크 | ❌ | ❌ | 옵션 |
| 줄 번호 정확성 | ✅ 완벽 | 소스맵 | 소스맵 |
| 장기 안정성 | Node.js 코어 | 서드파티 | 레거시화 진행 |
콜드 스타트 수치에 대해: 실제 차이는 프로젝트 규모와 의존성에 따라 크게 달라집니다. 네이티브 실행이 가장 빠르다는 방향성은 명확하며, 구체적인 측정 수치는 sebastian-staffa.eu의 벤치마크를 참고하세요.
"타입 체크 없음"이 단점처럼 보이지만: tsx도 마찬가지입니다. 실행 단계에서 타입 체크를 하던 설정은 ts-node의
transpileOnly: false모드뿐입니다. 대부분의 실용적 프로젝트는 이미 타입 체크를 빌드/CI 단계로 분리하고 있습니다.
실무에서 흔한 실수
실수 1: 타입 체크 없이 배포
네이티브 실행은 타입 오류를 잡지 않습니다. CI에 tsc --noEmit이 없으면 타입이 맞지 않아도 빌드가 통과합니다. 처음 도입할 때 가장 먼저 CI 파이프라인에 추가하세요.
실수 2: experimentalDecorators 의존 코드에 적용 시도
NestJS 프로젝트에서 node main.ts를 시도하면 데코레이터 관련 에러가 바로 납니다. NestJS는 현재 공식적으로 네이티브 실행을 지원하지 않습니다.
실수 3: tsconfig.json의 paths 별칭 그대로 사용
@/utils/helper 같은 경로 별칭을 쓰고 있다면 네이티브 실행은 이를 해석하지 않습니다. 앞서 설명한 --import 플래그 방식이나 상대 경로 전환이 필요합니다.
실수 4: Node.js 버전 확인 없이 적용
flag-free 실행은 22.18.0부터입니다. 22.6 ~ 22.17 범위라면 --experimental-strip-types 플래그가 필요합니다. package.json의 engines 필드를 명시하고 팀 내 버전이 통일돼 있는지 먼저 확인하세요.
마치며
Node.js 22.18+에서 .ts 파일은 추가 도구 없이 직접 실행됩니다. 핵심은 세 가지입니다:
- amaro가 타입을 공백으로 치환해 실행한다 — 변환이 아닌 제거, 소스맵 없이도 줄 번호 보존
- erasable 문법만 지원한다 — enum, 파라미터 프로퍼티, experimentalDecorators는 기본 모드 불가
- 타입 체크는 별도로 해야 한다 —
tsc --noEmit을 CI에 반드시 포함
지금 시작한다면 이 세 단계로 충분합니다:
1단계 — Node.js를 22.18 이상으로 올리고 node src/index.ts 실행해보기
2단계 — tsconfig에 erasableSyntaxOnly: true 추가해서 비호환 문법 사전 차단
3단계 — CI에 tsc --noEmit 스텝 추가해서 타입 안전성 확보
NestJS처럼 decorator에 의존하는 프레임워크를 쓰고 있다면 아직 기다리는 게 맞습니다. Express/Fastify 기반의 신규 서비스나 유틸리티 스크립트라면, ts-node를 설치하던 자리에 node만 써도 됩니다.
참고 자료
- Running TypeScript Natively | Node.js 공식 문서
- Modules: TypeScript | Node.js v26.5.0 API 문서
- Running TypeScript in Node.js: tsx vs. ts-node vs. native — LogRocket Blog
- TSX vs Native Node.js TypeScript — Better Stack
- Node.js Type Stripping Explained — Marco Satanacchio (Hashnode)
- Node.js Native TypeScript: The Complete Guide — Pockit Blog
- Node.js Native TypeScript in 2026: Type Stripping Guide — HireNodeJS
- TypeScript TSConfig: erasableSyntaxOnly 공식 문서
- TypeScript TSConfig: rewriteRelativeImportExtensions 공식 문서
- nodejs/amaro GitHub 저장소
- Node.js Moves Toward Stable TypeScript Support With Amaro 1.0 — InfoQ
- A look at native TypeScript performance benchmark — sebastian-staffa.eu
- A Modern Node.js + TypeScript Setup for 2025 — DEV Community
- Why We Chose Not to Run TypeScript Natively in Node.js — Medium
- Using TypeScript natively in Node.js 22 — dt.in.th