// 런타임 코드
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는:
- 개발 단계에서 타입 검사로 버그를 미리 잡아주고
- 런타임 전에 모든 타입 정보를 제거해서 순수한 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를 "빠르게 실행"하기 위한 도구입니다. 내부적으로:
- TypeScript 코드를 메모리에서 바로 JavaScript로 변환
- 타입 검사를 건너뛰거나 최소화 (속도를 위해)
- 변환된 JavaScript를 즉시 실행
타입 검사를 생략하거나 느슨하게 하기 때문에, 타입 에러가 있어도 "일단 돌아가면 됐지"하고 실행해버립니다.
tsc의 내부 동작
tsc는 TypeScript의 공식 컴파일러입니다. 타입 안정성이 핵심 목표이므로:
- 코드를 철저하게 타입 검사
- 타입 에러가 있으면 경고 (또는 컴파일 중단)
- 타입 검사를 통과한 코드만 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
컴파일러의 사고 과정:
- a의 타입:
number | string집합 (컴파일 타임) - 3의 타입:
number집합 number는number | string의 부분집한인가? 예- 할당 가능!
하지만 런타임에는:
- 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 해도 되는거 아닌가?
하지만 컴파일러는 이렇게 생각합니다:
- a의 선언된 타입:
number | string - a의 실제 값: 3 (
number타입) - 타입 단언 검사: "
number를string으로 단언하려고 하네?" number집합과string집합의 교집합이 있나? 없음!- 거부!
타입 검사는 "변수의 타입"이 아니라 "실제 값의 타입"을 기준으로 이루어집니다.
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을 하려고 하면? number를 string으로 바꾸는 거니까 당연히 막히는 거죠.
근본적인 문제: 타입과 값은 다른 세계에 산다
타입 세계 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의 모든 제약은 런타임 안정성을 위해 존재합니다:
- 타입은 컴파일 타임에만 존재 -> 런타임 오버헤드 없이 타입 안정성 확보
- 타입 단언의 제한 -> 명백한 타입 불일치를 미리 차단
- tsx vs tsc의 차이 -> 편의성과 안정성의 트레이드오프
tsx로 실행된다고 안심하지 마세요. 그건 타입 검사를 건너뛴 것일 뿐, 코드가 안전한 게 아닙니다. tsc가 에러를 낸다면, 그건 나중에 터질 버그를 미리 알려주는 것입니다.
TypeScript의 제약이 답답할 수 있지만, 그 제약 덕분에 "어? 왜 여기서 undefined가 나와?"하며 몇 시간씩 헤매는 일을 줄일 수 있습니다. 내부 동작 원리를 이해하면, 에러 메시지가 더 명확하게 보일 것입니다.