junyeokk
Blog
vanilla-js-patterns·2024. 09. 12

옵저버 패턴으로 상태 관리 Store 만들기

프레임워크 없이 Vanilla JS로 앱을 만들다 보면 가장 먼저 부딪히는 문제가 있다. 상태가 바뀌었을 때 UI를 어떻게 업데이트할 것인가?

가장 단순한 방법은 상태를 변경하는 함수 안에서 직접 DOM을 조작하는 것이다.

javascript
let count = 0;

function increment() {
  count++;
  document.querySelector('#counter').textContent = count;
  document.querySelector('#double').textContent = count * 2;
  document.querySelector('#is-even').textContent = count % 2 === 0 ? '짝수' : '홀수';
}

이 방식은 상태 변경과 UI 업데이트가 완전히 결합되어 있다. count를 변경하는 로직 안에 어떤 DOM을 어떻게 바꿔야 하는지가 하드코딩되어 있기 때문에, 새로운 UI 요소가 count에 의존하게 되면 increment 함수를 매번 수정해야 한다. 상태를 변경하는 코드가 UI 구조를 알고 있어야 한다는 것 자체가 문제다.

React나 Vue 같은 프레임워크가 이 문제를 해결해주지만, 프레임워크 없이도 옵저버 패턴을 적용하면 상태 관리와 UI 업데이트를 깔끔하게 분리할 수 있다.


옵저버 패턴이란

옵저버 패턴은 GoF 디자인 패턴 중 하나로, 한 객체의 상태가 변하면 그 객체에 의존하는 다른 객체들에게 자동으로 알림을 보내는 구조다.

등장하는 역할은 두 가지다.

  • Subject (발행자): 상태를 가지고 있고, 상태가 바뀌면 알림을 보내는 쪽
  • Observer (구독자): 알림을 받아서 자기가 할 일을 처리하는 쪽

핵심 아이디어는 Subject가 Observer가 누구인지, 몇 명인지, 뭘 하는지 전혀 모른다는 것이다. 그저 "상태가 바뀌었다"는 사실만 알려줄 뿐이다. 이걸 느슨한 결합 (Loose Coupling)이라고 한다.

현실 세계의 비유로 설명하면 유튜브 구독과 같다. 크리에이터(Subject)가 영상을 올리면 구독자(Observer)들에게 알림이 간다. 크리에이터는 구독자가 알림을 보고 뭘 하는지 모르고, 구독자는 언제든 구독을 취소할 수 있다.


가장 기본적인 옵저버 패턴 구현

옵저버 패턴의 뼈대는 놀라울 정도로 간단하다. Set으로 구독자 목록을 관리하고, 상태가 바뀌면 순회하면서 호출해주면 된다.

javascript
class EventEmitter {
  #listeners = new Set();

  subscribe(listener) {
    this.#listeners.add(listener);

    // 구독 해제 함수 반환
    return () => {
      this.#listeners.delete(listener);
    };
  }

  notify(data) {
    this.#listeners.forEach(listener => listener(data));
  }
}

사용법도 직관적이다.

javascript
const emitter = new EventEmitter();

// 구독
const unsubscribe = emitter.subscribe((data) => {
  console.log('알림 받음:', data);
});

// 알림 발행
emitter.notify('hello'); // "알림 받음: hello"

// 구독 해제
unsubscribe();
emitter.notify('world'); // (아무 일도 안 일어남)

subscribe가 구독 해제 함수를 반환하는 패턴은 React의 useEffect cleanup과 같은 원리다. 구독자가 자기가 등록했던 구독을 직접 해제할 수 있게 해준다.

왜 Set인가?

구독자 목록을 배열 대신 Set으로 관리하는 이유가 있다.

javascript
// 배열이면 같은 함수를 두 번 등록할 수 있다
const listeners = [];
listeners.push(handler);
listeners.push(handler); // 중복!
// notify하면 handler가 두 번 호출됨

// Set은 중복을 자동으로 방지한다
const listeners = new Set();
listeners.add(handler);
listeners.add(handler); // 무시됨
// notify하면 handler가 한 번만 호출됨

또한 Set.delete()는 O(1)이지만, 배열에서 특정 요소를 제거하려면 indexOf + splice로 O(n)이다. 구독/해제가 빈번한 상황에서 Set이 더 효율적이다.


