Node.js 22 타입 스트리핑으로 TypeScript 서버 코드의 tsc 컴파일 단계를 걷어내기
솔직히 말하면, TypeScript 프로젝트를 처음 세팅할 때마다 드는 감정이 있습니다. tsconfig.json 잡고, esbuild 또는 tsc 설정하고, Dockerfile에 멀티스테이지 빌드 구성하고, dist/ 디렉토리 관리하고... "그냥 서버 코드 돌리고 싶을 뿐인데 왜 이렇게 복잡하지?" 하는 그 감정요.
Node.js 22.6.0(2024년 8월)부터 --experimental-strip-types 플래그가 등장했고, Node.js 23.6.0부터는 플래그 없이 .ts 파일을 직접 실행할 수 있게 됐습니다. 22 LTS 라인에도 특정 릴리즈 이후 백포트가 반영됐는데, 정확한 포함 버전은 사용하시는 환경의 Node.js 릴리즈 노트를 직접 확인하시는 걸 권장드립니다. 어쨌든 Node.js 서버 사이드 코드에서 tsc 컴파일 단계를 제거할 수 있는 길이 열린 거죠. 다만 이걸 "tsc 대신 쓰는 것"으로 오해하면 꽤 당황스러운 상황을 만날 수 있습니다.
이 글에서는 타입 스트리핑이 정확히 무엇을 하는지, 어떤 한계가 있는지, 그리고 서버 배포 파이프라인을 어떻게 재구성하면 되는지를 실제 운영 관점에서 풀어보겠습니다. 번들링·트리 쉐이킹이 필요한 프론트엔드, 에지 런타임 배포, 번들 크기에 민감한 서비스에는 여전히 빌드 단계가 필요하다는 점은 미리 짚고 가겠습니다.
타입 스트리핑이란 — 트랜스파일과 다른 이유
"타입을 지운다"는 게 정확히 무슨 뜻인가
처음에 저도 헷갈렸습니다. "TypeScript를 실행한다"고 하면 반사적으로 "어딘가서 JS로 컴파일되겠지"라고 생각하게 되거든요. 그런데 --experimental-strip-types는 트랜스파일이 아닙니다.
내부적으로 Amaro 라이브러리(SWC 기반)를 사용해서 타입 어노테이션을 공백으로 치환하는 방식으로 동작합니다. 예를 들면 이렇습니다.
// 원본 TypeScript
function greet(name: string): string {
return `Hello, ${name}`;
}// 타입 스트리핑 후 (내부적으로 처리되는 형태)
function greet(name ) {
return `Hello, ${name}`;
}타입 어노테이션이 있던 자리가 공백으로 채워지고, 나머지 코드 구조는 그대로입니다. 덕분에 소스맵 없이도 스택 트레이스가 원본 .ts 파일의 정확한 라인을 가리킵니다. 이건 꽤 실용적인 장점이에요.
중요한 건 타입 검사를 전혀 하지 않는다는 점입니다. 타입 오류가 있어도 Node.js는 그냥 실행합니다. tsconfig.json도 읽지 않으므로 paths 별칭, 다운레벨링 등 tsconfig에 의존하는 기능도 동작하지 않습니다.
릴리즈 라인별로 어떻게 달라졌나
Node.js의 LTS 라인(22.x)과 Current 라인(23.x 이후)은 별개로 흘러가기 때문에, 플래그 기본값 변경이 각 라인에 다른 시점에 반영됩니다. 이 점을 그림으로 보면 이렇습니다.
2026년 기준으로, Node.js 24 릴리즈 노트에서 네이티브 TypeScript 지원이 실제로 어떤 상태(experimental / stable)인지는 사용하시는 LTS 버전의 릴리즈 노트와 공식 문서의 TypeScript 섹션에서 직접 확인하시는 걸 권장드립니다. 플래그 유무, enum·namespace 지원 범위가 마이너 버전 사이에서도 변하기 때문입니다.
# 플래그 기반 실행 (초기 22.x LTS)
node --experimental-strip-types server.ts
# 플래그 없는 실행이 활성화된 버전 (23.6+, 22.x LTS 백포트 반영 버전)
node server.ts
# enum, namespace 등 런타임 코드를 생성하는 구문이 포함된 경우
node --experimental-transform-types server.ts지원하지 않는 TypeScript 구문들
여기서 가장 자주 막히는 부분입니다. 타입 스트리핑은 지울 수 있는(erasable) 구문만 처리합니다. 런타임 코드를 생성하는 구문은 지울 수 없으니까요.
--experimental-strip-types 기준으로 지원하지 않는 것들:
enum: 런타임에 객체를 생성하는 코드가 됩니다namespace: 런타임 코드를 포함하는 경우- 파라미터 프로퍼티:
constructor(private name: string)형태 experimentalDecorators
이런 구문들이 필요하다면 --experimental-transform-types 플래그를 쓰거나, tsx 같은 외부 도구를 쓰는 게 현실적입니다.
실전 적용 — 서버 배포 파이프라인을 어떻게 바꿀 수 있나
tsconfig.json 설정 — 타입 스트리핑과 맞추기
TypeScript 5.8에서 추가된 erasableSyntaxOnly 옵션이 여기서 중요한 역할을 합니다. 이 옵션을 켜면 enum, namespace 같은 지울 수 없는 구문을 사용할 때 tsc가 미리 오류를 발생시킵니다. Node.js가 런타임에 오류를 내기 전에 CI에서 차단할 수 있는 거죠.
{
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"strict": true,
"noEmit": true
}
}noEmit: true를 주목하세요. 이 tsconfig는 빌드 결과물을 만들지 않고 타입 검사만 하도록 설정된 겁니다. 실행은 Node.js가 담당하니까요.
그리고 임포트 경로에는 .ts 확장자를 명시해야 합니다.
// 이렇게 써야 합니다
import { createUser } from './user.ts';
// 이렇게 쓰면 안 됩니다
import { createUser } from './user';Dockerfile — 멀티스테이지 빌드 작별하기
기존 방식은 이랬습니다.
# 기존: 멀티스테이지 빌드
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # tsc 실행 → dist/ 생성
FROM node:22-alpine AS runtime
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]타입 스트리핑을 쓰면 이렇게 바뀝니다.
# 단일 스테이지: 빌드 없음
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
EXPOSE 3000
CMD ["node", "src/index.ts"]Dockerfile이 절반 이하로 줄었습니다. dist/ 디렉토리도, 빌드 의존성을 위한 별도 스테이지도 없습니다.
GitHub Actions CI/CD 재구성
여기서 핵심은 타입 검사와 실행의 역할 분리입니다. Node.js가 타입을 검사하지 않으니, CI에서 별도 단계로 tsc --noEmit을 실행해서 타입 게이트를 만들어야 합니다.
name: CI/CD Pipeline
jobs:
type-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
- run: npx tsc --noEmit # 타입 오류만 검출, 빌드 없음
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
# 테스트 파일 패턴을 명시해야 .ts 테스트가 안정적으로 탐지됩니다
- run: node --experimental-strip-types --test 'src/**/*.test.ts'
deploy:
needs: [type-check, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build & Push Docker image
run: |
# 개념적 예시: 실제 환경에서는 Docker 이미지 빌드/푸시,
# 클라우드 CLI 호출, 매니페스트 적용 등으로 교체하세요.
# 예) docker build -t registry.example.com/app:${{ github.sha }} .
# docker push registry.example.com/app:${{ github.sha }}
# kubectl set image deploy/app app=registry.example.com/app:${{ github.sha }}
echo "실제 배포 명령으로 교체 필요"타입 검사와 테스트가 병렬로 실행되고, 둘 다 통과해야 배포가 진행되는 구조입니다. 중요한 포인트는 "타입 안전성은 CI가 책임지고, 실행은 Node.js가 책임진다"는 역할 분리를 명시적으로 만든다는 겁니다.
트레이드오프 — 언제 쓰고 언제 안 쓸지
도구별 비교
| 도구 | TypeScript 실행 | 타입 검사 | tsconfig 지원 | enum 지원 |
|---|---|---|---|---|
node --experimental-strip-types |
O | X | X | X |
node --experimental-transform-types |
O | X | X | O |
tsx |
O | X | 부분¹ | O |
ts-node |
O | 선택적 | 부분¹ | O |
tsc + node |
빌드 후 실행 | O | O | O |
Bun (bun run) |
O | X | O | O |
Deno (deno run) |
O | O² | 부분³ | O |
¹ paths 별칭 등 일부 tsconfig 기능은 tsconfig-paths 같은 별도 플러그인이 필요합니다.
² Deno는 기본적으로 타입 검사를 수행하며, --no-check로 끌 수 있습니다.
³ Deno는 자체 설정 파일(deno.json) 중심이며, tsconfig의 모든 옵션을 그대로 매핑하지는 않습니다.
Node.js 네이티브 지원이 왜 중요한가 — Bun·Deno와의 맥락
사실 네이티브 TypeScript 실행 자체는 새로운 아이디어가 아닙니다. Bun과 Deno는 수년 전부터 별도 설정 없이 .ts 파일을 실행해왔고, "타입스크립트를 그냥 돌릴 수 있다"는 개발자 경험을 이미 보여줬죠. Node.js의 타입 스트리핑은 그 격차를 좁히는 쪽에 가깝습니다.
차이가 있다면, Node.js는 "지울 수 있는 구문만 지우고 나머지는 손대지 않는다"는 보수적인 선택을 했다는 점입니다. 번들링, 트랜스파일, 타입 검사를 전혀 하지 않습니다. Bun처럼 번들러 역할을 겸하지도, Deno처럼 기본 타입 검사를 수행하지도 않습니다. 이건 "Node.js의 기존 모듈 로더 모델을 깨지 않으면서 TypeScript를 받아들이기 위한 최소 침습 설계"로 보는 게 맞습니다.
실무에서 잘 맞는 경우
- Node.js 22 LTS 백포트 반영 버전 또는 24+ 환경을 배포 타깃으로 확정할 수 있는 서버 프로젝트
enum,namespace,experimentalDecorators를 사용하지 않거나erasableSyntaxOnly로 제한할 수 있는 코드베이스- 빌드 파이프라인 복잡도를 줄이는 게 우선순위인 팀
- 모노레포에서 서비스별 빌드 설정 관리 부담을 줄이고 싶은 경우
주의가 필요한 경우
- 배포 타깃이 Node.js 20 LTS 이하: 이 경우엔
tsx가 현실적인 대안입니다 tsconfig.json의paths별칭을 적극 활용 중: 타입 스트리핑은 tsconfig를 읽지 않으므로 경로 별칭이 동작하지 않습니다node_modules안의 TypeScript 소스에 의존: npm 패키지는 여전히 JavaScript로 배포되어야 하며,node_modules내.ts파일은 타입이 제거되지 않습니다- 번들링·트리 쉐이킹·미니피케이션이 필요한 영역: 프론트엔드 코드, 에지 런타임, Lambda 콜드 스타트 최적화가 중요한 경우엔 빌드 단계가 여전히 필요합니다
- 레거시 TypeScript 구문이 코드베이스 전반에 퍼져 있는 경우: 점진적 마이그레이션 비용을 고려해야 합니다
흔히 하는 실수
런타임 타입 검사를 TypeScript에 의존하는 경우. 타입은 런타임에 존재하지 않습니다. 외부 API 응답이나 사용자 입력을 TypeScript 타입으로만 안전하다고 가정하면 안 됩니다.
// 위험: 타입은 런타임에 없습니다
async function handleRequest(body: UserPayload) {
// body가 실제로 UserPayload 형태인지 런타임에 보장되지 않음
await createUser(body);
}
// 안전: zod 같은 도구로 런타임 검증
import { z } from 'zod';
const UserPayload = z.object({
name: z.string(),
email: z.string().email(),
});
async function handleRequest(rawBody: unknown) {
const body = UserPayload.parse(rawBody); // 런타임 검증
await createUser(body);
}타입 스트리핑 환경에서 외부 입력은 zod, ajv 같은 런타임 검증 라이브러리로 반드시 별도 검증하는 게 필요합니다. 사실 이건 기존에도 권장 사항이었지만, 타입 스트리핑 환경에선 "컴파일러가 어쨌든 거쳐간다"는 심리적 안전망마저 사라지기 때문에 더 중요해집니다.
마무리 — 실행과 타입 검사를 분리한다는 것
이 글을 통틀어 하나만 가져가신다면 "타입 스트리핑은 tsc의 대체재가 아니라, 실행 책임과 타입 검증 책임을 분리하는 설계다"라는 점입니다. Node.js는 .ts 파일을 돌리고, tsc --noEmit은 PR 머지 전에 타입을 지키는 문지기 역할을 맡습니다. 이 분리가 명확하면 Dockerfile이 짧아지고 CI 그래프가 단순해지는 건 자연스러운 결과입니다.
실제로 이 전환을 시작하실 때, 가장 먼저 하실 만한 일은 기존 tsconfig에 erasableSyntaxOnly: true를 추가하고 tsc --noEmit을 한 번 돌려보는 겁니다. 그 순간 코드베이스에서 어떤 것들이 터지는지가 전부 드러납니다. 제 경험상 보통 이런 것들이 함께 쏟아져 나옵니다.
- 오래된 라우터 모듈에 남아 있던 상태 상수용
enum두세 개 - DI 컨테이너 설정 쪽에 쓰이는
namespace블록 - 서비스 클래스 생성자의
constructor(private readonly repo: Repo)형태의 파라미터 프로퍼티 import { X } from './x'처럼 확장자가 빠진 상대 경로 임포트tsconfig.json의paths별칭에 의존하던 임포트들
이 리스트가 짧으면 바로 전환하실 수 있고, 길면 어떤 모듈부터 손댈지 우선순위를 매기는 자료가 됩니다. 어느 쪽이든 "우리 코드가 지금 어디쯤 서 있는지"가 보이기 시작합니다. 그 지점에서부터는 더 이상 추상적인 "네이티브 TypeScript 실행" 논의가 아니라, 우리 레포의 구체적인 리팩터링 과제가 됩니다.