- Published on
TypeScript 5.5 noUncheckedIndexedAccess로 런타임 undefined 줄이기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/프론트 코드에서 가장 흔한 런타임 버그 중 하나가 Cannot read properties of undefined 류입니다. 원인은 대개 “배열/객체 인덱싱 결과는 항상 존재할 것”이라는 암묵적 가정인데, 실제 데이터는 누락·빈 배열·오타 키·경계값 등으로 쉽게 깨집니다.
TypeScript는 기본 설정에서 arr[i]나 obj[key] 같은 인덱스 접근을 비교적 낙관적으로 취급하는 편이라, 컴파일은 통과하지만 런타임에서 undefined가 튀는 경우가 많습니다. 이때 noUncheckedIndexedAccess를 활성화하면 인덱스 접근 결과에 undefined 가능성을 반영해, “안전하게 쓰기”를 강제하는 방향으로 타입 시스템이 움직입니다.
이 글에서는 TypeScript 5.5 기준으로 noUncheckedIndexedAccess가 정확히 무엇을 바꾸는지, 어떤 코드가 깨지는지, 그리고 팀 단위로 무리 없이 적용하는 마이그레이션 패턴을 예제로 정리합니다.
noUncheckedIndexedAccess란
noUncheckedIndexedAccess는 TypeScript 컴파일러 옵션입니다. 켜면 다음이 핵심적으로 바뀝니다.
- 배열 인덱싱
arr[i]결과가T가 아니라T | undefined로 추론될 수 있음 - 인덱스 시그니처(예:
Record<string, T>또는{ [key: string]: T }) 접근obj[key]도T | undefined로 강화될 수 있음 - 즉, “인덱스로 접근하면 항상 값이 있다”는 가정을 제거하고, 호출자가
undefined를 처리하도록 유도
strictNullChecks가 켜진 환경에서 특히 효과가 큽니다. (대부분의 프로젝트는 strict: true를 쓰므로 자연스럽게 함께 작동합니다.)
tsconfig 설정
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}
기존 코드가 많다면 한 번에 켜기 어렵습니다. 아래 “점진적 적용 전략” 섹션에서 현실적인 접근을 다룹니다.
왜 필요한가: 낙관적 인덱싱이 만드는 런타임 undefined
예시 1: 배열 경계값
const users = [{ id: 1, name: "A" }];
// 사용자가 2명이라고 가정하고 접근
const secondName = users[1].name;
기본 설정에서는 users[1]이 undefined일 수 있다는 점이 타입에 잘 드러나지 않아, 위 코드가 쉽게 통과합니다. 하지만 런타임에서는 users[1]이 undefined이므로 크래시가 납니다.
noUncheckedIndexedAccess를 켜면 users[1] 타입이 ({ id: number; name: string } | undefined) 쪽으로 기울면서, .name 접근에서 컴파일 에러가 발생합니다.
예시 2: Record 기반 맵
const priceBySku: Record<string, number> = {
"sku-1": 1000
};
function formatPrice(sku: string) {
return priceBySku[sku].toFixed(0);
}
sku가 항상 존재한다고 보장할 수 없다면 priceBySku[sku]는 undefined가 될 수 있습니다. 옵션을 켜면 이런 코드가 컴파일 단계에서 걸러져, 안전한 처리(기본값/에러/분기)를 강제할 수 있습니다.
깨지는 지점: 어떤 에러가 많이 나오나
옵션을 켜면 보통 아래 유형이 폭발적으로 발생합니다.
arr[i].prop형태의 체이닝obj[key].method()형태의 호출map.get(k)!대신record[k]로 접근하던 코드split결과를 바로 인덱싱하는 코드 (str.split("/")[1]등)
이런 지점은 실제로 런타임에서 자주 터지는 곳이기도 합니다.
해결 패턴 1: 가드(분기)로 확실히 처리
가장 정석적인 해결은 값 존재 여부를 확인하고 흐름을 분기하는 것입니다.
function getSecondUserName(users: Array<{ name: string }>) {
const second = users[1];
if (!second) return ""; // 혹은 throw
return second.name;
}
second가 존재하는 블록 안에서는 second가 undefined가 아님이 좁혀지므로 안전합니다.
throw로 계약을 명확히 하기
"없으면 그 자체가 데이터 오류"라면 조용히 기본값을 주기보다 예외를 던지는 편이 낫습니다.
function requireAt<T>(arr: readonly T[], index: number): T {
const v = arr[index];
if (v === undefined) {
throw new Error(`Index out of range: ${index}`);
}
return v;
}
const name = requireAt([{ name: "A" }], 0).name;
이 패턴은 “이 지점 이후로는 반드시 존재”라는 계약을 코드로 남기므로, 팀 유지보수에도 유리합니다.
해결 패턴 2: Optional chaining과 Nullish coalescing
UI 표시나 로그처럼 “없으면 대체값”이 자연스러운 경우에는 optional chaining과 기본값을 조합합니다.
const users = [{ name: "A" }];
const secondName = users[1]?.name ?? "(no user)";
주의할 점은, ??는 null/undefined에만 반응합니다. ""이나 0 같은 유효 값은 그대로 유지합니다.
해결 패턴 3: key 존재를 보장하는 타입으로 바꾸기
Record<string, T>는 “모든 문자열 키가 존재”하는 것처럼 보이지만, 실제로는 그렇지 않습니다. 키의 집합이 제한되어 있다면, 키를 유니온으로 좁히는 것이 훨씬 안전합니다.
type Sku = "sku-1" | "sku-2";
const priceBySku: Record<Sku, number> = {
"sku-1": 1000,
"sku-2": 2000
};
function formatPrice(sku: Sku) {
return priceBySku[sku].toFixed(0);
}
이렇게 하면 sku가 임의 문자열이 아니므로, 타입 레벨에서 키 존재가 보장됩니다.
해결 패턴 4: Map을 쓰고 get 결과를 다루기
키가 동적이고 존재 여부가 자연스럽게 변하는 구조라면 Record보다 Map이 의도를 더 잘 표현합니다.
const priceBySku = new Map<string, number>([["sku-1", 1000]]);
function formatPrice(sku: string) {
const price = priceBySku.get(sku);
if (price === undefined) return "N/A";
return price.toFixed(0);
}
Map.get은 원래부터 T | undefined를 반환하므로, noUncheckedIndexedAccess 없이도 안전한 코드를 유도합니다.
해결 패턴 5: “정말 확실한 경우”에만 non-null assertion 사용
!는 가장 빠른 탈출구지만, 남용하면 옵션을 켠 의미가 줄어듭니다.
const users = [{ name: "A" }];
// 정말로 1번 인덱스가 항상 존재한다는 강한 계약이 있을 때만
const secondName = users[1]!.name;
권장 방식은 다음 순서입니다.
- 1순위: 가드/throw로 계약을 코드화
- 2순위: optional chaining과 기본값
- 3순위: 타입 모델링 개선(키 유니온, Map 등)
- 최후:
!
실전에서 많이 만나는 케이스: split 결과 인덱싱
URL/경로 파싱에서 흔합니다.
function getOrgFromPath(path: string) {
return path.split("/")[2].toLowerCase();
}
/org/acme 형태만 온다고 믿었지만, 실제로는 빈 문자열, 슬래시 누락, 예상치 못한 경로가 들어올 수 있습니다.
안전한 버전:
function getOrgFromPath(path: string) {
const parts = path.split("/");
const org = parts[2];
if (!org) return null;
return org.toLowerCase();
}
여기서 null을 반환할지, 기본값을 줄지, 예외를 던질지는 도메인 계약에 따라 결정합니다.
점진적 적용 전략(팀/레거시 코드)
옵션을 켜는 순간 에러가 수백 개 나올 수 있습니다. 다음 순서가 현실적입니다.
- 새 코드부터 적용: 패키지/앱이 모노레포라면 일부 워크스페이스부터
noUncheckedIndexedAccess를 켭니다. - 핵심 경로부터 정리: 결제/인증/데이터 변환 레이어처럼 크래시 비용이 큰 곳부터 가드/계약 함수를 도입합니다.
- 공통 유틸로 수습:
requireAt,requireKey같은 유틸을 만들어 중복 가드를 줄입니다. !는 이슈 트래킹: 불가피하게!로 막았다면 주석과 함께 추후 개선 대상으로 남깁니다.
이런 “컴파일 타임에서 더 많이 잡고, 런타임 장애를 줄이는” 접근은 프론트에서는 Hydration/렌더 크래시를, 백엔드에서는 API 500을 줄이는 데 직접적으로 기여합니다. 프론트 런타임 오류가 사용자에게 어떻게 보이는지까지 함께 점검하려면 Next.js Hydration failed 원인 7가지와 해결도 같이 보면 흐름이 이어집니다.
테스트/관측 관점: undefined는 “조용히” 전파된다
undefined는 종종 크래시로 끝나지 않고, 잘못된 값(빈 문자열, NaN, 누락된 필드)으로 전파되다가 더 뒤에서 폭발합니다. 이때 원인 추적이 어려워집니다.
- 변환 레이어에서 조기에
throw로 터뜨리면 스택 트레이스가 명확해짐 - API 입력 검증과 결합하면 더 강력함
외부 API 호출 결과를 파싱하는 코드에서 특히 효과가 큽니다. 예를 들어 LLM 응답 파싱에서 필드 누락이 흔한데, 이런 경우는 “기본값”보다 “계약 위반”으로 처리하는 편이 장애를 줄입니다. 관련해서 입력/요청이 왜 깨지는지 관점은 OpenAI Responses API 400 에러 원인 8가지 글의 진단 흐름도 참고할 만합니다.
추천 유틸: 안전한 인덱스/키 접근을 표준화
팀에서 반복되는 패턴을 유틸로 고정하면 마이그레이션이 빨라집니다.
안전한 배열 접근
export function atOrThrow<T>(arr: readonly T[], index: number, message?: string): T {
const v = arr[index];
if (v === undefined) {
throw new Error(message ?? `Missing element at index ${index}`);
}
return v;
}
안전한 Record 접근
export function getOrThrow<V>(
record: Record<string, V>,
key: string,
message?: string
): V {
const v = record[key];
if (v === undefined) {
throw new Error(message ?? `Missing key: ${key}`);
}
return v;
}
이 유틸을 쓰면 noUncheckedIndexedAccess가 강제하는 undefined 처리를 “읽기 쉬운 계약”으로 바꿀 수 있습니다.
마무리: noUncheckedIndexedAccess는 비용이 아니라 보험
noUncheckedIndexedAccess는 처음엔 귀찮습니다. 하지만 실제로는 다음을 제공합니다.
- 런타임 크래시를 컴파일 타임으로 당겨서 비용을 낮춤
- 데이터 경계(입력/파싱/변환)에서 계약이 명확해짐
undefined전파를 초기에 차단해 디버깅 시간을 줄임
레거시가 많다면 한 번에 “완벽”을 목표로 하기보다, 핵심 경로부터 가드/계약 유틸을 도입하며 점진적으로 켜는 전략이 가장 안전합니다. 특히 인덱싱이 많은 파싱/매핑 코드부터 적용하면 효과가 빠르게 체감됩니다.