2시간 뒤 터질 버그를 미리 잡기
타입스크립트의 타입은 런타임에 사라져요. 컴파일 타임에만 검사된다는 걸 알면 버그도 같이 보여요.
실행에 2시간이 걸리는 작업을 돌려놓고 자리를 비웠어요. 돌아와 보니 마지막 줄에서 user.naem 오타 하나로 터져 있더라고요. 그 2시간 내내 코드는 멀쩡한 줄 알았던 거죠. 타입스크립트는 이런 버그를 실행 전으로 당겨서 잡아주지만, 그 검사는 컴파일이 끝나는 순간까지만 살아 있어요.
2시간 뒤에야 터지던 버그
자바스크립트만 쓰던 시절엔, 코드가 맞는지 확인하는 길이 실행밖에 없었어요. 함수에 인자를 잘못 넘겨도, 객체에 없는 프로퍼티를 읽어도, 그 줄이 실제로 실행되기 전까지는 아무도 모르거든요.
문제는 그 "실행되기 전까지"가 생각보다 길다는 거예요. 위에서 말한 2시간짜리 배치 작업이 그래요. 중간까지는 정상으로 보이다가 끝물에 가서야 undefined를 함수처럼 호출하면서 터지면, 그 2시간을 통째로 날리는 셈이죠.
더 까다로운 건 자주 실행되지 않는 코드예요. 에러 처리 분기나 특정 조건에서만 도는 코드는 몇 달에 한 번 실행될 수도 있어요. 그동안 버그는 조용히 숨어 있다가, 하필 장애 상황에서 처음 실행되면서 두 번째 장애를 만들죠.
이름을 바꿀 때도 마찬가지예요. user.name을 user.fullName으로 바꿨는데 화면 한구석의 사용처 하나를 빠뜨렸다고 해볼게요. 자바스크립트는 그 줄이 실행되는 순간까지 아무 불평도 안 해요. 운이 나쁘면 배포 후 사용자가 먼저 발견하고요.
타입스크립트가 바꾼 건 이 "발견 시점"이에요. 같은 오타, 같은 누락을 실행 전에, 그러니까 코드를 작성하는 그 순간에 빨간 줄로 보여줘요. 2시간을 기다릴 필요도, 그 분기가 실행되길 기다릴 필요도 없죠.
타입 소거, 컴파일이 끝나면 벌어지는 일
근데 여기서 한 가지를 짚고 가야 해요. 그 검사는 "실행 전"에만 일어나요. 정확히는 컴파일 단계에서요.
타입스크립트는 자바스크립트 위에 타입이라는 한 겹을 얹은 거예요. 우리가 적은 : string이나 interface User 같은 건 컴파일러가 코드를 검사할 때만 쓰여요. 검사가 끝나면 컴파일러는 그 타입 정보를 전부 지우고 순수한 자바스크립트를 내놓죠. 이걸 타입 소거(type erasure), 즉 타입을 떼어내는 과정이라고 불러요.
"Roughly speaking, once TypeScript's compiler is done with checking your code, it erases the types to produce the resulting compiled code." - TypeScript Handbook
대충 말하면, 컴파일러가 검사를 끝내는 순간 타입을 지우고 결과 코드를 만들어요. 검사가 끝나면 타입의 임무도 같이 끝나는 거죠.
그러니까 브라우저나 Node.js가 실제로 실행하는 코드에는 타입이 한 글자도 안 남아 있어요. interface도, : number도, as User도 전부 사라져요. 실행 중인 프로그램은 자기가 타입스크립트로 쓰였다는 사실조차 몰라요.
이 그림에서 중요한 건 검사와 실행이 다른 칸에 있다는 거예요. 타입은 위쪽 두 칸에서만 살아 있고, 아래 런타임으로 넘어가는 순간 증발해요. 타입스크립트가 "자바스크립트의 런타임 동작을 절대 바꾸지 않는다"고 약속하는 것도 이 구조 덕분이에요. 타입은 검사만 하고 빠지니까, 실행 결과는 같은 코드의 자바스크립트와 똑같죠.
그래서 타입이 못 막는 것들
타입이 런타임에 없다는 사실은, 타입스크립트가 못 막는 영역이 어디인지도 알려줘요. 검사할 정보가 실행 시점엔 사라지고 없으니까요.
첫째는 any예요. 어떤 값을 any로 두면 그 뒤로는 무슨 프로퍼티를 읽든 무슨 함수로 호출하든 컴파일러가 통과시켜요. any를 쓰는 순간 그 값에 대한 타입 검사 자체가 꺼지거든요. 빨간 줄은 사라지지만, 막아주던 안전망도 같이 사라지는 거죠.
둘째는 타입 단언이에요. res as User처럼 단언을 붙이면 컴파일러는 "네가 그렇다니 믿을게" 하고 넘어가요. 근데 단언도 컴파일 타임에 지워지는 거라, 실제로 그 값이 User 모양인지 실행 중에 확인하는 검사는 전혀 따라붙지 않아요. as가 정확히 무엇을 멈추고 무엇을 안 멈추는지는 지난 글에서 따로 다뤘어요.
셋째가 가장 흔한 함정인데, 바깥에서 들어오는 데이터예요. JSON.parse나 fetch 응답을 떠올려 보세요. JSON.parse의 반환값은 실행 시점의 입력 문자열에 따라 정해지니까, 타입스크립트는 그걸 미리 알 방법이 없어서 any로 둬요. 우리가 const user: User = JSON.parse(text)라고 적으면 빨간 줄은 없지만, 실제 응답에 name이 빠져 있어도 컴파일러는 침묵해요. 타입은 우리가 손으로 적는다고 생기는 게 아니라, 경계에서 한 번 검증해야 진짜로 얻어지거든요. 이 경계 검증을 어떻게 하는지는 받아온 데이터에 타입을 입히는 법에서 자세히 풀었어요.
예외, 런타임에 살아남는 타입
여기까지 보면 "타입스크립트 문법은 전부 컴파일하면 사라진다"고 정리하고 싶어져요. 근데 예외가 하나 있어요. enum이에요.
대부분의 타입스크립트 기능은 자바스크립트에 타입이라는 층만 얹지만, enum은 드물게 런타임에 실제 객체를 만들어내요.
// 우리가 쓴 코드
enum Direction { Up, Down }
// 컴파일된 자바스크립트 (대략)
var Direction;
(function (Direction) {
Direction[(Direction["Up"] = 0)] = "Up";
Direction[(Direction["Down"] = 1)] = "Down";
})(Direction || (Direction = {}));그래서 enum은 타입이면서 값이기도 해요. 이걸 보통 타입 공간(type space)과 값 공간(value space)으로 나눠서 설명하는데, 대부분의 타입은 타입 공간에만 살다가 소거되지만 enum은 양쪽에 다 존재하는 거죠. 클래스도 비슷해요. class User {}는 타입 검사에 쓰이는 타입이면서, 동시에 new User()로 부를 수 있는 런타임 값이고요.
재밌는 건 같은 enum이라도 const enum으로 선언하면 다시 소거된다는 거예요. const enum은 컴파일 시점에 사용처마다 값이 그대로 박혀 들어가서, 런타임 객체를 아예 안 남기거든요. 같은 키워드 안에서도 "런타임에 남느냐"가 갈리는 거죠.
이 구분이 중요한 이유는, 런타임 코드에서 쓸 수 있는 건 값 공간에 있는 것뿐이라서예요. 타입만 있는 interface를 if (x instanceof MyInterface)처럼 쓰려고 하면 안 되는 게 이 때문이에요. 그 interface는 실행 시점엔 존재하지도 않으니까요.
타입으로 버그를 막는 세 가지 도구
타입이 컴파일 타임에만 산다는 걸 알면, 그걸 가장 잘 쓰는 법도 보여요. 세 가지만 챙기면 돼요.
먼저 strict 모드예요. tsconfig에서 strict를 켜면 관련 검사가 한 번에 강해져요. 그중에서도 strictNullChecks가 핵심인데, 이게 켜져 있으면 null이나 undefined가 올 수 있는 자리를 컴파일러가 따로 추적해서, 그냥 쓰려고 하면 빨간 줄을 띄워요. 앞에서 본 "2시간 뒤 undefined 호출" 같은 사고를 작성 시점에 막아주는 거죠.
두 번째는 좁히기(narrowing), 즉 넓은 타입을 분기 안에서 더 구체적인 타입으로 좁히는 동작이에요. 흥미로운 점은 이게 타입스크립트만의 특별한 문법이 아니라는 거예요. typeof x === "string"이나 if (user) 같은 건 원래 자바스크립트가 런타임에 하는 검사잖아요. 타입스크립트는 그 런타임 분기 위에 타입 분석을 얹어서, 분기 안의 타입을 좁혀줘요. 런타임 검사와 컴파일 타임 타입이 만나는 지점이라, 타입 가드를 따로 보면 이 메커니즘이 더 선명해져요.
세 번째는 빠짐없는 검사(exhaustiveness check)예요. union 타입을 switch로 처리할 때, 마지막 default에서 남은 값을 never 타입에 할당해 보는 기법이에요.
function area(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.r ** 2;
case "square":
return shape.size ** 2;
default:
// 빠진 case 가 있으면 여기서 컴파일 에러
const _exhaustive: never = shape;
return _exhaustive;
}
}나중에 Shape에 triangle을 새로 추가했는데 case를 안 만들면, shape가 never로 좁혀지지 않아서 이 줄에서 컴파일 에러가 나요. 새 타입을 추가할 때마다 "처리 안 한 곳"을 컴파일러가 대신 찾아주는 거죠.
세 가지 모두 같은 원리 위에 있어요. 타입은 실행되기 전에만 살아 있으니, 그 잠깐을 최대한 활용해서 버그를 작성 시점으로 끌어당기는 거예요. 실행에 2시간이 걸리든 그 분기가 반년 뒤에 처음 돌든, 컴파일러는 지금 당장 답을 주거든요. 대신 런타임으로 넘어간 데이터엔 타입의 보호가 없다는 것, 그래서 바깥 경계에서는 직접 검증해야 한다는 것도 같은 동전의 양면이고요.
자주 묻는 질문
답을 펼치기 전에 스스로 답해보세요
참고 자료
- TypeScript Handbook - TypeScript for JavaScript Programmers
타입 소거와 런타임 동작을 바꾸지 않는다는 원칙의 근거
- TypeScript Handbook - TypeScript in 5 minutes
자바스크립트 위에 타입 층을 얹고 컴파일 단계에서 검사한다는 설명
- TypeScript Handbook - Everyday Types
any 가 검사를 끄고, 단언이 런타임 검사 없이 소거된다는 근거
- MDN - JSON.parse()
반환값이 런타임 입력 문자열에 따라 정해진다는 설명
- TypeScript Handbook - Enums
enum 이 런타임 객체로 컴파일되고 const enum 은 인라인된다는 근거
- TypeScript Handbook - Narrowing
런타임 분기 위에 타입 분석을 얹는 좁히기와 never 빠짐없는 검사
- TypeScript - TSConfig Reference
strict 패밀리가 검사 강도를 높인다는 설명