Programming Language/TypeScript

tsx는 되는데 tsc는 안돼요: 유니언 타입 단언의 함정

dev-power 2026. 1. 9. 16:13
// 런타임 코드
let a = 3
let b = a
b.toUpperCase()		// TypeError!​
let a: number | string = 3
let b = a as string

이 코드를 tsx practice.ts로 실행하면? 잘 돌아갑니다.

 

tsc practice.ts로 컴파일하면? 타입을 변환할 수 없다는 에러가 나옵니다.

 

어? a의 타입이 number | string 인데, string도 포함되어 있잖아? 왜 안돼?

 

이 의문을 풀려면 표면적인 문법이 아니라, TypeScript가 내부적으로 어떻게 동작하는지를 이해해야 합니다. 오늘은 코드 뒤에 숨은 원리를 파헤쳐보겠습니다.

 

 

 

TypeScript의 가장 중요한 진실: 타입은 컴파일 타임에만 존재한다


컴파일 타임 vs 런타임

프로그램이 실행되는 과정은 크게 두 단계로 나뉩니다:

  • 컴파일 타임: 코드를 작성하고, 컴파일러가 검사하고, 실행 파일로 변환하는 단계
  • 런타임: 실제로 프로그램에 메모리에 올라가서 실행되는 단계

TypeScript에서 모든 타입 정보는 컴파일 타임에만 존재합니다. 실제로 코드가 실행될 때는 타입이 완전히 사라지죠.

 

예를 들어볼까요:

// practice.ts
let age: number = 25
let name: string = '철수'
let value: number | string = 100

console.log(age, name, value)

위 코드를 tsc practice.ts 로 컴파일하면 practice.js 파일이 생성됩니다:

// practice.js (컴파일 결과)
let age = 25
let name = '철수'
let value = 100

console.log(age, name, value)

모든 타입 정보가 완전히 사라졌습니다. 런타임에는 그냥 평범한 JavaScript 변수일 뿐입니다.

 

 

왜 이렇게 설계되었을까?

자바스크립트 엔진(V8, SpiderMonkey 등)은 TypeScript를 이해하지 못합니다. 브라우저나 Node.js는 오직 JavaScript만 실행할 수 있죠. 그래서 TypeScript는:

  1. 개발 단계에서 타입 검사로 버그를 미리 잡아주고
  2. 런타임 전에 모든 타입 정보를 제거해서 순수한 JavaScript로 변환합니다

비유하자면, 타입 정보는 건축 설계도와 같습니다. 집을 짓기 전에는 필수적으로 필요하지만, 집이 완성된 후에는 설계도 없이도 그 집에서 살 수 있겠죠. TypeScript의 타입 정보도 마찬가지입니다.

 

 

 

TypeScript 컴파일러가 하는 진짜 일


TypeScript 컴파일러(tsc)는 두 가지 독립적인 작업을 수행합니다:

 

1단계: 타입 검사 (Type Checking)

코드를 쭉 읽으면서 "이 코드 타입이 안전한가?"를 검사합니다. 이때 컴파일러는 타입 시스템이라는 규칙 체계를 따릅니다.

let x: number = '안녕'	// 에러! number 자리에 string이 왔네?
let y: string = 122		 // 에러! string 자리에 number가 왔네?

이 검사는 완전히 컴파일 타임에만 일어납니다. 코드를 실행하기 전에, 미리 잘못된 부분을 찾아내는 거죠.

 

2단계: 코드 변환 (Transpilaton)

타입 검사가 끝나면 (또는 --noEmitOnError false 옵션으로 에러가 있어도), TypeScript 코드를 JavaScript 코드로 변환합니다. 이때:

  • 모든 타입 표기를 제거
  • JavaScript로 변환 (target 옵션에 따라)
  • import/export 같은 모듈 문법 변환

중요한 건, 이 두 단계가 독립적이라는 점입니다. 타입 검사를 통과하지 못해도 JavaScript 파일은 생성될 수 있습니다 (설정에 따라).

 

 

 

tsx vs tsc: 왜 하나는 되고 하나는 안될까?


이제 원래 질문으로 돌아옵니다. 왜 tsx는 실행이 되고 tsc는 에러를 낼까요?

 

tsx의 내부 동작

tsx(또는 ts-node)는 TypeScript를 "빠르게 실행"하기 위한 도구입니다. 내부적으로:

  1. TypeScript 코드를 메모리에서 바로 JavaScript로 변환
  2. 타입 검사를 건너뛰거나 최소화 (속도를 위해)
  3. 변환된 JavaScript를 즉시 실행

