- Published on
TS 5.5 noUncheckedIndexedAccess 에러 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 프론트 프로젝트를 TS 5.5로 올린 뒤 noUncheckedIndexedAccess 를 켜거나(혹은 기존에 켜져 있던 설정이 더 엄격하게 체감되면서) 갑자기 인덱스 접근 코드가 줄줄이 에러가 나는 경우가 많습니다. 대표적으로 배열의 arr[i], 객체의 obj[key], Map.get 결과를 당연히 존재한다고 가정하던 코드가 전부 undefined 가능성을 얻게 되기 때문입니다.
이 글에서는 noUncheckedIndexedAccess 가 정확히 무엇을 바꾸는지, 그리고 팀 코드베이스에서 “끄지 않고” 현실적으로 고치는 패턴을 정리합니다. (마지막엔 마이그레이션 체크리스트도 제공합니다.)
noUncheckedIndexedAccess가 바꾸는 것
tsconfig.json 에서 noUncheckedIndexedAccess: true 를 켜면, TypeScript는 인덱스로 접근하는 모든 값이 범위를 벗어나거나 키가 없을 수 있다고 가정합니다. 그래서 아래처럼 타입이 바뀝니다.
- 배열:
T[]에서arr[number]는T | undefined - 객체 인덱스 시그니처:
Record<string, T>에서obj[someKey]는T | undefined
즉, 런타임에서 실제로도 발생 가능한 “없는 값 접근”을 타입으로 강제하는 옵션입니다.
왜 TS 5.5에서 더 많이 체감될까
TS 5.5 자체가 이 옵션의 의미를 바꿨다기보다는, 다음 상황에서 체감이 커집니다.
- 프로젝트가 TS 버전을 올리며 타입 체크가 더 촘촘해짐
strict조합(예:strictNullChecks)이 이미 켜져 있어서undefined전파가 곧바로 에러로 이어짐Object.keys/for...in/ 동적 키 접근을 많이 쓰는 코드 스타일
결론적으로, 에러가 늘어난 건 “타입이 더 엄격해져서”가 아니라 원래 취약했던 인덱스 접근이 드러난 것에 가깝습니다.
가장 흔한 에러 패턴 5가지
1) 배열 인덱스 접근: arr[i] 가 undefined 를 포함
const users = [{ id: 1 }, { id: 2 }];
const u = users[0];
// u: { id: number } | undefined
이후 u.id 를 쓰면 에러가 납니다.
2) Record / 객체 동적 키 접근
type Dict = Record<string, number>;
const dict: Dict = { a: 1 };
function f(key: string) {
return dict[key] + 1; // dict[key]는 number | undefined
}
3) Object.keys 와 키 타입 손실
const obj = { a: 1, b: 2 };
for (const k of Object.keys(obj)) {
obj[k]; // k: string, obj[k]: number | undefined
}
4) find 결과를 바로 사용
Array.prototype.find 는 원래도 T | undefined 였지만, 인덱스 접근과 섞이면 더 자주 전파됩니다.
5) Map.get 결과를 당연히 존재한다고 가정
const m = new Map<string, number>([["a", 1]]);
const v = m.get("b"); // number | undefined
해결 전략: “끄지 않고” 고치는 실전 패턴
핵심은 2가지입니다.
- 존재를 보장하는 지점에서 가드로 좁히기
- 존재가 보장되는 자료구조/타입을 설계로 표현하기
패턴 A: 범위 체크로 배열 인덱스 좁히기
가장 정석적인 방식입니다.
function getAt<T>(arr: T[], idx: number): T | undefined {
return idx >= 0 && idx < arr.length ? arr[idx] : undefined;
}
const v = getAt(["a", "b"], 1);
if (v) {
console.log(v.toUpperCase());
}
이미 인덱스를 생성하는 로직이 있다면, 그 로직 단계에서 범위를 보장하도록 바꾸는 게 가장 좋습니다.
패턴 B: 기본값 제공(Nullish coalescing)
값이 없을 때의 정책이 명확하다면 ?? 로 끝내는 게 가장 유지보수에 좋습니다.
const dict: Record<string, number> = { a: 1 };
function inc(key: string) {
const n = dict[key] ?? 0;
return n + 1;
}
주의할 점은 || 대신 ?? 를 쓰는 것입니다. 0 같은 falsy 값이 의미 있는 경우가 많습니다.
패턴 C: early return 가드로 좁히기
중첩을 줄이고 흐름을 명확히 합니다.
function printUserName(users: { name: string }[], i: number) {
const u = users[i];
if (!u) return;
console.log(u.name);
}
패턴 D: 단언 연산자 ! 는 “최후의 수단”
const u = users[i]!;
console.log(u.name);
이건 컴파일러에게 “무조건 있다”고 속이는 것입니다. 다음 조건을 만족할 때만 제한적으로 쓰는 것을 권합니다.
- 인덱스 범위가 이미 다른 곳에서 강하게 보장됨
- 보장 로직이 변경될 가능성이 낮음
- 테스트로 그 보장이 커버됨
그렇지 않으면 런타임에서 Cannot read properties of undefined 를 다시 만나게 됩니다.
패턴 E: Object.keys 를 타입 안전하게 다루기
Object.keys 는 string[] 을 반환하므로 키 타입이 깨집니다. 해결은 보통 두 가지입니다.
방법 1) 안전한 헬퍼로 키를 보존
function typedKeys<T extends object>(obj: T) {
return Object.keys(obj) as Array<keyof T>;
}
const obj = { a: 1, b: 2 };
for (const k of typedKeys(obj)) {
const v = obj[k]; // v: number
console.log(k, v);
}
방법 2) hasOwn 가드와 함께 사용
const hasOwn = (o: object, p: PropertyKey): p is keyof typeof o =>
Object.prototype.hasOwnProperty.call(o, p);
const obj = { a: 1, b: 2 };
for (const k of Object.keys(obj)) {
if (!hasOwn(obj, k)) continue;
obj[k];
}
패턴 F: “반드시 존재하는” 맵을 타입으로 표현
키가 제한된 도메인이라면 Record 를 더 강하게 만들 수 있습니다.
type Env = "dev" | "prod";
const apiBase: Record<Env, string> = {
dev: "http://localhost:3000",
prod: "https://example.com",
};
function getBase(env: Env) {
return apiBase[env]; // string (undefined 아님)
}
포인트는 key: string 같은 광범위한 키를 받지 않게 만드는 것입니다.
패턴 G: 인덱스 접근을 API로 감싸 “정책을 한 곳에”
대규모 코드베이스에서 가장 효과적인 방법입니다. 예를 들어 “없으면 에러” 정책을 공통화합니다.
export function mustGet<T>(value: T | undefined, msg: string): T {
if (value === undefined) throw new Error(msg);
return value;
}
const users = [{ id: 1 }, { id: 2 }];
const u = mustGet(users[0], "user not found");
console.log(u.id);
런타임 정책이 명확해지고, 타입도 깔끔해집니다.
tsconfig에서의 선택지: 완화 vs 유지
noUncheckedIndexedAccess 를 켠 상태에서 너무 많은 에러가 발생하면, 흔히 “끄자”로 흐르지만 보통은 다음 순서가 더 안전합니다.
- 문제가 되는 접근을 유틸로 감싸기 (예:
getAt,mustGet) - 도메인 키를 유니온으로 좁히기 (예:
string대신"a" | "b") - 정말 필요할 때만
!단언
옵션을 끄는 것은 단기적으로는 편하지만, 인덱스 기반 버그가 다시 숨어들 가능성이 큽니다.
마이그레이션 체크리스트(실전)
1) 에러를 “종류별”로 분류
- 배열 인덱스
Record동적 키Object.keysMap.get
각 그룹마다 해결 패턴이 거의 정해져 있어 일괄 적용이 가능합니다.
2) 팀 규칙 정하기
- 기본값이 자연스러운 곳은
?? - 반드시 있어야 하는 곳은
mustGet !는 코드리뷰에서 사유 필수
3) 테스트로 보장 강화
noUncheckedIndexedAccess 는 런타임 안정성을 올리려는 옵션입니다. 인덱스 범위를 보장하는 로직(예: 페이지네이션, 슬라이싱, 정렬 후 첫 요소 선택)은 단위 테스트로 고정해두는 게 좋습니다.
빌드/배포 환경에서 설정 변화가 문제를 키우는 경우도 많습니다. 예를 들어 CI에서만 TS 체크가 더 엄격하게 도는 식입니다. 이런 류의 “환경 차이로 인한 실패”는 Docker 레이어 캐시나 빌드 파이프라인 설정에서도 비슷한 양상으로 나타납니다. 관련해서는 Docker 빌드 캐시가 무효화되는 원인 7가지도 함께 참고하면, 재현/고립 전략을 잡는 데 도움이 됩니다.
자주 묻는 질문
Q1. Record<string, T> 인데 실제로는 키가 항상 존재해요. 왜 undefined 가 붙죠?
컴파일러는 런타임 데이터의 완전성을 믿지 않습니다. 특히 string 키는 무한히 많고, 오타나 외부 입력으로 언제든 깨질 수 있습니다. 정말로 제한된 키라면 string 을 쓰지 말고 유니온 타입으로 좁히는 게 정답입니다.
Q2. 성능에 영향이 있나요?
타입 시스템 옵션이므로 런타임 성능에는 직접 영향이 없습니다. 다만 개발자가 추가하는 가드/검증 로직이 늘면 그 부분은 비용이 생길 수 있습니다. 보통은 “없을 수 있는 값”을 다루는 정책을 정리하면서 오히려 장애 비용이 줄어드는 편입니다.
Q3. ! 로 다 밀어버리면 안 되나요?
가능은 하지만, 그 순간 옵션을 켠 의미가 거의 사라집니다. 특히 배열 인덱스는 데이터 길이 변화에 취약해서 운영 중에만 터지는 케이스가 많습니다.
결론
TS 5.5에서 noUncheckedIndexedAccess 로 드러나는 에러는 대부분 “인덱스 접근이 안전하지 않다”는 신호입니다. 해결의 핵심은 인덱스 접근을 그대로 두고 땜질하기보다,
- 존재를 보장하는 가드(범위 체크, early return)
- 기본값 정책(
??) - 반드시 존재해야 할 때의 공통 유틸(
mustGet) - 키 타입을 좁히는 설계(
string대신 유니온)
로 정리하는 것입니다. 한 번 정리해두면 이후 기능 추가 속도도 빨라지고, 런타임 장애도 눈에 띄게 줄어듭니다.