본문으로 건너뛰기

널 병합(nüllish coalescing)

· 약 6분
저스틴 리지웰(Justin Ridgewell)

널 병합 제안 (??)은 기본값을 처리하기 위한 새로운 단축 평가 연산자를 추가합니다.

여러분은 이미 단축 평가 연산자인 &&||를 알고 있을 가능성이 높습니다. 이 연산자들은 “true값”과 “false값”을 처리합니다. 예를 들어 lhs && rhs라는 코드 샘플을 생각해 봅시다. lhs(좌측 피연산자)가 false값이면, 표현식은 lhs를 평가합니다. 그렇지 않으면 rhs(우측 피연산자)를 평가합니다. 반대로 lhs || rhs라는 코드 샘플의 경우에는, lhs가 true값이면 표현식은 lhs를 평가합니다. 그렇지 않으면 rhs를 평가합니다.

하지만 “true값”과 “false값”이 정확히 무슨 의미일까요? 명세 용어로는 이것이 ToBoolean 추상 연산과 동등합니다. 일반적인 JavaScript 개발자들에게는, 모든 값이 true값이며, false값은 undefined, null, false, 0, NaN, 그리고 빈 문자열 ''뿐입니다. (기술적으로 document.all에 연결된 값도 false값이지만, 이것은 나중에 다룰 것입니다.)

그렇다면 &&||의 문제는 무엇일까요? 왜 새로운 널 병합 연산자가 필요할까요? 그것은 true값과 false값의 정의가 모든 상황에 들어맞지 않아서 버그가 발생하기 때문입니다. 다음과 같은 예를 생각해 봅시다:

function Component(props) {
const enable = props.enabled || true;
// …
}

이 예제에서 enabled 속성을 구성 요소의 특정 기능이 활성화될지 여부를 제어하는 선택적 불 대수 속성으로 취급한다고 가정해 봅시다. 이는 enabledtrue 또는 false로 명시적으로 설정할 수 있음을 의미합니다. 하지만 선택적 속성이기 때문에 전혀 설정하지 않음으로써 암시적으로 undefined로 설정할 수도 있습니다. undefined이면 구성 요소가 enabled = true(기본값)인 것처럼 다루고 싶습니다.

이제 코드 예제에서 버그를 발견할 수 있을 것입니다. 우리가 enabled = true를 명시적으로 설정하면 enable 변수는 true입니다. enabled = undefined를 암시적으로 설정하면 enable 변수는 true입니다. 그리고 enabled = false를 명시적으로 설정하면 enable 변수가 여전히 true입니다! 우리는 값을 기본값으로 true로 설정하려고 했지만 실제로는 값을 강제로 설정했습니다. 이 경우 문제를 해결하려면 우리가 기대하는 값을 명확히 해야 합니다:

function Component(props) {
const enable = props.enabled !== false;
// …
}

우리는 모든 false값에서 이러한 유형의 버그가 나타나는 것을 볼 수 있습니다. 이것은 아주 쉽게 선택적 문자열(빈 문자열 ''이 유효한 입력으로 간주되는 경우) 또는 선택적 숫자(0이 유효한 입력으로 간주되는 경우)가 될 수 있습니다. 이러한 문제는 매우 일반적이어서 이제 널 병합 연산자를 도입하여 기본값 할당을 처리하려 합니다:

function Component(props) {
const enable = props.enabled ?? true;
// …
}

널 병합 연산자 (??)는 || 연산자와 매우 비슷하게 작동하지만, 연산자를 평가할 때 “true값”을 사용하지 않습니다. 대신 “널 값(nullish)”의 정의를 사용합니다. 즉, 값이 null 또는 undefined와 엄격히 동등한지 여부를 판단합니다. 따라서 lhs ?? rhs 표현식을 생각해 보면, lhs가 널 값이 아니면 lhs를 평가합니다. 그렇지 않으면 rhs를 평가합니다.

명시적으로, 이는 값 false, 0, NaN, 그리고 빈 문자열 '' 모두가 false값이며, 널 값이 아님을 의미합니다. 이러한 false값이지만 널 값이 아닌 값들이 lhs ?? rhs의 좌측에 있을 때, 표현식은 우측이 아닌 해당 값을 평가합니다. 이제 버그는 사라집니다!

false ?? true;   // => false
0 ?? 1; // => 0
'' ?? '기본값'; // => ''

null ?? []; // => []
undefined ?? []; // => []

객체 구조 분해 시 기본값 할당은 어떨까요?

마지막 코드 예제를 객체 구조 분해 내에서 기본값 할당을 사용하여 해결할 수도 있음을 알아차렸을 겁니다:

function Component(props) {
const {
enabled: enable = true,
} = props;
// …
}

조금 장황하게 느껴질 수 있지만, 이는 완전히 유효한 JavaScript입니다. 다만, 약간 다른 의미론을 사용합니다. 객체 구조 분해 내에서의 기본값 할당은 속성이 undefined와 엄격히 동등한지를 확인하고, 그렇다면 기본값을 할당합니다.

