Published on

TS 5.5에서 Object is possibly undefined 줄이기

Authors

서버나 프론트 코드에서 엄격한 strictNullChecks 를 켜면, 가장 자주 마주치는 경고가 바로 Object is possibly undefined 입니다. 이 경고 자체는 유용하지만, 코드가 커질수록 “이미 체크했는데도 또 뜨는” 상황이 반복되면 생산성이 떨어지고, 결국 ! 같은 강제 단언을 남발하게 됩니다.

TS 5.5는 타입 시스템의 방향성 자체가 바뀌는 버전은 아니지만, 실제 프로젝트에서는 다음 두 가지 때문에 체감이 달라집니다.

  • 타입 가드와 컨트롤 플로우 분석을 “타입이 이해할 수 있는 형태”로 작성했을 때 효과가 더 크게 나타남
  • noUncheckedIndexedAccess 같은 옵션을 함께 쓰는 팀이 늘면서, undefined 가 더 자주 타입에 섞이고 경고가 폭증

이 글은 “경고를 억지로 숨기기”가 아니라, TS가 추론할 수 있게 코드를 정리해서 경고를 줄이는 패턴을 다룹니다.

참고로 대규모 Next.js 코드베이스에서 이런 경고가 렌더링 로직과 결합되면, 런타임에서 다른 종류의 문제로도 이어질 수 있습니다. 예를 들어 상태 불일치가 있으면 하이드레이션 경고로 터지기도 하니, 관련 경험이 있다면 Next.js Hydration mismatch 원인 9가지와 해결법 도 함께 보는 것을 권합니다.

1) 경고가 뜨는 대표 패턴부터 분류하기

Object is possibly undefined 는 보통 아래 케이스에서 발생합니다.

  1. 옵셔널 프로퍼티 접근: user.profile.name
  2. 인덱스 접근: map[key] 또는 arr[i]
  3. find 결과: items.find(...)
  4. 비동기/클로저로 인해 체크가 무효화되는 경우
  5. 유니온 타입에서 특정 분기만 undefined 를 포함하는 경우

각각의 원인에 맞는 해법이 다릅니다. 하나의 요령으로 모두 해결하려 하면 ! 로 덮게 되고, 결국 런타임 버그를 타입 시스템이 잡아주지 못하게 됩니다.

2) if (x) 만으로는 부족한 경우: 명시적 타입 가드 만들기

가장 흔한 실수는 “사람이 보기엔 체크했는데 TS는 확신하지 못하는” 조건문입니다. 특히 제네릭이나 유니온이 섞이면 더 자주 발생합니다.

2-1) NonNullable 기반 타입 가드

아래처럼 nullundefined 를 제거하는 타입 가드를 만들어 두면, filtermap 에서 경고가 크게 줄어듭니다.

export function isDefined<T>(value: T): value is NonNullable<T> {
  return value !== undefined && value !== null;
}

const raw = [1, undefined, 2, null, 3];
const nums = raw.filter(isDefined);
// nums: number[]

포인트는 반환 타입을 value is NonNullable<T> 로 선언하는 것입니다. 이 한 줄이 TS의 컨트롤 플로우 분석에 “이 함수가 통과하면 undefined가 없다”는 사실을 알려줍니다.

2-2) asserts 로 실패를 명확히 하기

값이 없으면 예외를 던지는 흐름이라면, asserts 를 쓰는 편이 호출부를 깔끔하게 만듭니다.

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

function printLength(s?: string) {
  assertDefined(s, "s is required");
  // 여기부터 s: string
  console.log(s.length);
}

이 방식은 “값이 없으면 더 진행하지 않는다”는 런타임 의미와 타입 의미가 일치합니다. 팀 코드에서 ! 를 줄이는 데 특히 효과적입니다.

3) 옵셔널 체이닝은 만능이 아니다: 반환 타입을 의도적으로 만들기

옵셔널 체이닝 ?. 은 경고를 빠르게 없애지만, 결과 타입에 undefined 를 전파합니다. 즉, 경고를 미루는 도구이지 해결 도구는 아닙니다.

3-1) ?? 로 기본값을 명확히

