[디자인패턴] JS로 발행-구독 (Pub-Sub) 패턴 구현해보기

옵저버 패턴

발행-구독 패턴에 대해 공부하기 전 챌린지반에서 옵저버 패턴을 공부했다.

 

어떤 변화가 일어났을 때 그 변화를 감지해야할 곳에서 변화 상태를 구독하는 방식으로 구현하는 것을 의미한다.

이 패턴을 구현하기 위해 특정 요소의 업데이트가 발생할 때 호출할 함수들 목록(구독자/관찰자 목록이라고 할 수 있다!)을 만들어두고, 구독할 요소들에서 변경된 값을 반영하기위한 함수(변화시 호출될 콜백함수)들을 그 구독자 목록에 push해주면 된다.

 

옵저버 패턴의 특징

- 상태 변화가 발생하는 요소(주체)가 자신의 변화가 미칠 영향을 몰라도 된다.

- 주체와 그 관찰자의 결합도가 낮다.

- 옵저버로 등록(구독)해야하므로 두 요소는 완전히 모르는 상태가 아니다.

 

구현 예제

// Subject.js -> 주체
const createSubject = () => {
  const observers = new Map();

  const addObserver = (key, observer) => {
    if (!observers.has(key)) {
      observers.set(key, []);
    }
    observers.get(key).push(observer);
  };

  const removeObserver = (key, observer) => {
    if (observers.has(key)) {
      const filtered = observers.get(key).filter((obs) => obs !== observer);
      observers.set(key, filtered);
    }
  };

  const notify = (key, data) => {
    if (observers.has(key)) {
      observers.get(key).forEach((observer) => observer(data));
    }
  };

  const subscribe = (key, callback) => {
    addObserver(key, callback);
    return () => removeObserver(key, callback);
  };

  return { notify, subscribe };
};

export const subject = createSubject();
// userCounterObserver.js
import { useState, useEffect } from "react";
import { subject } from "./Subject";

export const useCounterObserver = () => {
  const [state, setState] = useState(0);

  useEffect(() => {
    const unsubscribe = subject.subscribe("counter", setState);

    return () => unsubscribe();
  }, []);

  // 상태를 업데이트하고 구독자들에게 알림을 주는 함수
  const increment = () => {
    const newState = state + 1;
    subject.notify("counter", newState); // 'counter' 키에 대해 알림
  };

  return [state, increment];
};
// App.jsx -> 관찰자/구독자
import React from "react";
import { useCounterObserver } from "./useCounterObserver";

const CounterComponent = () => {
  const [count, increment] = useCounterObserver();

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
};

const App = () => {
  return (
    <div>
      <h1>React Observer Pattern with Closures</h1>
      <CounterComponent />
      <CounterComponent />
    </div>
  );
};

export default App;

 

 

발행-구독 패턴

옵저버 패턴에서 더 나아가 두 요소가 서로를 아예 모르게 만들 수 있는 방법이다.

옵저버 패턴에서는 구독이라는 행위때문에 어느정도의 결합도가 있을 수 밖에 없지만, 발행-구독 패턴은 발행자와 구독자 사이의 중재자 (메시지 브로커/이벤트버스)가 있어 서로를 모르게 설계할 수 있다!

즉, 직접적으로 연결하여 변화를 알리고 감지하는 것이 아니라 중재자를 통해 메시지를 전달하는 것이다.

 

발행자와 구독자는 왜 서로를 몰라야할까?

이는 유지보수성과 확장성과 연결되어있다!

 

1) 확장성: 발행자/구독자가 서로를 신경쓰지 않고 쉽게 새로운 것을 발행 or 새로운 것을 구독할 수 있다.

- 발행자는 구독자가 어떤 항목을 어떤 방식으로 구독하고있는지 고려하지 않고 새로운 항목을 발행할 수 있다.

- 구독자는 언제든지 구독/구독해제 하거나 새로 발행된 내용을 구독할 수 있다.

- 즉, 이렇게 새로운 topic을 추가하는 등의 과정이 쉬워진다!

 

2) 낮은 결합도: 서로에 대한 의존도를 없애 발행자 혹은 구독자의 변경이 서로에게 영향을 미치지 않는다.

- 발행자의 구현 방식이 변경됐더라도 그에 맞춰 구독자의 구현 방식을 변경할 필요가 없다는 의미!

- ex. fetch, Promise 등이 모두 양쪽의 구현 방식을 알지 못해도 동일한 방법으로 원하는 동작을 실행하는 것처럼!

3) 재사용성 증가: 발행자와 구독자가 독립적으로 설계되어 다양한 상황에 더 쉽게 재사용할 수 있다.

 

발행-구독 패턴의 특징

- 발행자와 구독자가 서로의 존재를 전혀 알지 못한다.

- 이벤트를 구독하고 발행하는 과정이 이벤트 버스에 모두 모여있어 응집도가 높다.

- 구독자가 더이상 변화를 감지할 필요가 없을 때, 이벤트 버스에 여전히 구독자의 참조가 남아있다면 메모리 누수로 이어질 수 있다. (명시적으로 구독 해제 과정 필요!)

- 옵저버 패턴과 발행-구독 패턴 모두 동일하게 결합도가 높지 않아 에러 발생시 추적과 디버깅이 어려울 수 있다

 

 

구현 예시

