Why Your TypeScript Code Still Crashes in Production
TypeScript 코드가 프로덕션에서 계속 크래시하는 이유
TypeScript는 컴파일 시 타입을 지우므로 API 응답 같은 외부 데이터의 유효성을 런타임에 확인하지 않습니다. 글은 unknown, 판별 유니온, satisfies 등을 활용해 이 경계를 드러내고 잘못된 상태를 줄이는 방법을 설명합니다.
- 주제
AI 요약
TypeScript 타입을 꼼꼼히 선언했는데도 프로덕션에서 undefined를 읽다가 애플리케이션이 멈출 수 있습니다. 타입 선언이 런타임까지 남아 실제 값을 검사한다고 생각하기 쉽지만, TypeScript는 컴파일 과정에서 타입을 지우고 JavaScript를 실행합니다. 컴파일러는 작성한 코드의 타입 오류를 확인할 뿐, API나 사용자 입력이 실제로 어떤 값을 보내는지 검사하지 않습니다.
타입 선언은 런타임 검증이 아닙니다
interface User를 선언해도 컴파일 뒤에는 User가 사라집니다. 따라서 런타임 객체의 프로토타입을 확인하는 instanceof User는 쓸 수 없습니다. response.json() as ApiResponse처럼 단언해도 달라지지 않습니다. 서버가 { error: 500 }이나 null을 반환해도 컴파일러는 이를 알지 못하며, 이후 user.username.toUpperCase()에서 오류가 납니다. API, localStorage, 사용자 입력처럼 앱 외부에서 들어오는 값은 Zod 같은 런타임 검증기나 사용자 정의 타입 가드로 확인해야 합니다.
TypeScript는 이름보다 구조를 봅니다
TypeScript는 구조적 타입 시스템을 사용합니다. 속성 이름이 다른 Point2D와 Vector2D라도 두 타입이 모두 x: number, y: number를 요구하면 서로 대입할 수 있습니다. 타입 이름이 다르면 다른 타입으로 취급하는 Java나 C#의 명목적 타입 시스템과 차이가 있습니다.
객체 리터럴을 함수에 바로 전달할 때는 초과 속성 검사(Excess Property Checks)가 적용됩니다. Options가 timeout만 선언했는데 { timeout: 5000, port: 8080 }을 직접 전달하면 오류가 납니다. 하지만 같은 객체를 myConfig 변수에 담은 뒤 전달하면, 필요한 timeout 속성을 갖췄으므로 구조적 타입 검사에서 통과합니다. 직접 쓴 객체의 추가 속성은 오타일 수 있다고 보고 검사하는 동작입니다.
불확실한 값은 `unknown`으로 다룹니다
any를 쓰면 해당 값에 대한 타입 검사가 중단되고, 그 값을 사용하는 코드에도 안전성이 이어지지 않습니다. 타입을 아직 모르는 입력에는 unknown을 쓰는 편이 낫습니다. unknown 값은 곧바로 메서드를 호출할 수 없고, typeof input === 'string' 같은 검사로 타입을 좁힌 뒤 사용할 수 있습니다.
never는 도달할 수 없어야 하는 상태를 표시합니다. 판별 유니온인 Action을 switch로 처리할 때 기본 분기에서 const _exhaustiveCheck: never = action을 대입하면, 나중에 RESET_PASSWORD 같은 새 액션을 추가하고 분기를 빠뜨렸을 때 컴파일 오류가 납니다.
판별 유니온으로 불가능한 상태를 막습니다
isLoading, 선택 속성인 data, error를 한 객체에 담으면 로딩 중인데 데이터와 오류가 함께 있는 조합처럼 의미가 맞지 않는 상태도 표현됩니다. 글은 이를 idle, loading, success, error를 구분하는 status 기반 판별 유니온으로 바꾸자고 제안합니다. 각 상태에 필요한 속성만 선언하면 status 분기 안에서 TypeScript가 data나 error의 존재를 좁혀 판단합니다.
다만 실제 UI에서는 새로고침에 실패해도 이전 데이터를 화면에 남겨야 할 수 있습니다. 이때는 단순히 success와 error를 나누기보다, 이전 데이터를 보존하는 refreshing이나 refresh_error 상태를 모델링할 수 있습니다. 상태 타입은 구현 편의만이 아니라 제품 동작도 나타내야 합니다.
`as`보다 `satisfies`를 우선합니다
as Palette는 값이 해당 타입을 만족한다고 컴파일러에 단언합니다. 필요한 키를 빠뜨리거나 값을 잘못 써도 오류를 가릴 수 있고, 구체적인 리터럴 타입도 넓은 타입으로 바뀔 수 있습니다. TypeScript 4.9부터 쓸 수 있는 satisfies는 객체가 Palette에 맞는지 검사하면서 colors.light의 리터럴 타입 '#ffffff'도 보존합니다.
dev.to 반응
- @anh_nguynvn_0478e614ba — 컴파일 시점의 안전성과 런타임의 현실이 다른 지점에서 개발자들이 가장 자주 걸립니다. 특히 외부 API 응답을 다룰 때 그렇습니다. 코드에 아무리 탄탄한 인터페이스를 정의해도, 들어오는 데이터를 Zod나 Valibot 같은 런타임 검증 라이브러리로 파싱하지 않으면 프로덕션에 배포된 뒤 그 타입은 바람에 빈 소원에 불과합니다. 백엔드 변경이 TypeScript 정의를 거치지 않아 생긴, “있을 수 없는” 널 포인터 예외를 디버깅하느라 밤을 너무 많이 새웠습니다. 타입 단언이나 논 널 단언 연산자에만 의존하는 건 분산 시스템에서 재앙을 부르는 방식입니다.
- @sinarezaei —
as부분을 가장 강조하고 싶습니다. 타입 단언은 경계가 실제보다 안전해 보이게 만들 수 있습니다.response.json()을ApiResponse라고 단언하면, 실제로는 페이로드를 검사하지 않았는데도 계약을 이미 확인한 것처럼 코드가 읽힙니다. 그래서 저는unknown을 단순히any보다 안전한 대안 이상으로 봅니다. 코드에서 신뢰 경계를 드러내고, 데이터가 애플리케이션의 타입이 지정된 영역으로 들어오기 전에 검증하거나 타입을 좁히도록 강제합니다. 복잡한 부분을 어디에 둘지도 달라집니다. 애플리케이션 곳곳에 방어 검사를 흩뿌리는 대신 경계에서 한 번 검증하고 내부 도메인 모델은 강한 타입으로 유지할 수 있습니다. 프로덕션 시스템에서는 이처럼 신뢰할 수 없는 입력과 신뢰할 수 있는 애플리케이션 상태를 분리하는 일이strictTypeScript를 켜는 것보다 더 중요할 수 있습니다.- @smtahosin — 맞습니다.
as는 처음엔 도움이 되는 기능처럼 느껴지지만, 실제 신뢰 경계를 조용히 숨길 수 있습니다.unknown으로 그 경계를 드러내자는 말이 좋습니다. 경계에서 데이터를 검증하고 나면 방어 검사를 여기저기 흩뿌리는 대신 애플리케이션 나머지 부분을 훨씬 쉽게 추론할 수 있습니다. 신뢰할 수 없는 입력과 신뢰할 수 있는 도메인 상태를 분리하는 방식을 저도 점점 더 높이 평가하게 됐습니다.
- @smtahosin — 맞습니다.
- @marcusykim —
RequestState예시에는 제품 결정이 하나 더 있습니다. 새로고침에 실패했을 때 사용자가 이미 보고 있던 데이터를 숨길지 여부입니다. 대시보드라면 마지막으로 성공한 결과를 화면에 남겨 두고, 그 데이터를 포함하는 명시적인refreshing과refresh_error상태를 모델링하곤 합니다. 유니온은 여전히 우발적인 조합을 막지만, 허용하는 상태는 실제 UI를 반영해야 합니다. 최초 요청만 확인하지 말고success → refresh → failure순서를 테스트하겠습니다.- @smtahosin — 이 차이가 좋습니다. 제 예시는 불가능한 상태를 표현하지 못하게 하는 데 초점을 뒀지만, 실제 UI 상태에서는 새로고침 중에도 이전 데이터를 보존해야 할 때가 많습니다. 이 경우 이전 성공 데이터를 담은
refreshing이나refresh_error를 모델링하는 편이 더 적절합니다.success → refresh → failure순서도 좋은 테스트 시나리오입니다. 이런 상태 모델링의 결정이 바로 그때 드러나기 때문입니다.
- @smtahosin — 이 차이가 좋습니다. 제 예시는 불가능한 상태를 표현하지 못하게 하는 데 초점을 뒀지만, 실제 UI 상태에서는 새로고침 중에도 이전 데이터를 보존해야 할 때가 많습니다. 이 경우 이전 성공 데이터를 담은
- @amorizz —
as ApiResponse예시는 실제 스택 트레이스에서 계속 보입니다. 타입 단언은 컴파일러에게 하는 약속이지 검사가 아닙니다. 그래서 크래시는 가져온 지점에서 몇 개의 함수를 지난 뒤 발생하고, 잘못된 데이터를 들여보낸 경계가 아니라toUpperCase를 가리킵니다.schema.safeParse(await res.json())처럼 경계에서 파싱하고 그곳에서 명확히 실패시키면 실제 문제를 알려주는 오류가 납니다. 한 가지 덧붙이면noUncheckedIndexedAccess는 API 응답보다 배열이나 레코드에서 생기는 “undefined의 속성을 읽을 수 없음” 오류를 꽤 많이 잡아줍니다.- @smtahosin — 오류가 실제로 나타나는 위치에 대한 지적이 좋습니다.
as ApiResponse가 위험한 이유는 틀릴 수 있다는 점만이 아닙니다. 잘못된 가정이 여러 함수를 거쳐 이동하다가toUpperCase()같은 곳에서야 터질 수 있습니다. 경계에서 검증하면 잘못된 데이터가 들어온 위치를 정확히 알 수 있어 실패 원인이 훨씬 분명해집니다.noUncheckedIndexedAccess도 다른 종류의undefined오류를 잡는 유용한 방어층입니다.
- @smtahosin — 오류가 실제로 나타나는 위치에 대한 지적이 좋습니다.
원문: dev.to / 번역·요약: Trawling