subscribe 한 줄에 숨은 옵저버 패턴
Redux의 store.subscribe와 Zustand의 selector, 이름만 바꾼 옵저버 패턴이에요.
Redux를 처음 배울 때 store.subscribe(listener)를 읽고 저는 "dispatch 때문에 리렌더가 돌아가는구나" 정도로 대충 넘겼어요. 나중에 Zustand를 쓰면서 useStore(s => s.count)라는 selector를 보고는 또 "신기하네" 하고 넘겼죠. 어느 날 두 코드를 나란히 놓고 보니까 같은 뼈대가 보이더라고요. 이름표만 다를 뿐 GoF의 옵저버 패턴이 그대로 들어있었어요.
옵저버와 Pub-Sub은 자주 섞여요
디자인 패턴 책을 펼쳐보면 "옵저버 패턴"과 "발행-구독(Pub-Sub)"이 같은 챕터에 들어있는 경우가 꽤 있어요. 인터넷 글을 찾아봐도 두 이름을 교차로 쓰는 경우가 더 많고요. 근데 둘이 정확히 같은 건 아니에요.
GoF가 말하는 옵저버는 Subject(통지하는 쪽)와 Observer(통지받는 쪽) 사이에 직접 참조 관계 가 있어요. Subject는 자기가 알고 있는 Observer 리스트를 들고 있고, 상태가 바뀌면 그 리스트를 돌면서 update를 호출해요. 사이에 중개자가 없고 호출은 동기적이에요.
Pub-Sub은 그 사이에 broker(메시지 버스)가 들어가요. Publisher는 "channel-a로 이벤트 나간다" 하고 broker에 던지고, Subscriber는 "channel-a 받을래" 하고 broker에 등록해요. 둘은 서로를 몰라요. 분산 시스템이나 언어 간 연결에서 자주 보이는 구조죠.
프론트엔드에서 자주 쓰는 Node.js EventEmitter는 구조상 Pub-Sub 쪽에 가까워요. 이벤트 이름을 key로 삼아 여러 listener를 묶는 multiplexing broker거든요. 반면 Redux, Zustand의 subscribe는 broker가 없고 store가 listener 리스트를 직접 들고 있어요. 엄밀히는 옵저버예요.
Redux의 store.subscribe가 교과서예요
Flux가 남긴 단방향 흐름이 아키텍처 사상이라면, 그 안에서 뷰와 store를 잇는 메커니즘이 바로 subscribe예요. Redux 공식 문서를 보면 Store가 지는 책임 네 가지가 꽤 익숙한 이름으로 쓰여 있어요.
state를 보유하고, getState()로 꺼내고, dispatch(action)으로 바꾸고, subscribe(listener)로 변화를 듣게 하고. 이 네 가지가 그대로 옵저버 패턴의 요구 조건에 매핑돼요. store가 Subject, listener가 Observer, dispatch 직후의 listener 순회가 notify인 거죠.
"It is, however, guaranteed that all subscribers registered before the dispatch() started will be called with the latest state by the time it exits." - Redux Store API
dispatch가 시작되기 전에 등록된 listener들은 dispatch가 끝나기 전까지 최종 state 를 반드시 한 번씩 본다는 얘기예요. 중간 state는 보장 안 해요. 한 번의 dispatch가 reducer를 돌리고, 그 결과가 확정된 뒤에야 listener가 호출되거든요.
listener는 인자가 없어요. 시그니처가 () => void예요. 새 state를 건네주는 게 아니라, 듣는 쪽이 필요하면 getState()를 직접 호출해서 읽어요. 이 분리가 중요한데, listener는 "state가 바뀌었다" 는 신호만 받고 읽는 타이밍은 스스로 정할 수 있다는 뜻이에요.
subscribe 안에서 dispatch도 허용돼요. reducer 안에서 dispatch가 금지되는 거랑 다른 규칙이에요. reducer는 순수 함수여야 하니까요. listener에서 dispatch를 하면 그건 다음 dispatch를 쌓는 거고, 지금 돌고 있는 listener 순회에 끼어들지는 않아요. 다음 cycle로 밀려나요.
직접 만들어보면 옵저버가 보여요
Redux에서 미들웨어와 devtools 연결을 걷어내면 남는 건 state 하나, listener 집합 하나, 그리고 notify 루프 하나예요. 이걸 그대로 코드로 옮기면 40줄이 안 돼요.
Set에 listener를 담고, dispatch가 끝난 뒤 forEach로 한 번씩 호출해요. subscribe가 해제 함수를 돌려주는 것도 Redux와 동일해요. 정말 "최소" 형태의 옵저버예요. 이 뼈대 위에 미들웨어, devtools 연결, selector를 얹으면 Redux에 가까워져요.
한 가지 짚고 넘어갈 점. 이 구현에서 listener는 전부 똑같이 호출돼요. count가 바뀌지 않는 action이 와도 모든 listener가 뛰어요. 실제 Redux도 기본 동작은 이와 비슷해서, react-redux가 selector 기반 비교를 따로 얹어서 해결하죠. 여기서 Zustand 얘기가 시작돼요.
Zustand가 바꾼 한 지점, 필터링
Zustand도 store의 기본 subscribe는 Redux와 거의 같아요. subscribe(listener)가 있고, 모든 setState마다 listener가 돌죠. 다른 점은 listener가 (state, prevState) 두 인자를 받는다는 정도예요.
진짜 다름은 subscribeWithSelector 미들웨어에서 드러나요. 이걸 끼우면 subscribe의 시그니처가 이렇게 바뀌어요.
import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";
const useStore = create(
subscribeWithSelector(() => ({
x: 0,
y: 0,
}))
);
const unsubscribe = useStore.subscribe(
(state) => state.x,
(x, prevX) => {
console.log("x 바뀜:", prevX, "->", x);
}
);
useStore.setState({ y: 10 });
// listener 호출 안 됨. y는 selector 밖이니까.
useStore.setState({ x: 5 });
// listener 호출됨. "x 바뀜: 0 -> 5"
unsubscribe();(selector, listener)로 두 인자. selector가 state에서 뽑아낸 값이 이전 값과 다를 때만 listener가 호출돼요. 비교는 기본적으로 Object.is로 하고, 옵션으로 equalityFn을 넘겨서 shallow 비교로 바꿀 수도 있어요. "state가 바뀌면 전부 통지" 라는 GoF 옵저버의 기본 전제에서 "내가 보는 부분이 바뀔 때만 통지" 로 한 지점을 바꾼 거예요.
이 선택적 통지가 왜 중요한가. React에서는 Context가 Object.is로 value 전체를 비교해서, 바뀌면 구독자 전원이 리렌더돼요. Context를 값과 dispatch로 쪼개야 하는 이유와도 맞닿아있죠. Zustand는 store 차원에서 그 쪼개기를 selector로 해결해요. 같은 store를 쓰면서도 컴포넌트마다 보는 조각이 달라서 불필요한 리렌더를 피할 수 있어요.
React는 useSyncExternalStore로 이어요
Redux든 Zustand든 store 자체는 React와 상관없는 바닐라 구조예요. 이걸 React의 렌더 사이클과 안전하게 연결하는 공식 도구가 useSyncExternalStore예요. React 18에서 추가됐어요.
시그니처가 단순해요. useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?). 첫 인자가 우리가 봐온 subscribe 그대로고, 둘째 인자가 현재 값을 읽어오는 함수예요. React가 subscribe로 콜백을 걸어두고, notify가 들어오면 getSnapshot으로 새 값을 읽은 뒤 이전 값과 Object.is로 비교해요. 다르면 리렌더, 같으면 건너뛰고요.
여기 숨은 함정이 하나 있어요. getSnapshot은 진짜 안 바뀌었을 때 같은 참조 를 돌려줘야 해요. 매번 새 객체를 만들면 참조가 매번 달라서 무한 루프에 빠져요. Zustand 같은 라이브러리는 selector 결과를 캐시해서 이 조건을 자동으로 만족시켜줘요. 직접 external store를 만들어 붙일 때는 이 부분을 스스로 챙겨야 해요.
"The useSyncExternalStore API is mostly useful if you need to integrate with existing non-React code." - react.dev
"가능하면 useState나 useReducer를 먼저 쓰세요" 라는 맥락의 문장이에요. useSyncExternalStore는 이미 존재하는 비 React 상태 저장소와 이어붙이는 용도예요. 처음부터 이걸 쓸 이유는 거의 없어요.
라이브러리 제작자가 아닌 이상 직접 호출할 일은 드물어요. 근데 원리를 알아두면 react-redux 8 이후와 Zustand의 React 바인딩이 왜 비슷하게 동작하는지가 자연스럽게 보여요. 둘 다 내부적으로 이 훅을 쓰거든요.
같은 뼈대, 다른 필터
다시 돌아보면 Redux와 Zustand의 차이는 "언제 통지할지" 한 지점에 있어요. Redux는 상태가 바뀔 가능성이 있을 때마다 모든 listener를 깨우고, 듣는 쪽이 "getState로 읽어보고 내가 쓰는 부분이 바뀌었나" 를 확인해요. Zustand는 이 판단을 store 안쪽으로 끌고 들어와서 selector로 미리 필터링해요.
누가 더 좋다는 얘기가 아니에요. 통지 범위를 넓게 잡고 구독자가 판단하게 하는 모델과, 범위를 좁게 잡아서 미리 걸러서 보내주는 모델. 둘 다 옵저버 패턴의 유효한 변형이에요. "패턴" 이라는 이름이 공식 문서에 안 써 있을 뿐이죠.
만약 Redux, Zustand가 broker를 두고 "stateChange라는 토픽에 가입하세요" 식으로 동작했다면 그건 Pub-Sub이에요. 실제로는 store가 listener 리스트를 직접 들고, 상태 변화의 원인이 된 바로 그 호출이 listener까지 이어져요. 이게 옵저버고요. 이 둘을 섞어 쓰면 "어떻게 통지가 끝까지 갔는지" 를 추적하기 어려워져요. 이름을 제대로 구분해두면 코드 읽을 때 한 층이 벗겨져요.
참고 자료
- Redux - Store API
subscribe/dispatch/getState 계약과 listener 호출 타이밍 근거
- Redux - Fundamentals Part 4 Store
Store의 네 가지 책임과 subscribe 예시
- Zustand - createStore API
바닐라 StoreApi의 subscribe 기본 시그니처
- Zustand - subscribeWithSelector 미들웨어
selector 기반 선택적 통지와 equalityFn 옵션
- React - useSyncExternalStore
외부 store를 React 렌더 사이클에 연결하는 공식 훅