타입 검사는 통과했는데, 앱은 그래도 크래시된다
tsconfig.json에서 strict: true를 켰다. 모든 빨간 물결표시를 고쳤다. null과 undefined는 이제 해결된 문제라는 자신감을 안고 프로덕션에 배포했다.
그런데 백엔드 응답 구조가 바뀌고, DOM 쿼리가 아무것도 반환하지 않았고, TypeScript가 안전하다고 말한 바로 그 코드에서 user.profile.name이 Cannot read properties of null을 던지며 죽었다. 무슨 일이 일어난 것일까?
TypeScript의 strict null check는 컴파일 타임 계약이다. 내가 작성한 타입이 내부적으로 일관성이 있는지 검증할 뿐, 런타임에 도착하는 데이터가 그 타입과 일치하는지는 검증하지 않는다. 이 두 가지 사이의 간극이 대부분의 프로덕션 null 크래시가 발생하는 곳이다.
strictNullChecks가 실제로 도움이 되는 곳
strictNullChecks를 켜면 TypeScript는 null과 undefined를 반드시 명시적으로 처리해야 하는 별개의 타입으로 다룬다.
// Without strictNullChecks, this compiles. With it, you get a type error.
function greet(name: string) {
console.log(name.toUpperCase())
}
greet(null) // Error: Argument of type 'null' is not assignable to parameter of type 'string'.
이것은 진짜로 유용하다. 내 코드에서 직접 유입한 null—초기화되지 않은 변수, 누락된 반환값, 잊어버린 기본 case—를 잡아낸다.
문제는 이것이 컴파일 타임에 TypeScript가 볼 수 있는 코드에만 작동한다는 점이다. 앱이 한번 실행되면 TypeScript는 사라진다. 타입 시스템은 증발하고, 남는 것은 순수한 JavaScript뿐이며, 순수한 JavaScript는 기꺼이 null을 내가 남겨둔 모든 구멍으로 흘려 본다.
strictNullChecks가 메울 수 없는 네 가지 런타임 허점
1. API 응답은 내가 정의한 타입과 일치하는 척한다
API 응답에 타입을 입혔다. TypeScript는 그것을 믿었다.
interface User {
id: number
profile: {
name: string
avatar: string
}
}
async function fetchUser(): Promise<User> {
const res = await fetch('/api/user')
return res.json() as User // TypeScript trusts this cast. The backend does not.
}
const user = await fetchUser()
console.log(user.profile.name) // Crashes if profile is null
as User 어서션은 TypeScript에게 질문을 그만하라고 말한다. 이것은 페이로드를 검증하지 않는다. 백엔드가 { id: 1, profile: null }을 반환하면, TypeScript는 거짓말에 대해 코드를 컴파일한 것이다. 크래시는 진짜다.
2. DOM 쿼리는 의도적으로 null을 반환한다
const button = document.getElementById('submit')
button.addEventListener('click', handleClick) // Crashes if the element is missing
TypeScript는 getElementById가 HTMLElement | null을 반환한다는 것을 안다. strict mode에서는 null case를 처리하도록 강제한다. 많은 개발자가 이것을 non-null assertion으로 우회한다.
const button = document.getElementById('submit')!
!은 TypeScript에게 내가 더 잘 안다고 약속하는 것이다. 내가 틀리면, 크래시는 프로덕션에 착륙한다.
3. noUncheckedIndexedAccess는 기본적으로 꺼져 있다
strict: true를 켜도 TypeScript는 배열이나 객체의 인덱스 접근이 잠재적으로 undefined일 수 있다고 표시하지 않는다.
const users: User[] = []
const first = users[0]
first.name // No type error, but first is undefined at runtime
레코드에도 마찬가지다:
const cache: Record<string, string> = {}
const value = cache['missing'] // Type: string. Runtime value: undefined.
이 허점을 막으려면 tsconfig.json에 noUncheckedIndexedAccess: true가 필요하다. 대부분의 팀은 이것을 켜지 않는다. 왜냐하면 일상적인 코드가 시끄러워지기 때문이다. 그 시끄러움이 바로 핵심이다.
4. any와 타입 어서션은 모든 것을 묵력화한다
const data: any = JSON.parse(raw)
const user = data.user as User // All null checks are now voluntarily disabled
codebase의 모든 any나 as는 내가 잠금 해제한 채 남겨둔 문이다. TypeScript는 문턱에서 멈춘다. 그 문을 통해 걸어 들어오는 것은 내 책임이다.
런타임에서 실제로 통하는 것
TypeScript의 역할은 내가 작성한 코드의 실수를 잡아내는 것이다. 런타임 안전성은 두 번째 방어 계층을 필요로 한다.
경계에서 검증하라
외부 데이터가 타입이 지정된 시스템으로 들어오기 전에 Zod 같은 스키마 라이브러리로 검증하라.
import { z } from 'zod'
const UserSchema = z.object({
id: z.number(),
profile: z.object({
name: z.string(),
avatar: z.string().optional(),
}).nullable(),
})
const res = await fetch('/api/user')
const raw = await res.json()
const user = UserSchema.parse(raw) // Throws if the shape is wrong
// user.profile is typed as { name: string; avatar?: string } | null
if (user.profile) {
console.log(user.profile.name) // Safe
}
비용은 모든 경계에서 런타임 검증 단계를 추가하는 것이다. 이익은 내 타입이 더 이상 희망 사항이 아니라는 것이다. 이것들은 강제된다.
noUncheckedIndexedAccess를 사용하라
켜라. 마찰을 받아들여라.
// With noUncheckedIndexedAccess
const first = users[0] // Type: User | undefined
if (first) {
console.log(first.name) // Safe
}
그렇다, 인덱싱하는 모든 곳에 검증을 추가한다. null 안전성을 진지하게 생각한다면, 그것이 바로 원하는 것이다.
non-null assertion 대신 옵셔널 체이닝을 기본으로 하라
// Dangerous
const name = user.profile!.name
// Safe
const name = user.profile?.name ?? 'Anonymous'
옵셔널 체이닝은 null이나 undefined에서 단락한다. nullish coalescing 연산자는 대체값을 제공한다. 이 패턴은 기본적으로 방어적이며, 데이터가 누락되었을 때 무슨 일이 일어나는지 명시적이다.
any를 데이터 흐름에서 배제하라
any를 유독물 유출처럼 다뤄라. 꼭 써야 한다면 최소한의 스코프로 가두고 출력을 즉시 검증하라.
// Bad: any propagates
const data: any = JSON.parse(raw)
processData(data)
// Better: validate and narrow immediately
const parsed = JSON.parse(raw) as unknown
const data = UserSchema.parse(parsed)
processData(data)
unknown은 TypeScript가 사용하도록 허락하기 전에 형태를 증명하도록 강제한다. 이것은 결과를 동반한 any이다.
팀이 이런 방어책을 건너뛰는 이유
대부분의 팀이 무지해서 이런 방어책을 건너뛰는 것은 아니다. 더 안전한 패턴이 모든 데이터 접근, 모든 API 호출, 모든 DOM 쿼리에 마찰을 추가하기 때문이다. 코드는 길어지고, 타입은 시끄러워지고, 마감이 다가올수록 !이나 as를 문제에 덧붙이는 유혹은 커진다.
대안은 TypeScript가 없을 거라고 약속한 프로덕션 크래시다. 컴파일러는 약속을 지켰다. 내가 제공한 타입에 대해 코드를 검증했다. 이것이 내 코드를 현실에 대해 검증할 수는 없다.
해결책은 한 겹이 아니라 두 겹이다
strictNullChecks는 첫 번째 계층이다. 내가 직접 유입시킨 null을 잡아낸다. 두 번째 계층은 그 외의 모든 것이다: 스키마 검증, 방어적 인덱싱, 옵셔널 체이닝, 그리고 any를 피하는 규율.
TypeScript 앱이 여전히 null로 크래시된다면, 컴파일러가 너를 실망시킨 것이 아니다. 너의 타입과 런타임 데이터 사이의 간극이 그렇게 만든 것이다. 경계에서 그 간극을 닫고, noUncheckedIndexedAccess를 켜고, 백엔드가 내 interface와 일치할 거라고 믿는 것을 그만둬라. TypeScript는 여전히 strict할 것이다. 하지만 적어도 너의 타입은 실제로 도착하는 세상을 묘사하게 될 것이다.