PUBLISHED

미들웨어 조합 구조에서의 unique symbol 활용기

작성일: 2025.01.22

미들웨어 조합 구조에서의 unique symbol 활용기

요즘 다른 전역 상태 라이브러리 대신 caro-kann 3 버전을 적극적으로 사용하고 있다. 여러 기능을 미들웨어 단위로 분리할 수 있어 번들 크기를 700B대까지 줄일 수 있었고, zustand, useReducer, useState 스타일을 상황에 따라 선택할 수 있다는 점도 다른 상태 관리 라이브러리 못지않은 장점으로 느껴진다.

며칠 전에는 reducerpersist 미들웨어를 결합해, 세션 종료 이후에도 상태를 유지하는 장바구니 기능을 구현하고 있었다. 일반적으로 장바구니는 여러 개의 product를 보관하고, 장바구니 페이지에서는 이 목록 전체를 렌더링한다. 자연스럽게 전역 장바구니 상태는 배열 형태를 띠게 된다.

untitled
TS
export const useCartStore = create(
  reducer((state, action: { type: string; payload: Product }) => {
    switch (action.type) {
      case "ADD_ITEM":
        if (state.find(item => item.id === action.payload.id)) {
          alert("이미 장바구니에 담겨있습니다");
          return state;
        }
        return [...state, action.payload];
      case "REMOVE_ITEM":
        return state.filter(item => item.id !== action.payload.id);
      case "CLEAR_CART":
        return [] as ProductList;
      default:
        return state;
    }
  }, persist<ProductList>([], {
    local: "cart"
  }))
);

그런데 useCartStore를 사용해 상태를 확인해보니, cart가 배열이 아니라 undefined로 출력되고 있었다. 다른 초기값을 넣어보면 정상적으로 동작하는데, 초기 상태가 배열일 때만 문제가 발생했다.

untitled
JS
const [cart] = useCartStore();
console.log(cart); // undefined

문제의 원인을 찾기 위해 caro-kann의 미들웨어 구현을 살펴보았고, 생각보다 빠르게 핵심을 발견할 수 있었다. 기존 persist 미들웨어는 initStateT | [Store<T>, string] 형태로 받고, 본인 역시 [Store<T>, "persist"] 형태의 튜플을 반환하고 있었다.

이 구조는 의도 자체는 명확하다. 미들웨어를 중첩해서 사용하더라도, “이미 한 번 가공된 store인지”를 구분하기 위해 튜플을 사용하는 방식이다. 실제로 zustand 미들웨어를 제외한 caro-kann의 미들웨어는 모두 이 패턴을 따른다.

문제는 store를 생성하는 로직에 있었다.

untitled
TS
export const persist: Middleware["persist"] = <T,>(
  initState: T | [Store<T>, string],
  options: PersistConfig<T>
) => {
  const Store =
    initState instanceof Array
      ? initState[0]
      : createStore(initState);

  ...

  return [{ ...Store, setStore }, "persist"] as [Store<T>, "persist"];
};

나는 instanceof ArrayT[Store<T>, string]을 구분할 수 있을 것이라 생각했다. 하지만 initState배열 자체를 넘겨버리는 순간, 이 가정은 완전히 무너진다. 빈 배열을 초기 상태로 넘기면 initState instanceof Array는 참이 되고, initState[0]에서 Store를 찾으려 하지만 거기에는 undefined밖에 없다. 이후 로직 전반에서 Store 대신 undefined를 사용하게 되고, 그 결과 최종 상태 역시 undefined가 되어버린다.

결론은 명확했다. 미들웨어가 튜플을 받고, 튜플을 리턴하는 구조는 근본적인 한계를 갖고 있다. 그래서 나는 구조 자체를 바꿔야겠다고 판단했다. 거의 본능적으로 “객체를 써야겠다”는 생각이 들었지만, 여기에는 한 가지 껄끄러운 문제가 있었다. 타입스크립트 레벨에서 각 미들웨어의 정체성을 추적하려면 일종의 storeTypeTag가 필요한데, 이를 일반 문자열 키로 정의하면 사용자가 동일한 프로퍼티 이름을 사용하는 순간 충돌이 발생할 수 있다.

이 문제를 해결하지 못한다면, 튜플을 객체로 바꾸는 것은 단지 문제가 발생할 확률을 낮추는 것에 불과하다. 여기서 등장한 것이 unique symbol이다. unique symbol은 타입스크립트가 제공하는 특수한 심볼 타입으로, 선언 시점마다 타입 레벨에서 완전히 고유한 식별자로 취급된다. 여기에 자바스크립트의 Symbol을 결합하면, 타입 시스템과 런타임 양쪽에서 충돌 없는 키를 만들 수 있다.

나는 런타임에서도 이 값이 필요했기 때문에 다음과 같이 정의했다.

untitled
TS
// 타입 시스템에서만 존재
export declare const storeTypeTag: unique symbol;

// 런타임에서도 존재
export const storeTypeTag: unique symbol = Symbol("storeTypeTag");

그리고 미들웨어의 반환 타입을 튜플 대신 명시적인 객체 구조로 정의했다.

untitled
TS
export type TMiddlewareStore<
  TInitState,
  TStoreType = TMiddlewareStore,
  TSetStore = SetStore
> = {
  store: Store<TInitState, TSetStore>;
  [storeTypeTag]: TStoreType;
};

이제 모든 미들웨어는 isMiddlewareStore라는 커스텀 타입 가드를 통해 initState를 판별한다. 이 타입 가드는 내부적으로 storeTypeTag 심볼을 사용하므로, 값이 실제로 미들웨어에서 생성된 store인지를 런타임에서도 정확히 구분할 수 있다.

untitled
TS
export const persist: Middleware["persist"] = <T,>(
  initState: T | TMiddlewareStore<T>,
  options: PersistConfig<T>
) => {
  const Store = isMiddlewareStore(initState)
    ? initState.store
    : createStore(initState);

  ...

  return {
    store: { ...Store, setStore },
    [storeTypeTag]: "persist"
  };
};

export const isMiddlewareStore = <T>(
  initState: T | TMiddlewareStore<T>
): initState is TMiddlewareStore<T> => {
  return storeTypeTag in (initState as object);
};

물론 storeTypeTag 심볼이 export 되어 있는 이상, 사용자가 이를 import 해서 악의적으로 사용하지 말라는 법은 없다. 이론적으로는 caro-kann이 잘못된 판단을 하도록 유도할 수도 있다. 하지만 이 단계까지 방어하는 것은 실질적인 비용 대비 효용이 낮다고 판단했다.

중요한 점은, 배열이라는 합법적인 초기 상태 값 때문에 라이브러리가 오작동하는 구조를 제거했다는 것이다. 튜플 기반 설계에서 객체 + unique symbol 기반 설계로 전환하면서, 타입 안정성과 런타임 안정성을 동시에 확보할 수 있었다. 이번 경험을 통해 다시 한 번 느낀 것은, 라이브러리 설계에서 “편해 보이는 패턴”보다 “잘못 사용될 여지가 없는 구조”가 훨씬 중요하다는 사실이다.