하지만 이러한 undefined만에 대한 엄격한 동등성 검사가 항상 바람직한 것은 아니며, 구조 분해할 객체가 항상 제공되는 것도 아닙니다. 예를 들어, 함수의 반환 값에서 기본값을 설정하고 싶을 수도 있습니다(구조 분해할 객체가 없음). 또는 함수가 null을 반환할 수도 있습니다(DOM API에서 흔히 발생). 이럴 때 널 병합을 사용하는 것이 좋습니다:

// 간결한 널 병합
const link = document.querySelector('link') ?? document.createElement('link');

// 기본 할당 구조 분해와 상용구
const {
link = document.createElement('link'),
} = {
link: document.querySelector('link') || undefined
};

또한 옵셔널 체이닝과 같은 특정 새로운 기능은 구조 분해와 완벽하게 작동하지 않을 수 있습니다. 구조 분해는 객체를 필요로 하므로, 옵셔널 체인이 객체 대신 undefined를 반환했을 경우를 대비해 구조 분해를 보호해야 합니다. Nullish 병합 연산자를 사용할 경우 이런 문제가 없습니다:

// 옵셔널 체이닝 및 Nullish 병합 연산자의 동시 사용
const link = obj.deep?.container.link ?? document.createElement('link');

// 옵셔널 체이닝과 기본 할당 구조 분해
const {
link = document.createElement('link'),
} = (obj.deep?.container || {});

연산자 혼합 및 조합

언어 디자인은 어렵습니다. 새로운 연산자를 만드는 경우 개발자의 의도를 약간 모호하게 만들 가능성이 있습니다. &&|| 연산자를 혼합하여 사용한 적이 있다면 이 모호성을 직접 경험해봤을 것입니다. 표현식 lhs && middle || rhs를 생각해 보세요. 자바스크립트에서는 이것을 (lhs && middle) || rhs로 해석합니다. 이제 lhs || middle && rhs 표현식을 생각해 보면 이것은 lhs || (middle && rhs)로 해석됩니다.

&& 연산자는 || 연산자보다 왼쪽 및 오른쪽에서 더 높은 우선 순위를 갖습니다. 따라서 묵시적 괄호가 || 대신 &&를 감싸게 됩니다. ?? 연산자를 설계할 때, 우리는 우선 순위가 어떻게 되어야 할지 결정해야 했습니다. 다음 중 하나를 선택해야 했습니다:

  1. &&||보다 낮은 우선 순위
  2. &&보다 낮지만 ||보다는 높은 우선 순위
  3. &&||보다 높은 우선 순위

각각의 우선 순위 정의를 네 가지 가능한 테스트 케이스에 통과시켜야 했습니다:

  1. lhs && middle ?? rhs
  2. lhs ?? middle && rhs
  3. lhs || middle ?? rhs
  4. lhs ?? middle || rhs

각 테스트 표현식에서 묵시적 괄호가 어디에 위치해야 할지 결정해야 했습니다. 괄호가 개발자가 의도한 표현을 정확히 감싸지 않으면 잘못 작성된 코드가 됩니다. 불행히도 어떤 우선 순위를 선택하더라도 하나의 테스트 표현식은 개발자의 의도를 위반할 수 있었습니다.

결국 우리는 ??와 (&& 또는 ||)를 혼합할 때 명시적 괄호를 요구하도록 결정했습니다 (괄호 그룹은 명시적으로 표시했습니다! 메타 농담!). 혼합하는 경우 연산자 그룹 중 하나를 괄호로 묶어야 하며 그렇지 않으면 구문 오류가 발생합니다.

// 혼합하려면 명시적 괄호 그룹이 필요합니다
(lhs && middle) ?? rhs;
lhs && (middle ?? rhs);

(lhs ?? middle) && rhs;
lhs ?? (middle && rhs);

(lhs || middle) ?? rhs;
lhs || (middle ?? rhs);

(lhs ?? middle) || rhs;
lhs ?? (middle || rhs);

이 방식으로 언어 파서는 항상 개발자가 의도한 대로 대응하며, 이후에 코드를 읽는 사람도 즉시 이해할 수 있습니다. 훌륭하죠!

document.all에 대해 말해주세요

document.all은 절대로 사용해서는 안 되는 특별한 값입니다. 그러나 이것을 사용한 경우, 이것이 어떻게 “truthy” 및 “nullish”와 상호작용하는지 이해하는 것이 중요합니다.

document.all은 배열과 비슷한 객체로, 배열처럼 인덱스 속성과 길이를 가지고 있습니다. 객체는 보통 truthy이지만 document.all은 놀랍게도 falsy로 간주됩니다! 사실 이것은 nullundefined와 느슨하게 동일합니다 (이는 일반적으로 속성을 가질 수 없음을 뜻합니다).

document.all&& 또는 ||와 함께 사용할 때, 이것은 falsy로 간주됩니다. 그러나 이것은 null 또는 undefined와 엄격하게 동일하지 않으므로, nullish가 아닙니다. 따라서 document.all??와 함께 사용할 때는 일반 객체처럼 동작합니다.

document.all || true; // => true
document.all ?? true; // => HTMLAllCollection[]

Nullish 병합 연산자에 대한 지원