Zustand 미들웨어를 읽는 순서로 돌려놓기
작성일:2026.06.22|수정일:2026.06.22|조회수:7

Zustand로 스토어를 만들다 보면 처음에는 코드가 꽤 단정해 보인다. 상태 타입을 만들고, create()에 생성 함수를 넘기고, 필요한 미들웨어를 하나씩 감싸면 된다. persist를 붙이면 새로고침 뒤에도 값이 남고, devtools를 붙이면 Redux DevTools에서 변경을 볼 수 있고, immer를 붙이면 깊은 객체도 비교적 편하게 바꿀 수 있다.
문제는 미들웨어가 하나씩 늘어날 때부터 시작된다. 하나일 때는 함수 호출이다. 둘일 때도 아직 괜찮다. 셋이나 넷이 되면 이제 코드는 상태를 설명하는 문장이라기보다 괄호를 맞추는 의식에 가까워진다. 옵션은 위아래로 흩어지고, 기본 생성 함수는 가장 깊은 곳에 묻히고, 다음에 이 코드를 읽는 사람은 스토어의 모양을 보기 전에 래퍼를 머릿속으로 하나씩 벗겨야 한다. 물론 그 다음 사람이 나일 확률이 높다. 늘 그렇듯 가장 잔인한 리뷰어는 미래의 나다.
const useCounterStore = create<CounterState>()(
devtools(
subscribeWithSelector(
persist(
immer((set) => ({
count: 0,
inc: () => set((state) => { state.count += 1 }),
})),
{ name: 'counter' },
),
),
{ name: 'CounterStore' },
),
)이 코드는 틀리지 않았다. Zustand는 원래 이런 방식으로 미들웨어를 조합한다. 바깥 래퍼가 먼저 보이고, 안쪽 래퍼를 따라 들어가면 마지막에 상태 생성 함수가 나온다. 런타임 관점에서는 자연스럽다. 하지만 읽는 사람 입장에서는 조금 깝깝하다. persist의 옵션이 { name: 'counter' }인지 { name: 'CounterStore' }인지, subscribeWithSelector에 옵션을 넘기려면 어디를 수정해야 하는지 당췌 와닿지가 않는다.
zustand-middleware-pipe는 이 문제를 해결하기 위해 고안된 zustand 호환 패키지다. zustand의 동작을 바꾸지 않고도 같은 래퍼 스택을 읽는 순서로 다시 쓰게 해주는 얇은 파이프라인 레이어다. 이 글을 쓰는 시점의 최신 버전은 1.0.0이고, 저장소는 github.com/ayden94/zustand-middleware-pipe에 있다. 사용해보고 싶은 사람이 있다면 아래의 설치 명령어로 패키지를 써볼 수 있다.
pnpm add zustand-middleware-pipe래퍼를 없애는 것이 아니라 순서를 드러내기
처음부터 내 목적은 분명했다. Zustand의 스토어 모델은 그대로 두어야 한다. create()도 그대로 쓰고, StateCreator도 그대로 쓰고, devtools, persist, immer, subscribeWithSelector가 제공하는 타입 의미론도 건드리고 싶지 않았다. 바꾸고 싶었던 것은 딱 하나였다. 중첩된 래퍼 표현을 사람이 읽는 순서에 가깝게 펼치는 것이다.
그래서 zustand-middleware-pipe의 기본 사용법은 의도적으로 별것 없어 보인다.
const useCounterStore = create<CounterState>()(
pipe
.use(devtools({ name: 'CounterStore' }))
.use(subscribeWithSelector())
.use(persist<CounterState>({ name: 'counter' }))
.use(immer())
.create((set) => ({
count: 0,
inc: () => set((state) => { state.count += 1 }, false, 'counter/inc'),
})),
)여기서 pipe는 미들웨어 파이프라인 빌더다. .use(...)는 래퍼를 하나씩 쌓고, .create(...)는 마지막 상태 생성 함수를 받는다. 런타임 결과는 여전히 같은 중첩 호출이다.
// 개념적으로는 여전히 이 모양이다
devtools(subscribeWithSelector(persist(immer(baseCreator), options)), options)차이는 중첩을 없앤 데 있지 않다. 중첩의 의미를 더 잘 보이는 표면으로 옮긴 데 있다. devtools 옵션은 devtools() 옆에 있고, persist 옵션은 persist() 옆에 있다. 기본 생성 함수는 마지막에 온다. 코드를 읽는 사람은 이제 괄호를 추적하기보다 미들웨어 스택을 위에서 아래로 훑으면 된다.
이런 변화는 작은 것처럼 보이지만 스토어가 커질수록 체감이 커진다. 상태 로직 자체도 이미 충분히 복잡하다. 거기에 래퍼 들여쓰기까지 더해지면, 코드가 어려워서가 아니라 형태가 시끄러워서 이해 비용이 올라간다. 좋은 헬퍼는 가끔 문제를 해결한다기보다는, 불필요한 머릿속 소음을 줄여주는 것 같다.
순서는 타입 문제이기도 하다
Zustand 미들웨어에서 순서는 단순한 취향이 아니다. devtools, persist, immer, subscribeWithSelector는 런타임 래퍼이면서 동시에 타입에도 흔적을 남긴다. Zustand의 타입 모델은 StateCreator<T, Mis, Mos, U>처럼 mutator tuple을 전달하고, 각 미들웨어는 자신이 소비하거나 만들어내는 mutator를 타입에 반영한다.
예를 들어 immer()가 붙으면 .create(...) 안에서 draft 변경이 가능한 set 타입이 들어온다. devtools()가 붙으면 set(update, replace, actionName) 형태의 세 번째 인자를 사용할 수 있다. subscribeWithSelector()가 붙으면 store의 subscribe에 셀렉터 오버로드가 생긴다. 겉으로는 래퍼 하나지만, 실제로는 스토어 API와 생성 함수의 타입이 같이 이동한다.
이 지점 때문에 pipe는 단순히 배열에 미들웨어를 모아 나중에 reduce하는 정도로 끝낼 수 없다. 순서가 잘못되었을 때 타입이 이상해지고, 어떤 경우에는 런타임에서도 사용자가 기대한 의미와 달라진다. 특히 내장 미들웨어에는 어느 정도 안정적인 순서가 있다.
pipe
.use(devtools(options))
.use(subscribeWithSelector())
.use(persist<State>(options))
.use(immer())
.create(baseCreator)이 순서는 바깥쪽에서 안쪽으로 읽는다. zustand-middleware-pipe는 이 내장 순서에서 벗어난 조합을 타입 레벨과 런타임 레벨에서 함께 막으려 한다. 타입스크립트가 잡을 수 있는 곳에서는 .use(...) 호출 자체가 실패하고, 타입을 우회한 경우에도 런타임 가드가 같은 규칙을 다시 확인한다.
이런 이중 방어는 약간 과해 보일 수 있다. 하지만 사용자 영역의 헬퍼는 사용자의 코드를 완전히 통제하지 못한다. 타입은 우회될 수 있고, 자바스크립트 사용자는 애초에 타입 검사를 거치지 않을 수도 있다. 그래서 .use(...)는 단순히 체인을 이어주는 메서드가 아니라, 미들웨어가 들어오는 문이 된다. 문이 있다면 문지기도 있어야 한다. 친절한 문지기는 아니지만, 적어도 잘못된 방향으로 들어가는 사람은 붙잡는다.
definePipeableMiddleware가 하는 일
내장 미들웨어만 다룬다면 규칙은 상대적으로 단순하다. devtools, subscribeWithSelector, persist, immer를 알고 있으니, 각 래퍼에 패키지 내부에서 메타데이터를 붙이면 된다. 하지만 Zustand 생태계에서 미들웨어는 내장된 것만 있는 것이 아니다. 프로젝트마다 작은 로깅 래퍼를 만들 수도 있고, zundo처럼 외부 패키지의 미들웨어를 함께 쓰고 싶을 수도 있다.
여기서 필요한 것은 미들웨어 함수 자체를 분석하는 일이 아니다. 함수 이름을 보고 추측하거나, 소스 코드를 뜯어보는 방식은 오래 버티기 어렵다. 대신 명시적인 메타데이터를 붙이는 편이 낫다. definePipeableMiddleware는 바로 그 명시적 참여 지점을 제공한다.
const temporal = definePipeableMiddleware(temporalMiddleware, {
id: 'zundo/temporal',
duplicate: 'reject',
order: {
after: ['zustand/persist'],
before: ['zustand/immer'],
},
})이 헬퍼는 미들웨어 함수를 다른 함수로 바꾸지 않는다. 타입이 잡힌 미들웨어를 그대로 돌려주면서, 숨겨진 심볼을 통해 id와 순서 메타데이터를 붙인다. 그래서 Zustand mutator tuple 타입 지정은 미들웨어 자신이 계속 책임지고, pipe는 그 위에 “이 미들웨어는 무엇이며, 어디에 놓여야 하는가”라는 조합 정보를 얹는다.
이 분리가 나는 마음에 든다. 미들웨어의 타입 의미론과 조합 정책을 한 덩어리로 섞지 않는다. 어떤 미들웨어가 스토어 타입을 어떻게 바꾸는지는 Zustand 타입 모델의 일이다. 어떤 미들웨어가 중복될 수 있는지, 어떤 미들웨어 앞뒤에 와야 하는지는 pipeable 메타데이터의 일이다. 같은 함수에 붙어 있지만 책임지는 층은 다르다.
메타데이터 엔진은 작지만 공통이어야 한다
definePipeableMiddleware가 의미 있으려면 내장 미들웨어와 사용자 영역 미들웨어가 같은 엔진을 지나야 한다. 내장 미들웨어는 특별 취급하고, 사용자 영역 미들웨어는 대충 허용하는 식이면 이 헬퍼는 금방 반쪽짜리가 된다. 실제 스토어에서는 내장 미들웨어와 외부 미들웨어가 섞이기 때문이다.
그래서 내부에는 공통 메타데이터 엔진이 있다. 내장 래퍼도 메타데이터를 갖고, 사용자 영역 래퍼도 메타데이터를 갖는다. .use(...)가 호출되면 현재 체인에 쌓인 메타데이터를 보고 중복을 확인하고, 순서 제약을 확인하고, 필요한 경우 순환도 검사한다. 내장이라고 별도의 길로 보내지 않고, 사용자 영역이라고 느슨하게 풀어주지도 않는다.
개념적으로는 이런 흐름이다.
pipe
.use(persist<CounterState>({ name: 'counter' }))
.use(temporal<CounterState>({ limit: 50 }))
.use(immer())
.create((set) => ({ ... }))이 체인에서 persist와 immer는 패키지 내부의 내장 메타데이터를 갖는다. temporal은 zundo/temporal이라는 사용자 영역 id를 갖는다. pipe는 이들을 서로 다른 세계로 나누지 않고, 같은 present-id 집합 안에서 본다. temporal이 persist 뒤에 와야 한다고 선언했다면, 그 선언은 내장 id와도 비교된다. 없는 대상은 무시하고, 현재 있는 대상만 검사한다. 그래서 메타데이터는 전체 우주에 대한 법률이 아니라, 현재 체인에 놓인 미들웨어 사이의 계약이 된다.
이 방식은 zundo 같은 외부 미들웨어를 다룰 때 특히 유용하다. undo/redo 기록은 그 자체로 스토어 API를 확장하고, 경우에 따라 메인 스토어 영속성과 temporal history의 영속성이 함께 등장한다. 모든 조합에 대해 라이브러리가 정답을 강제할 수는 없다. 대신 명시적인 id와 순서 힌트를 두면, 최소한 사용자가 말한 규칙은 같은 파이프라인 안에서 검증할 수 있다.
combine과 redux는 왜 .use()에 넣지 않을까
Zustand에는 combine과 redux도 있다. 이름만 보면 미들웨어처럼 함께 체인에 넣고 싶어진다. 하지만 이들은 일반적인 래퍼 미들웨어라기보다 상태 생성 함수를 만드는 헬퍼에 가깝다. 그래서 pipe에서는 .use(...)에 넣지 않고 .create(...) 안에 둔다.
const useCombinedCounterStore = create<CounterState>()(
pipe
.use(devtools({ name: 'CombinedCounterStore' }))
.create(
combine({ count: 0 }, (set) => ({
inc: () => set((state) => ({ count: state.count + 1 })),
})),
),
)이 구분은 사소해 보이지만 중요하다. 모든 것을 .use()로 밀어 넣으면 체인은 겉보기에는 더 균일해진다. 하지만 실제 의미는 흐려진다. .use()는 미들웨어 래퍼를 쌓는 자리이고, .create()는 마지막 상태 생성 함수를 확정하는 자리다. combine과 redux가 들어갈 곳은 두 번째다.
좋은 DSL(Domain-Specific Language)은 사용자가 원하는 모든 모양을 허용하지 않는다. 허용하지 않는 모양을 통해 의미를 지키기도 한다. combine을 .use()에 넣을 수 없다는 사실은 제한이 아니라, 체인의 각 단계가 무엇을 의미하는지 보존하는 장치에 가깝다.
이 헬퍼가 해결하지 않는 것
zustand-middleware-pipe는 Zustand의 공식 가이드가 아니다. Zustand 팀이 “앞으로 미들웨어는 이렇게 쓰세요”라고 말할 일도 없을 거 같다. 나는 심지어 README에다가 "이미 잘 동작하고 잘 읽힌다면, 굳이 pipe를 사용해서 다시 만들지 마세요"라고 써놓기까지 했다. 스토어에 미들웨어 한 두개 붙은 정도로 zustand-middleware-pipe를 쓰는 건 아무래도 좀 과한 거 같다.
또한 모든 외부 미들웨어를 자동으로 안전하게 만들어주는 것도 아니다. 함수에 메타데이터가 없으면 pipe는 그 미들웨어의 정체를 알 수 없다. 함수 이름을 추측하지 않고, 소스를 파싱하지 않고, “아마 persist겠지” 같은 선의를 발휘하지 않는다. 이런 무심함은 조금 불친절하지만 필요하다. 잘못된 추측은 헬퍼가 아니라 버그 제조기가 된다.
그래서 사용자 영역 미들웨어가 pipe의 순서/중복 검사의 굴레에 들어오고자 한다면 명시적으로 definePipeableMiddleware를 사용해야 한다. 이 선택은 라이브러리 사용자에게 작은 부담을 남긴다. 대신 규칙이 어디서 왔는지가 분명해진다. 누군가가 id를 붙였고, 누군가가 중복 정책을 정했고, 누군가가 before/after 관계를 선언했다. 조합의 안전성은 자동 마법이 아니라 명시적인 계약에서 온다고 나는 믿는다.
읽기 좋은 코드는 때로 같은 코드를 다른 표면에 놓는 일이다
이 패키지를 만들면서 흥미로웠던 점은 런타임 로직보다 표면의 모양이었다. 실제로 일어나는 일은 여전히 래퍼 조합이다. pipe는 Zustand를 대체하지 않고, 미들웨어를 새로 발명하지 않고, 상태 모델을 바꾸지 않는다. 그런데도 같은 코드를 위에서 아래로 읽을 수 있게 만들면 스토어의 인상이 꽤 달라진다.
상태 관리 코드를 어렵게 만드는 것이 늘 거대한 추상화만은 아니다. 가끔은 옵션이 어디에 붙어 있는지, 어떤 래퍼가 바깥이고 어떤 래퍼가 안쪽인지, 타입이 어떤 순서로 쌓이는지 같은 작은 위치 정보가 더 큰 피로를 만든다. zustand-middleware-pipe가 옮기려는 비용은 바로 그 위치 정보다.
내장 미들웨어와 사용자 영역 미들웨어를 같은 메타데이터 엔진에 태우는 것도 같은 이유다. 라이브러리가 제공하는 것과 사용자가 만든 것이 서로 다른 규칙을 타기 시작하면, 파이프라인은 금방 예외 목록이 된다. 반대로 둘 다 id, 중복 정책, 순서 힌트라는 같은 언어를 쓰면, 작은 헬퍼라도 어느 정도 일관된 조합 경험을 만들 수 있다.
좋은 상태 관리 도구는 모든 상태를 대신 관리해주지 않는다. 좋은 헬퍼도 모든 미들웨어 조합을 대신 판단해주지 않는다. 대신 사용자가 이미 하고 있던 조합을 더 잘 보이게 만들고, 실수하기 쉬운 몇몇 경계에서 멈춰 세운다. zustand-middleware-pipe는 그 정도의 도구다. 대단한 정답이라기보다는 작은 정리 도구에 가깝다. 그리고 어떤 날에는, 그 정도가 딱 필요하다.
댓글
댓글을 불러오는 중...