Store 패턴: 옵저버 + 상태 관리

기본 옵저버 패턴에 상태 저장 기능을 결합하면 프레임워크의 Store와 비슷한 것을 만들 수 있다. 단순히 알림만 보내는 게 아니라, 내부에 상태를 가지고 있고, 상태가 변경되면 구독자들에게 변경된 상태를 전달하는 구조다.

javascript
class Store {
  #state;
  #observers = new Set();

  constructor(initialState) {
    this.#state = initialState;
  }

  getState() {
    return this.#state;
  }

  setState(updater) {
    // 함수를 받으면 이전 상태를 인자로 전달
    if (typeof updater === 'function') {
      this.#state = updater(this.#state);
    } else {
      this.#state = updater;
    }

    this.notify();
  }

  subscribe(observer) {
    this.#observers.add(observer);
    return () => this.#observers.delete(observer);
  }

  notify() {
    this.#observers.forEach(observer => observer(this.#state));
  }
}

이 Store를 사용하면 상태 변경과 UI 업데이트가 완전히 분리된다.

javascript
const counterStore = new Store({ count: 0 });

// UI 컴포넌트 A: 카운터 표시
counterStore.subscribe((state) => {
  document.querySelector('#counter').textContent = state.count;
});

// UI 컴포넌트 B: 짝수/홀수 표시
counterStore.subscribe((state) => {
  document.querySelector('#parity').textContent =
    state.count % 2 === 0 ? '짝수' : '홀수';
});

// 상태 변경 — UI는 자동으로 업데이트된다
counterStore.setState(prev => ({ count: prev.count + 1 }));

setState는 상태만 변경할 뿐 DOM이 어떻게 생겼는지 전혀 모른다. 각 구독자가 자기가 담당하는 DOM만 업데이트한다. 새로운 UI 요소를 추가하고 싶으면 subscribe만 하나 더 호출하면 된다.

setState에 함수를 받는 이유

직접 값을 전달하는 방식과 함수를 전달하는 방식 두 가지를 지원하는 것이 좋다.

javascript
// 값 전달 — 이전 상태와 무관하게 덮어쓸 때
store.setState({ count: 0 });

// 함수 전달 — 이전 상태를 기반으로 업데이트할 때
store.setState(prev => ({ count: prev.count + 1 }));

함수 방식이 필요한 이유는 경쟁 조건(race condition) 때문이다. 비동기 작업이 끼면 getState()로 읽은 값이 setState 시점에는 이미 바뀌어 있을 수 있다. 함수를 전달하면 setState 내부에서 현재 상태를 읽기 때문에 항상 최신 상태를 기반으로 업데이트된다. React의 setState(prev => ...) 패턴과 동일한 이유다.


실전 예시: 도메인별 Store

범용 Store도 좋지만, 실무에서는 특정 도메인에 특화된 Store를 만드는 경우가 더 많다. 예를 들어 할 일 목록을 관리하는 Store를 보자.

javascript
class TodoStore {
  #items = [];
  #observers = new Set();

  subscribe(observer) {
    this.#observers.add(observer);
    return () => this.#observers.delete(observer);
  }

  #notify() {
    // 그룹핑된 데이터를 구독자에게 전달
    const grouped = this.#groupByStatus();
    this.#observers.forEach(observer => observer(grouped));
  }

  #groupByStatus() {
    const result = { todo: [], doing: [], done: [] };
    this.#items.forEach(item => {
      if (result[item.status]) {
        result[item.status].push(item);
      }
    });
    return result;
  }

  async fetchAll() {
    try {
      const response = await fetch('/api/items');
      const data = await response.json();
      if (Array.isArray(data.items)) {
        this.#items = data.items;
        this.#notify();
      }
    } catch (error) {
      console.error('Error fetching items:', error);
    }
  }

  async add(item) {
    try {
      const response = await fetch('/api/items', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(item),
      });
      const result = await response.json();
      this.#items.push({ ...item, id: result.id });
      this.#notify();
    } catch (error) {
      console.error('Error adding item:', error);
    }
  }

  remove(id) {
    this.#items = this.#items.filter(item => item.id !== id);
    this.#notify();
  }
}

이 구현에서 주목할 점이 몇 가지 있다.

