Published on

TypeScript 5.5+ noUncheckedIndexedAccess 오류 실전해결

Authors

서버/프론트 공통으로 TypeScript를 엄격하게 운영하다 보면, 어느 순간 noUncheckedIndexedAccess를 켜는 순간 빌드가 붉게 물드는 경험을 하게 됩니다. 특히 TypeScript 5.5+로 올라오면서(또는 기존 코드에 strict 옵션을 단계적으로 적용하면서) 인덱스 접근이 전부 T | undefined로 변해, “분명 런타임에 값이 있는데 왜 타입이 불안정하다고 하지?”라는 불만이 폭발합니다.

하지만 이 옵션은 실제 런타임에서 자주 발생하는 undefined 접근 버그를 컴파일 타임에 끌어올리는 장치입니다. 문제는 “안전해지긴 했는데, 너무 많은 곳이 깨져서 적용이 어렵다”는 점이죠. 이 글에서는 흔히 깨지는 지점을 패턴별로 나누고, 팀 코드베이스에서 대량 수정 가능한 실전 해결법을 제시합니다.

> 관련해서 TypeScript 5.5에서 타입 추론이 바뀌며 noImplicitAny가 갑자기 터지는 케이스도 많습니다. 함께 점검하려면 TypeScript 5.5 noImplicitAny 폭탄 - inferred type 디버깅도 참고하세요.

noUncheckedIndexedAccess가 바꾸는 것

noUncheckedIndexedAccess는 “인덱스로 접근하는 값은 항상 존재한다고 가정하지 말라”는 규칙을 타입 시스템에 강제합니다.

  • 배열: arr[i]의 타입이 TT | undefined
  • 객체 인덱스 시그니처: obj[key]의 타입이 VV | undefined
  • Record<string, V>도 마찬가지로 V | undefined

즉, 다음이 기본이 됩니다.

// tsconfig: { "compilerOptions": { "noUncheckedIndexedAccess": true, "strict": true } }

const xs: number[] = [1, 2, 3];
const n = xs[10];
//    ^? number | undefined

이게 귀찮은 이유는, 기존 코드가 암묵적으로 “있을 것”을 가정하고 작성된 경우가 많기 때문입니다.

오류가 터지는 대표 패턴 6가지와 해결법

1) 배열 인덱스 접근 후 바로 사용

const users = [{ id: "a" }, { id: "b" }];

const firstId = users[0].id;
// Object is possibly 'undefined'.

해결 A: 구조적으로 안전한 분기(가장 권장)

const first = users[0];
if (!first) throw new Error("users is empty");

const firstId = first.id;

해결 B: optional chaining + 기본값(의도가 "없으면 기본값")일 때

const firstId = users[0]?.id ?? "";

해결 C: non-null assertion ! (최후의 수단)

const firstId = users[0]!.id;

!정말로 불변 조건이 보장되는 경우에만 쓰세요. 예: 직전에 length 체크가 있고, 그 체크가 리팩터링으로 깨지지 않게 구조화된 경우.


2) Record/객체 맵에서 키로 조회

type User = { id: string; name: string };

const userById: Record<string, User> = {};

function getName(id: string) {
  return userById[id].name;
  // Object is possibly 'undefined'.
}

여기서 핵심은 Record는 “모든 string 키가 존재”를 의미하지 않는다는 겁니다. 런타임에서는 대부분 부분 맵(partial map)입니다.

해결 A: 반환 타입에 undefined를 반영(가장 정직)

function getName(id: string): string | undefined {
  return userById[id]?.name;
}

해결 B: “없으면 에러” 정책이면 명시적으로 throw

function requireUser(id: string): User {
  const u = userById[id];
  if (!u) throw new Error(`User not found: ${id}`);
  return u;
}

function getName(id: string) {
  return requireUser(id).name;
}

이 패턴은 대규모 코드베이스에서 특히 유용합니다. requireXxx를 만들어두면, 이후 인덱스 접근 난사를 한 곳에서 수렴시킬 수 있습니다.


