CustomEvent 로 React 외부에서 관리되는 값의 변화를 관측하자.

RookieAND·2024년 2월 3일
2

Solve My Question

목록 보기
24/29
post-thumbnail

React 외부에서 관리되는 값의 변화를 컴포넌트에서 관측할 수는 없을까?

📖 Introduction

현재 개발 중인 사이드 프로젝트에서는 TMap SDK 에서 제공되는 여러 정보와 지도와 관련된 기능을 Class 로 정의하여 사용하고 있다. (TMapModule)

그리고 실제 애플리케이션에는 해당 클래스 인스턴스를 최상단에서 생성한 후, 이를 Context API 를 통해 하위 컴포넌트들에게 주입하는 방식을 채택했다.

하지만 내부 로직에 의해 클래스 인스턴스의 속성 중 일부가 변경되었을 경우에는 이를 컴포넌트에 알릴 수단이 한정적이다. 게다가 Context 에서는 모듈 인스턴스를 ref 객체로 보관된 상태이기 때문에 사실상 없다고 봐도 무방하다.

따라서 현재 구조는 컴포넌트의 변화를 TmapModule 에 알리는 단방향 흐름이기 때문에 이를 역행하기가 대단히 어렵다는 결론에 이르렀다. 그렇다고 useEffect 를 사용하자니 이것도 별로 좋은 생각은 아니었다.

따라서 궁여지책 끝에 모듈 내 변경 사항을 컴포넌트가 인지할 수 있도록 내부적으로 CustomEvent 를 사용하기로 결정했다.

✒️ CustomEvent

  • 기본적으로 정의된 이벤트 외에 사용자 정의 이벤트를 정의하여 발생시키도록 하는 인터페이스다.
  • 이벤트를 Trigger 시키고 이를 감지하여 변화를 관측한다는 점에서 Observer Pattern 을 정의하기 위한 수단으로 쓰인다.

✒️ How to use

  • CustomEventEvent 인터페이스를 상속하여 이벤트를 생성하고 발행하는 구조는 기존과 동일하다.
    • addEventListener
    • removeEventListener
    • dispatchEvent
  • 아래와 같이 새로운 이벤트를 정의하고, dispatchEvent 함수를 통해 새롭게 정의한 이벤트를 발행한다.
  • 이때 두번째 인자로 detail 속성에 전달하고자 하는 값을 담아 보낼 수 있다 (매우 중요!)
window.dispatchEvent(
    new CustomEvent('markers:create', { detail: createdMarker }),
);

window.dispatchEvent(
    new CustomEvent('markers:remove', { detail: removedMarker }),
);
  • 이벤트를 수신 받는 쪽에서는 이벤트 객체 내 detail 속성에서 전달된 데이터를 받아 내부 로직을 처리한다.
  • Component 에서는 컴포넌트가 mount 될 때 이벤트 리스너를 부착하고, unmount 시 제거하도록 로직을 설계했다.
useEffect(() => {
    window.addEventListener('marker:create', (event) => setMarker([...event.detail]));

    return () => {
        window.removeEventListener('marker:create', (event) => setMarker([...event.detail]));
    };
}, []);