1. #notify가 원본 데이터를 그대로 전달하지 않는다. 내부 배열 #items를 그대로 넘기면 구독자가 직접 수정할 수 있어서 위험하다. #groupByStatus()로 가공된 새 객체를 만들어서 전달하기 때문에, 구독자가 받은 데이터를 변경해도 Store 내부 상태에 영향이 없다.

2. 모든 변경 메서드 끝에 #notify()가 있다. fetchAll, add, remove 어떤 방식으로 상태가 바뀌든 구독자에게 알림이 간다. 이 일관성이 중요하다. 하나라도 빠뜨리면 UI와 상태가 동기화되지 않는 버그가 생긴다.

3. # private 필드로 상태를 캡슐화했다. #items#observers에 외부에서 직접 접근할 수 없다. 상태 변경은 반드시 Store가 제공하는 메서드를 통해서만 가능하므로, notify 호출이 누락될 일이 없다.


View와 Store 연결하기

Store만 있으면 절반이다. Store를 구독해서 실제 DOM을 업데이트하는 View와 어떻게 연결하는지가 나머지 절반이다.

javascript
class TodoListView {
  #container;
  #store;
  #unsubscribe;

  constructor(container, store) {
    this.#container = container;
    this.#store = store;

    // Store 구독 — 상태가 바뀔 때마다 render 호출
    this.#unsubscribe = this.#store.subscribe((data) => {
      this.#render(data);
    });
  }

  #render(groupedItems) {
    this.#container.innerHTML = '';

    Object.entries(groupedItems).forEach(([status, items]) => {
      const section = document.createElement('div');
      section.className = `column column-${status}`;

      const title = document.createElement('h2');
      title.textContent = `${status} (${items.length})`;
      section.appendChild(title);

      items.forEach(item => {
        const el = document.createElement('div');
        el.className = 'item';
        el.textContent = item.title;

        const deleteBtn = document.createElement('button');
        deleteBtn.textContent = '삭제';
        deleteBtn.addEventListener('click', () => {
          this.#store.remove(item.id);
        });
        el.appendChild(deleteBtn);

        section.appendChild(el);
      });

      this.#container.appendChild(section);
    });
  }

  destroy() {
    this.#unsubscribe();
    this.#container.innerHTML = '';
  }
}
javascript
// 초기화
const store = new TodoStore();
const view = new TodoListView(document.querySelector('#app'), store);

// 데이터 로드 → Store 업데이트 → View 자동 렌더링
store.fetchAll();

이 구조의 핵심은 데이터 흐름이 단방향이라는 점이다.

사용자 액션 → Store.메서드() → 상태 변경 → notify() → View.render()

View는 Store의 상태를 직접 변경하지 않는다. 항상 Store의 메서드를 호출하고, Store가 상태를 변경한 후 notify를 통해 View가 업데이트된다. 이 패턴이 React나 Redux의 단방향 데이터 흐름과 같은 원리다.


이벤트별 구독: 여러 종류의 알림

지금까지의 구현은 모든 구독자에게 동일한 알림을 보낸다. 하지만 Store의 상태 변경이 다양한 종류일 때, 구독자가 관심 있는 변경만 받고 싶을 수 있다.

javascript
사용자 액션 → Store.메서드() → 상태 변경 → notify() → View.render()
javascript
class EventStore {
  #listeners = new Map(); // 이벤트명 → Set<listener>

  on(event, listener) {
    if (!this.#listeners.has(event)) {
      this.#listeners.set(event, new Set());
    }
    this.#listeners.get(event).add(listener);

    return () => {
      const set = this.#listeners.get(event);
      if (set) {
        set.delete(listener);
        if (set.size === 0) this.#listeners.delete(event);
      }
    };
  }

  emit(event, data) {
    const set = this.#listeners.get(event);
    if (set) {
      set.forEach(listener => listener(data));
    }
  }
}

이 방식은 Node.js의 EventEmitter와 동일한 패턴이다. 단순한 Store에서는 과할 수 있지만, 이벤트 종류가 많아지면 필요해진다.


메모리 누수 주의

옵저버 패턴에서 가장 흔한 실수는 구독 해제를 잊는 것이다.

javascript
const store = new EventStore();

// 'add' 이벤트만 구독
store.on('add', (item) => {
  console.log('새 아이템:', item);
});

