본문 바로가기

FrontEnd

리액트를 위한 이벤트 버스🚌 [Event Bus for React]

반응형

리액트를 이용하여 이벤트 버스를 만들어 봅시다.

원문 링크입니다 : https://dawchihliou.github.io/articles/event-bus-for-react

TL;DR

  • 🚌 단 60줄로 이벤트 버스를 구현해 봅니다.
  • 🌱 React에서 Event Bus를 활용하는 방법을 배웁니다.
  • 🏋️‍♀️ 데모에 Google Maps API와 Event Bus를 적용해 봅니다.

저는 최근에 직장에서 Event Bus의 흥미로운 사용 사례를 발견했습니다.
글로벌 규모의 웹 애플리케이션에서 분석을 위한 로깅을 구현하는 매우 간단한 모듈입니다.
큰 코드 베이스에서 매우 유용헌 이 디자인 패턴에 대한 연구사례를 공유하고자 합니다.

이벤트 버스가 뭐죠?

이벤트 버스는 컴포넌트 간 느슨한 결합을 유지하면서,
컴포넌트 간에 PubSub 스타일 통신을 허용하는 디자인 패턴입니다.

컴포넌트는 이벤트 버스의 위치와 무관하게 메시지를 보낼 수 있습니다.
또한, 컴포넌트는 이벤트 버스에서 메시지를 수신하고 메시지의 출처를 모른 채로 메시지로 무엇을 할지 결정할 수 있습니다.
이 디자인을 사용하면 독립적인 컴포넌트가 서로를 모른 채 통신할 수 있습니다.
  • Event : 이벤트 버스가 보내고 받는 메시지 입니다.
  • Publisher : 이벤트를 발생시키는 발신자 입니다.
  • Subscriber: 이벤트를 수신하는 수신자 입니다.
이벤트 버스에 대해 자세히 살펴보겠습니다.

밑바닥부터 이벤트 버스 구현하기

