본문 바로가기
JavaScript/JavaScript

Pub-Sub(발행-구독) Pattern / Publish-Subsribe Pattern

by 봉이로그 2023. 11. 19.

서론

최근 진행했던 고객사의 프로젝트에서 16개의 컴포넌트에서 데이터를 취합하여 API 요청을 해야 하는 상황이 있었다.

 

기존 프로젝트에 비슷하게 구현된 코드베이스가 있어 그걸 참고해서 구현을 하던 와중에 Pub-Sub Pattern을 사용한걸 발견했다.

 

컴포넌트 페이지마다 데이터를 취합하여 다음 페이지로 넘어갈때, 최상단의 커스텀훅스에 선언한 핸들링 함수에

 

Pub-Sub Pattern을 사용하여 페이지 이동/데이터 핸들링 이벤트처리를 하여 결합도를 낮추고, 응집도를 높인 코드였다.

 

몇년동안 외주 프론트엔드 개발을 해왔지만, 처음 봤기때문에 내겐 꽤 신선한 충격(?) 이었다.

 

나라면 처음부터 이렇게 설계할 생각을 할수 있을까? 라는 생각이 들었다.

 

그리고 Pub-Sub Pattern을 모른다면, 이해 할수가 없는 코드기도 했다.

 

그리하여 어렴풋이 알고 있던 나의 지식을 조금 더 짙게 만들자 공부해보고 기록하기로 결심했다.

 

 

본론

우리가 개발을 하면서 자주 사용하는, addEventListener / Promise / useState, props / Fetch / webSocket / RxJS 등의 API들이 Pub-Sub패턴 / 옵저버 패턴과 유사하게 구현이 되어져있다. 

 

Pub-Sub 패턴을 사용하면 컴포넌트 간에 상태를 전파하거나, 비동기 작업 완료 시 다른 부분에 알림을 보내는 등의 다양한 상황에서 활할 수 있다.

Pub-Sub 패턴

- Publisher(발행자)와 Subscriber(구독자) 사이의 중간 매개체인 Broker(브로커)를 사용한다.

- Publisher는 특정 주제나 토픽에 이벤트를 발행하고, Broker는 해당 이벤트를 구독하는 모든 Subscriber에게 발행된

   내용을 전달한다.

- Publisher와 Subscriber는 서로에 대해 알 필요가 없고, 중간에 Broker를 통해 통신한다.

- 주제나, 토픽을 중심으로 이벤트를 발행하고, 해당 주제를 구독하는 모든 구독자에게 전달한다.

 

 

지도에 마커를 표시하는 웹을 구현해보는 포스팅을 직접해보면서 이해했다.

<!-- map.html -->

<!DOCTYPE html>
<html>
  <head>
    <title>My Favorite Places</title>
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <div class="sidebar">
      <h1>My fav places on earth v1.0</h1>
      <!-- footer를 여기에 둔다 -->
      <div class="cities">
        <b>Added cities:</b>
        <div id="citiesList"></div>
      </div>
    </div>

    <div class="main-content-area">
      <div id="map"></div>
    </div>

    <script src="https://maps.googleapis.com/maps/api/js?key=API키넣어주세요."></script>
    <script src="map.js" type="module"></script>
    <script src="sidebar.js" type="module"></script>
  </body>
</html>

 

 

/* map.js */

let googleMap;
import { addPlace, getPlaces, subscribe } from "./dataService.js";

function renderMarkers(placesArray) {
  googleMap.markerList.forEach((m) => m.setMap(null)); // 모든 마커 제거
  googleMap.markerList = [];
  // myPlaces 배열의 요소를 기반으로 마커를 추가한다
  placesArray?.forEach((place) => {
    const marker = new google.maps.Marker({
      position: place.position,
      map: googleMap,
    });
    googleMap.markerList.push(marker);
  });
}

function init() {
  googleMap = new google.maps.Map(document.getElementById("map"), {
    center: { lat: 0, lng: 0 },
    zoom: 3,
  });
  googleMap.markerList = [];
  googleMap.addListener("click", addMarker);
}

function addMarker(event) {
  addPlace(event.latLng);
  // addMarket를 하면, 리렌더링이 돌아야한다.
  // 여기서 renderMarkers(getPlaces()) 를 해줄필요가 없어진다.
  // subscribe(renderMarkers)를 dataService함수의 addPlace 에서 publish를 호출하기에 가능하다.
  // 즉 발행자함수에서 구독자들의 callbackFunction(여기서는 renderMarkers)을 호출하기때문에 가능한것이다.
}

init();

renderMarkers(getPlaces());

