
최근 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만 수정하면 전체 흐름이 자동으로 따라오게 되었다. 무엇보다도, 명시적으로 전달된 값은 어떤 경우에도 덮어쓰지 않는다는 규칙이 코드 자체로 보장된다.
널 병합 할당 연산자는 널 병합 연산자에 비해 상대적으로 덜 알려져 있지만, “기본값을 책임지는 로직”을 다루는 라이브러리 코드에서는 특히 강력한 도구다. 값의 우선순위를 명확히 표현하면서도, 불필요한 조건 분기를 제거할 수 있기 때문이다.
이번 경험을 통해 다시 한 번 느낀 점은, 버그 자체보다도 그 버그를 만들어낸 구조가 더 중요하다는 사실이다. ??=는 단순히 문법 하나를 추가로 배운 사례가 아니라, 기본값과 옵션 병합이라는 문제를 더 올바른 위치에서 해결하도록 도와준 계기였다. 실무에서 라이브러리를 설계한다면, 이런 작은 연산자 하나가 코드의 품질과 유지보수성을 크게 바꿔놓을 수 있다.
더 읽어보기
2026.04.11
Trie 자료구조
문자열 데이터를 다룰 때 단순히 “이 단어가 있나?”만으로는 부족한 순간이 있다. 자동 완성처럼 특정 접두사로 시작하는 후보를 모아야 할 때도 있고, 어떤 키에 값을 두고 빠르게 찾고 싶을 때도 있다. 이럴 때 가장 자연스럽게 떠올릴 수 있는 자료구조가 바로 Trie이다. Trie는 무…
2026.03.19
Streams API 부록 2. 왜 이미지는 위에서 아래로 나타날까
느린 네트워크에서 큰 이미지를 열면 가끔 화면이 위에서 아래로 채워진다. 마치 누군가 아주 성실하게 롤러로 이미지를 칠하는 것처럼 보인다. 물론 브라우저 안에 그런 직원은 없다. 있다면 우리보다 야근을 더 많이 하고 있을 것이다. 이 현상은 단순한 시각 효과가 아니라 네트워크 전송, 브…
2026.03.19
Streams API 부록 1. HTTP 다운로드 진행률은 어떻게 계산될까
다운로드 화면에서 진행 막대가 조금씩 차오르면 이상하게 안심된다. 반대로 스피너만 계속 돌면 파일이 오는 중인지, 서버가 고민 중인지, 내 인생이 잠깐 멈춘 건지 알 수 없다. 사용자는 둘 다 “다운로드 중”이라고 느끼지만, 내부적으로는 꽤 다른 상황일 수 있다. 진행률 계산 자체는 복…
2026.03.13
Streams API 4. 왜 모든 언어에는 Stream API가 존재할까
Streams API를 공부하다 보면 묘한 기시감이 온다. JavaScript에서 ReadableStream, WritableStream, TransformStream을 보고 있는데, Java의 InputStream, Go의 io.Reader, Rust의 Read와 Write가 멀리서…
2026.05.26
차트는 멈췄는데 윈도우가 움직인다
상황 어느날 서비스를 살펴보시던 팀장님께서 이런 말씀을 slack에 남기셨다. 진호님, 예측 차트에서 zoom을 계속하면 어느 순간 라인 차트가 아니라 단일 스캐터 차트처럼 보이는 데, 이거 수정하면 좋을 거 같아요. 어느정도 zoom을 하면 그 이후로는 zoom이 안 되도록 할 수 없…
2026.05.21
피자가게로 이해하는 디자인 패턴
에이든 피자는 처음부터 복잡한 시스템을 만들 생각이 없었다. 처음에는 메뉴 몇 개만 만들면 됐다. 그런데 손님은 커스텀 주문을 넣기 시작했고, 주방은 상태를 나눠야 했고, 결제와 배달앱과 알림이 하나씩 붙었다. 코드도 가게를 닮는다. 장사가 잘될수록 이상하게 더 쉽게 망가진다. 디자인…
2026.05.21
7. Decorator — 토핑 추가할 때마다 클래스를 새로 만들 수 없다
에이든 피자에서 주문서를 객체로 만들자 취소와 재주문은 한결 편해졌다. 그런데 주문이 편해지자 손님들도 한결 편해졌다. 편해진 손님은 더 많은 요구를 한다. "치즈 추가요", "올리브도 추가요", "소스 많이요", "조금 더 바삭하게 구워주세요" 같은 요청이 주문대 위로 쌓이기 시작했다…
2026.05.21
6. Command — 주문서를 객체로 만들면 취소도 재주문도 쉬워진다
에이든 피자의 시범 운영이 어느덧 일주일을 향해 가고 있다. 주방은 이제 제법 능숙하게 피자를 구워내고, 포스기(POS) 시스템도 팩토리 메서드 덕분에 다양한 지점 메뉴를 무리 없이 받아낸다. 하지만 시스템이 안정될수록 요구사항은 더 정교해지기 마련이다. 오늘은 주문 그 자체가 문제가…
댓글
댓글을 불러오는 중...