unique symbol
요즘 프론트 과제를 진행하면서 caro-kann 3 버전을 적극적으로 사용하고 있다. 여러 기능을 미들웨어로 분리하여 번들 크기도 700b대로 줄였고, zustand∙useReducer∙useState 문법을 취사 선택할 수 있다는 점이 나에게는 다른 라이브러리 못지 않은 장점으로 여겨지기 때문이다.
며칠 전에도 caro-kann에 reducer와 persist 미들웨어를 결합하여 세션 종료에도 버티는 장바구니 기능을 만들고 있었다. 보통 장바구니에는 product를 보관하고, 장바구니 페이지에서는 보관하고 있는 product를 모두 랜더링한다. 그런 만큼 전역 장바구니 상태는 배열의 형태를 띄는 게 일반적이다.
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로 출력되는 것이 아닌가! 다른 값들로 확인해보았지만 문제는 없었고, 오직 initState가 배열일 때만 이러한 현상이 발생하였다. 나는 caro-kann의 미들웨어 로직을 확인해보았고, 어렵지 않게 그 이유를 찾을 수 있었다.
const [cart, cartDispatcher] = useCartStore()
console.log(cart) // undefined
아래는 기존 persist 미들웨어의 구현 코드 일부이다. 잘 보면 initState 파라미터가 T | [Store<T>, string] 타입을 받고, persist 자신도 [Store<T>, "persist"] 타입을 리턴하고 있다. 이처럼 caro-kann의 미들웨어는 ─ zustand 미들웨어를 제외하면 ─ 튜플을 인자로 받아서 처리하고, 본인도 튜플을 리턴하도록 되어있다. 이를 통해 서로 다른 미들웨어를 조합하여 사용하여도 caro-kann은 문제를 일으키지 않는다.
진짜 문제는 Store를 정의하는 과정에 있다. 미들웨어가 튜플을 리턴하므로 나는 instanceof Array를 사용하면 T와 [Store<T>, string]을 구분할 수 있을 거라 생각했다. 하지만 initState에 배열을 제공해버리는 순간 모든 게 꼬여버린다. initState가 배열이므로 initState[0]에서 Store를 찾으려 하지만, 나는 빈 배열을 제공했으므로 거기에는 undefined 밖에 없다. 자연히 이후 과정에서 Store가 필요한 곳마다 undefined를 사용하게 되고, 최종적으로 cart 또한 undefined가 되어버린 것이다.
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"];
};
미들웨어가 튜플을 받고 튜플을 리턴하는 방식은 근본적으로 문제를 안고 있다. 따라서 나는 이를 처리할 다른 방법을 찾아야만 했다. 거의 본능적으로 나는 객체를 써야겠다 생각했는데, 여기에는 한 가지 껄끄러운 지점이 있었다. 타입스크립트를 통해 각각의 미들웨어를 추적하기 위해서는 storeTypeTag가 필요하다. 문제는 사용자가 동일한 이름의 프로퍼티를 쓰는 순간 그 즉시 caro-kann이 고장날 것이라는 점이었다. 이를 해결하지 못한다면 튜플을 객체로 바꾸는 건 그저 문제가 일어날 확률을 낮추는 것 뿐이다.
그리고 이 과정에서 unique symbol이 등장한다. unique symbol은 타입스크립트에서 제공하는 특별한 심볼 타입으로, 생성될 때마다 고유한 값을 가지기 때문에 프로퍼티 이름의 중복 문제를 완벽히 방지할 수 있다. Caro-Kann 내부에서 storeTypeTag를 unique symbol로 정의하면, 사용자가 동일한 이름의 프로퍼티를 정의하더라도 충돌이 발생하지 않기 때문에 안정적으로 동작하게 된다.
나는 런타임에서도 이 값이 필요하기에 아래와 같이 storeTypeTag 심볼을 정의했다. 그리고 미들웨어가 리턴하는 타입을 튜플 대신 TMiddlewareStore으로 정의했다.
// 타입 시스템에서만 존재
export declare const storeTypeTag: unique symbol
// 런타임에서도 존재
export const storeTypeTag: unique symbol = Symbol("storeTypeTag")
export type TMiddlewareStore<
TInitState,
TStoreType = TMiddlewareStore,
TSetStore = SetStore
> = {
store: Store<TInitState, TSetStore>,
[storeTypeTag]: TStoreType
}
이제 모든 미들웨어는 isMiddlewareStore라는 커스텀 타입 가드를 사용해 initState 값을 판단한다. 이 커스텀 타입 가드는 내부적으로 storeTypeTag 심볼을 사용하고 있기 때문에 initState 값이 미들웨어가 리턴한 store인지 아닌지를 런타임에서도 정확히 구분할 수 있다.
import { Middleware, TMiddlewareStore, PersistConfig, storeTypeTag } from "../types";
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 | MiddlewareStore<T, string>): initState is MiddlewareStore<T, string> => {
return storeTypeTag in (initState as object)
}
물론 storeTypeTag 심볼이 export 되어있는 만큼 사용자가 이 심볼을 import 하여 사용할 가능성이 없는 것은 아니다. 원리적으로는 모든 문제가 해결되었다고 볼 수 없으며, 최악의 경우 이 심볼을 사용해서 caro-kann이 잘못된 판단을 하도록 만들 수도 있다. 하지만 여기까지 고려하는 건 시간 낭비라고 생각한다.
블로그의 정보
Ayden's journal
Beard Weard Ayden