subscribe(renderMarkers); // 구독자 callbackFunction 등록

 

/* sidebar.js */
import { getPlaces, subscribe } from "./dataService.js";

function renderCities(placesArray) {
  // 도시 목록을 표현하기 위한 DOM 엘리먼트를 가져온다
  const cityListElement = document.getElementById("citiesList");
  // 먼저 클리어 하고
  cityListElement.innerHTML = "";
  // forEach 함수를 써서 하나씩 다시 리스트를 그려낸다.
  // getPlaces 함수 호출로 얻은 placesArray로 교체
  placesArray.forEach((place) => {
    const cityElement = document.createElement("div");
    cityElement.innerText = place.name;
    cityListElement.appendChild(cityElement);
  });
}

renderCities(getPlaces());

subscribe(renderCities); // 구독자 callbackFunction 등록

 

/* dataService.js */

let myPlaces = [];
const geocoder = new google.maps.Geocoder();

// 함수를 받도록 배열로 처리
let changeListeners = [];

/**
 * 
 * @param {*} callbackFunction 
 * @description 구독자 함수
 */
export function subscribe(callbackFunction) {
  changeListeners.push(callbackFunction);
}

/**
 *
 * @param {*} data
 * @title 발행자 함수
 * @description 
 * sidebar / map 함수내에서 subscribe를 함수를 실행한다.
 * subsribe(구독자) 함수의 인자는 publish 될때, 실행할 함수이다.
 * sidebar에서는 renderCities 이고, map에서는 renderMarkers 이다.
 * publish(발행자) 가 될때, renderCities함수와 renderMarkers함수에서 사용될 첫번째, 인자값인 myPlaces를 넘긴다.
 * publisher(발행자) 함수에서는 changeListeners를 순회하면서,
 * publish에 인자로 넘긴 myPlaces를 changeListeners배열에 담겨있는 renderCities, renderMarkers 함수 인자로 넘겨 실행(호출)한다.
 * 이로써 renderCities / renderMarkers 함수가 실행되면서,
 * 도시리스트 최신화 / 마커리스트 최신화가 실행이 된다.
 * 이렇게 함으로써 dataService.js에서는 데이터만 핸들링한다.
 * map.js에서는 지도를 그리기만 한다.
 * sidebar.js에서는 마커를 그리기만 한다.
 * 결과적으로 관심사가 분리되고 코드의 결합도는 낮아진다.
 */
function publish(data) {
  changeListeners.forEach((changeListener) => changeListener(data));
}

export function addPlace(latLng) {
  // Google API 를 실행하여 도시 이름을 검색한다.
  // 두 번째 인자는 요청한 결과에 따른 응답이 왔을 때 처리를 담당하는 콜백 함수
  geocoder.geocode({ location: latLng }, function (results) {
    try {
      // 콜백 안에서 결과에 따른 도시 이름을 추출한다
      const cityName = results.find((result) =>
        result.types.includes("locality")
      ).address_components[0].long_name;
      // 그리고 우리가 준비해놓은 변수에 집어넣는다
      myPlaces.push({ position: latLng, name: cityName });

      publish(myPlaces);

      // 그 다음 localStorage와 동기화한다
      localStorage.setItem("myPlaces", JSON.stringify(myPlaces));
    } catch (e) {
      // 도시를 찾을 수 없을 때 콘솔에 메세지를 출력한다
      console.error("No city found in this location! :(");
    }
  });
}

// localStorage에 있는 정보를 꺼내 콜렉션에 넣는 함수
function initLocalStorage() {
  const placesFromLocalStorage = JSON.parse(localStorage.getItem("myPlaces"));
  if (Array.isArray(placesFromLocalStorage)) {
    myPlaces = placesFromLocalStorage;
    publish(); // 지금은 만들어지지 않은 함수. 나중에 적용될 예정
  }
}

// 현재 가지고 있는 장소의 목록을 출력
export function getPlaces() {
  return myPlaces;
}

initLocalStorage();

 

body {
  display: flex;
}

.main-content-area {
  width: 100%;
}

#map {
  height: 100vh;
}

 

 

p.s

js파일 module 임포트를 하면 cors에러가 발생해, http-server를 npm으로 받아 로컬에서 실행했다.

Reference

 

https://itnext.io/why-every-beginner-front-end-developer-should-know-publish-subscribe-pattern-72a12cd68d44

 

Why every beginner front-end developer should know publish-subscribe pattern?

AKA: How to implement asynchronous code in a less painful way.

itnext.io

https://www.rinae.dev/posts/why-every-beginner-front-end-developer-should-know-publish-subscribe-pattern-kr

 

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

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

www.rinae.dev