type User = {
  profile?: {
    nickname?: string;
  };
};

function greeting(user: User) {
  const nickname = user.profile?.nickname ?? "guest";
  // nickname: string
  return `hello, ${nickname}`;
}

여기서 || 대신 ?? 를 쓰는 이유는 빈 문자열 같은 falsy 값을 의도치 않게 기본값으로 바꾸지 않기 위해서입니다.

3-2) “없으면 안 된다”면 조기에 실패시키기

?? 로 기본값을 넣는 게 아니라, 정말 필수라면 초기에 예외를 던져야 합니다.

function getNickname(user: User) {
  const nickname = user.profile?.nickname;
  if (!nickname) {
    throw new Error("nickname is required");
  }
  return nickname;
}

이때 if (!nickname) 은 빈 문자열도 거부합니다. 빈 문자열을 허용해야 한다면 아래처럼 작성합니다.

if (nickname === undefined) {
  throw new Error("nickname is required");
}

타입 경고를 줄이는 핵심은 “정책을 코드로 명확히 만들기”입니다.

4) 인덱싱이 만드는 undefined: 객체와 배열을 다르게 다루기

TS에서 obj[key] 는 기본적으로 “없을 수 있다”는 의미를 갖습니다. 여기에 noUncheckedIndexedAccess 까지 켜면, 존재가 보장되지 않는 모든 인덱싱이 T | undefined 로 바뀌면서 경고가 폭증합니다.

4-1) 딕셔너리는 Record 대신 Map 이 더 명확한 경우가 많다

const cache = new Map<string, number>();
cache.set("a", 1);

const v = cache.get("a");
// v: number | undefined

Map.get 은 항상 undefined 가능성을 드러냅니다. 오히려 이게 장점입니다. “없을 수도 있음”이 API에 박혀 있으니, 호출부가 자연스럽게 처리하게 됩니다.

필수 키만 다룬다면 has 와 함께 타입 흐름을 만들 수 있습니다.

if (!cache.has("a")) {
  throw new Error("missing key");
}
const value = cache.get("a");
// 여전히 number | undefined 일 수 있어 보이지만,
// 이 패턴은 assert 함수로 마무리하는 게 깔끔합니다.

여기서 assertDefined(value) 를 결합하면 호출부가 더 단단해집니다.

4-2) 배열 인덱스 접근은 “경계 체크”가 타입에 반영되게

function head<T>(arr: T[]): T {
  if (arr.length === 0) {
    throw new Error("empty array");
  }
  return arr[0];
}

arr[0]T | undefined 로 잡히는 설정이라면, 위처럼 길이 체크 후 반환하는 유틸을 만들어서 경고를 한 곳에 모으는 것이 좋습니다.

5) find 의 결과 처리: 타입 가드로 후처리하지 말고, API를 바꾸기

Array.prototype.find 는 항상 T | undefined 를 반환합니다. 호출부에서 매번 처리하면 코드가 지저분해집니다.

5-1) “없으면 예외” 버전 유틸 만들기

export function findOrThrow<T>(
  arr: T[],
  predicate: (value: T) => boolean,
  message = "not found"
): T {
  const found = arr.find(predicate);
  if (!found) {
    throw new Error(message);
  }
  return found;
}

const users = [{ id: "1" }, { id: "2" }];
const u = findOrThrow(users, (x) => x.id === "2");
// u: { id: string }

이렇게 하면 “비즈니스 규칙상 반드시 존재”하는 데이터를 찾을 때, 경고를 없애는 동시에 런타임 의미도 명확해집니다.

5-2) “없으면 기본값” 버전도 분리

export function findOrDefault<T>(
  arr: T[],
  predicate: (value: T) => boolean,
  defaultValue: T
): T {
  return arr.find(predicate) ?? defaultValue;
}

한 함수에 정책을 섞지 말고, 실패 정책별로 API를 분리하는 것이 경고를 줄이는 가장 확실한 방법 중 하나입니다.

6) 체크가 무효화되는 순간: 비동기와 클로저

