본문 바로가기

Web

[Web] Observer 패턴과 Store - 직접 구현하며 이해한 상태 관리

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 패턴의 subscribenotify 역할을 포함하며,
현재 상태를 나타내는 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 를 기반으로 상태 관리 로직을 분리했고, DropdownStoreStore 를 확장하도록 설계했다.

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 에서는 useStateuseReducer 를 통해 컴포넌트 내부의 상태를 관리한다.
useState 로 개별 상태를 선언하고, useReducer 로 관련된 상태 변경 로직을 한 곳에 모을 수 있었다.



이 두 훅에 대한 자세한 비교와 예시는 아래의 포스트에서 확인할 수 있다.


Context API 와 전역 상태 공유

useReducer 로 상태 변경 로직을 한 곳에 모을 수 있게 되었지만,
이것만으로는 해결되지 않는 문제가 하나 남아 있었다.


useReducer 는 상태를 어떻게 바꿀 것인가 에 대한 도구이지,
그 상태를 여러 컴포넌트에서 어떻게 공유할 것인가 에 대한 답은 아니었다.



실제로 이 문제를 경험한 적이 있었다.


당시 웹에서 동작하는 OS 시뮬레이터를 만들고 있었고,

os-simulator


그중 데스크탑 위의 윈도우를 자유롭게 드래그하고, 리사이즈하고, 최소화/최대화할 수 있는 기능을 구현해야 했다.

window-manager

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 에서 상태를 관리하고, WindowTitleBar / 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 로 윈도우를 조작할 수 있게 된 것이다.


정리하면, useReducerContext 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 는 컴포넌트 트리 바깥에 존재하기 때문에, 페이지가 바뀌어도 상태가 유지된다.
대화방에 속해 있는 한, 어느 페이지에서든 LocalChatPanelVoiceAudioLayer 가 렌더링된다.


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 구조를 직접 만들어 보고 다양한 상태 관리 방식을 거쳐 보면서,

결국 각 도구는 상태를 어디에 두고, 누가 바꾸며, 어떻게 전달하는가 라는 동일한 질문에 대해 서로 다른 답을 제시하고 있다는 점을 이해할 수 있었다.


관련 포스팅

728x90