Published on

TS 5.5 noUncheckedIndexedAccess 에러 해결 가이드

Authors

서버/프론트 공통 코드베이스에서 TypeScript를 업그레이드하거나 "noUncheckedIndexedAccess": true를 켠 뒤, 갑자기 Object is possibly 'undefined' 류의 에러가 대량으로 발생하는 경험은 흔합니다. 특히 TS 5.5로 올라오면서 타입 추론/체크가 더 정교해진 환경에서는, 기존에 “암묵적으로 안전하다고 믿고 있던” 인덱스 접근이 실제로는 런타임에서 undefined를 만들 수 있다는 점이 더 명확히 드러납니다.

이 글은 noUncheckedIndexedAccess가 정확히 무엇을 바꾸는지, 그리고 에러를 무지성 non-null assertion(!)로 덮지 않고 안전하게 해결하는 패턴을 정리합니다. 마지막에는 대규모 레거시 프로젝트에서의 점진적 마이그레이션 전략도 제시합니다.

noUncheckedIndexedAccess가 바꾸는 것

noUncheckedIndexedAccess는 말 그대로 **인덱스로 접근하는 모든 값이 “없을 수도 있다(undefined)”**고 가정하도록 만듭니다.

대표적으로 영향을 받는 케이스:

  • 배열 인덱싱: arr[i]T | undefined
  • 객체 인덱싱: obj[key]T | undefined (특히 Record<string, T>나 인덱스 시그니처)
  • 맵/딕셔너리 패턴: map[id] 같은 접근

이 옵션은 런타임 안정성 측면에서 매우 유용합니다. 자바스크립트에서 인덱싱은 실패해도 예외가 아니라 undefined를 반환하므로, 타입 시스템이 그 가능성을 반영하는 게 맞습니다.

TS 5.5에서 “더 많이 터지는” 이유

엄밀히 말해 옵션 자체의 의미가 바뀌었다기보다, TS 5.x 계열에서 전반적으로 제어 흐름 기반 타입 좁히기제네릭/인덱싱 조합에서의 체크가 강화되며, 예전엔 통과하던 코드가 더 정확히 잡히는 경우가 늘었습니다.

흔한 에러 패턴과 해결법

아래는 현장에서 가장 많이 보는 패턴별 해결책입니다.

1) 배열 인덱싱: arr[i]T | undefined가 됨

문제 코드

const users: { id: string; name: string }[] = getUsers();

const firstName = users[0].name;
//            ~~~~~~~~ Object is possibly 'undefined'.

해결 1: 길이 체크로 타입 좁히기

if (users.length === 0) {
  throw new Error("No users");
}

const firstName = users[0].name; // OK

해결 2: 구조 분해 + 기본값(단, 의미가 맞을 때)

const [first] = users;
const firstName = first?.name ?? "(unknown)";

해결 3: at() 사용 + 옵셔널 체이닝

const firstName = users.at(0)?.name;

at()도 결과가 T | undefined이므로, 결국 undefined 처리는 필요합니다.

2) find() 결과 처리

find()는 원래부터 T | undefined인데, noUncheckedIndexedAccess를 켠 프로젝트에서는 이런 “undefined 처리 누락”이 더 눈에 띄게 됩니다.

문제 코드

const user = users.find(u => u.id === "42");
return user.name;
//     ~~~~ Object is possibly 'undefined'.

해결: 가드 함수(타입 가드)로 명확히

function assertDefined<T>(value: T, message = "Value is undefined"): asserts value is NonNullable<T> {
  if (value == null) throw new Error(message);
}

const user = users.find(u => u.id === "42");
assertDefined(user, "User not found");

return user.name; // OK

이 패턴은 레거시에서 !를 남발하는 것보다 훨씬 안전하고, 에러 메시지도 개선됩니다.

3) Record<string, T>/딕셔너리 인덱싱

문제 코드

type User = { id: string; name: string };
const byId: Record<string, User> = {};

const name = byId["42"].name;
//           ~~~~~~~~~ Object is possibly 'undefined'.

Record<string, User>라고 해도 런타임에서 해당 키가 없을 수 있습니다. noUncheckedIndexedAccess는 이를 반영합니다.

해결 1: in 연산자 체크

if (!("42" in byId)) {
  throw new Error("Missing user");
}

const name = byId["42"].name; // OK

해결 2: Map으로 전환(의미적으로 더 정확)

const byId = new Map<string, User>();

const user = byId.get("42");
if (!user) throw new Error("Missing user");

const name = user.name;

Map#get은 원래부터 T | undefined를 반환하므로, 팀에 “없을 수 있음”을 강제하는 효과가 있습니다.

4) Object.keys()/Object.entries()와 인덱싱 조합

Object.keys(obj)string[]을 반환합니다. 따라서 obj[key]가 안전하다는 보장이 약해집니다.

문제 코드

const config = { host: "localhost", port: 5432 };

for (const k of Object.keys(config)) {
  console.log(config[k]);
  //          ~~~~~~~~~ Element implicitly has an 'any' type ...
  // 또는 noUncheckedIndexedAccess 환경에서 undefined 관련 문제가 결합
}

해결: 타입 안전한 keys 헬퍼

function typedKeys<T extends object>(obj: T): (keyof T)[] {
  return Object.keys(obj) as (keyof T)[];
}

const config = { host: "localhost", port: 5432 };

for (const k of typedKeys(config)) {
  const v = config[k];
  console.log(v);
}

