createStoreImpl은 "인자 하나 받아서 store를 만드는" 단순한 함수인데, 밖으로 노출되는 createStore는 커링 형태(createStore<State>()(...))까지 지원해야 해서 시그니처가 복잡해진다.
그래서 진짜 로직은 Impl에 단순하게 몰아넣고, export되는 쪽은 그 위에 분기만 얇게 씌운 다음 as CreateStore로 원하는 공개 타입을 붙인다.
setState처럼 "이렇게 부르면 이 타입, 저렇게 부르면 저 타입"으로 호출 형태별 타입을 여러 벌 선언해둔 걸 오버로드라고 하는데, zustand는 이런 걸 Impl 함수 안에서 다 맞추려 하지 않는다. 그냥 느슨한 타입으로 편하게 구현해놓고, 마지막에 as CreateStore로 "이 함수는 이런 타입이야"라고 한 번에 못박아버린다. 구현은 편하게, 타입은 깔끔하게 분리한 셈이다.
partial은 우리가 컴포넌트에서 set({ count: 1 })이나 set((state) => ({ count: state.count + 1 }))처럼 호출할 때 넘기는 그 인자다. 객체를 바로 넘길 수도 있고, 이전 state를 받아서 다음 state를 반환하는 함수를 넘길 수도 있다는 뜻이다.
replace는 두 번째 인자로, "상태를 통째로 갈아끼울지, 기존 값이랑 합칠지"를 정하는 옵션이다. 평소에 set({ count: 1 })만 쓸 땐 신경 쓸 일이 없어서 존재조차 몰랐다.
setState의 흐름을 순서대로 따라가보면,
partial이 함수면 실행해서 다음 상태를 얻고, 함수가 아니면 그대로 nextState로 쓴다.
Object.is로 현재 state와 nextState가 같은지 비교한다. 같으면 아무 일도 안 하고 끝난다.
다르면 previousState에 현재 state를 저장해둔다.
replace 여부(정확히는 replace ?? (원시값이거나 null인지))에 따라 통째로 교체하거나, Object.assign으로 기존 state와 합친다.
마지막에 listeners를 순회하면서 구독 컴포넌트에 변경을 알린다.
state = (replace ?? (typeof nextState !== 'object' || nextState === null)) ? (nextState as TState) : Object.assign({}, state, nextState)
여기서 처음 봤을 땐 "replace가 true면 교체, false면 병합"이라고 단순하게 이해했는데, 다시 보니 replace를 아예 안 넘기는 경우(undefined)까지 고려한 코드였다.
nextState가 객체가 아니거나 null이면 replace를 명시 안 해도 자동으로 전체 교체로 처리된다. 그러니까 상태가 숫자나 문자열 같은 원시값이면 병합을 시도하지 않고 그냥 통째로 바뀐다는 뜻이다.
getInitialState()는 처음엔 "스토어 생성 시 초기 상태를 반환할 때 쓰는 것" 정도로만 이해했는데, 핵심은 그게 아니었다.
state는 setState가 호출될 때마다 계속 재할당되면서 바뀌지만, initialState는 스토어가 처음 만들어질 때의 값을 그대로 들고 있다.
따라서 store.setState(store.getInitialState(), true)처럼 리셋 기능을 구현할 때 쓰인다.
상태 관리 라이브러리라면 리셋은 당연히 필요한 기능이다..!
여담으로 마지막 줄 const initialState = (state = createState(...)) 이 부분도 눈여겨봐야했다.
getInitialState가 이 줄보다 먼저 정의되는데도 정상 동작한다.
처음엔 이게 호이스팅 때문인가 싶었는데, var나 function 호이스팅이 아니라 클로저가 변수 자체를 참조하고 있어서였다. getInitialState가 실제로 호출되는 시점(스토어 생성이 다 끝난 뒤)에는 이미 initialState가 할당되어 있으니 문제가 없는 것.
함수 선언 순서와 실행 순서를 헷갈리면 안 된다는 걸 다시 깨달았다.
알아둘 개념 — Pub/Sub 패턴
subscribe와 listeners.forEach 구조를 보면서 처음 접한 개념이다.
구독자(Subscriber)가 미리 등록해두면, 발행자(Publisher)가 이벤트가 생겼을 때 등록된 구독자 전부에게 알려주는 패턴이다. 핵심은 발행자가 구독자를 몰라도 된다는 느슨한 결합이다.
Zustand에 대입하면 subscribe(listener)가 구독, setState 끝의 listeners.forEach(...)가 발행이다. setState는 누가 구독 중인지 몰라도 되고, 구독자가 늘든 줄든 구현은 그대로다.
그럼 평소 쓰던 element.addEventListener('click', cb)나 emitter.on('message', cb)랑은 뭐가 다를까 찾아봤는데, 메커니즘은 사실상 같다. 둘 다 리스너 목록을 들고 있다가 이벤트 발생 시 순회하며 호출하는 옵저버 패턴이다.
차이는 두 가지다. on/addEventListener는 이벤트 이름으로 여러 종류를 구분하지만 zustand는 "상태 변경" 딱 하나뿐이고, emit은 아무 값이나 실어 보낼 수 있지만 zustand는 항상 (새 state, 이전 state)만 넘긴다.
그래서 "Pub/Sub"이라 부르지만 실제로는 옵저버 패턴에 더 가깝고, addEventListener/on도 같은 뿌리에서 나온 다른 이름이라고 이해하고 넘어갔다.
추가로..
listeners는 왜 Set인가?
Set을 쓰면 같은 리스너가 중복으로 등록되는 걸 막을 수 있고, Set.delete는 배열처럼 인덱스를 찾아 순회할 필요 없이 O(1)로 제거된다. 구독/해지가 잦은 구조에서는 배열보다 Set이 합리적인 선택이다.
Step 3. react.ts — React와 연결되는 지점
react.ts를 열어보니 생각보다 분량이 짧았다. 크게 useStore, createImpl, create 세 부분으로 나눠서 봤다.
store는 devtools(fn, { store: 'user' })처럼 이 스토어를 구분할 이름표다.
extensionConnector는 앞서 확인한 그 window.__REDUX_DEVTOOLS_EXTENSION__ 객체이다.(실제로 connection을 생성).
options는 devtoolsOptions에서 enabled, anonymousActionType, store를 뺀 나머지, 즉 { name, ... } 같은 설정값이다.
여기서 "창"이라고 부르는 것의 개념을 짚고 가면, 크롬 확장 프로그램 Redux DevTools를 설치하면 브라우저 개발자도구 안에 State/Diff/Action 같은 탭이 있는 패널이 하나 생기는데, 그 패널 하나(인스턴스 하나)를 의미한다.
상태 트리랑 액션 로그가 실시간으로 찍히는 화면이다.
여기서 name의 역할도 헷갈렸는데, name은 패널과 짝지어지는 connection을 구분하는 키다. 패널이 눈에 보이는 UI라면, connection은 그 패널과 통신하기 위해 코드가 들고 있는 객체(손잡이)다.
같은 name을 쓰는 스토어들끼리만 trackedConnections에서 같은 connection을 나눠 쓴다.
반면 store는 그렇게 합쳐진 창 안에서 "어떤 스토어인지" 표시하는 이름표 역할을 한다.
name이 창을 정하고, store가 그 창 안의 자리를 정하는 셈이다.
그러면 여기서 계속 나오는 connection은 정확히 뭘까.
extensionConnector.connect(options)가 반환하는 값인데, Redux DevTools 확장 프로그램이 만들어주는 채널 객체이다.
connect()를 부르는 순간 브라우저 확장 프로그램 쪽에 이 앱 전용 탭(혹은 인스턴스)이 하나 열리고, 그 탭과 통신할 수 있는 손잡이가 connection으로 돌아온다.
이 connection이 들고 있는 메서드들이 지금까지 본 코드 전체를 관통한다. connection.send(action, state)로 앱 → DevTools 방향으로 상태를 보고하고, connection.init(state)로 DevTools 트리의 최초 상태를 세팅하고, connection.subscribe(listener)로 반대로 DevTools → 앱 방향 메시지를 받는다.
즉 connection 하나가 devtools 미들웨어와 브라우저 확장 프로그램 사이를 잇는 유일한 통로였다.
devtools(fn)에 store 옵션을 안 주면 untracked. 그냥 매번 새 connection을 만든다.
store 옵션을 주면 tracked, 이땐 options.name을 키로 하는 trackedConnections를 먼저 뒤져본다.
같은 name으로 이미 등록된 connection이 있으면 그걸 재사용하고, 없으면 새로 만들어서 Map에 등록해둔다.
왜 Map이 최상단 범위에 선언돼있는지 의아했는데, 스토어를 여러 개 만들어도(useUserStore, useCartStore 등) 이 Map은 모듈이 로드될 때 딱 한 번만 만들어지고 계속 살아있어야 하기 때문이었다.
그래야 서로 다른 스토어 파일에서 create(devtools(fn, { name: 'app', store: 'user' })), create(devtools(fn, { name: 'app', store: 'cart' })) 이렇게만 적어도 같은 name이면 자동으로 같은 DevTools 창 하나에 묶인다.
이 함수가 api.setState 바로 다음, initialState를 계산하기도 전에 미리 선언되어 있었다. 뒤에 나올 두 곳(초기 상태 계산, devtools 메시지 처리)에서 공통으로 쓰이기 때문이었다.
devtools에서 JUMP_TO_STATE(타임머신) 같은 메시지가 오면 이 setStateFromDevtools가 호출된다. 여기서 isRecording을 잠깐 꺼놓고 set을 호출한 다음 다시 켜놓는다.
만약 이 플래그가 없으면 어떻게 될지 에이전트에 물어봤다.
devtools가 보낸 상태로 set을 호출 → set이 상태를 바꿈 → 그런데 이 set 호출도 결국 api.setState를 거치게 되면 → connection.send가 또 실행돼서 devtools에 "상태 바뀜"을 다시 보고 → devtools 창은 이미 자기가 보낸 값인데 그걸 또 받아서 새 액션으로 기록... 이게 반복되면 무한루프에 빠진다...
devtool로 상태변경하고, 앱의 상태변경을 하고, 앱을 보고 또 devtool은 상태변경하고... 난장판이 되는 것이다..
그래서 isRecording을 꺼둔 상태에서 set을 호출하면, api.setState 내부의 if (!isRecording) return r 라인에서 바로 걸러져서 connection.send까지 가지 않게 된다.
devtools가 만든 변경은 다시 devtools한테 보고하지 않는것이 루프를 막는 원칙이다.
Step 2에서 본 createState(setState, getState, api)와 같은 모양인데, 여기선 원본 set이 아니라 방금 만든 **devtools 로직이 붙은 api.setState**를 넘긴다.
그래야 유저가 스토어 정의 안에서 set(...)을 호출할 때도 devtools 로그가 남는다고 한다.
untracked(store 전달이 안된 상태)면 간단하다.
connection.init(initialState)로 자기 상태만 넘기고 끝이다.
tracked면 다른데, connectionInformation.stores(tracked connection의 공유 Map 안의 stores 객체)에 자기 자신(api- set, get, subscribe 등이 있는 객체)을 먼저 등록해두고, 등록된 스토어 전부를 순회하면서 "자기 자신이면 방금 계산한 initialState, 자신이 아니면 그 store의 getState()"로 합쳐서 하나의 객체로 connection.init에 넘긴다.
여러 스토어가 DevTools 창 하나에서 { user: {...}, cart: {...} } 이런 식으로 함께 뜰 수 있는 이유가 이것이다!
redux - 미들웨어 연동
const shouldDispatchFromDevtools = (api) => !!api.dispatchFromDevtools && typeof api.dispatch === 'function'if (shouldDispatchFromDevtools(api)) { let didWarnAboutReservedActionType = false const originalDispatch = api.dispatch api.dispatch = (...args) => { if (args[0].type === '__setState' && !didWarnAboutReservedActionType) { console.warn('"__setState" action type is reserved. Avoid using it.') didWarnAboutReservedActionType = true } originalDispatch(...args) }}
redux 미들웨어를 같이 쓰면 api.dispatchFromDevtools = true가 세팅되고, api.dispatch도 생긴다.
__setState는 devtools가 상태를 직접 주입할 때 예약해둔 액션 이름이다.
유저가 redux 미들웨어를 쓰면서 실수로 이 이름으로 액션을 dispatch하면 충돌이 날 수 있어서, api.dispatch를 한 번 감싸서 그 이름이 쓰이면 콘솔에 경고를 띄워준다.
didWarnAboutReservedActionType으로 경고는 플래그로써 한 번만 뜨게 했다.
devtools와 redux 미들웨어가 서로 존재를 모르는 채로도, api라는 공유된 객체에 심어둔 플래그 하나로 연동되는 구조였다.
connection.subscribe
지금까지 "앱이 devtools한테 상태를 보고한다"는 방향만 봤는데, connection.subscribe는 반대로 DevTools 확장 프로그램이 우리 앱한테 메시지를 보내는 방향이었다.
connection.subscribe((message) => { switch (message.type) { case 'ACTION': if (typeof message.payload !== 'string') return return parseJsonThen(message.payload, (action) => { if (action.type === '__setState') { setStateFromDevtools(action.state) return } if (shouldDispatchFromDevtools(api)) { api.dispatch(action) } }) case 'DISPATCH': switch (message.payload.type) { case 'RESET': setStateFromDevtools(initialState) return connection?.init(api.getState()) case 'COMMIT': return connection?.init(api.getState()) case 'ROLLBACK': return parseJsonThen(message.state, (state) => { setStateFromDevtools(state) connection?.init(api.getState()) }) case 'JUMP_TO_STATE': case 'JUMP_TO_ACTION': return parseJsonThen(message.state, (state) => { setStateFromDevtools(state) }) case 'IMPORT_STATE': { const { nextLiftedState } = message.payload const lastComputedState = nextLiftedState.computedStates.slice(-1)[0]?.state if (!lastComputedState) return setStateFromDevtools(lastComputedState) connection?.send(null, nextLiftedState) return } case 'PAUSE_RECORDING': return (isRecording = !isRecording) } }})
하나씩 짚어봤다.
ACTION — DevTools 창의 "Dispatch" 탭에서 개발자가 직접 JSON 액션을 만들어서 보냈을 때 온다. __setState라는 예약된 타입이면 상태를 통째로 갈아끼우고, 아니면(진짜 redux 액션이면) shouldDispatchFromDevtools(api)가 참일 때만 api.dispatch(action)으로 실제 리덕스 액션을 흘려보낸다.
RESET — 스토어가 처음 만들어질 때 계산해둔 initialState로 되돌리고, DevTools 쪽 기록도 그 값으로 다시 init해서 새 기준점을 잡는다.
COMMIT — 지금 상태를 그대로 다시 connection.init에 넘겨서 "여기가 새 기준점"이라고 알려주기만 한다. 상태 자체는 바뀌지 않는다.
ROLLBACK — message.state(JSON 문자열)를 파싱해서 그 값으로 되돌리고, 역시 새 기준점으로 init한다.
JUMP_TO_STATE / JUMP_TO_ACTION — DevTools 타임라인에서 과거 특정 지점을 클릭했을 때 온다. message.state를 그 시점 값으로 파싱해서 setStateFromDevtools로 바꾸기만 한다. RESET/ROLLBACK과 다르게 connection.init을 다시 안 부르는데, 지금 시점을 "새 기준점"으로 확정 짓는 게 아니라 그냥 잠깐 과거를 보여주는 것뿐이라 그런 것 같다.
IMPORT_STATE — 미리 내보내둔 전체 기록(nextLiftedState)을 통째로 불러올 때다. computedStates 배열의 마지막 항목이 가장 최신 상태라서 그걸 꺼내 적용하고, 불러온 기록 전체를 다시 connection.send로 DevTools에 돌려준다.
PAUSE_RECORDING — isRecording을 그냥 토글한다. DevTools 창의 일시정지 버튼을 누르면 이게 호출된다.
이 하나하나를 다 외울 필요는 없겠지만, "시간여행 디버깅"이라는 게 결국 message.state에 저장된 과거 값을 스위칭 케이스에 따라 바꿔끼우며 setStateFromDevtools로 되돌리는 것뿐이었다는 것을 알자.
JSON으로 직렬화해서 주고받는 이유가 뭘까?
connection은 확장 프로그램과 웹페이지, 즉 메모리를 공유하지 않는 서로 다른 실행 컨텍스트를 잇는 채널이다. 참조를 그대로 넘길 수 없으니, 값을 문자열로 직렬화해서 보내고 받는 쪽이 다시 파싱해서 복원해야 한다. message.payload나 message.state가 JSON 문자열로 오고 parseJsonThen으로 파싱하는 이유가 여기 있다.
추가로...
api.setState를 덮어쓰는 방식과, set을 감싸서 새 함수를 반환하는 방식은 뭐가 다른가?
set을 감싸서 새 함수를 만들면, 그 새 함수를 실제로 손에 쥔 코드만 감싸진 로직(devtools 전송)을 타게 된다.
그러나 api는 스토어 전체에서 공유하는 단 하나의 객체이기에, api.setState 자체를 바꿔치기해두면 그 이후로 누가 api.setState를 부르든(다른 미들웨어, 유저 코드, 심지어 나중에 체이닝되는 또 다른 미들웨어) 전부 자동으로 devtools 로직을 거치게 된다.
참조가 여기저기 퍼질 수 있는 상황에서는 감싸는 것보다 재할당이 훨씬 확실하게 가로챈다고 한다.
isRecording 대신 메시지에 "출처"를 표시하는 방법은 안 될까?
set(state, replace, action)처럼 이미 공개 API로 노출된 시그니처에 "이건 devtools가 보낸 거야" 같은 내부 플래그를 끼워 넣으려면 시그니처를 오염시키거나 별도의 채널이 필요해진다.
반면 isRecording은 클로저 안에 숨어있는 변수 하나로 온오프만 하면 되니 구현이 훨씬 단순하다.
zui()도 아마 GUI ↔ 앱 사이에서 똑같은 문제를 만날 텐데, 이 플래그 방식을 그대로 가져다 쓰면 될 듯하다.
여기까지 devtools.ts를 읽고 나니, zui()도 결국 이 구조 — api.setState 가로채기, 양방향 메시지 처리, isRecording 같은 재진입 방지 플래그 — 를 거의 그대로 참고하게 될 것 같다.
다음 포스팅부터는 이 패턴을 참고해서 실제로 zui() 미들웨어 구현에 들어가볼 예정이다.
아직 라이브러리를 비롯해 상태 관리를 다루는 수준이 부족하기에, 혼자서 무턱대고 만들다간 시간이 매우 오래걸릴 것이라 판단했다.
따라서, AI 에이전트를 활용하되, 모든 구현과 기능 개발을 맡기지 않고, 스텝과 가이드를 나누게 하여 마치 과제를 내주듯 지시하여 이를 채우며, 리팩토링하고 코드리뷰 등 페어 프로그래밍 할 예정이다.