Published on

TypeScript 5.5 noUncheckedIndexedAccess 오류 해결

Authors

서버/프론트 공용 코드베이스에서 TypeScript의 타입 안정성을 한 단계 끌어올리려면 noUncheckedIndexedAccess 옵션을 켜는 것이 꽤 효과적입니다. 다만 TypeScript 5.5로 올리면서(혹은 기존에 잠재되어 있던 문제가 드러나면서) 배열/객체 인덱싱 지점이 줄줄이 빨갛게 변하는 경험을 하게 됩니다.

이 글에서는 noUncheckedIndexedAccess가 정확히 무엇을 바꾸는지, 왜 5.5에서 더 자주 “오류처럼” 느껴지는지, 그리고 실무에서 가장 안전하고 유지보수 가능한 해결 패턴을 정리합니다.

참고로 Next.js/Node 환경에서 타입 설정을 조정하다 보면 런타임 이슈도 같이 만나는 경우가 많습니다. ESM 전환과 경로 처리 이슈가 함께 얽혀 있다면 Node.js ESM에서 __dirname 없는 에러 해결법도 같이 확인해두면 좋습니다.

noUncheckedIndexedAccess가 바꾸는 규칙

noUncheckedIndexedAccess를 켜면, 아래 형태의 접근이 “항상 존재한다”는 가정이 깨집니다.

  • 배열: arr[i]T가 아니라 T | undefined
  • 객체: obj[key]V가 아니라 V | undefined (특히 index signature, Record, 동적 키 접근)

즉, 인덱싱은 본질적으로 “키가 없을 수 있음”을 동반하므로, 타입 시스템이 그 가능성을 강제로 반영하게 됩니다.

예시: 배열 인덱싱

// tsconfig.json에서 noUncheckedIndexedAccess: true

const nums: number[] = [10, 20];
const x = nums[1];
// x: number | undefined

const y: number = nums[1];
// 오류: Type 'number | undefined' is not assignable to type 'number'.

예시: 객체 동적 키

const dict: Record<string, number> = { a: 1 };
const v = dict["b"];
// v: number | undefined

이 변화는 단순히 “타입이 더 귀찮아졌다”가 아니라, 런타임에서 실제로 undefined가 나올 수 있는 지점을 컴파일 타임에 드러내는 장치입니다.

왜 TypeScript 5.5에서 더 크게 체감될까

TypeScript는 버전이 올라가면서 제어 흐름 분석, 좁히기(narrowing), in 체크, hasOwnProperty 패턴 인식 등이 점점 정교해집니다. 그 결과 예전에는 “운 좋게” 통과하던 코드가 5.5에서 더 엄격하게 잡히거나, 반대로 특정 패턴을 쓰면 더 잘 좁혀지기도 합니다.

결론적으로 5.5가 갑자기 옵션을 바꿨다기보다는, 프로젝트가 noUncheckedIndexedAccess를 도입하거나 strictness를 강화하는 과정에서 그 영향이 크게 드러나는 경우가 많습니다.

가장 흔한 오류 패턴 6가지와 해결법

아래는 실무에서 가장 자주 마주치는 케이스를 “원인별”로 정리한 것입니다.

1) 인덱스가 범위를 벗어날 수 있는 배열 접근

문제 코드

function head<T>(arr: T[]): T {
  return arr[0];
  // 오류: T | undefined
}

해결 1: 반환 타입을 현실에 맞추기

function head<T>(arr: T[]): T | undefined {
  return arr[0];
}

이 방식이 가장 정직합니다. 호출자가 빈 배열을 처리하게 만듭니다.

해결 2: 런타임 가드로 좁히기

function headOrThrow<T>(arr: T[]): T {
  const v = arr[0];
  if (v === undefined) {
    throw new Error("Empty array");
  }
  return v;
}

“비어 있으면 예외”라는 정책이 명확한 API라면 이 패턴이 좋습니다.

해결 3: Non-null assertion !는 최후의 수단

function headUnsafe<T>(arr: T[]): T {
  return arr[0]!;
}

!는 타입만 속이고 런타임 안전성을 보장하지 않습니다. 테스트/불변조건이 강하게 보장되는 구간(예: 바로 앞에서 길이 체크를 했는데 TS가 인식 못하는 경우)에만 제한적으로 쓰는 것이 좋습니다.

2) Record/딕셔너리에서 키가 없을 수 있음

문제 코드

type Prices = Record<string, number>;

function getPrice(prices: Prices, sku: string): number {
  return prices[sku];
  // 오류: number | undefined
}

해결 1: 기본값 제공

function getPrice(prices: Record<string, number>, sku: string): number {
  return prices[sku] ?? 0;
}

해결 2: 키 존재를 강제하는 설계로 바꾸기

가능하다면 sku가 임의 문자열이 아니라 제한된 유니온이어야 합니다.

type Sku = "A" | "B" | "C";

const prices: Record<Sku, number> = {
  A: 100,
  B: 200,
  C: 300,
};

function getPrice(prices: Record<Sku, number>, sku: Sku): number {
  return prices[sku];
  // 안전: number
}

이건 단순한 타입 트릭이 아니라, 데이터 모델을 “키 누락이 불가능한 구조”로 바꾸는 리팩터링입니다.

3) Map.get과 결합되며 더 자주 보이는 undefined

Map.get은 원래 V | undefined입니다. noUncheckedIndexedAccess와 결합되면 컬렉션 처리 코드에서 undefined 전파가 늘어납니다.

const m = new Map<string, { id: string }>();

function mustGet(id: string) {
  const v = m.get(id);
  if (!v) throw new Error("not found");
  return v;
}

