HOME
javascripttypescript

optional chaining은 만병통치약?

optional chaining은 만병통치약일까?

우리는 optional chaining을 주로 언제 사용하는가

TypeScript로 개발을 하다보면 서버로부터 가져온 객체의 속성에 접근하면서 optional chaining을 별 생각 없이 남발(?)하게 되는 경우가 많았던 것 같습니다. 주로 아래와 같이 말이죠.

const { data: products } = useProducts(id);

return (
	<div>
		{products?.map((product) => (
			<div key={product?.id}>
				...
			</div>
		}
	</div>
)

위와 같이 optional chaining을 객체의 속성에 접근할 때마다 사용해주면 TypeScript에서 종종 뱉는 불만(_Uncaught TypeError: Cannot read property ‘foo’ of undefined)_이 사라집니다.

optional chaining의 내부 동작

기본 개념

💡 왼쪽 피연산자(obj)가 null 또는 undefined이면 TypeError를 던지지 않고, 전체 표현식 결과를 undefined로 반환한다.

optional chaining의 개념적 의미는 위와 같습니다. 즉,

a?.b

는 내부적으로

(a == null) ? undefined : a.b

와 동일한 의미를 가집니다.

Babel의 트랜스파일 예시

Babel은 호환성을 위해 optional chaining을 다음처럼 변환합니다.

const result = data?.methods?.fetch?.(results?.payload?.options)?.result ?? [];

트랜스파일 후:

var _data, _data_methods, _data_methods_fetch, _results_payload, _data_methods_fetch_call;
const result =
  (_data = data) == null ? void 0 :
  (_data_methods = _data.methods) == null ? void 0 :
  (_data_methods_fetch = _data_methods.fetch) == null ? void 0 :
  (_data_methods_fetch_call = _data_methods_fetch((_results_payload = results.payload) == null ? void 0 : _results_payload.options)) == null ? void 0 :
  _data_methods_fetch_call.result ?? [];

보면 각 체이닝 포인트마다 nullish 검사와 임시 변수 할당이 추가되어 있습니다. 이 때문에 번들 사이즈가 커지고 성능이 저하될 수 있습니다.

optional chaining을 남용했을 때의 문제점

가독성을 해친다

오류를 찾기 어렵게 한다

  • optional chaining은 실패를 침묵시키기 때문에 문제 원인을 찾기 어렵습니다.

optional chaining 대신 사용할 수 있는 패턴

AND(&&) 연산자 사용

if (foo && foo.bar && foo.bar.baz) {
  foo.bar.baz();
}

Proxy 객체 활용

ES6 Proxy를 이용해 안전하게 값을 반환하는 패턴도 있습니다. Proxy를 활용하면 undefined 속성이 접근될 경우 커스텀 동작(예: 특별한 객체 반환 등)이 가능합니다.

function safe(obj) {
  return new Proxy(obj, {
    get: function(target, name){
      return name in target ? (typeof target[name] === 'object' ? safe(target[name]) : target[name]) : undefined;
    }
  });
}

사용자 정의 헬퍼 함수

const pick = (target, path) => path.split(".").reduce((acc, key) => acc && acc[key], target);
const age = pick(data, 'student.age');

zod를 사용한 스키마 검증 & 정규화 (API 경계에서의 검증)

import { z } from "zod";

export const TabDto = z.object({
  // 서버가 최소한 제공한다 가정하는 필드들 (실제 키 이름에 맞게 수정)
  id: z.string().or(z.number()).transform(String),
  label: z.string().trim().min(1).optional(),
  slug: z.string().trim().min(1).optional(),     // routeName 후보
  icon: z.string().optional(),
});

export const TabsResponseDto = z.array(TabDto);

// 앱 내부에서 쓸 도메인 모델: name/label은 항상 존재
export type Tab = {
  id: string;
  routeName: string;   // React Navigation screen name (고유)
  label: string;       // 탭에 보여줄 글자
  icon?: string;
};
// libs/normalizeTabs.ts
import slugify from "slugify";
import { TabsResponseDto, Tab } from "./schemas";
import { uniqBy } from "lodash";

type NormalizeResult =
  | { kind: "ok"; tabs: Tab[]; issues: string[] }
  | { kind: "empty"; tabs: Tab[]; issues: string[] }; // 폴백 발생

const toSlug = (s: string) =>
  slugify(s, { lower: true, strict: true }) || "tab";

export function normalizeTabs(input: unknown): NormalizeResult {
  const issues: string[] = [];

  const parsed = TabsResponseDto.safeParse(input);
  if (!parsed.success) {
    issues.push("tabs: invalid shape");
    return { kind: "empty", tabs: defaultTabs(), issues };
  }
  const raw = parsed.data;

  // 필수 정보 정규화: label/slug 비어있으면 대체
  let mapped: Tab[] = raw.map((t, i) => {
    const base = t.slug ?? t.label ?? `tab-${i + 1}`;
    const routeName = toSlug(base);
    const label = t.label?.trim() || `Tab ${i + 1}`;
    return { id: t.id, routeName, label, icon: t.icon };
  });

  // 중복 routeName 처리
  const seen = new Map<string, number>();
  mapped = mapped.map((t) => {
    const count = (seen.get(t.routeName) ?? 0) + 1;
    seen.set(t.routeName, count);
    return count === 1 ? t : { ...t, routeName: `${t.routeName}-${count}` };
  });

  // 완전 중복(같은 id, 같은 routeName) 제거 (보수적)
  mapped = uniqBy(mapped, (t) => `${t.id}:${t.routeName}`);

  // 결과가 비거나 모두 “의미 없는” 값이면 폴백
  if (mapped.length === 0) {
    issues.push("tabs: empty after normalization");
    return { kind: "empty", tabs: defaultTabs(), issues };
  }

  return { kind: "ok", tabs: mapped, issues };
}

function defaultTabs(): Tab[] {
  return [
    { id: "home", routeName: "home", label: "Home" },
    { id: "feed", routeName: "feed", label: "Feed" },
  ];
}

optional chaining을 남용하는 휴먼 에러를 방지할 수 있는 방법

ESLint rule 설정

  1. no-unsafe-optional-chaining

  2. unicorn/explicit-length-check

  3. @typescript-eslint/no-unnecessary-condition

참고 자료