✒️ useEventListener

  • 이 작업을 보다 간편하게 진행하기 위해 useEventListener 커스텀 훅을 제작했다.
  • 보통은 DocumentEventMap, WindowEventMap, HTMLElementEventMap 에 있는 이벤트를 모두 포괄하도록 해야 한다.
  • 하지만 현재는 window 전역 객체에 이벤트 리스너를 부착하도록 설계했기에 타입 정의를 간소화했다.
    import { useEffect } from 'react';
    
    export const useEventListeners = <T extends keyof WindowEventMap>(
        eventName: T,
        handler: (event: WindowEventMap[T]) => void,
        options?: boolean | AddEventListenerOptions,
    ): void => {
        useEffect(() => {
            window.addEventListener(eventName, handler, options);
    
            return () => {
                window.removeEventListener(eventName, handler, options);
            };
        }, [eventName, handler, options]);
    };
  • 사용단에서는 아래와 같이 보다 간편하게 이벤트 리스너를 부착할 수 있다.
    import { useState } from 'react';
    
    import { useEventListeners } from '@/hooks/useEventListeners';
    import { useTmap } from '@/hooks/useTmap';
    import type { MarkersType } from '@/types/map';
    
    const CourseView = () => {
        const [markers, setMarkers] = useState<MarkersType[]>([]);
    		const { tmapModuleRef } = useTmap();
    
    		// 새로운 마커가 모듈 내부에서 추가되었을 때 이를 관측하여 컴포넌트 상태를 변경
        useEventListeners('marker:create', (event) => {
            setMarkers([...markers, event.detail]);
        });
    		
    		// 이하 중략...
    };
    
    export default CourseView;
  • 단, Typescript 에서는 WindowEventMap 인터페이스에 새롭게 정의한 이벤트 정보가 없어 타입 체커에 의해 에러가 발생한다.
  • 따라서 global 네임스페이스에 새로이 사용하고자 하는 이벤트 인터페이스를 정의하여 WindowEventMap 에 상속시킨다.

declare global {
    interface CustomEventMap {
        'marker:create': CustomEvent<MarkerType>;
        'marker:remove': CustomEvent<MarkerType>;
    }

		// 새롭게 정의한 이벤트 맵을 WindowEventMap 이 상속받도록 한다.
    interface WindowEventMap extends CustomEventMap {}
}

✒️ Result

  • 이제 지도에서 마커가 생성되면 모듈 내부에서는 marker:create 이벤트를 생성한 후 발행한다.
  • 이벤트를 발행하는 로직은 dispatchEvent 메서드를 사용했고, detail 에는 새롭게 생성된 마커 인스턴스를 주입했다.
    // 마커 생성
    createMarker({
        name,
        originName,
        address,
        id,
        lat,
        lng,
        iconHTML,
    }: {
        name: string;
        originName: string;
        address: string;
        id: string;
        lat: number;
        lng: number;
        iconHTML?: string;
    }) {
        if (this.#markers.length >= this.#maxMarkerCount) return;

        const createdMarker: MarkerType = {
            marker: new Tmapv3.Marker({
                position: new Tmapv3.LatLng(lat, lng),
                iconHTML,
                map: this.#mapInstance,
            }),
            name,
            originName,
            address,
            id,
            lat,
            lng,
        };

		// ... 이하 중략

        this.#markers.push(createdMarker);

        window.dispatchEvent(
            new CustomEvent('marker:create', {
                detail: createdMarker,
            }),
        );
    }
  • 컴포넌트 단에서는 새로운 마커가 추가되었음을 알리는 이벤트가 발동됐다면, 추가된 마커의 정보를 핸들러로 받아 setState 를 호출하여 컴포넌트를 리렌더링한다.
  • 이렇게 되면 React 의 라이프사이클 내부에서 관리되는 값이 아니어도, 해당 값의 변화를 관측하여 컴포넌트를 렌더링할 수 있게 된다!
const CourseView = () => {
    const [markers, setMarkers] = useState<MarkerType[]>([]);

    useEventListeners('marker:create', (event) => {
        setMarkers((previous) => [...previous, event.detail]);
    });
  
  	// ... 이하 생략

✒️ Caution

  • 이벤트 리스너가 많이 부착된다는 것은 결코 좋은 현상이 아니다. 너무 과도한 이벤트가 부착되지 않도록 항상 주의해야 한다.
  • 변경 사항을 발행하는 주체와 이를 수신하는 주체를 명확히 정리하지 않았을 경우 추후 추적이 어렵다. (유지 보수에 주의해야 함)
  • 특히 애플리케이션에 쓰이는 커스텀 이벤트가 많아지고, 이에 대한 명세가 제대로 되어있지 않다면 오히려 특정 변경 사항에 대한 원인을 찾기가 어려워지므로 주의해야겠다는 생각이 많이 든다.

📒 참고 문서

profile
항상 왜 이걸 써야하는지가 궁금한 사람

0개의 댓글