TypeScript를 쓰고 있는데도 사용자가 빈 화면을 보는 버그가 발생했어요.
빌드는 정상적으로 통과했고, 타입 체크도 문제없었습니다. 그런데 실제 서비스에서는 이렇게 보였어요.
// → undefined 반환
// → 화면에 아무것도 안 나옴
문제의 코드는 아주 평범했습니다.
switch (currentLesson.kind) {
case LessonKind.VIDEO:
return<VideoWrapper/>;
case LessonKind.TEXT:
return<TextbookContent/>;
case LessonKind.CODING:
return<CodeEditorWrapper/>;
case LessonKind.EXAM:
return<QuizWrapper/>;
// LessonKind.PDF 누락
}
단 하나의 case를 빠뜨렸을 뿐인데, 이 코드는 컴파일도 통과하고 런타임에서만 터졌어요.
여기서 질문이 생기더라구요.
TypeScript를 쓰고 있는데, 왜 이런 걸 못 잡았을까?
우리는 이런 패턴의 로직을 흔하게 작성하고 있을 거에요.
// 결제 수단 분기
switch (paymentMethod) {
case'CARD':return<CardPayment/>;
case'VBANK':return<VirtualBankPayment/>;
case'KAKAO_PAY':return<KakaoPayPayment/>;
}
등등…
또한, 이런 코드는 공통적으로 특징이 있어요
그리고 이 조합은 어찌보면 위험한 함정이 있어요. 케이스를 빠뜨린다고 해서 에러를 내지 않는다는 것이에요.
문제는 단순했어요.
TypeScript는 기본적으로 “모든 case를 처리했는지”는 검사하지 않아요.
다시 말해 이 코드는 타입적으로 문제가 없는 것이죠.
switch (kind) {
case'A':return1;
case'B':return2;
}
// C가 있어도 에러 없음
컴파일러 입장에서는 이렇게 생각해요.
“함수가 undefined를 반환할 수도 있겠네. 그건 네 선택이야.”
결국 이 문제는 타입 시스템을 안 쓴 게 아니라, 덜 쓴 것에 가까웠어요.
어떻게 해결 할 수 있을지 찾아보면 중 발견한 해결법은 생각보다 간단했어요.
“모든 케이스를 처리하지 않으면 컴파일 에러를 내게 만들자”
이때 사용하는 것이 바로 Exhaustiveness Checking에요.
TypeScript에는 never라는 특수한 타입이 있습니다.
절대 발생할 수 없는 값
이걸 switch 문과 결합하면 제가 원했던 모든 케이스를 처리하지 않으면 컴파일 에러를 내게 할 수 있었어요.
switch (currentLesson.kind) {
caseLessonKind.VIDEO:
return<VideoWrapper/>;
caseLessonKind.TEXT:
return<TextbookContent/>;
caseLessonKind.CODING:
return<CodeEditorWrapper/>;
caseLessonKind.EXAM:
return<QuizWrapper/>;
// PDF 누락
}
function assertNever(value:never):never {
throw new Error(`Unexpected value:${value}`);
}
switch (currentLesson.kind) {
caseLessonKind.VIDEO:
return<VideoWrapper/>;
caseLessonKind.TEXT:
return<TextbookContent/>;
caseLessonKind.CODING:
return<CodeEditorWrapper/>;
caseLessonKind.EXAM:
return<QuizWrapper/>;
default:
assertNever(currentLesson.kind);
}
이제 상황이 완전히 달라졌어요.
TypeScript는 switch 문을 따라가면서 가능한 타입을 하나씩 제거해요. 마치 집합 내에서 값을 하나씩 빼는 것과 동일한 개념이에요.
예를 들어,
typeKind='A'|'B'|'C';
모든 케이스를 처리하면 마지막에는 이렇게 되죠.
default:
// 여기서 Kind는?
// → never
하지만 하나라도 빠뜨리면:
// 'C'를 처리하지 않았다면
default:
// 여기서 Kind는 'C'
// → never가 아님 → 에러 발생
즉,
남은 타입이 있으면 = 버그
남은 타입이 없으면 = never
레슨 타입을 하나 추가해봅니다.
enum LessonKind {
VIDEO,
TEXT,
CODING,
EXAM,
AR_VR,// 새로 추가
}
아무것도 안 해도 결과는 명확합니다.
❌ Argument of type'LessonKind.AR_VR' is not assignable to parameter of type'never'
컴파일러가 이렇게 말해줘요.
“너 switch 문 하나 빠뜨렸어.”
이제는 사람이 찾지 않아도 TypeScript가 TypeScript처럼 개발자를 도와주고 있어요.
이 작은 변경 하나로 큰 차이가 생겼어요.
| 구분 | Before | After |
|---|---|---|
| 버그 발견 시점 | 런타임 | 컴파일 타임 |
| 발견 주체 | 사용자 / QA | 컴파일러 |
| 안정성 | 불안 | 높음 |
개발자가 신경쓰는 범위도 바뀌었어요.
TypeScript를 쓰고 있다고 해서 항상 안전한 코드를 작성하고 있는 건 아니었어요. assertNever는 단순한 헬퍼 함수지만, 이걸 추가한 것만으로도 중요한 비즈니스 로직 등에서 안정성을 더할 수 있었어요.
때로는 복잡한 해결책이 아니라, 단순한 방식으로 안전성을 확보할 수 있다는 것이 기억에 남습니다!
이 글을 읽게 되시는 분들도 switch문을 쓰실 때 assertNever와 같은 never타입을 활용하는 헬퍼 함수를 도입해보시는 것도 좋을 것 같아요!