이 패턴은 keyof T로 키를 제한해 불필요한 undefined/any 확산을 줄입니다.

5) 함수가 “항상 존재하는 인덱스”를 전제로 하는 경우

예: tuple[0]은 보통 존재하지만, 타입이 T[]로만 잡히면 undefined가 섞입니다.

해결: 튜플로 모델링

type Pair = [number, number];

function sumPair(p: Pair) {
  return p[0] + p[1]; // OK (튜플은 길이가 고정)
}

반대로, 길이가 가변이면 undefined 가능성을 인정하고 처리해야 합니다.

“빠르게 통과”시키는 해법의 함정

non-null assertion(!) 남발은 기술 부채

const name = byId[id]!.name;

이 코드는 컴파일러를 조용히 만들지만, 키가 없을 때 런타임에서 터집니다. 특히 API 응답/캐시/비동기 초기화가 섞인 시스템에서 이런 크래시는 재현이 어렵습니다.

as 캐스팅으로 덮는 것도 동일

const user = byId[id] as User;

타입 안정성을 포기하는 순간, noUncheckedIndexedAccess를 켠 의미가 사라집니다.

실전 해결 패턴(추천 순서)

프로젝트에서 반복적으로 쓰기 좋은 “표준 패턴”을 정해두면 마이그레이션이 빨라집니다.

1) assertDefined/invariant 유틸을 표준화

export function invariant(condition: unknown, message = "Invariant failed"): asserts condition {
  if (!condition) throw new Error(message);
}

export function assertDefined<T>(value: T, message = "Value is undefined"): asserts value is NonNullable<T> {
  if (value == null) throw new Error(message);
}

사용 예:

const u = byId[id];
assertDefined(u, `User ${id} not found`);

return u.name;

2) “없을 수 있음”을 타입에 드러내기

딕셔너리라면 애초에 다음처럼 모델링하는 게 자연스럽습니다.

type UserById = Record<string, User | undefined>;

이렇게 하면 팀이 undefined 처리를 더 명시적으로 하게 되고, 의도도 선명해집니다.

3) 데이터 경계에서 정규화/검증하기

API 응답을 받은 직후, 혹은 캐시를 로드한 직후에 필수 키/필드를 검증하면 이후 로직이 단순해집니다.

  • 경계(입력)에서 undefined를 제거
  • 내부 도메인 로직에서는 “항상 존재”를 가정

이 방식은 운영 장애를 줄이는 데 특히 효과적입니다. (장애를 빠르게 진단하는 체크리스트형 글이 필요하다면, 인프라 쪽이지만 문제 원인 좁히는 접근은 비슷합니다: EKS IRSA는 되는데 S3만 403? 30분 진단)

점진적 마이그레이션 전략

레거시가 크다면 한 번에 켜고 전부 고치기 어렵습니다. 아래 순서가 현실적입니다.

1) tsconfig에서 옵션을 켜되, 영향 범위를 통제

  • 패키지/폴더 단위로 tsconfig를 분리(모노레포라면 특히)
  • 신규 코드(또는 핵심 도메인)부터 noUncheckedIndexedAccess를 적용

2) 에러를 유형별로 분류해 “공통 유틸”로 흡수

  • assertDefined
  • typedKeys
  • getOrThrow(map, key) 같은 접근자
export function getOrThrow<K, V>(map: Map<K, V>, key: K, message?: string): V {
  const v = map.get(key);
  if (v === undefined) throw new Error(message ?? `Missing key: ${String(key)}`);
  return v;
}

3) 런타임 에러를 “빨리 실패”로 바꾸기

undefined가 나중에 NPE로 터지면 원인 추적이 어렵습니다. 인덱싱 직후, 혹은 경계에서 바로 실패시키면 디버깅 비용이 줄어듭니다. 이런 접근은 애플리케이션 레이어뿐 아니라 인프라 트러블슈팅에도 그대로 적용됩니다. 예를 들어 TLS 핸드셰이크 실패를 조기에 좁히는 방식처럼요: EKS ALB Ingress 502/504 - TLS 핸드셰이크 실패 진단

체크리스트: 어떤 해결을 선택할까?

  • 정말로 항상 존재해야 하는 값인가?
    • Yes: assertDefined, invariant, 경계 검증
    • No: ?., ??, 기본값, 분기 처리
  • 키/인덱스의 도메인이 제한적인가?
    • Yes: keyof/유니온/튜플로 모델링
    • No: Map/Record<string, T | undefined>로 명시
  • 성능이 중요한 핫패스인가?
    • 반복 루프에서 매번 throw 가드가 부담이면, 루프 전에 데이터 정규화(필터링)로 한 번만 보장

결론

TS 5.5에서 noUncheckedIndexedAccess로 인해 발생하는 에러는 귀찮지만, 본질은 간단합니다. 자바스크립트 인덱싱은 실패해도 undefined를 준다는 현실을 타입이 정직하게 반영한 것입니다. 해결의 핵심은 두 가지입니다.

  1. 값이 반드시 있어야 한다면 assertDefined 같은 방식으로 빠르게 실패하게 만들고,
  2. 값이 없을 수 있다면 옵셔널 체이닝/기본값/분기 처리로 의도를 코드에 드러내는 것.

이 원칙만 지키면, 단기적으로는 컴파일 에러를 줄이고 장기적으로는 런타임 장애를 확실히 줄일 수 있습니다.