타입 검사를 생략하거나 느슨하게 하기 때문에, 타입 에러가 있어도 "일단 돌아가면 됐지"하고 실행해버립니다.

 

tsc의 내부 동작

tsc는 TypeScript의 공식 컴파일러입니다. 타입 안정성이 핵심 목표이므로:

  1. 코드를 철저하게 타입 검사
  2. 타입 에러가 있으면 경고 (또는 컴파일 중단)
  3. 타입 검사를 통과한 코드만 JavaScript로 변환

 

왜 이런 차이가 생길까?

tsx같은 도구들은 개발 경험(DX)을 우선시합니다. "일단 빠르게 테스트해보고 싶은데, 타입 에러 때문에 막히면 답답하잖아"라는 철학이죠.

 

하지만 이건 위험한 함정입니다. tsx로 돌아간다고 해서 코드가 안전한 게 아닙니다. 배포할 때는 어차피 tsc로 컴파일해야 하고, 그때 가서야 에러를 발견하면 시간 낭비죠.

 

핵심: tsx는 편의 도구일 뿐, tsc의 타입 검사가 진짜 기준입니다.

 

 

 

유니언 타입의 내부 구현: 집합론으로 이해하기


타입의 값은 집합이다

TypeScript의 타입 시스템은 집합론에 기반합니다. 각 타입은 "가능한 값들의 집합"으로 생각할 수 있습니다:

type Number집합 = number		// 모든 숫자들의 집합
type String집합 = string		// 모든 문자열들의 집합
type True집합 = true		// { true } 단 하나만 포함하는 집합

 

유니언 티압은 합집합이다

number | string합집합(Union)을 의미합니다:

type NumberOrString = number | string
// = { ..., -1, 0, 1, 2, ... } ∪ { "", "a", "hello", ... }
// = 숫자 집합과 문자열 집합을 합친 집합

컴파일러는 이렇게 생각합니다:

  • number | string 타입의 변수는 "숫자 또는 문자열" 중 하나를 담을 수 있다
  • 하지만 동시에 둘 다는 아니다
  • 런타임에는 항상 정확히 하나의 타입만 가진다

 

타입 검사는 어떻게 이루어질까?

let a: number | string = 3

컴파일러의 사고 과정:

  1. a의 타입: number | string 집합 (컴파일 타임)
  2. 3의 타입: number 집합
  3. numbernumber | string의 부분집한인가?
  4. 할당 가능!

하지만 런타임에는:

  • a라는 변수에 숫자 3이 저장됨
  • 타입 정보는 완전히 사라짐
  • 메모리에는 그냥 3이라는 값만 존재

 

 

 

타입 단언 (Type Assertion) 의 내부 동작 원리


타입 단언은 컴파일러에게 하는 약속이다

타입 단언(as)은 런타임에 아무런 영향도 미치지 않습니다. 완전히 컴파일 타임에만 존재하는 지시어입니다.

let x = 'hello' as any
let y = x as number

컴파일하면:

let x = 'hello'		// as any 사라짐
let y = x		// as number 사라짐

보이시나요? 런타임 코드에는 아무 흔적도 없습니다. 타입 단언은 컴파일러에게만 "내가 이 타입을 보장할게"라고 말하는 것이지, 실제로 타입을 변환하는 게 아닙니다.

 

타입 단언의 안전성 규칙

TypeScript는 타입 단언이 "완전히 말도 안 되는 경우"는 막습니다. 이걸 구조적 호환성(Structural Compatibility) 체크라고 합니다.

 

두 타입 A와 B 사이에 타입 단언이 가능하려면:

  • A가 B의 부분집합이거나
  • B가 A의 부분집합이거나
  • 둘 사이에 겹치는 부분(교집합)이 있어야 합니다
let a: number | string = 3

// OK: number | string -> number (부분집합으로 좁히기)
let b = a as number

// 에러: number | string에서 a의 실제 값은 number
// number와 string은 교집합이 없음
let c = a as string

number -> string 단언이 안될까요?

여기가 핵심입니다. 많은 사람들이 이렇게 생각할 것입니다:

a의 타입이 number | string 이고, string도 포함되어 있으니까, as string 해도 되는거 아닌가?

 

하지만 컴파일러는 이렇게 생각합니다:

  1. a의 선언된 타입: number | string
  2. a의 실제 값: 3 (number 타입)
  3. 타입 단언 검사: "numberstring으로 단언하려고 하네?"
  4. number 집합과 string 집합의 교집합이 있나? 없음!
  5. 거부!

