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 설정
-
no-unsafe-optional-chaining
-
unicorn/explicit-length-check
-
@typescript-eslint/no-unnecessary-condition



dev.to