여기서 if (!v)는 값이 falsy일 때도 걸립니다(예: 0, ""). 객체라면 괜찮지만 일반화된 유틸은 더 엄격하게 쓰는 게 좋습니다.

function mustGet<K, V>(m: Map<K, V>, k: K): V {
  const v = m.get(k);
  if (v === undefined) throw new Error("not found");
  return v;
}

4) Object.keys 루프에서 인덱싱이 깨짐

문제 코드

const obj = { a: 1, b: 2 };

for (const k of Object.keys(obj)) {
  const v = obj[k];
  // 오류 또는 v가 (number | undefined)처럼 넓어짐
}

Object.keysstring[]을 반환하므로, k가 실제로 "a" | "b"라는 정보가 사라집니다.

해결: 타입 안전한 keys 헬퍼

function keysOf<T extends object>(o: T): Array<keyof T> {
  return Object.keys(o) as Array<keyof T>;
}

const obj = { a: 1, b: 2 };

for (const k of keysOf(obj)) {
  const v = obj[k];
  // v: number
}

이 패턴은 noUncheckedIndexedAccess뿐 아니라, 많은 strict 옵션에서 반복적으로 도움이 됩니다.

5) 배열 find 결과를 바로 인덱싱/사용

문제 코드

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

function getName(users: User[], id: string): string {
  const u = users.find((x) => x.id === id);
  return u.name;
  // 오류: u is possibly 'undefined'
}

해결: 가드 + 명확한 정책

function getName(users: { id: string; name: string }[], id: string): string {
  const u = users.find((x) => x.id === id);
  if (u === undefined) return "";
  return u.name;
}

혹은 예외 정책이면 throw.

6) 문자열 인덱싱(str[i])도 undefined 가능

문자열도 인덱스가 범위를 벗어나면 undefined입니다.

function firstChar(s: string): string {
  return s[0];
  // 오류: string | undefined
}

해결

function firstChar(s: string): string {
  return s.charAt(0);
  // charAt은 범위 밖이면 "" 반환
}

또는 s[0] ?? ""처럼 정책을 고정하세요.

실무에서 추천하는 “해결 우선순위”

noUncheckedIndexedAccess로 드러난 오류를 처리할 때는, 아래 우선순위가 유지보수에 유리합니다.

  1. 타입을 현실에 맞게 바꾸기: 반환 타입을 T | undefined로, 입력 키를 유니온으로 제한
  2. 런타임 가드 추가: if (v === undefined) ... 후 좁히기
  3. 기본값 제공: ??로 정책을 명시
  4. 어설션(!, as)은 마지막: 불변조건이 코드로 증명되기 어려운 경우에만 제한적으로

이 순서를 지키면 “컴파일만 통과시키는” 방향으로 흐르지 않고, 실제 장애 가능성을 줄이는 쪽으로 코드가 정리됩니다.

tsconfig.json에서 옵션을 켤 때의 체크리스트

점진 도입을 원하면 아래처럼 운영할 수 있습니다.

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}
  • strict와 함께 켜면 효과가 큽니다.
  • 다만 레거시가 크면 한 번에 켜기 어렵습니다. 패키지/폴더 단위로 TS Project Reference를 쪼개거나, CI에서 단계적으로 경고를 줄이는 전략을 고려하세요.

패턴화하면 빨리 끝난다: 재사용 유틸 3종

프로젝트에서 반복되는 해결을 유틸로 묶으면 수정량이 크게 줄어듭니다.

1) assertDefined

export function assertDefined<T>(v: T, msg = "Expected value to be defined"): asserts v is NonNullable<T> {
  if (v === undefined || v === null) {
    throw new Error(msg);
  }
}

const arr = ["a", "b"];
const v = arr[0];
assertDefined(v);
// v: string

2) getOrThrow for Record

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

키 타입을 K extends string으로 제한했기 때문에, 키가 유니온이라면 누락을 더 잘 잡아냅니다.

3) 안전한 keysOf

export function keysOf<T extends object>(o: T): Array<keyof T> {
  return Object.keys(o) as Array<keyof T>;
}

마이그레이션 팁: “오류를 없애는 것”보다 “정책을 정하는 것”

noUncheckedIndexedAccess 오류를 잡는 과정은 사실상 다음 질문에 답하는 과정입니다.

  • 키가 없으면 기본값을 줄 것인가?
  • 없으면 예외를 던질 것인가?
  • 없을 수 있으니 호출자에게 undefined를 돌려줄 것인가?

이 정책이 API 경계(예: 서비스 레이어, DB 액세스, 외부 API 응답 파싱)에서 명확해질수록, 내부 로직은 오히려 더 단순해집니다.

프론트엔드에서는 특히 서버 응답/상태 동기화 과정에서 타입이 자주 흔들립니다. RSC나 hydration 관련으로 데이터 불일치가 발생했다면 Next.js 14 RSC에서 hydration mismatch 해결법처럼 “런타임 불일치”를 먼저 잡고, 그 다음 타입 안정성을 강화하는 순서가 효율적일 때가 많습니다.

결론

TypeScript 5.5에서 noUncheckedIndexedAccess로 발생하는 오류는 대부분 “인덱싱 결과가 undefined일 수 있다”는 사실을 코드가 인정하지 않았기 때문에 생깁니다. 해결의 핵심은 !로 덮는 것이 아니라, 데이터 모델(키를 유니온으로 제한), API 정책(없으면 기본값/예외/옵셔널 반환), 그리고 반복되는 가드 패턴(유틸화)을 통해 코드베이스 전체의 안정성을 끌어올리는 것입니다.

한 번 정리해두면 이후에는 배열/딕셔너리 접근에서 생기는 잠재 버그가 컴파일 타임에 걸러져, 장애 대응 비용이 눈에 띄게 줄어듭니다.