Vue의 레거시 이벤트 API(Vue's legacy Events API)에서 영감을 받아 이벤트 버스를 위해 다음 API를 구현합니다.
type EventHandler = (payload: any) => void

interface EventBus {
  on(key: string, handler: EventHandler): () => void
  off(key: string, handler: EventHandler): void
  emit(key: string, ...payload: Parameters<EventHandler>): void
  once(key: string, handler: EventHandler): void
}
  • on: Subscriber가 이벤트를 수신(구독)하고 이벤트 핸들러를 등록합니다.
  • off: Subscriber가 이벤트 및 이벤트 핸들러를 제거(구독 취소)하는 경우.
  • once: Subscriber가 이벤트를 한 번만 수신하도록 합니다.
  • emit: Publisher가 이벤트 버스에 이벤트를 보낼 수 있습니다.

이제 이벤트 버스의 데이터 구조는 두 가지 일 을 할 수 있어야 합니다.

  • Publisher를 위해 : emit이 호출될 때 이벤트 키와 연결된 이벤트 핸들러를 실행할 수 있습니다.
  • Subscriber를 위해 : on, once 또는 off가 호출될 때 이벤트 핸들러를 추가하거나 제거할 수 있습니다.

우리는 키-값 구조를 사용할 수 있습니다.

type Bus = Record<string, EventHandler[]>

on 메소드를 구현하려면 버스에 이벤트 키를 추가하고 핸들러 배열에 이벤트 핸들러를 추가하기만 하면 됩니다.
또한 이벤트 핸들러를 제거하기 위해 구독 취소 함수를 반환합니다.

export function eventbus(config?: {
  // error handler for later
  onError: (...params: any[]) => void
}): EventBus {
  const bus: Bus = {}

  const on: EventBus['on'] = (key, handler) => {
    if (bus[key] === undefined) {
      bus[key] = []
    }
    bus[key]?.push(handler)

    // unsubscribe function
    return () => {
      off(key, handler)
    }
  }

  return { on }
}
off를 구현하려면 버스에서 이벤트 핸들러를 제거하면 됩니다.
const off: EventBus['off'] = (key, handler) => {
  const index = bus[key]?.indexOf(handler) ?? -1
  bus[key]?.splice(index >>> 0, 1)
}
emit이 호출될 때 우리가 하고 싶은 것은 이벤트와 관련된 모든 이벤트 핸들러를 실행하는 것입니다.
여기에 오류 처리를 추가하여 오류에도 불구하고 모든 이벤트 핸들러가 실행되도록 할 것입니다.
const emit: EventBus['emit'] = (key, payload) => {
  bus[key]?.forEach((fn) => {
    try {
      fn(payload)
    } catch (e) {
      config?.onError(e)
    }
  })
}

once는 이벤트를 정확히 한 번만 수신하므로, 핸들러를 한번만 실행하고 제거하는 역할을 하는 함수로 생각할 수 있습니다.
이 때, 자기 자신이 핸들러 역할을 하면서 실제 함수를 대신 실행해 줍니다. 즉 고차 핸들러 입니다.
그리고 자기 자신을 제거합니다.

const once: EventBus['once'] = (key, handler) => {
  const handleOnce = (payload: Parameters<typeof handler>) => {
    handler(payload)
    off(key, handleOnce as typeof handler)
  }

  on(key, handleOnce as typeof handler)
}

이제 우리는 이벤트 버스의 모든 메소드를 구현하였습니다!

타입스크립트 타입 강화하기

이벤트 버스에 대한 현재 타이핑은 매우 개방적입니다.
이벤트 키는 모든 문자열이 될 수 있고
이벤트 핸들러는 모든 함수가 될 수 있습니다.
더 안전하게 사용하기 위해 이벤트 키와 핸들러 연결의 타입 검사를 EventBus에 추가할 수 있습니다.
type EventKey = string | symbol
type EventHandler<T = any> = (payload: T) => void
type EventMap = Record<EventKey, EventHandler>

interface EventBus<T extends EventMap> {
  on<Key extends keyof T>(key: Key, handler: T[Key]): () => void
  off<Key extends keyof T>(key: Key, handler: T[Key]): void
  emit<Key extends keyof T>(key: Key, ...payload: Parameters<T[Key]>): void
  once<Key extends keyof T>(key: Key, handler: T[Key]): void
}
- type Bus = Record<string, EventHandler[]>
+ type Bus<E> = Record<keyof E, E[keyof E][]>


- export function eventbus(config?: {
+ export function eventbus<E extends EventMap>(config?: {
  onError: (...params: any[]) => void
- }): EventBus {
+ }): EventBus<E> {
- const bus: Bus = {}
+ const bus: Partial<Bus<E>> = {}

- const on: EventBus['on'] = (key, handler) => {
+ const on: EventBus<E>['on'] = (key, handler) => {

- const off: EventBus['off'] = (key, handler) => {
+ const off: EventBus<E>['off'] = (key, handler) => {

- const emit: EventBus['emit'] = (key, payload) => {
+ const emit: EventBus<E>['emit'] = (key, payload) => {

- const once: EventBus['once'] = (key, handler) => {
+ const once: EventBus<E>['once'] = (key, handler) => {

  return { on, off, once, emit }
}
이제 우리는 TypeScript에 키가 T 키 중 하나여야 하고 핸들러가 해당 핸들러 타입을 가져야 한다고 지시합니다.
예를 들어:
interface MyBus {
  'on-event-1': (payload: { data: string }) => void
}

const myBus = eventbus<MyBus>()
개발 시 명확한 타입 정의를 볼 수 있어야 합니다.

리액트에서 이벤트 버스 사용하기

방금 구축한 Event Bus를 사용하는 방법을 보여주기 위해 Remix 애플리케이션을 만들었습니다.

GitHub repository for the demo here

데모는 위와 동일한 React 애플리케이션에서 이벤트 버스로 로깅을 구성하는 방법을 보여줍니다.
기록할 세 가지 이벤트를 선택했습니다.
 
지도용
  • onMapIdle: 지도 인스턴스화가 완료되거나 사용자가 지도 드래그 또는 확대/축소를 완료하면 이벤트가 발생합니다.
  • onMapClick: 사용자가 지도를 클릭할 때 이벤트가 발생합니다.

마커용

  • onMarkerClick: 사용자가 지도 마커를 클릭할 때 이벤트가 발생합니다.
두 개의 이벤트 채널을 만들어 보겠습니다. 하나는 지도용이고 하나는 마커용입니다.

app/eventChannels/map.ts

import { eventbus } from 'eventbus'

export const mapEventChannel = eventbus<{
  onMapIdle: () => void
  onMapClick: (payload: google.maps.MapMouseEvent) => void
}>()

app/eventChannels/marker.ts

import { eventbus } from 'eventbus'
import type { MarkerData } from '~/data/markers'

export const markerEventChannel = eventbus<{
  onMarkerClick: (payload: MarkerData) => void
}>()

 

이벤트 채널을 분리하는 이유는 관심사를 명확하게 분리하기 위함입니다.
이 패턴은 애플리케이션의 수평적 성장을 장려합니다.
이제 React 컴포넌트에서 이벤트 채널을 사용해 봅시다.

app/routes/index.tsx

import { markers } from '~/data/marker'
import { logUserInteraction } from '~/utils/logger'
import { mapEventChannel } from '~/eventChannels/map'
import { markerEventChannel } from '~/eventChannels/marker'

export async function loader() {
  return json({
    GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
  })
}

export default function Index() {
  const data = useLoaderData()
  const portal = useRef<HTMLDivElement>(null)
  const [selectedMarker, setSelectedMarker] = useState<MarkerData>()

  useEffect(() => {
    // subscribe to events when mounting
    const unsubscribeOnMapIdle = mapEventChannel.on('onMapIdle', () => {
      logUserInteraction('on map idle.')
    })
    const unsubscribeOnMapClick = mapEventChannel.on(
      'onMapClick',
      (payload) => {
        logUserInteraction('on map click.', payload)
      }
    )
    const unsubscribeOnMarkerClick = markerEventChannel.on(
      'onMarkerClick',
      (payload) => {
        logUserInteraction('on marker click.', payload)
      }
    )

    // unsubscribe events when unmounting
    return () => {
      unsubscribeOnMapIdle()
      unsubscribeOnMapClick()
      unsubscribeOnMarkerClick()
    }
  }, [])

  const onMapIdle = (map: google.maps.Map) => {
    mapEventChannel.emit('onMapIdle')

    setZoom(map.getZoom()!)
    const nextCenter = map.getCenter()
    if (nextCenter) {
      setCenter(nextCenter.toJSON())
    }
  }

  const onMapClick = (e: google.maps.MapMouseEvent) => {
    mapEventChannel.emit('onMapClick', e)
  }

  const onMarkerClick = (marker: MarkerData) => {
    markerEventChannel.emit('onMarkerClick', marker)
    setSelectedMarker(marker)
  }

  return (
    <>
      <GoogleMap
        apiKey={data.GOOGLE_MAPS_API_KEY}
        markers={markers}
        onClick={onMapClick}
        onIdle={onMapIdle}
        onMarkerClick={onMarkerClick}
      />

      <Portal container={portal.current}>
        {selectedMarker && <Card {...selectedMarker} />}
      </Portal>

      <div ref={portal} />
    </>
  )
}
인덱스 컴포넌트 안에서 이벤트를 구독하고,
GoorleMap 컴포넌트에서 지도와 마커가 상호 작용할 때 이벤트를 내보내는 것입니다.
또한 컴포넌트의 생명 주기에 따라 구독 및 구독 취소함으로써
사용자 여정의 주어진 순간에 필요한 이벤트 핸들러만 등록할 수 있습니다.

마지막 생각들

Event Bus 라이브러리를 찾고 있다면 Vue.js에서 권장하는 몇 가지 선택 사항이 있습니다.

Reddit에서 Redux를 이벤트 버스로 사용하는 것에 대한 흥미로운 토론도 있습니다.

(discussion on Reddit about using Redux as an Event Bus)
Redux 메인테이너 중 한 명이 이벤트를 처리하기 위해 몇 가지 Redux 기반 도구를 제안했습니다.

추가

vue.js 권장 라이브러리 링크를 타고 들어가니 아래와 같은 말이 있네요.

대부분의 경우 컴포넌트 간 통신에 전역 이벤트 버스를 사용하는 것은 권장되지 않습니다.
단기적으로는 가장 간단한 솔루션인 경우가 많지만 장기적으로는 거의 예외 없이 유지 보수의 골칫거리입니다.

상황에 따라 이벤트 버스의 대안이 있습니다.

  • Pinia와 같은 글로벌 상태 관리 솔루션
  • Prop과 이벤트는 부모-자식 커뮤니케이션을 위한 첫 번째 선택이 되어야 합니다.
    • 형제 자매는 부모를 통해 통신할 수 있습니다.
  • Provide/Inject는 컴포넌트가 슬롯 내부과 통신할 수 있도록 합니다.
    • 이것은 항상 함께 사용되는 밀접하게 결합된 컴포넌트에 유용합니다.
  • Provide/Inject는 컴포넌트 간의 장거리 통신에도 사용할 수 있습니다.
    • props이 필요하지 않은 여러 수준의 컴포넌트를 통해 props를 전달해야 하는 'prop 드릴링'을 방지하는 데 도움이 될 수 있습니다.
  • 슬롯을 사용하도록 리팩토링하여 prop 드릴링을 피할 수도 있습니다.
    • 중간 컴포넌트에 prop이 필요하지 않은 경우 관심사 분리 문제가 있음을 나타냅니다.
    • 해당 컴포넌트에 슬롯을 도입하면 부모가 콘텐츠를 직접 생성할 수 있으므로 중간 컴포넌트가 개입할 필요 없이 prop을 전달할 수 있습니다.

References

반응형