Node.js 네이티브 TypeScript: 컴파일 없이 .ts 파일 실행하는 완벽 가이드
10년 넘게 Node.js에서 TypeScript를 실행하려면 반드시 "먼저 컴파일"해야 했어요. tsc를 쓰든, ts-node를 쓰든, tsx를 쓰든, .ts 파일을 실행하기 전에 항상 중간 단계가 있었죠. 이제 그 시대가 끝나고 있어요.
Node.js 22부터 시작해서 Node.js 25에서 안정화된 네이티브 TypeScript 지원을 쓰면 이렇게 실행할 수 있어요:
node app.ts
빌드 과정 없어요. tsconfig.json도 필수가 아니에요. 외부 의존성도 필요 없고요. 그냥 Node.js와 TypeScript 코드만 있으면 돼요.
장난감 수준의 기능이 아니에요. 모든 TypeScript 개발자에게 영향을 미치는 근본적인 변화예요. 이 글에서는 어떻게 동작하는지, 뭐가 되고 안 되는지, 그리고 실제 프로젝트에서 어떻게 적용하는지 자세히 알아볼게요.
동작 원리: 타입 체킹이 아니라 타입 스트리핑
Node.js의 접근 방식은 놀라울 정도로 단순해요. 타입을 벗겨내고, 남은 JavaScript를 실행한다. 이게 전부예요.
TypeScript를 컴파일하지 않아요. 타입 체크도 안 해요. 문법 변환도 없어요. 말 그대로 타입 어노테이션을 제거하고 남은 코드를 일반 JavaScript로 실행할 뿐이에요.
이런 TypeScript 파일이 있다고 해볼까요:
// app.ts interface User { name: string; age: number; } function greet(user: User): string { return `Hello, ${user.name}! You are ${user.age} years old.`; } const user: User = { name: 'Alice', age: 30 }; console.log(greet(user));
Node.js는 이걸 대략 이렇게 바꿔요:
function greet(user) { return `Hello, ${user.name}! You are ${user.age} years old.`; } const user = { name: 'Alice', age: 30 }; console.log(greet(user));
interface User 선언? 날아가요. : User 타입 어노테이션? 벗겨져요. : string 리턴 타입? 제거돼요. 남은 건 V8이 바로 실행할 수 있는 깔끔한 JavaScript뿐이에요.
이걸 "erasable syntax"(지울 수 있는 문법) 이라고 불러요. 런타임 동작을 바꾸지 않고 제거할 수 있는 TypeScript 문법이죠. 개발자들이 매일 쓰는 TypeScript 기능 대부분이 여기에 해당해요.
내부 구현: Amaro와 SWC
내부를 까보면, Node.js는 Amaro라는 라이브러리로 타입 스트리핑을 처리해요. Amaro는 @swc/wasm-typescript의 얇은 래퍼예요. SWC의 TypeScript 파서를 WebAssembly로 빌드한 거죠.
파이프라인은 이렇게 생겼어요:
your-file.ts → Amaro (SWC WASM) → 타입 제거된 JS → V8 실행
이 아키텍처에서 중요한 포인트가 세 가지 있어요:
-
빨라요. SWC는 Rust로 작성되어 WebAssembly로 컴파일돼요. 파싱하고 타입만 제거하니까
tsc전체 컴파일보다 수 배 빨라요. 타입 검사, 제약 조건 확인, 선언 파일 생성 같은 건 안 하거든요. -
내장이에요. Amaro는 Node.js에 같이 들어있어요.
npm install필요 없고,node_modules도 안 더러워져요. 런타임의 일부예요. -
의도적으로 제한돼 있어요. 기본 모드에서는 SWC로 스트리핑만 하기 때문에(전체 컴파일이 아님), erasable TypeScript 문법만 지원돼요.
타임라인: 실험에서 안정까지
네이티브 TypeScript 지원이 어떤 과정을 거쳤는지 정리해볼게요:
| 버전 | 마일스톤 |
|---|---|
| Node.js 22.6.0 (2024년 7월) | --experimental-strip-types 플래그 도입 |
| Node.js 22.7.0 (2024년 8월) | enum 지원을 위한 --experimental-transform-types 추가 |
| Node.js 23.x (2024년 말) | 개선 및 광범위 테스팅 |
| Node.js 22.18.0 / 23.6.0 (2025년) | 타입 스트리핑 기본 활성화 (플래그 불필요) |
| Node.js 25.2.0 (2026년) | 안정 릴리스, 실험 경고 제거 |
2026년 초 기준으로 Node.js 22.18+ 또는 최신 Node.js를 쓰고 있다면, 타입 스트리핑은 별도 설정 없이 바로 동작해요.
작동하는 것들: Erasable TypeScript 문법
아래 TypeScript 기능들은 런타임 동작에 영향 없이 깔끔하게 제거할 수 있어서 완벽하게 동작해요:
타입 어노테이션
// 이것들은 전부 스트리핑됩니다 const name: string = 'hello'; function add(a: number, b: number): number { return a + b; } const items: Array<string> = ['a', 'b', 'c'];
인터페이스와 타입 별칭
interface Config { host: string; port: number; debug?: boolean; } type DatabaseURL = `postgres://${string}`; // 런타임에는 존재하지 않아요. 완전히 지워져요
제네릭
function identity<T>(value: T): T { return value; } class Container<T> { constructor(private value: T) {} get(): T { return this.value; } }
타입 단언과 as 캐스트
const input = document.getElementById('name') as HTMLInputElement; const data = JSON.parse(body) as ApiResponse;
satisfies 연산자
const config = { host: 'localhost', port: 3000, } satisfies Config;
유틸리티 타입
type ReadonlyUser = Readonly<User>; type PartialConfig = Partial<Config>; type UserKeys = keyof User; // 타입 스트리핑 시점에 전부 지워집니다
작동하지 않는 것들: Non-Erasable 문법
여기서부터 좀 까다로워져요. 일부 TypeScript 기능은 런타임 코드를 생성하거든요. 제거하면 동작이 바뀌기 때문에 단순히 스트리핑할 수 없어요. 이런 걸 "non-erasable" 기능이라고 불러요.
Enum (클래식 Enum)
// ❌ 타입 스트리핑만으로는 런타임 에러 발생 enum Direction { Up = 'UP', Down = 'DOWN', Left = 'LEFT', Right = 'RIGHT', }
Enum은 런타임에 JavaScript 객체를 생성해요. enum 키워드를 스트리핑하면 유효하지 않은 문법이 남게 되죠. Enum을 쓰려면 --experimental-transform-types 플래그가 필요해요:
node --experimental-transform-types app.ts
더 좋은 대안: enum 대신 as const 객체를 사용하세요:
// ✅ 일반 타입 스트리핑으로 동작합니다 const Direction = { Up: 'UP', Down: 'DOWN', Left: 'LEFT', Right: 'RIGHT', } as const; type Direction = typeof Direction[keyof typeof Direction];
파라미터 프로퍼티
// ❌ --experimental-transform-types 필요 class User { constructor(public name: string, private age: number) {} }
파라미터 프로퍼티(생성자 매개변수의 public, private, protected, readonly)는 할당 코드를 생성해요. public name: string이라는 축약형이 생성자 본문에서 this.name = name;이 되거든요. 단순 스트리핑을 넘어서는 변환이에요.
대안:
// ✅ 일반 타입 스트리핑으로 동작합니다 class User { name: string; // 타입 어노테이션 — 스트리핑됨 private _age: number; constructor(name: string, age: number) { this.name = name; this._age = age; } }
레거시 데코레이터와 emitDecoratorMetadata
// ❌ 지원되지 않음 — 런타임 메타데이터를 생성합니다 @Controller('/users') class UserController { @Get('/:id') getUser(@Param('id') id: string) { ... } }
레거시(실험적) 데코레이터와 emitDecoratorMetadata는 런타임에 리플렉션 메타데이터를 생성해요. NestJS, TypeORM, Angular 같은 프레임워크에서 흔히 쓰이죠.
참고: TC39 Stage 3 데코레이터(모던 표준)는 JavaScript 기능이라 Node.js의 JS 엔진에서 별도로 처리돼요.
런타임 병합이 있는 네임스페이스
// ❌ 지원되지 않음 — 네임스페이스는 IIFE를 생성합니다 namespace Validation { export function isEmail(str: string): boolean { return str.includes('@'); } }
대안: ES 모듈을 대신 사용하세요:
// validation.ts export function isEmail(str: string): boolean { return str.includes('@'); }
중요한 제약사항과 주의점
타입 체크는 안 해요
이게 제일 중요해요. Node.js는 코드의 타입을 검사하지 않아요. 타입 에러가 있어도:
const name: number = "hello"; // 타입 에러!
Node.js는 : number 어노테이션을 아무 말 없이 벗겨내고 코드를 실행해요. 타입이 맞는지 전혀 관심 없어요. 그건 여전히 tsc의 몫이에요.
그래서 워크플로우가 이렇게 바뀌어요:
# 개발: 그냥 실행 node app.ts # CI/CD: 타입 검사는 별도로 npx tsc --noEmit
사실 이게 상당한 속도 향상이에요. 개발 중에는 타입 체킹을 완전히 건너뛰고 즉시 실행하거든요. 타입 체킹은 에디터(TypeScript 언어 서버)와 CI에서 하면 돼요.
Import 확장자: .ts vs .js
마이그레이션할 때 가장 까다로운 부분 중 하나예요. ES 모듈을 쓸 때 Node.js는 import에 명시적 파일 확장자를 요구하거든요:
// ❌ 모호함, Node.js가 어떤 파일인지 알 수 없어요 import { greet } from './utils'; // ✅ 명시적 .ts 확장자 import { greet } from './utils.ts';
근데 TypeScript 컴파일러는 역사적으로 import에 .js 확장자를 쓰도록 권장했어요(.ts 파일이어도요). 컴파일 후에 파일이 .js 확장자를 갖게 되니까요:
// tsc가 전통적으로 원했던 방식 import { greet } from './utils.js';
해결 방법은 간단해요. TypeScript 5.7+에서 rewriteRelativeImportExtensions 컴파일러 옵션이 도입됐거든요:
{ "compilerOptions": { "rewriteRelativeImportExtensions": true } }
이 옵션을 켜면 tsc가 .ts import를 받아들이고 출력에서 .js로 바꿔줘요. 이제 어디서든 .ts import를 쓸 수 있어요.
tsconfig.json Path 별칭은 안 돼요
tsconfig.json에 정의된 경로 별칭은 작동하지 않아요:
// tsconfig.json { "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }
// ❌ Node.js는 tsconfig.json을 읽지 않습니다 import { db } from '@/database';
Node.js는 tsconfig.json을 읽지도 않거든요. Path 별칭은 컴파일 타임 기능이라서요. 대안으로는:
package.json의 Node.js subpath imports:
{ "imports": { "#src/*": "./src/*" } }
import { db } from '#src/database.ts';
- 상대 경로 import (대부분의 경우 가장 단순한 방법)
소스 맵
Node.js가 타입을 스트리핑하면 에러 스택 트레이스의 줄 번호가 원본 .ts 파일과 다를 수 있어요. 스트리핑된 버전은 줄 수가 달라지거든요(예: 인터페이스가 제거되면 오프셋이 바뀜).
다행히 Node.js가 자동으로 소스 맵을 생성해줘요. 스택 트레이스가 .ts 소스 파일의 올바른 줄을 가리키게 돼요.
실전 마이그레이션: ts-node에서 네이티브로
일반적인 ts-node 셋업에서 어떻게 갈아타는지 살펴볼게요.
이전: ts-node 셋업
// package.json { "scripts": { "dev": "ts-node --esm src/index.ts", "start": "node dist/index.js", "build": "tsc" }, "devDependencies": { "typescript": "^5.5.0", "ts-node": "^10.9.0", "@types/node": "^22.0.0" } }
이후: Node.js 네이티브 TypeScript
// package.json { "scripts": { "dev": "node --watch src/index.ts", "start": "node src/index.ts", "typecheck": "tsc --noEmit", "build": "tsc" }, "devDependencies": { "typescript": "^5.7.0", "@types/node": "^22.0.0" } }
뭐가 바뀌었는지 보세요:
ts-node이 사라졌어요.devDependencies에서 완전히 제거됐죠.dev스크립트가 그냥node예요.--watch와 결합해서 내장 감시 모드를 사용해요.start가.ts를 직접 실행해요.dist/로 컴파일할 필요 없어요.typecheck가 분리됐어요. 타입 검사가 명시적이고 선택적인 단계가 됐죠.build는 여전히tsc를 써요. 선언 파일이 필요하거나 오래된 런타임을 타겟할 때 쓰면 돼요.
마이그레이션 단계
-
Node.js 업데이트 22.18+ (또는 경고 없는 안정 버전 25+)
-
ts-node이랑 tsx 제거:
npm uninstall ts-node tsx
- import 확장자를
.ts로 업데이트:
// 이전 import { db } from './database.js'; // 이후 import { db } from './database.ts';
-
enum을
as const객체로 교체 (또는--experimental-transform-types사용) -
tsconfig에
erasableSyntaxOnly활성화 (TypeScript 5.8+):
{ "compilerOptions": { "erasableSyntaxOnly": true } }
이 플래그를 켜면 tsc가 non-erasable 문법(enum, 파라미터 프로퍼티, 네임스페이스)을 컴파일 타임에 거부해요. Node.js 타입 스트리핑과 호환 안 되는 기능을 실수로 쓰면 TypeScript가 런타임 전에 잡아주니까 안심이죠.
-
package.json의 scripts 업데이트 -
CI에
typecheck추가:
# GitHub Actions - name: Type Check run: npx tsc --noEmit
지금 도입해야 할까요?
판단 기준을 정리해봤어요:
| 시나리오 | 권장 사항 |
|---|---|
| 새 프로젝트, 최신 Node.js | ✅ 네이티브 TypeScript 사용 |
| CLI 도구와 스크립트 | ✅ 완벽한 활용 사례 — 빌드 단계 제로 |
| 개발/프로토타이핑 | ✅ 가장 빠른 반복 속도 |
| API 서버 (Express, Fastify) | ✅ 잘 동작함, CI에 tsc 추가 |
| NestJS / TypeORM (데코레이터) | ⚠️ 대기 — 레거시 데코레이터 미지원 |
| 선언 파일이 필요한 라이브러리 | ⚠️ .d.ts 생성을 위해 여전히 tsc 필요 |
| 오래된 Node.js 대상 프로덕션 빌드 | ❌ 컴파일을 위해 tsc 유지 |
권장 하이브리드 워크플로우
2026년 대부분의 프로젝트에서 최적의 워크플로우는 이렇습니다:
개발: node --watch app.ts (즉시 실행, 빌드 없음)
에디터: TypeScript LSP (실시간 타입 검사)
CI: tsc --noEmit (엄격한 타입 검증)
프로덕션: node app.ts (직접 실행)
타입 체킹은 에디터와 CI 파이프라인에서 하고, 실행은 바로 해요. 선언 파일이나 오래된 런타임 지원, 브라우저용 번들링이 아니면 중간 빌드 단계가 없는 거예요.
생태계 영향
Node.js 네이티브 TypeScript 지원은 생태계 전반에 파급 효과를 만들고 있어요:
덜 필요해지는 도구들:
ts-node가장 직접적인 대체. 개발용으로는 거의 불필요해짐tsx더 빠른 ts-node 대안이었지만, 같은 상황esbuild/swc(개발 트랜스파일러로서) Node.js가 이제 이걸 처리함
여전히 필수적인 도구들:
tsc타입 체킹, 선언 파일 생성, 오래된 환경 타겟- 번들러 (Vite, webpack, Rollup) 브라우저 코드, 트리 셰이킹, 최적화용
@swc/core/esbuild완전한 변환이 필요한 프로덕션 빌드 파이프라인용
적응하고 있는 프레임워크들:
- Deno와 Bun은 이미 네이티브 TypeScript 지원이 있었어요. Node.js가 합류하면서 경쟁이 평준화됐죠.
- NestJS는 마이그레이션 경로로 TC39 데코레이터(타입 변환이 필요 없는)를 탐색 중이에요.
- Express와 Fastify는 네이티브 타입 스트리핑과 완벽하게 호환돼요. 변경 사항 없어요.
앞으로 어떻게 될까요?
Node.js의 네이티브 TypeScript 지원은 더 큰 흐름을 보여줘요. TypeScript와 JavaScript 사이의 경계가 흐려지고 있다는 것. TypeScript는 더 이상 전통적인 의미의 "JS로 컴파일하는 언어"가 아니에요. 런타임이 네이티브로 이해하는 JavaScript의 방언이 되어가고 있죠.
TypeScript 팀도 이 흐름을 빡세게 밀고 있어요. TypeScript 5.8에서 --erasableSyntaxOnly 플래그를 추가해서 컴파일 타임에 Node.js 호환성을 강제할 수 있게 했고, TypeScript 6.0 Beta가 2026년 2월에 나왔어요. 그리고 가장 기대되는 건 TypeScript 7.0 (Project Corsa) 인데, 컴파일러와 언어 서비스를 Go로 완전히 다시 짜는 프로젝트예요. 10배의 성능 향상을 목표로 하고 있어요. 2026년 중반 출시 예정인데, 이게 실현되면 tsc --noEmit이 너무 빨라져서 "개발 중 타입 체킹 건너뛰기"라는 말 자체가 의미 없어질 수도 있어요.
한편, TC39 Type Annotations 제안(Stage 1)은 타입 어노테이션을 JavaScript 사양 자체에 넣으려는 시도예요. 이게 진행되면 브라우저와 런타임이 네이티브로 타입 어노테이션을 무시하게 돼요. Node.js가 지금 Amaro로 하고 있는 것과 정확히 같은 거죠.
"TypeScript를 어떻게 컴파일하지?"가 아니라 "왜 컴파일을 해야 하지?"를 묻는 시대가 오고 있어요.
대부분의 경우, 그 답은 점점 더 명확해지고 있어요. 안 해도 돼요.
결론
Node.js 네이티브 TypeScript 지원은 단순한 편의 기능이 아니에요. 패러다임의 전환이에요. 개발 루프가 편집 → 컴파일 → 실행에서 편집 → 실행으로 줄어들어요. 의존성 트리가 가벼워지고, 빌드 설정 때문에 머리 아플 일이 없어져요.
2026년에 새 Node.js 프로젝트를 시작한다면, 개발용 TypeScript 컴파일 파이프라인을 설정할 이유가 없어요. 그냥 .ts 파일을 작성하고 node로 실행하면 돼요. tsc는 CI에서 타입 체킹하거나, 선언 파일이 필요한 경우에만 쓰면 돼요.
Node.js에서 TypeScript의 미래는 더 나은 컴파일러가 아니에요. 컴파일러가 전혀 필요 없게 되는 것이에요.
관련 도구 둘러보기
Pockit의 무료 개발자 도구를 사용해 보세요