// EventBus.js
const EventBus = {
  // 구독 목록
  listeners: {},

  // 구독자 등록 (구독자가 사용)
  // 지금 구독하려는 이벤트가 우선 구독자 목록의 키로 존재하는지 확인
  // 존재하지 않는 경우 지금 동작이 첫 구독이므로 event: [] 형태의 요소를 하나 추가해준다
  // 그 후 event: [구독자에서 변경된 값을 받아 사용하기 위한 callback] 형식으로 구독을 추가해준다
  on(event, callback) {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event].push(callback);
  },

  // 구독 해제 (구독자가 사용)
  // 어떤 이벤트에 대한 어떤 callback을 제거할지 매개변수로 전달
  // 구독 목록에 구독 해제하려는 이벤트가 있는지 확인한다 없는 경우 구독이 없으므로 별도의 처리 하지 않음
  // 이벤트가 갖고있는 요소들에서 callback을 필터하여 구독 해제한다
  // ex. event: [callback1, callback2] -> event: [callback2] 라면 callback1이 구독 취소한 것
  off(event, callback) {
    if (this.listeners[event]) {
      this.listeners[event] = this.listeners[event].filter(
        (listener) => listener !== callback,
      );
    }
  },

  // 이벤트 발행 (발행자가 사용)
  // 이벤트와 그 이벤트로 변경이 발생한 값을 매개변수로 전달
  // 구독 목록에 이벤트가 있는 경우, 그 이벤트를 구독하고있는 구독자들이 있다는 의미이므로 등록돼있는 callback 목록을 순회하면서 데이터를 전달해줌
  // 구독 목록(listeners)에 이벤트가 없는 경우 아직 그 이벤트를 구독하고있는 구독자가 없다는 의미이므로 별도의 처리하지 않음
  emit(event, data) {
    if (this.listeners[event]) {
      this.listeners[event].forEach((callback) => {
        callback(data);
      });
    }
  },
};

export default EventBus;
import EventBus from "./EventBus.js";
import { useState } from "react";

// 새로운 과제를 입력바다 EventBus를 통해 발행한다.
const Publisher = () => {
  const [formValue, setFormValue] = useState({ deadline: "", details: "" });
  const handleInput = (e) => {
    setFormValue({ ...formValue, [e.target.name]: e.target.value });
  };

  const handlePublish = (e) => {
    e.preventDefault();
    EventBus.emit("taskPublish", formValue);
    setFormValue({ deadline: "", details: "" });
  };

  return (
    <div>
      <h1>과제 제출하기</h1>
      <form onSubmit={handlePublish}>
        <input
          placeholder="과제 마감기한"
          value={formValue.deadline}
          name="deadline"
          onChange={handleInput}
        />
        <input
          placeholder="과제 내용"
          value={formValue.details}
          name="details"
          onChange={handleInput}
        />
        <button>과제 제출하기</button>
      </form>
    </div>
  );
};

export default Publisher;
import { useEffect, useState } from "react";
import EventBus from "./EventBus.js";

const Subscriber = () => {
  const [tasks, setTasks] = useState([]);

  useEffect(() => {
    // 구독하는 이벤트 발생시 호출할 콜백함수
    const callback = (newTask) => {
      setTasks((prevTasks) => [...prevTasks, newTask]);
    };

    // 구독
    EventBus.on("taskPublish", callback);

    // 언마운트시 구독 해제
    return () => {
      EventBus.off("taskPublish", callback);
    };
  }, []);
  
  return (
    <div>
      <h1>나의 과제 목록</h1>
      {tasks.map((task, index) => {
        return (
          <div key={`${Date.now() + index}`}>
            <h3>과제 {index}</h3>
            <p>마감기한: {task.deadline}</p>
            <p>내용: {task.details}</p>
          </div>
        );
      })}
    </div>
  );
};

export default Subscriber;

머리속으로는 알겠는데 직접 구현하려니 어려워서 하나하나 주석으로 동작에 대한 설명을 추가했다..

 

구현 중 헤맸던 부분

문제

새로운 과제를 등록했을 때 배열의 뒤에 추가되는 것이 아니라 마지막 요소가 삭제되고 추가되는 현상이 발견됨

 

기존 코드

구독자에서 추가된 데이터를 처리하는 부분

useEffect(() => {
    const callback = (newTask) => {
      setTasks([...tasks, newTask]);
    };

    EventBus.on("taskPublish", callback);

    return () => {
      EventBus.off("taskPublish", callback);
    };
  }, []);

 

시도한 것

코드 수정 등의 영향으로 다시 렌더링됐을 때 등록돼있는 task 외에 이후 Publisher의 발행으로 등록되는 새로운 과제는 추가되지 않는 것을 확인함

개발자도구로 확인해보니 클로져로 tasks가 등록돼있는 것에서 힌트를 얻음

지금 코드에서 구조분해할당으로 펼쳐지는 tasks는 초기값인 빈 배열이고, callback이 선언될 때의 tasks인 []가 클로져로 유지되고 있는 건지 추측함

 

const callback = (newTask) => {
    setTasks((prevTasks) => [...prevTasks, newTask]);
};

state 업데이트 방식을 함수형 업데이트로 변경!

 

완성!

알맞게 동작하는 것을 확인했다! callback 내부의 tasks는 선언 당시의 tasks 초기값인 []이기 때문에 그 값이 계속 유지되었던 것!

 

 

참고자료


 

 

[Unity/디자인 패턴] 옵저버 패턴 & 발행-구독 패턴

게임 개발을 하다보면 어떤 행위가 일어났을 때 다른 객체에게 알림이 가게 하는 시스템이 필요할 때가 많다. 예를 들어 한 게임에서 몬스터가 플레이어의 공격을 맞아 처치하는 이벤트가 발생

velog.io

 

 

[번역] 초보 프론트엔드 개발자들을 위한 Pub-Sub(Publish-Subscribe) 패턴을 알아보기

비동기 자바스크립트 코드를 덜 괴롭게 이해하는 방법

rinae.dev