PUBLISHED
Nullish Coalescing Assignment
작성일: 2025.09.29

최근 Grunfeld를 사용해본 지인으로부터 다소 기묘한 버그 리포트를 전달받았다. 모달의 기본 동작을 전역적으로 설정할 수 있도록 제공한 GrunfeldProvider의 options 프로퍼티가, 특정 상황에서 기대와 전혀 다른 방식으로 동작한다는 내용이었다. 사용자는 단순히 defaultRenderMode만 지정했을 뿐인데, 나머지 기본 옵션들이 전부 무시되거나 undefined로 처리되어 모달이 의도치 않은 형태로 렌더링되고 있었다.
코드를 직접 확인해보니 문제의 원인은 비교적 명확했다. 그리고 그 원인은 외부 환경이나 사용 방식이 아니라, 전적으로 내가 작성한 코드 구조에 있었다. 아래 코드에서 볼 수 있듯이, GrunfeldProvider의 options는 기본값을 가진 매개변수로 선언되어 있다.
export function GrunfeldProvider({
children,
options = {
defaultPosition: "center",
defaultLightDismiss: true,
defaultRenderMode: "inline",
defaultBackdropStyle: { backgroundColor: "rgba(0, 0, 0, 0.3)" },
},
}: GrunfeldProviderProps) {}문제는 이 구조가 “options 자체가 전달되지 않았을 때”만 기본값을 적용한다는 점이다. 사용자가 options={{ defaultRenderMode: "portal" }}처럼 일부 옵션만 전달하면, 매개변수의 기본값은 전혀 사용되지 않는다. 그 결과, defaultRenderMode를 제외한 나머지 값들은 모두 undefined가 되고, 이후 로직에서 의도치 않은 동작으로 이어진다.
아래 JSX 코드에서는 이를 더 분명히 확인할 수 있다.
return (
<Grunfeld
key={index}
position={position ?? options.defaultPosition}
element={element}
lightDismiss={lightDismiss ?? options.defaultLightDismiss}
backdropStyle={backdropStyle ?? options.defaultBackdropStyle}
renderMode={renderMode ?? options.defaultRenderMode}
/>
);겉보기에는 ??를 통해 잘 처리하고 있는 것처럼 보이지만, options.defaultPosition 자체가 이미 undefined라면 아무런 의미가 없다. 결국 “라이브러리 기본값 → 사용자 전역 기본값 → 컴포넌트 개별 props”라는 명확한 계층 구조가 코드 어디에도 제대로 표현되어 있지 않았던 것이다.
더 큰 문제는 확장성에 있었다. 이런 방식에서는 옵션 하나가 추가될 때마다 Provider, JSX, 기본값 선언부 등 여러 군데를 동시에 수정해야 한다. 이는 실수 가능성을 높이고, 라이브러리의 진화 속도를 불필요하게 늦춘다. 결국 이 문제는 단순한 버그가 아니라, “기본값을 책임지는 코드가 흩어져 있다”는 구조적인 결함에 가까웠다.
이 문제를 해결하기 위해, 나는 기본값 병합 로직을 한 곳으로 모으기로 했다. 그렇게 도입한 함수가 getMergedProps다. 이 함수의 역할은 명확하다.
라이브러리에서 정의한 기본값을 가장 먼저 적용하고
그 위에 사용자가 Provider를 통해 전달한 전역 기본값을 덮어쓰며
마지막으로 컴포넌트에 직접 전달된 props를 최종적으로 반영한다
이 우선순위가 코드 레벨에서 분명하게 드러나도록 만드는 것이 목표였다.
type Default = GrunfeldProviderProps["options"];
export function getMergedProps(
grunfeldComponentProps: GrunfeldProps,
propsFormUser: Default = {}
) {
const props = isValidGrunfeldElement(grunfeldComponentProps)
? grunfeldComponentProps
: { element: grunfeldComponentProps };
const defaultProps = {
defaultPosition: "center",
defaultLightDismiss: true,
defaultRenderMode: "inline",
defaultBackdropStyle: { backgroundColor: "rgba(0, 0, 0, 0.3)" },
};
Object.keys(defaultProps).forEach((key) => {
const originalKey = key.replace("default", "") as string;
// 유저가 지정한 값이 없으면 라이브러리 기본값 할당
(propsFormUser as any)[key] ??= defaultProps[key as keyof Default];
// 최종 props에 유저 기본값 또는 라이브러리 기본값 할당
(props as any)[originalKey] ??= propsFormUser[key as keyof Default];
});
return props;
}여기서 눈에 띄는 연산자가 하나 있다. 바로 널 병합 할당 연산자(??=)다. 처음 보는 개발자라면 다소 생소할 수도 있지만, 이 문제를 해결하는 데에는 매우 적합한 연산자다.
??=는 좌변의 값이 null 또는 undefined일 때만 우변을 할당한다. 즉, 값이 “존재하지 않을 때만” 기본값을 채워 넣는다. 이는 false, 0, ""처럼 의도적으로 전달된 falsy 값이 덮어써지는 문제를 깔끔하게 방지해준다.
// 라이브러리 기본값과 유저 기본값 병합
propsFromUser[key] ??= defaultProps[key];
// 최종 컴포넌트 props에 병합
(props as any)[normalizedKey] ??= propsFromUser[key];개념적으로는 널 병합 연산자(??)와 유사하지만, 중요한 차이가 있다. ??는 값을 “반환”할 뿐이고, ??=는 좌변을 직접 “갱신”한다. 다시 말해 foo ??= bar는 foo = foo ?? bar와 동일한 의미를 가지지만, 의도가 훨씬 명확하고 코드도 간결하다.
이 연산자를 활용함으로써, Grunfeld의 기본값 병합 로직은 한 곳에 응집되었다. JSX에서 반복적으로 ??를 사용하던 코드도 사라졌고, 새로운 옵션이 추가되더라도 defaultProps만 수정하면 전체 흐름이 자동으로 따라오게 되었다. 무엇보다도, 명시적으로 전달된 값은 어떤 경우에도 덮어쓰지 않는다는 규칙이 코드 자체로 보장된다.
널 병합 할당 연산자는 널 병합 연산자에 비해 상대적으로 덜 알려져 있지만, “기본값을 책임지는 로직”을 다루는 라이브러리 코드에서는 특히 강력한 도구다. 값의 우선순위를 명확히 표현하면서도, 불필요한 조건 분기를 제거할 수 있기 때문이다.
이번 경험을 통해 다시 한 번 느낀 점은, 버그 자체보다도 그 버그를 만들어낸 구조가 더 중요하다는 사실이다. ??=는 단순히 문법 하나를 추가로 배운 사례가 아니라, 기본값과 옵션 병합이라는 문제를 더 올바른 위치에서 해결하도록 도와준 계기였다. 실무에서 라이브러리를 설계한다면, 이런 작은 연산자 하나가 코드의 품질과 유지보수성을 크게 바꿔놓을 수 있다.