// 'remove' 이벤트만 구독
store.on('remove', (id) => {
  console.log('삭제됨:', id);
});

store.emit('add', { id: 1, title: 'test' });
store.emit('remove', 1);

이 컴포넌트의 인스턴스가 사라져도 Store의 #observers Set에는 여전히 이 콜백에 대한 참조가 남아 있다. 컴포넌트가 생성/삭제를 반복하면 구독자가 계속 쌓이면서 메모리가 누수된다. 그리고 notify() 호출 시 이미 사라진 DOM을 조작하려는 에러가 발생할 수도 있다.

javascript
class SomeComponent {
  constructor(store) {
    // ❌ 구독만 하고 해제를 안 하면?
    store.subscribe((state) => {
      this.update(state);
    });
  }
}

React의 useEffect cleanup이 자동으로 해주는 일을 Vanilla JS에서는 직접 관리해야 한다. 컴포넌트에 destroy (또는 unmount, dispose) 메서드를 만들고, 그 안에서 모든 구독을 해제하는 패턴을 습관화하는 게 좋다.


옵저버 vs Pub/Sub vs EventEmitter

비슷한 패턴들이 여럿 있어서 헷갈릴 수 있다.

패턴Subject가 Observer를 아는가중간 매개체사용 예
옵저버Yes (직접 참조)없음Store → View 업데이트
Pub/SubNo이벤트 버스 (중간 브로커)모듈 간 통신
EventEmitterYes (이벤트명으로 관리)없음 (자기 자신이 Subject)Node.js 이벤트, DOM 이벤트

옵저버 패턴은 Subject가 Observer 목록을 직접 가지고 있다. 구독과 알림이 직접 연결된다.

Pub/Sub은 발행자와 구독자 사이에 이벤트 버스(브로커)가 존재한다. 발행자는 버스에 이벤트를 보내고, 구독자는 버스에서 이벤트를 받는다. 서로의 존재를 모른다.

javascript
class SomeComponent {
  #unsubscribe;

  constructor(store) {
    // ✅ 구독 해제 함수를 저장해둔다
    this.#unsubscribe = store.subscribe((state) => {
      this.update(state);
    });
  }

  destroy() {
    // ✅ 컴포넌트가 제거될 때 구독 해제
    this.#unsubscribe();
  }
}

실무에서는 이 세 가지의 경계가 모호하고, 이름도 혼용된다. 중요한 것은 개념의 차이가 아니라 "상태가 바뀌면 관심 있는 쪽에 알려준다"는 핵심 원리를 이해하는 것이다.


Redux와의 관계

Redux의 createStore를 열어보면 내부 구조가 이 글에서 만든 Store와 거의 같다.

javascript
// Pub/Sub — 중간에 eventBus가 있다
eventBus.publish('item:added', item);  // 발행자
eventBus.subscribe('item:added', handler);  // 구독자
// 발행자와 구독자가 서로를 모름

// Observer — 직접 연결
store.subscribe(handler);  // 구독자가 Subject를 직접 알고 있음
store.notify();  // Subject가 구독자를 직접 호출

차이점은 Redux가 상태 변경을 reducer 함수로 제한한다는 것이다. dispatch(action)reducer(state, action) → 새 상태 → notify. 직접 setState를 호출하는 대신 action 객체를 보내고 reducer가 상태를 계산한다.

이 제약이 오히려 장점이다. 모든 상태 변경이 action으로 기록되기 때문에 디버깅할 때 "어떤 순서로 상태가 바뀌었는지" 추적할 수 있다. 하지만 단순한 앱에서는 과도한 보일러플레이트가 된다.


정리

옵저버 패턴 기반 Store는 결국 세 가지 책임을 분리하는 것이다.

  1. 상태 저장: Store가 단일 진실 공급원(Single Source of Truth)으로 데이터를 관리
  2. 변경 알림: 상태가 바뀌면 등록된 구독자들에게 자동 통지
  3. UI 반영: 각 View가 자기 담당 영역만 업데이트

이 패턴을 이해하면 React의 useState, Redux의 store.subscribe, MobX의 autorun이 왜 그런 API를 가지고 있는지 자연스럽게 이해된다. 결국 모두 "상태 변경 → 구독자 알림"이라는 동일한 원리 위에 구축된 것이기 때문이다.