타입 검사는 "변수의 타입"이 아니라 "실제 값의 타입"을 기준으로 이루어집니다.

let a: number | string = 3
//     ^^^^^^^^^^^^^^^   ^
//        선언된 타입     실제 값의 타입 (number)

컴파일러는 우변(3)을 보고 "아, 이 값은 number 타입이구나"라고 추론합니다. 그래서 a에는 number | string 타입이 붙지만, 내부적으로는 "이 시점에 a는 number 값을 가지고 있다"는 걸 알고 있습니다.

 

타입 내로잉 (Type Narrowing)

이걸 좀 더 명확하게 보여주는 예시:

let a: number | string = 3

// 타입스크립트는 여기서 a가 number라는 걸 안다
a.toFixed(2)  // OK

// 하지만 string 메서드는 안 됨
a.toUpperCase()  // 에러!

컴파일러가 "a는 지금 number야"라고 내로잉(좁히기)한 겁니다. 이 상태에서 as string을 하려고 하면? numberstring으로 바꾸는 거니까 당연히 막히는 거죠.

 

 

 

근본적인 문제: 타입과 값은 다른 세계에 산다


타입 세계 vs 값 세계

TypeScript에는 두 개의 평행 세계가 존재합니다:

타입 세계 (컴파일 타임):

type User = { name: string }
interface Product { price: number }
let x: number | string

값 세계 (런타임):

const user = { name: '철수' }
const product = { price: 1000 }
let x = 3

 

이 두 세계는 완전히 분리되어 있습니다:

  • 타입 세계는 컴파일 타임에만 존재
  • 값 세계는 런타임에 실제로 실행됨
  • 컴파일이 끝나면 타입 세계는 완전히 사라짐

 

타입 단언의 착각

우리가 착각하기 쉬운 부분:

let a: number | string = 3
let b = a as string

이렇게 쓰면 마치 "a를 string으로 변환한다"는 느낌이 들지만, 실제로는:

타입 세계에서: "컴파일러야, a를 string 타입으로 취급해줘"

값 세계에서: b = a (그냥 값 복사, 타입 변환 없음)

 

런타임에는 3이라는 숫자가 그대로 복사됩니다. string으로 변환되지 않습니다!

 

그래서 왜 에러를 낼까?

TypeScript가 이 단언을 막는 이유는 런타임 버그를 예방하기 위해서입니다:

let a: number | string = 3
let b = a as string		// 만약 허용된다면?

// 개발자는 b가 string이라고 빋고 있음
console.log(b.toUpperCase())		// 런타임 에러!

실제로는 b에 숫자 3이 들어있는데, string 메서드를 호출하면 터지겠죠. TypeScript는 이런 명백한 실수를 컴파일 타임에 잡아주는 겁니다.

 

억지로라도 단언하고 싶다면: 이중 단언의 위험성

물론 방법은 있습니다:

let a: number | string = 3
let b = a as unknown as string

이건 "일단 unknown(뭐든지 될 수 있음)으로 갔다가 string으로 가는" 우회로입니다. 컴파일러의 안전장치를 완전히 무시하는 거죠.

 

하지만 이렇게 하면:

// 런타임 코드
let a = 3;
let b = a;
b.toUpperCase();  // TypeError!

TypeScript는 에러를 안 내지만, 런타임에서 터집니다. 타입 시스텝을 무력화시킨 대가입니다.

 

 

 

정리: 왜 이런 디자인인가?


TypeScript의 모든 제약은 런타임 안정성을 위해 존재합니다:

  1. 타입은 컴파일 타임에만 존재 -> 런타임 오버헤드 없이 타입 안정성 확보
  2. 타입 단언의 제한 -> 명백한 타입 불일치를 미리 차단
  3. tsx vs tsc의 차이 -> 편의성과 안정성의 트레이드오프

tsx로 실행된다고 안심하지 마세요. 그건 타입 검사를 건너뛴 것일 뿐, 코드가 안전한 게 아닙니다. tsc가 에러를 낸다면, 그건 나중에 터질 버그를 미리 알려주는 것입니다.

 

TypeScript의 제약이 답답할 수 있지만, 그 제약 덕분에 "어? 왜 여기서 undefined가 나와?"하며 몇 시간씩 헤매는 일을 줄일 수 있습니다. 내부 동작 원리를 이해하면, 에러 메시지가 더 명확하게 보일 것입니다.