문제점: 중첩 객체 접근은 너무 취약하다
중첩된 객체를 파고들어 값을 꺼내는 건 별 문제 없어 보입니다. 그런데 중간 단계 중 하나라도 값이 없으면 바로 터져버리죠:
user.address가 undefined인 상태에서 .city에 접근하면 TypeError: Cannot read properties of undefined 에러가 터집니다. API 응답이나 파싱한 JSON, DOM 쿼리 결과처럼 실무에서 다루는 데이터는 있을 수도, 없을 수도 있는 필드로 가득하죠. 그런데 매 단계마다 일일이 방어 코드를 작성하다 보면 금세 지저분해집니다:
const city = user && user.address && user.address.city;
두 단계 정도까지는 그럭저럭 읽을 만합니다. 하지만 네 단계쯤 되면 정말 괴롭죠. 이럴 때 ?. 연산자를 쓰면 훨씬 깔끔하게 해결할 수 있습니다.
?.는 null과 undefined에서 단락 평가됩니다
옵셔널 체이닝 연산자는 앞의 값이 null이나 undefined가 아닐 때만 프로퍼티를 읽습니다. 만약 해당 값이라면 전체 표현식은 그 자리에서 평가를 멈추고 undefined를 반환합니다.
첫 번째 줄은 name을 평범하게 읽어옵니다. 두 번째 줄은 user.address에서 undefined를 만나 곧바로 중단되고, 나머지 체인은 아예 실행되지 않아요. 세 번째 줄은 ?.이 없었다면 undefined에 .toUpperCase()를 호출하려다 에러가 났겠지만, ?. 덕분에 조용히 undefined로 평가됩니다.
이해하는 요령은 이렇습니다. ?.은 "내 앞에 있는 값이 null이거나 undefined면 포기하고 undefined를 돌려줘, 아니면 계속 진행해"라는 뜻이에요.
배열과 함수 호출에도 쓸 수 있는 옵셔널 체이닝
문법은 세 가지지만, 핵심 아이디어는 모두 같습니다:
?.[...]는 배열 인덱스나 동적 프로퍼티 접근에 사용합니다.?.()는 함수일 수도, 아닐 수도 있는 값을 호출할 때 씁니다.?.name은 일반적인 프로퍼티 접근 방식입니다.
세 가지 모두 동일한 방식으로 단락 평가(short-circuit)됩니다. user?.notAMethod?.()처럼 써도 에러가 나지 않는데, notAMethod가 존재하지 않더라도 두 번째 ?.가 undefined를 감지하고 바로 중단하기 때문이죠.
특히 선택적 콜백을 다룰 때 이 패턴이 아주 유용합니다:
if (typeof onDone === "function") 같은 방어 코드를 매번 호출 앞에 붙일 필요가 없어진 거죠.
null과 undefined만 단락 평가를 유발한다
이 부분에서 많이들 헷갈립니다. ?. 연산자는 오직 nullish 값(null, undefined)에만 반응합니다. 다른 falsy 값들 — 0, "", false, NaN — 은 멀쩡히 체이닝할 수 있는 값으로 취급돼요 (물론 오토박싱이 허용하는 범위 내에서요):
data.count는 0인데, 0은 falsy이긴 해도 nullish는 아니기 때문에 ?.toFixed(2)가 그대로 실행되어 "0.00"이 반환됩니다. 같은 상황을 &&로 처리하면 어떻게 달라지는지 비교해 볼까요:
&& 버전은 data.count가 falsy라서 단락 평가(short-circuit)로 0을 반환합니다. 반면 ?. 버전은 0이 nullish가 아니기 때문에 "0.00"을 반환하죠. 0에서도 멈추고 싶다면 &&가 맞고, "값이 아예 없을 때"만 멈추고 싶다면 ?.를 써야 합니다.
?를 어디에 붙이느냐가 중요하다
?.는 뒤가 아니라 앞 에 있는 값을 보호합니다. 그러니 비어 있을 가능성이 있는 바로 그 단계에 붙여야 해요:
여기서는 셋 다 문제없이 동작합니다. 실제로 빠진 값이 없으니까요. 하지만 config.server가 undefined일 가능성이 있다면 config.server?.host로 써야 합니다. server 앞에 ?.을 붙이는 건 도움이 안 됩니다. 진짜 문제는 존재하지 않는 server에서 .host를 읽어내려는 것이거든요.
한 가지 원칙을 기억해 두세요. ?.은 바로 앞에 있는 값이 실제로 nullish일 수 있는 위치에만 붙이는 겁니다. "혹시 모르니까"라며 점마다 ?.을 뿌려두면 버그가 가려지고 코드만 지저분해집니다.
?.으로는 값을 할당할 수 없습니다
옵셔널 체이닝은 읽기 전용입니다. 할당 연산자의 왼쪽에는 쓸 수 없어요:
user?.address?.city = "파리"; // SyntaxError
생각해 보면 당연한 얘기죠. undefined의 프로퍼티에 값을 할당한다는 게 대체 무슨 의미일까요? 부모 객체가 존재할 때만 값을 설정하고 싶다면, 이렇게 풀어서 작성해야 합니다:
언제 쓰고, 언제 쓰지 말아야 할까
옵셔널 체이닝 연산자 ?.는 값이 정말로 있을 수도 있고 없을 수도 있는 상황에서 빛을 발합니다.
- 특정 필드가 포함될 수도, 안 될 수도 있는 API 응답을 다룰 때
- DOM 요소를 찾을 때:
document.querySelector(".banner")?.remove() - 전달되지 않을 수도 있는 콜백을 호출할 때:
options.onError?.(err) - 중간 결과가
null이 될 수 있는 라이브러리 체인 호출
반대로, 값이 항상 존재해야 하는 자리에서는 쓰지 않는 게 좋습니다. 단순히 에러를 없애려고 ?.를 남발하면, 눈에 잘 띄는 버그("42번째 줄에서 TypeError")가 조용한 버그(어디선가 변수가 슬그머니 undefined가 되어 세 단계 떨어진 함수에서 터지는)로 바뀌어 버립니다. 반드시 있어야 할 값이라면 차라리 에러가 나도록 두세요. 스택 트레이스가 오히려 여러분을 돕는 셈입니다.
실전 예제
일부 필드가 빠져 있을 수도 있는 API 응답에서 값을 꺼내 보겠습니다.
?. 하나는 딱 한 단계의 불확실성을 처리합니다. 그래서 avatarUrl은 깔끔하게 undefined로 떨어지고, onClick?.()은 핸들러가 있을 때만 호출됩니다.
displayName 줄에 쓰인 ??도 눈에 띄었을 텐데요, 이게 바로 옵셔널 체이닝과 짝을 이루는 패턴의 나머지 반쪽입니다. ?.는 값이 없을 때 undefined를 돌려주고, ??는 0이나 "" 같은 멀쩡한 falsy 값을 건드리지 않으면서 기본값을 지정할 수 있게 해줍니다.
다음 주제: 널 병합 연산자(Nullish Coalescing)
?.와 ??는 같은 발상을 서로 다른 문제에 적용한 연산자입니다. 둘 다 null과 undefined만 "값이 없는 것"으로 보고, 다른 falsy 값은 그대로 둡니다. 다음 글에서는 ??로 제대로 된 기본값을 지정하는 방법, 그리고 ||가 왜 오랫동안 이 역할에서 은근히 잘못된 결과를 내왔는지 살펴보겠습니다.
자주 묻는 질문
자바스크립트에서 ?.는 어떤 역할을 하나요?
?.는 어떤 역할을 하나요??. 앞에 있는 값이 null이나 undefined가 아닐 때만 프로퍼티, 배열 인덱스, 메서드에 접근합니다. 만약 null이나 undefined라면 에러를 던지는 대신 전체 표현식이 단락 평가되어 undefined가 됩니다. 예를 들어 user?.address?.city는 user나 address가 없어도 터지지 않습니다.
옵셔널 체이닝은 언제 쓰는 게 좋나요?
값이 실제로 없을 수도 있는 상황에 쓰는 게 맞습니다. 선택적 필드가 있는 API 응답, 노드를 못 찾을 수 있는 DOM 조회, 넘어올 수도 있고 아닐 수도 있는 콜백 같은 경우죠. 반대로 '원래 반드시 있어야 하는 값'을 숨기는 용도로 쓰면 안 됩니다. 그런 상황에서 값이 비었다는 건 감춰야 할 게 아니라 오히려 빨리 드러나야 할 버그 신호입니다.
?.와 &&는 뭐가 다른가요?
?.와 &&는 뭐가 다른가요?결과가 같아 보이는 경우가 많지만, ?.는 오직 null과 undefined에서만 단락되는 반면 &&는 0, '', false 같은 모든 falsy 값에서 단락됩니다. 즉 count가 0일 때 obj && obj.count는 0을 반환하지만(체인이 끊김), obj?.count는 0을 정상적으로 반환합니다. 옵셔널 체이닝은 딱 'nullish' 값에서만 깔끔하게 멈춥니다.
배열이나 함수 호출에도 옵셔널 체이닝을 쓸 수 있나요?
네, 가능합니다. arr?.[0]는 배열 인덱스에 안전하게 접근하고, fn?.()는 함수가 존재할 때만 호출합니다. 규칙은 동일합니다. ?. 앞의 값이 null이나 undefined면 전체 표현식이 undefined가 되고 뒤쪽은 아예 실행되지 않습니다.