3) Object.keys()/for...in에서 키로 접근

다음 코드는 직관적으로 안전해 보이지만, 타입 시스템 관점에서는 kstring이라 안전하지 않습니다.

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

for (const k of Object.keys(cfg)) {
  const v = cfg[k];
  // Element implicitly has an 'any' type ... 또는 v가 (string|number)|undefined로 흐트러짐
}

해결 A: satisfies + 키 타입 보존

const cfg = {
  host: "localhost",
  port: 5432,
} satisfies Record<string, string | number>;

const keys = Object.keys(cfg) as (keyof typeof cfg)[];

for (const k of keys) {
  const v = cfg[k];
  // v: string | number (noUncheckedIndexedAccess에서도 보통 undefined가 덜 섞임)
}

해결 B: 타입 안전한 typedKeys 유틸

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

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

for (const k of typedKeys(cfg)) {
  const v = cfg[k];
}

이 유틸은 프로젝트 전반에 퍼진 Object.keys 문제를 빠르게 정리할 때 효과가 큽니다.


4) Array.prototype.find + 인덱싱/접근

find는 원래부터 T | undefined를 반환합니다. noUncheckedIndexedAccess와 만나면 “undefined 처리”가 더 강제됩니다.

type Item = { id: string; price: number };
const items: Item[] = [];

const price = items.find(x => x.id === "a").price;
// Object is possibly 'undefined'.

해결: 정책에 따라 분기

const item = items.find(x => x.id === "a");
if (!item) return 0; // 또는 throw
return item.price;

또는 “없으면 에러”로 일관하고 싶다면 invariant 유틸을 두세요.

function invariant(cond: unknown, msg: string): asserts cond {
  if (!cond) throw new Error(msg);
}

const item = items.find(x => x.id === "a");
invariant(item, "item not found");

const price = item.price;

asserts를 쓰면 타입이 깔끔하게 좁혀져서 !보다 유지보수성이 좋습니다.


5) 튜플/고정 길이 배열처럼 쓰지만 타입은 T[]

많은 코드가 사실상 “항상 2개가 들어오는 배열”을 다루면서 타입은 string[]로 둡니다.

function parsePair(s: string) {
  const parts = s.split(":");
  return { key: parts[0], value: parts[1] };
  // parts[1]는 string | undefined
}

해결 A: 런타임 검증 + 튜플로 승격

function parsePair(s: string): { key: string; value: string } {
  const parts = s.split(":");
  if (parts.length < 2) throw new Error(`Invalid pair: ${s}`);

  const [key, value] = parts as [string, string];
  return { key, value };
}

해결 B: 파서 함수 자체가 Result/undefined를 반환

function tryParsePair(s: string): { key: string; value: string } | undefined {
  const parts = s.split(":");
  if (parts.length < 2) return;
  return { key: parts[0]!, value: parts[1]! };
}

여기서 !는 길이 체크로 안전성이 확보됐을 때만 제한적으로 사용합니다.


6) “인덱스가 범위 내”라는 전제가 코드에 숨어있음

function takeNth<T>(xs: T[], n: number): T {
  return xs[n];
  // Type 'T | undefined' is not assignable to type 'T'.
}

해결 A: API 계약을 바꾸기(가장 안전)

function takeNth<T>(xs: T[], n: number): T | undefined {
  return xs[n];
}

해결 B: 범위 체크 후 throw

function takeNth<T>(xs: T[], n: number): T {
  const v = xs[n];
  if (v === undefined) throw new RangeError(`Index out of range: ${n}`);
  return v;
}

이렇게 하면 호출자가 “항상 존재”를 기대하는 경우에도 타입이 정합성을 갖습니다.

대규모 코드베이스에서의 적용 전략

옵션을 켜면 수백/수천 개 에러가 날 수 있습니다. 이때 중요한 건 “전부를 !로 덮지 않고”, 패턴을 수렴시켜 수정 비용을 낮추는 것입니다.

1) requireXxx / getOrThrow 계열 유틸로 중앙집중화

