Vanilla JS 환경에서 외부 프레임워크 없이 상태 관리를 직접 구현해보면서, Observer 패턴과 Store 구조를 먼저 접하게 되었다.
이 글은 그 경험 기반으로, React 의 Context API 를 거쳐 Zustand 와 같은 전역 상태 관리 방식까지
상태의 공유 범위와 구독 방식이 어떻게 확장되어 가는지를 구조적인 흐름으로 정리한 문서이다.
특정 도구를 우열로 비교하기보다는, 각 단계에서 어떤 문제를 해결하려 했는지와
그 과정에서 상태의 공유 범위와 구독 방식이 어떻게 달라졌는지를 회고하며 정리해보고자 한다.
Observer 패턴
상태 관리를 공부하면서 가장 먼저 만나게 된 개념이 Observer 패턴이었다.
const subject = {
observers: [],
subscribe(fn) { this.observers.push(fn); },
notify(data) { this.observers.forEach(fn => fn(data)); }
};
const observer = (data) => console.log("data:", data);
subject.subscribe(observer);
subject.notify({ text: "Hello World" });
data: { text: 'Hello World' }
- Subject (발행자): 변화가 생기면, 등록된 관찰자들에게 알림을 보낸다.
- Observer (구독자): Subject를 구독하고 있다가, 알림이 오면 반응한다.
Subject 는 Observer 가 누구인지, 몇 명인지 알 필요가 없이 등록된 함수들을 순서대로 호출한다.
서로를 직접 참조하지 않아도 연결이 되는 이 구조를 느슨한 결합(loose coupling) 이라고 하는데, 옵저버 패턴의 핵심적인 특징이다.
Store
Observer 패턴은 변화가 발생했다는 사실만 notify 한다. 하지만 FE 에서는 단순 알림만으로는 부족하다.
컴포넌트는 단순히 변화가 발생했다는 사실 뿐 아니라, 현재 상태 값 자체를 필요로 하는 경우가 많기 때문이다.
그래서 FE 에서는 상태를 한 곳에 모아 관리하고,
상태가 변경되면 구독자에게 알림까지 보내는 구조를 사용한다.
이처럼 상태 저장과 변경 통지를 함께 담당하는 것이 Store 이다.
const store = {
state: {},
listeners: [],
setState(patch) {
this.state = { ...this.state, ...patch };
this.listeners.forEach(fn => fn(this.state));
},
subscribe(fn) { this.listeners.push(fn); }
};
따라서 Store 는 일반적으로 observer 패턴의 subscribe 와 notify 역할을 포함하며,
현재 상태를 나타내는 state 를 함께 지닌다.
이때 상태를 변경하면서 동시에 구독자에게 알림을 전달하는 책임을 setState 가 맡게 된다.
구현마다 명칭이나 구조는 다르지만, 대체적으로 Store 는 subscribe, state, setState 의 API 를 제공한다고 할 수 있다.
다른 형태의 Store
위에서는 객체 리터럴 방식으로 store 를 구성했다.
이는 구조가 단순하고 직관적이지만, 상태 은닉이나 확장성 측면에서 한계가 있을 수 있다.
Store 는 클로저 기반의 함수 형태로 만들 수도 있고, 클래스를 사용해 상태와 동작을 묶어 구성할 수도 있다.
함수형 Store
function createStore(initialState = {}) {
let state = initialState;
const listeners = new Set();
function setState(patch) {
state = { ...state, ...patch };
listeners.forEach(fn => fn(state));
}
function subscribe(fn) { listeners.add(fn); }
return { getState: () => state, setState, subscribe };
}
Store 를 함수형(Factory) 패턴으로 구현한 방식이다.
함수형 Store 는 다음의 특징을 가진다.
- 클로저를 통해 내부 state 를 외부에서 직접 수정하지 못하도록 보호할 수 있다.
- 인스턴스마다 독립적인 상태를 쉽게 생성할 수 있다.
- 함수 호출만으로 store 를 만들 수 있어 구성 방식이 단순하다.
후술할 Zustand 역시 내부적으로 이와 유사한 클로저 기반 함수형 구조를 사용해 상태를 캡슐화한다.
클래스형 Store
class Store {
constructor(initialState = {}) {
this.state = initialState;
this.listeners = new Set();
}
setState(patch) {
this.state = { ...this.state, ...patch };
this.notify();
}
subscribe(fn) { this.listeners.add(fn); }
unsubscribe(fn) { this.listeners.delete(fn); }
notify() { this.listeners.forEach(fn => fn(this.state)); }
}
Store 를 클래스로 구현한 방식이다.
클래스형 Store 는 상태와 메서드를 명확히 구조화할 수 있으며, 상속이나 확장을 통한 기능 추가에 유리하다.
Vanilla JS 로 상태관리 하기
나는 외부 프레임워크 없이 Vanilla JS 만으로 Dropdown 컴포넌트의 상태를 직접 관리해야 했던 경험이 있다.
이때 클래스형 Store 를 기반으로 상태 관리 로직을 분리했고, DropdownStore 가 Store 를 확장하도록 설계했다.
class DropdownStore extends Store {
constructor(items = []) {
super({
isOpen: false,
selectedIndex: -1,
});
this.items = items;
}
toggle() { this.setState({ isOpen: !this.state.isOpen }); }
select(index) { this.setState({ selectedIndex: index, isOpen: false }); }
}
// 싱글톤 (전역 상태 유지)
const dropdownStore = new DropdownStore(["item-1", "item-2", "item-3"]);
export default dropdownStore;
TS 가 아닌 JS 환경이었기 때문에 상태의 타입 안정성에 대한 제약은 크게 고려하지 않았다.
Dropdown 의 상태는 열린 여부 (isOpen) 와 현재 선택된 인덱스 (selectedIndex) 로 구성했다.
또 외부에서는 상태를 직접 수정하지 않도록 toggle, select 와 같은 명확한 API 를 통해서만 상태를 변경하도록 했다.
이를 통해 UI 로직과 상태 변경 로직을 분리할 수 있었다.
컴포넌트에서 Store 구독하기
이제 컴포넌트가 이 Store 어떻게 사용하는지 알아보려고 한다.
Dropdown 컴포넌트를 구현하였다.
Vanilla JS 에서 컴포넌트를 어떻게 아래처럼 구현하였는지에 대한 내용은 아래 포스트에서 확인할 수 있다.
export function Dropdown(text) {
const className = "dropdown";
const handleClick = () => dropdownStore.toggle();
const props = { className, onClick: handleClick };
const buildSelected = (state) => {
return dropdownStore.items[state.selectedIndex] ?? null;
}
const build = (state) => tags.button(
props, buildSelected(state) ?? text, Icon("chevronDown"),
);
let indicator = build(dropdownStore.state); // 1. 초기 렌더링
dropdownStore.subscribe((state) => { // 2. 상태 변화 구독
const next = build(state); // 3. 새 상태로 다시 빌드
indicator.replaceWith(next); // 4. DOM 교체
indicator = next; // 5. 참조 갱신
});
return indicator;
}
이 과정을 하나의 흐름으로 보면, 상태에서 UI로 이어지는 단방향 데이터 흐름이 만들어진다.
컴포넌트는 상태를 직접 수정하지 않고, 단지 Store가 제공하는 메서드(toggle, select)를 호출할 뿐이다.
이후 Store 내부에서 setState() 가 실행되고, notify() 를 통해 구독자에게 변경된 상태가 전달되면,
컴포넌트는 새 상태를 받아 스스로를 다시 그리게 된다.
flowchart LR
A[사용자 클릭] --> B["store.toggle()"]
B --> C["setState()"]
C --> D["notify()"]
D --> E[구독자 콜백 실행]
E --> F[DOM 갱신]
결국 이벤트는 Store 로 전달되고, UI 는 변경된 상태를 받아 다시 렌더링되는 흐름이 만들어진다.
React 의 상태 관리 훅
Vanilla JS 에서 Store 를 직접 구현해 보면서, React 의 상태 관리 방식이 왜 그렇게 설계되었는지 조금씩 이해되기 시작했다.
React 에서는 useState 와 useReducer 를 통해 컴포넌트 내부의 상태를 관리한다.useState 로 개별 상태를 선언하고, useReducer 로 관련된 상태 변경 로직을 한 곳에 모을 수 있었다.
이 두 훅에 대한 자세한 비교와 예시는 아래의 포스트에서 확인할 수 있다.
Context API 와 전역 상태 공유
useReducer 로 상태 변경 로직을 한 곳에 모을 수 있게 되었지만,
이것만으로는 해결되지 않는 문제가 하나 남아 있었다.
useReducer 는 상태를 어떻게 바꿀 것인가 에 대한 도구이지,
그 상태를 여러 컴포넌트에서 어떻게 공유할 것인가 에 대한 답은 아니었다.
실제로 이 문제를 경험한 적이 있었다.
당시 웹에서 동작하는 OS 시뮬레이터를 만들고 있었고,
![]()
그중 데스크탑 위의 윈도우를 자유롭게 드래그하고, 리사이즈하고, 최소화/최대화할 수 있는 기능을 구현해야 했다.
![]()
flowchart TD
Desktop --> WindowManager
WindowManager --> Window
Window --> TitleBar["TitleBar(드래그, 닫기, 최소화, 최대화)"]
Window --> Content["Content"]
Window --> Resizer["Resizer(리사이즈)"]
각 윈도우의 위치(x, y), 크기(w, h), z-index, 표시 모드(normal, minimized, maximized) 등을 상태로 관리해야 했고,
이 상태를 조작하는 함수들(handleDragStart, handleResizeEnd, handleClose 등)도 함께 필요했다.
문제는 이 상태와 함수들을 사용하는 곳이 Window, TitleBar, Resizer 등 여러 컴포넌트에 걸쳐 있었다는 점이다.
WindowManager 에서 상태를 관리하고, Window → TitleBar / Resizer 로 props 를 내려주는 방식을 먼저 시도했지만,
전달해야 하는 상태와 함수의 수가 많았고 계층이 깊어질수록 전달 코드가 급격히 늘어났다.
이처럼 상위에서 하위로 props 를 계속 내려줘야 하는 상황은 prop drilling 이라고 한다.
React 는 이 문제를 해결하기 위해 Context API 를 제공한다.
Context 를 사용하면 props 를 일일이 전달하지 않아도, Provider 로 감싼 하위 트리 어디에서든 같은 값에 접근할 수 있다.
이를 활용해 윈도우의 상태와 조작 함수를 하나의 Context 로 묶었다.
const WindowManagerContext = createContext(null);
function WindowManagerProvider({ children }) {
const [states, setStates] = useState([]); // 전체 윈도우 상태 목록
const [focused, setFocused] = useState(null); // 현재 포커스된 윈도우
const updateBounds = (id, patch) => { /* 위치/크기 업데이트 */ };
const updateMode = (id, mode) => { /* 표시 모드 변경 */ };
const handleDragStart = (id, e) => { /* 드래그 시작 */ };
const handleDragging = (id, e) => { /* 드래그 중 */ };
const handleDragEnd = (id, e) => { /* 드래그 종료, 위치 반영 */ };
const handleResizeStart = (id, dir) => (e) => { /* 리사이즈 시작 */ };
const handleResizing = (id, e) => { /* 리사이즈 중 */ };
const handleResizeEnd = (id, e) => { /* 리사이즈 종료, 크기 반영 */ };
const handleMinimize = (id) => { /* 최소화 */ };
const handleMaximize = (id) => { /* 최대화 토글 */ };
const handleClose = (id) => { /* 닫기 */ };
const handleFocus = (id) => { /* 포커스 변경 */ };
const value = {
states, focused,
handleDragStart, handleDragging, handleDragEnd,
handleResizeStart, handleResizing, handleResizeEnd,
handleMinimize, handleMaximize, handleClose, handleFocus,
};
return (
<WindowManagerContext.Provider value={value}>
{children}
</WindowManagerContext.Provider>
);
}
이제 하위 컴포넌트들은 useContext 를 통해 필요한 상태와 함수에 직접 접근할 수 있다.
function TitleBar({ title, state }) {
const {
handleDragStart, handleDragging, handleDragEnd,
handleClose, handleMinimize, handleMaximize,
} = useContext(WindowManagerContext);
return (
<div className={styles.titleBar}>
<div
className={styles.pointerArea}
onPointerDown={(e) => handleDragStart(state.id, e)}
onPointerMove={(e) => handleDragging(state.id, e)}
onPointerUp={(e) => handleDragEnd(state.id, e)}
/>
<TitleBarButton type="close" onClick={() => handleClose(state.id)} />
<TitleBarButton type="minimize" onClick={() => handleMinimize(state.id)} />
<TitleBarButton type="maximize" onClick={() => handleMaximize(state.id)} />
<div className={styles.title}>{title}</div>
</div>
);
}
function Resizer({ state, dir }) {
const { handleResizeStart, handleResizing, handleResizeEnd } = useContext(WindowManagerContext);
return (
<div
onPointerDown={handleResizeStart(state.id, dir)}
onPointerMove={(e) => handleResizing(state.id, e)}
onPointerUp={(e) => handleResizeEnd(state.id, e)}
/>
);
}
TitleBar 는 드래그와 윈도우 제어를, Resizer 는 리사이즈만을 담당한다.
각 컴포넌트는 Context 에서 자신에게 필요한 함수만 꺼내 사용하고,
상태 관리의 구체적인 구현은 WindowManagerProvider 안에 감춰져 있다.
props 를 거쳐 내려줄 필요 없이, 어디에서든 동일한 API 로 윈도우를 조작할 수 있게 된 것이다.
정리하면, useReducer 와 Context API 는 서로 다른 문제를 해결한다.
useReducer- 상태를 어떻게 바꿀 것인가 (상태 변경 규칙)Context API- 누가 그 상태에 접근할 수 있는가 (상태 공유 범위)
Context API 를 사용하며 느낀 점
Context 를 사용하면서 한 가지 알게 된 특성이 있었다.
Context 의 값이 변경되면, 해당 Provider 하위의 컴포넌트들이 다시 렌더링될 수 있다는 점이다.
위의 윈도우 매니저 예시에서도, 하나의 윈도우를 드래그할 때마다 states 가 변경되면서
드래그와 무관한 다른 윈도우나 하위 컴포넌트까지 리렌더링이 발생할 수 있었다.
물론 Context 를 여러 개로 분리하거나, memo 를 적절히 사용하면 어느 정도 대응할 수 있다.
하지만 하나의 Provider 에 상태가 집중될수록 이 제어가 점점 번거로워진다는 점은 체감할 수 있었다.
Context 는 prop drilling 을 해결하고 값을 공유하는 데에는 충분히 효과적인 도구였다.
다만, 상태 변경이 잦고 구독 범위를 세밀하게 나눠야 하는 환경에서는
Context 가 해결하려는 문제와 실제로 필요한 구조 사이에 간극이 생길 수 있다는 점을 체감하게 되었다.
Zustand
이후 참여하게 된 새로운 프로젝트에서, 상태 관리에 대해 다시 한번 고민하게 되었다.
해당 프로젝트는 텍스트 채팅과 음성 채팅이 동시에 동작하는 실시간 커뮤니케이션 서비스였다.
해당 프로젝트의 코드는 GitHub 저장소에서 확인할 수 있다.
사용자는 하나의 대화방(Room) 에 소속될 수 있었고,
대화방을 나가지 않는 이상 페이지를 이동하더라도 채팅 패널과 음성 연결이 유지되어야 했다.
핵심은 현재 내가 속한 대화방 정보 였다. 이 상태는 앱 전반에 걸쳐 항상 정확하게 유지되어야 했다.
PageLayout— 현재 소속된 방이 있으면 채팅 패널과 음성 레이어를 표시LocalChatPanel— 방의 참여자 목록과 채팅 메시지를 렌더링RoomTextChat— 메시지 전송 시 현재 방 ID 를 참조VoiceAudioLayer— 방에 속해 있는 동안 음성 연결을 유지
이처럼 서로 다른 계층과 역할을 가진 컴포넌트들이 동일한 Room 상태에 의존하고 있었다.
Zustand 를 선택한 이유
Context 로도 이 상태를 공유하는 것 자체는 가능했을 것이다.
다만 이 프로젝트에서는 Context 가 제공하는 공유 방식과는 결이 다른 요구사항이 있었고,
컴포넌트 트리와 독립적인 전역 Store 가 더 자연스러운 선택이었다.
- Room 상태는 참여자 입퇴장, 방 설정 변경 등으로 인해 변경이 잦았다.
하나의 Provider 에 이 상태가 집중되면, 변경이 발생할 때마다 하위 컴포넌트 전체에 영향을 줄 수 있다고 판단했다. PageLayout은 방 ID 만,LocalChatPanel은 참여자 목록만 필요한 상황에서
각 컴포넌트가 자신에게 필요한 상태만 구독 할 수 있는 구조가 필요했다.- Room 상태는 특정 페이지의 Provider 생명주기에 종속되지 않고, 페이지 이동에도 유지 되어야 했다.
이러한 조건들을 종합했을 때, Zustand 가 이 프로젝트에 가장 적합한 선택이라고 판단했다.
RoomStore
Zustand 의 create 는 상태와 상태 변경 함수를 하나의 Store 로 정의하는 함수이다.
Room 상태를 다음과 같이 구성했다.
export const roomStore = create((set) => ({
...createEmptyRoomState(),
replaceRoom: (room) => set(() => ({ ...room })),
updateRoom: (patch) => set((state) => ({ ...state, ...patch })),
addParticipant: (participant) => set((state) => ({
participants: [...(state.participants ?? []), participant],
})),
removeParticipant: (userId) => set((state) => ({
participants: (state.participants ?? []).filter((p) => p.userId !== userId),
})),
resetRoom: () => set(() => ({ ...createEmptyRoomState() })),
}));
replaceRoom 은 방에 입장했을 때 전체 상태를 교체하고,addParticipant, removeParticipant 는 실시간으로 참여자 목록을 갱신한다.
resetRoom 은 방을 나갈 때 상태를 초기화한다.
Zustand 의 Store 는 컴포넌트 트리 바깥에 모듈 레벨로 존재하기 때문에, 페이지 이동이 발생해도 상태가 유실되지 않는다.
컴포넌트의 생명주기와 무관하게 상태가 유지되어야 했던 요구사항에 이 구조가 잘 맞았다.
전역 source of truth
이제 서비스 어디에서든 roomStore 를 통해 동일한 Room 상태에 접근할 수 있게 되었다.
PageLayout 에서는 방 ID 를 기준으로 채팅 패널과 음성 레이어의 표시 여부를 결정했다.
const roomId = roomStore((state) => state.id);
{roomId && !isRoomPage && <LocalChatPanel key={roomId} />}
{roomId && <VoiceAudioLayer />}
roomStore 는 컴포넌트 트리 바깥에 존재하기 때문에, 페이지가 바뀌어도 상태가 유지된다.
대화방에 속해 있는 한, 어느 페이지에서든 LocalChatPanel 과 VoiceAudioLayer 가 렌더링된다.
Selector 기반 구독
LocalChatPanel 에서는 방 ID 와 참여자 목록만 필요했다.
const roomId = roomStore((state) => state.id);
const participants = roomStore((state) => state.participants);
Zustand 는 selector 를 통해 컴포넌트가 자신이 필요한 상태만 골라서 구독할 수 있다.participants 가 변경되더라도, roomId 만 구독하는 컴포넌트는 리렌더링되지 않는다.
참여자 입퇴장이 빈번한 실시간 환경에서,
방 ID 만 참조하는 컴포넌트가 참여자 변경에 영향을 받지 않는다는 점은 설계상 큰 이점이었다.
클로저 기반 Store
Zustand 를 쓰면서 느꼈던 점은, 앞서 직접 만들었던 함수형 Store 와 매우 유사한 형태라는 점이었다.
function createStore(initialState = {}) {
let state = initialState;
const listeners = new Set();
function setState(patch) {
state = { ...state, ...patch };
listeners.forEach(fn => fn(state));
}
function subscribe(fn) { listeners.add(fn); }
return { getState: () => state, setState, subscribe };
}
Zustand 역시 내부적으로 클로저를 사용해 state, setState, subscribe 를 캡슐화한다.
직접 구현했던 Store 의 구조가 실제 라이브러리에서도 그대로 사용되고 있다는 점이 인상 깊었다.
차이가 있다면, Zustand 는 여기에 React 와의 연동 (훅 반환, selector 기반 구독, 리렌더링 최적화)을 얹은 것이라고 이해할 수 있었다.
Flux 패턴
React 의 useReducer 에서도 dispatch 를 통해 action 을 전달하고, reducer 가 새로운 상태를 계산하는 구조를 사용한다.
Zustand 를 사용하면서, 이런 단방향 흐름이 특정 라이브러리만의 방식이 아니라
상태 관리 전반에서 공통적으로 나타나는 패턴이라는 것을 알게 되었다.
flowchart LR
A["Action"] --> B["Store"]
B --> C["State 변경"]
C --> D["View 업데이트"]
D -->|"사용자 이벤트"| A
이 흐름을 Flux 패턴 이라고 부른다.
상태는 항상 한 방향으로만 흐르며, View 가 상태를 직접 수정하지 않고 정해진 경로를 통해서만 변경을 요청한다.
돌이켜보면, 이 글에서 다뤄온 흐름이 결국 이 패턴과 맞닿아 있었다.
- Vanilla JS Store 에서
toggle(),select()를 통해서만 상태를 변경했던 것 useReducer에서dispatch({ type: "toggle" })로 action 을 전달했던 것- Zustand 에서
set()을 통해 상태를 업데이트하는 것
모두 컴포넌트가 상태를 직접 수정하지 않고, Store 가 제공하는 함수를 통해 변경을 요청한다 는 동일한 원칙을 따르고 있었다.
마무리
이번 글에서는 Vanilla JS 로 상태 관리를 직접 구현하던 시점부터,
React 의 Context API, 그리고 Zustand 까지 이어지는 상태 공유와 구독의 흐름을 정리해 보았다.
Observer 패턴과 Store 구조를 직접 만들어 보고 다양한 상태 관리 방식을 거쳐 보면서,
결국 각 도구는 상태를 어디에 두고, 누가 바꾸며, 어떻게 전달하는가 라는 동일한 질문에 대해 서로 다른 답을 제시하고 있다는 점을 이해할 수 있었다.
관련 포스팅
'Web' 카테고리의 다른 글
| [Web] React 의 useState 와 useReducer (0) | 2026.02.16 |
|---|---|
| [Web] 여러 방식으로 DOM 조작하기 - createElement 에서 JSX 까지 (0) | 2026.02.15 |
| [Web] CSR (Client-Side Rendering) (0) | 2025.12.26 |
| [Web] SSR (Server-Side Rendering) (0) | 2025.12.25 |
| [Web] 다국어 웹(i18n Web) 개발하기 (0) | 2025.06.04 |