
요즘 다른 전역 상태 라이브러리 대신 caro-kann 3 버전을 적극적으로 사용하고 있다. 여러 기능을 미들웨어 단위로 분리할 수 있어 번들 크기를 700B대까지 줄일 수 있었고, zustand, useReducer, useState 스타일을 상황에 따라 선택할 수 있다는 점도 다른 상태 관리 라이브러리 못지않은 장점으로 느껴진다.
며칠 전에는 reducer와 persist 미들웨어를 결합해, 세션 종료 이후에도 상태를 유지하는 장바구니 기능을 구현하고 있었다. 일반적으로 장바구니는 여러 개의 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로 출력되고 있었다. 다른 초기값을 넣어보면 정상적으로 동작하는데, 초기 상태가 배열일 때만 문제가 발생했다.
const [cart] = useCartStore();
console.log(cart); // undefined문제의 원인을 찾기 위해 caro-kann의 미들웨어 구현을 살펴보았고, 생각보다 빠르게 핵심을 발견할 수 있었다. 기존 persist 미들웨어는 initState를 T | [Store<T>, string] 형태로 받고, 본인 역시 [Store<T>, "persist"] 형태의 튜플을 반환하고 있었다.
이 구조는 의도 자체는 명확하다. 미들웨어를 중첩해서 사용하더라도, “이미 한 번 가공된 store인지”를 구분하기 위해 튜플을 사용하는 방식이다. 실제로 zustand 미들웨어를 제외한 caro-kann의 미들웨어는 모두 이 패턴을 따른다.
문제는 store를 생성하는 로직에 있었다.
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 Array로 T와 [Store<T>, string]을 구분할 수 있을 것이라 생각했다. 하지만 initState로 배열 자체를 넘겨버리는 순간, 이 가정은 완전히 무너진다. 빈 배열을 초기 상태로 넘기면 initState instanceof Array는 참이 되고, initState[0]에서 Store를 찾으려 하지만 거기에는 undefined밖에 없다. 이후 로직 전반에서 Store 대신 undefined를 사용하게 되고, 그 결과 최종 상태 역시 undefined가 되어버린다.
결론은 명확했다. 미들웨어가 튜플을 받고, 튜플을 리턴하는 구조는 근본적인 한계를 갖고 있다. 그래서 나는 구조 자체를 바꿔야겠다고 판단했다. 거의 본능적으로 “객체를 써야겠다”는 생각이 들었지만, 여기에는 한 가지 껄끄러운 문제가 있었다. 타입스크립트 레벨에서 각 미들웨어의 정체성을 추적하려면 일종의 storeTypeTag가 필요한데, 이를 일반 문자열 키로 정의하면 사용자가 동일한 프로퍼티 이름을 사용하는 순간 충돌이 발생할 수 있다.
이 문제를 해결하지 못한다면, 튜플을 객체로 바꾸는 것은 단지 문제가 발생할 확률을 낮추는 것에 불과하다. 여기서 등장한 것이 unique symbol이다. unique symbol은 타입스크립트가 제공하는 특수한 심볼 타입으로, 선언 시점마다 타입 레벨에서 완전히 고유한 식별자로 취급된다. 여기에 자바스크립트의 Symbol을 결합하면, 타입 시스템과 런타임 양쪽에서 충돌 없는 키를 만들 수 있다.
나는 런타임에서도 이 값이 필요했기 때문에 다음과 같이 정의했다.
// 타입 시스템에서만 존재
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 심볼을 사용하므로, 값이 실제로 미들웨어에서 생성된 store인지를 런타임에서도 정확히 구분할 수 있다.
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 기반 설계로 전환하면서, 타입 안정성과 런타임 안정성을 동시에 확보할 수 있었다. 이번 경험을 통해 다시 한 번 느낀 것은, 라이브러리 설계에서 “편해 보이는 패턴”보다 “잘못 사용될 여지가 없는 구조”가 훨씬 중요하다는 사실이다.
더 읽어보기
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.06.01
React Server Components를 위한 컴포넌트 아키텍처
이 포스트는 Vercel의 Next.js 팀 소속 개발자 Aurora Scharff가 자신의 블로그에 올린 Component Architecture for React Server Components 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는…
2026.05.26
차트는 멈췄는데 윈도우가 움직인다
상황 어느날 서비스를 살펴보시던 팀장님께서 이런 말씀을 slack에 남기셨다. 진호님, 예측 차트에서 zoom을 계속하면 어느 순간 라인 차트가 아니라 단일 스캐터 차트처럼 보이는 데, 이거 수정하면 좋을 거 같아요. 어느정도 zoom을 하면 그 이후로는 zoom이 안 되도록 할 수 없…
2026.05.21
피자가게로 이해하는 디자인 패턴
에이든 피자는 처음부터 복잡한 시스템을 만들 생각이 없었다. 처음에는 메뉴 몇 개만 만들면 됐다. 그런데 손님은 커스텀 주문을 넣기 시작했고, 주방은 상태를 나눠야 했고, 결제와 배달앱과 알림이 하나씩 붙었다. 코드도 가게를 닮는다. 장사가 잘될수록 이상하게 더 쉽게 망가진다. 디자인…
2026.05.21
7. Decorator — 토핑 추가할 때마다 클래스를 새로 만들 수 없다
에이든 피자에서 주문서를 객체로 만들자 취소와 재주문은 한결 편해졌다. 그런데 주문이 편해지자 손님들도 한결 편해졌다. 편해진 손님은 더 많은 요구를 한다. "치즈 추가요", "올리브도 추가요", "소스 많이요", "조금 더 바삭하게 구워주세요" 같은 요청이 주문대 위로 쌓이기 시작했다…
댓글
댓글을 불러오는 중...