맵 조회, 배열 nth 접근, 환경변수 조회 등 “없으면 안 되는 값”은 한 군데로 모으세요.

export function getOrThrow<K extends PropertyKey, V>(
  map: Record<K, V>,
  key: K,
  msg = `Missing key: ${String(key)}`
): V {
  const v = map[key];
  if (v === undefined) throw new Error(msg);
  return v;
}

// 사용
const price = getOrThrow(priceBySku, sku).amount;

장점:

  • ! 남발 대신 정책(throw)을 일관되게 적용
  • 에러 메시지 품질 향상
  • 이후 로깅/메트릭을 붙이기도 쉬움

2) “없을 수 있음”을 모델링하는 타입을 도입

특히 설정/캐시/룩업 테이블 계열은 Record<string, T> 대신 Partial<Record<string, T>>로 의도를 드러내면, 팀원이 코드를 읽을 때도 “undefined 처리 필요”가 자연스러워집니다.

type Cache<T> = Partial<Record<string, T>>;
const userCache: Cache<{ name: string }> = {};

const name = userCache[id]?.name;

3) 린트/리뷰 규칙: ! 사용을 제한

  • !는 허용하되, 바로 위 줄에 근거가 되는 체크가 존재하거나
  • asserts 유틸을 사용하도록 유도

이렇게 규칙을 두면 “컴파일만 통과하는 코드”가 늘어나는 걸 막을 수 있습니다.

tsconfig에서 같이 보면 좋은 옵션 조합

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noPropertyAccessFromIndexSignature": true
  }
}
  • exactOptionalPropertyTypes: optional 프로퍼티의 의미를 더 정확히 하여, undefined 취급이 더 일관됩니다.
  • noPropertyAccessFromIndexSignature: 인덱스 시그니처 객체에서 obj.foo 같은 접근을 막아, 키 기반 접근을 더 명시적으로 만듭니다.

이 조합은 초기에는 불편하지만, 일단 정착하면 “설정/데이터 경계에서 undefined가 새는 문제”를 크게 줄여줍니다.

실전 디버깅 팁: 에러를 "종류"로 묶어 처리하기

에러를 한 줄씩 고치기 시작하면 끝이 없습니다. 대신 다음 순서로 묶어 처리하면 속도가 납니다.

  1. Record/Map 조회(키 기반): getOrThrow, ?.로 일괄 정리
  2. 배열 인덱스 접근: 범위 체크/반환 타입 변경/throw 정책 결정
  3. Object.keys 루프: typedKeys 유틸로 치환
  4. 파서/스플릿 결과: 런타임 검증 후 튜플화

이 방식은 장애 대응에서 “원인별 체크리스트로 시간을 줄이는” 접근과 유사합니다. 예를 들어 인프라 장애를 원인별로 분류해 빠르게 좁혀가는 방법론은 AWS ALB 502·504 난사 - 원인별 해결 체크리스트 같은 글에서도 동일한 결로 적용됩니다. 타입 에러도 결국은 원인(패턴) 분류가 핵심입니다.

결론: noUncheckedIndexedAccess는 귀찮지만, 버그를 줄이는 비용 대비 효과가 크다

noUncheckedIndexedAccess는 단순히 “타입을 더 엄격하게” 만드는 옵션이 아니라, 인덱스 접근이라는 고위험 지점에 안전장치를 강제하는 옵션입니다. 적용 초기에 발생하는 대량 오류는 정상이며, 다음 원칙으로 접근하면 현실적으로 수습 가능합니다.

  • “없으면 안 됨”은 throw/asserts로 계약을 만들고 중앙화
  • “없을 수 있음”은 반환 타입/도메인 타입에 반영
  • Object.keys/인덱싱 패턴은 유틸로 수렴
  • !는 최소화하고 근거가 있는 곳에만 제한적으로 사용

이 과정을 한번 거치면, 이후에는 런타임에서만 터지던 Cannot read properties of undefined류 버그가 눈에 띄게 줄고, 리팩터링 안정성이 올라갑니다.