TS는 기본적으로 “체크 이후에 값이 바뀔 수 있다”는 가능성을 고려합니다. 특히 객체 프로퍼티는 더 그렇습니다.

6-1) 로컬 변수로 스냅샷을 잡기

type State = { currentUser?: { id: string } };

async function load(state: State) {
  const user = state.currentUser;
  if (!user) return;

  // 여기부터 user는 안정적인 스냅샷
  const res = await fetch(`/api/users/${user.id}`);
  return res.json();
}

state.currentUser 를 직접 여러 번 참조하면, 중간에 값이 바뀔 수 있다는 이유로 경고가 남을 수 있습니다. 로컬 변수에 담아 “이 시점의 값”을 고정하면 컨트롤 플로우가 단순해집니다.

7) ! 를 써야 한다면: “마지막 한 번만” 그리고 근거를 남기기

강제 단언 ! 는 완전히 금지할 필요는 없지만, 다음 조건을 만족할 때만 쓰는 편이 안전합니다.

  • 외부 라이브러리의 타입 정의가 현실과 불일치
  • 런타임에서 이미 보장되는데 TS가 표현하지 못함
  • 해당 지점이 단일한 경계 레이어이며, 내부로 undefined 를 전파하지 않음

예를 들어 DOM에서 특정 엘리먼트가 반드시 있다고 가정하는 경우입니다.

const el = document.getElementById("app")!;
// 이 파일이 앱 엔트리이고, index.html에 app이 항상 존재한다는 전제가 있을 때만

중요한 건 ! 를 여기저기 흩뿌리지 말고, “경계에서 한 번만” 사용하거나, 가능하면 assertDefined 같은 유틸로 모으는 것입니다.

8) 팀 설정 관점: 옵션이 경고를 늘리는 이유를 이해하기

프로젝트에서 아래 옵션 조합을 쓰면 경고가 늘어나는 것이 정상입니다.

  • strict: true
  • noUncheckedIndexedAccess: true
  • exactOptionalPropertyTypes: true

이 조합은 “없을 수 있는 값”을 더 정직하게 표현합니다. 경고를 줄이려면 옵션을 끄기보다, 앞에서 소개한 방식처럼 정책을 코드로 고정하는 편이 장기적으로 유지보수 비용이 낮습니다.

운영 환경에서 작은 타입 경고가 누적되어 장애로 이어지는 것처럼, 개발 환경에서도 작은 경고가 누적되면 디버깅 비용이 크게 늘어납니다. 장애 원인을 빠르게 좁히는 접근이 궁금하다면, 원인 추적 관점에서 systemd 서비스가 계속 재시작될 때 원인 추적법 같은 글의 “증거를 남기는 방식”도 참고할 만합니다.

9) 실전 체크리스트: 경고를 줄이면서 안전성 유지하기

아래 순서대로 적용하면, Object is possibly undefined 를 “의미 있게” 줄일 수 있습니다.

  1. 값이 필수인지 선택인지 정책부터 결정
  2. 선택이면 ?.?? 로 기본값을 명시
  3. 필수면 조기 실패 throw 또는 asserts 로 흐름을 끊기
  4. find, 인덱싱 결과는 호출부에서 처리하지 말고 유틸로 정책을 캡슐화
  5. 객체 프로퍼티는 로컬 변수 스냅샷으로 컨트롤 플로우 단순화
  6. ! 는 경계에서만, 근거가 있을 때만

10) 마무리: 경고를 “끄는” 대신 “모으는” 방향으로

TS 5.5에서 Object is possibly undefined 를 줄이는 핵심은 특정 문법 트릭이 아니라, undefined 가 생기는 지점을 경계로 모으고, 그 경계에서 정책을 확정하는 것입니다.

  • 내부 로직에서는 가능한 한 string | undefined 같은 타입을 들고 다니지 않기
  • 대신 assertDefined, findOrThrow, head 같은 유틸로 “여기서 해결하고 넘어간다”를 팀 규칙으로 만들기

이렇게 하면 경고가 줄어드는 것은 물론이고, 코드 리뷰에서도 “여기는 필수 값이구나”가 타입으로 드러나서 의사소통 비용이 크게 줄어듭니다.