CustomEvent를 활용해서 Observer 패턴을 적용하기

krkorklo·2022년 12월 16일
0

서론

기존 프로젝트의 구조

우리 프로젝트의 핵심은 워크스페이스(화이트보드)이다. figjam처럼 사용자들이 포스트잇을 붙이거나 드로잉을 할 수 있는 기능이 핵심이다. 워크스페이스 구조는 간단하게 위와 같다. canvas와 canvas와의 상호작용을 담당하는 컴포넌트를 모아둔 layout으로 구성되어있다

Canvas(화이트보드) 컴포넌트에서는 Fabric.js canvas와 실시간 화이트보드 공유를 위한 socket 로직을 처리하는 custom hook들이 동작한다.
그리고 layout은 header와 Toolkit 컴포넌트가 포함되어 있다. Toolkit에서는 Canvas에 어떤 오브젝트를 놓고 어떤 상호작용을 할 것인지 결정할 수 있는 기능을 제공한다. header는 현재 참여중인 사용자 목록 및 워크스페이스 공유, canvas export를 담당한다.

기존 구조의 문제점

해당 프로젝트에서는 Fabric.js의 이벤트, Socket의 이벤트를 다루고 있다. 화이트보드를 활용한 실시간 공유 편집을 주로 작업하고 있기 때문에 모든 Socket 이벤트는 Fabric.js의 이벤트를 거쳐서 가게 되는 구조이다.

Fabric.js 변경 발생 → Fabric.js 이벤트 수신 → Socket 이벤트 발생 → Socket 이벤트 수신 → Fabric.js 변경 발생
각 모듈 사이에서 단방향으로 이벤트가 흐르고 있다.

이번에 구현하게 된 권한 수정 기능은 실시간으로 워크스페이스의 내 권한, 다른 사용자 권한이 바뀌었다는 것을 확인할 수 있어야하기 때문에 Socket 이벤트를 사용해야 했다. 근데,, 권한 수정의 경우 Fabric.js의 이벤트가 아니다.

위 UI에서 볼 수 있듯이 header에 포함된 share 버튼을 클릭하면 권한 수정할 수 있는 modal이 뜨게 된다. 결국 권한 수정은 layout header에 포함된 단순한 컴포넌트 로직이다.

그런데 socket 관련 관심사를 모아둔 hook이 canvas 컴포넌트에 선언이 되어있어 canvas 컴포넌트 외에서는 직접적으로 사용할 수 없다. 즉 Fabric.js 이벤트를 발생시켜 socket 이벤트를 발생시키거나 canvas 컴포넌트 내에 포함되어있는 로직이 아닌 이상 다른 모듈에서 socket 이용이 어렵다. 어떻게 header 컴포넌트에서 socket 이벤트를 호출할 수 있을까?

대안을 생각해보자

  1. useSocket을 workspace 컴포넌트에서 선언해서 canvas와 layout에 props로 넘겨주고 쓰면 되지 않나?

    → 지금은 저렇게 간단하게 도식화되어서 가능한 문제처럼 보이나, 실제 코드를 보면 현재 canvas 컴포넌트에서 socket을 사용해서 몇 가지 다른 hook들을 호출하고 있다. (canvas에서 socket 이벤트를 호출하는 hook, canvas를 정의하는 hook 등,,,)

    결국 useSocket hook을 workspace 컴포넌트로 빼게되면 해당 hook들도 다같이 빼야하고, 그 모든 리턴값들을 canvas 컴포넌트에 props로 넘겨줘야 한다. 지금 세어보니까 대략 12개 정도의 props가 넘어가야 한다,,,😱

  2. 수정 modal을 canvas로 빼면 되지 않나?

    → 일단 관심사가 어긋난다는 점에서 마음에 들지 않았다. 그리고 잘 생각해보면 얘도 로직적으로 불가능하다. 현재 권한 수정 modal은 헤더에서 아이콘을 클릭하면 뜨게 되어있다. 결국 header에 종속적인 modal이라는 것이고,,, canvas로 뺄 수가 없었다.

  3. socket을 전역 상태로 빼보면 되지 않나?!

    → socket은 인스턴스라서 recoil로 상태관리를 할 수 없다. 굳이 전역으로 뺀다면 새로운 모듈로 빼볼 수 있겠으나, 현재 설계에서 대규모 수정이 필요한지라 최후의 수단으로 보류해두었다.

어떻게 해야하나,,, 코드 뒤적거리다가 Fabric.js 이벤트 로직을 보고 번뜩 생각이 났다. Fabric.js에서 이벤트를 발생시켜 다른 모듈에 갱신 사항을 전달해주는 것처럼 우리도 observer pattern을 활용해 직접 이벤트를 발생시키고 이벤트를 구독하고 있는 모듈을 연결해주면 되지 않을까?!

Observer Pattern

Observer Pattern이란 한 객체의 상태가 바뀌면 해당 객체에 의존하는 다른 객체들에게 해당 상태를 알리고 갱신이 되도록 만드는 패턴이다. 해당 객체를 중심으로 1:N 의존성을 가질 수 있다.

Observer Pattern의 가장 큰 장점은 느슨한 결합이다. 주제 객체와 옵저버는 상태를 알리고 상호작용하지만 해당 상태 외에 서로에 대해서는 알지 못한다. 주제 객체에 대해 알 필요 없이 자유롭게 등록하고 해제할 수 있다.

→ 주제 객체와 옵저버가 독립적이기에 상호 의존성을 최소화하고 유연하게 작동할 수 있다.

Observer Pattern을 정의하는 방식은 다양하다. class를 정의한 후 옵저버를 등록하고 옵저버에게 업데이트를 알리는 로직을 구현한 방식이 가장 기본적인 것 같다. 우리는 class 방식을 사용하기보다는 우리 프로젝트스러운 방식을 택했는데, 바로 Event이다.

현재 진행중인 프로젝트는 이벤트 중심으로 모듈들이 소통한다.

  • Canvas에서 변경이 발생하면 Fabric.js에서 제공하는 이벤트가 발생 → socket 이벤트 발생
  • socket 이벤트 수신 → Fabric.js Object 변경

위와 같은 흐름으로 모든 Fabric.js 오브젝트들의 변경이 발생하고 실시간 화이트보드 동기화가 가능하다.

결국 위와 같은 흐름에 맞게 Event를 사용해 Observer 패턴을 적용해보자!

아, 그런데 Event는 어떻게 발생시킬까?

Custom Event

CustomEvent는 이미 정의된 이벤트 외에 사용자가 원하는 이벤트를 발생시킬 수 있도록 만들어주는 인터페이스이다.

현재 우리가 발생시키고자 하는 이벤트는 이미 정의된 scroll이나 keydown같은 이벤트가 아니라 권한 수정 요청이 가는 경우 notify를 해주고자하는 것이기 때문에 CustomEvent를 생성해야 한다.

interface CustomEvent: Event {
	constructor(DOMStringtype, optionalCustomEventIniteventInitDict = {});

  readonly attribute any detail;
};

CustomEvent는 다음과 같이 Event 인터페이스를 상속한 인터페이스이다. detail 속성을 가지고 있어 이벤트 세부 정보에 접근할 수 있다.

Event 조금 더 알아보기

EventTarget

  • Event 인터페이스를 구현한 객체들이 발생시키는 이벤트는 모두 EventTarget에게 도달한다.
  • EventTarget.addEventListener() : EventTarget에 특정 이벤트 listener를 등록한다.
  • EventTarget.removeEventListener() : EventTarget에서 특정 이벤트 listener를 제거한다.
  • EventTarget.dispatchEvent() : EventTarget으로 이벤트를 발생시킨다.

이벤트 적용하기

Observer Pattern에 맞게 수정된 구조는 다음과 같다.

권한 수정 modal이 subject, useCustomEvent hook이 observer로 생각해볼 수 있을 것 같다.
modal에 변경이 발생하면 custom event로 useCustomEvent hook에 notify가 발생한다. notify를 받으면 socket 이벤트를 발생시켜 워크스페이스 참여자들에게 권한 수정 사항을 전달할 수 있다.

const roleChangeEvent = new CustomEvent<RoleChangeEvent>('role:changed', { detail: { userId, role } });
document.dispatchEvent(roleChangeEvent)

위와 같이 customEvent는 dispatchEvent를 사용해 custom event를 호출할 수 있다. observer pattern의 notify가 발생한 것이다!

useEffect(() => {
	document.addEventListener('role:changed', (e) => {
		const { userId, role } = e.detail;

		socket.current?.emit('change_role', { userId, role });
	});

	return () => {
		document.removeEventListener('role:changed');
	};
}, []);

document에 이벤트를 등록해두고 다음과 같이 이벤트를 dispatch 해주었다.

근데? Type 오류가 났다ㅎ

TypeScript 적용 문제

document.addEventListener('role:changed', (e) => {
	const { userId, role } = e.detail;

	socket.current?.emit('change_role', { userId, role });
});

위 부분에서 'Event' 형식에 'detail' 속성이 없습니다. 라며 오류가 발생했다.

있는데 왜 없다고 하냐

생각해보니까,,,

interface CustomEvent: Event {
  constructor(DOMStringtype, optionalCustomEventIniteventInitDict = {});

  readonly attribute any detail;
};

아하ㅎ detail은 CustomEvent에만 있는 속성인데 이벤트 리스너는 이벤트의 최상위 인터페이스인 Event를 속성이라고 생각하고 받고 있기 때문에 detail 속성이 없다고 오류가 나는 것이었다.

document.addEventListener('role:changed', (e: CustomEvent) => {
	const { userId, role } = e.detail;

	socket.current?.emit('change_role', { userId, role });
});

후다닥 이렇게 고쳐주었다.

근데 또 오류가 났다. 이번에는 빨간줄이 더 많았다.

그래도 친절하게 오류가 자세하게 나와있었다,,, 기본 정의된 listener와 일치하지 않는 listener를 받는다고 한다.

현재 addEventListener는 매개변수를 Event 타입으로 하는 listener인데 내가 냅다 CustomEvent가 타입이야! 하고 넣어줬더니 생긴 문제이다.

해결 방법

1. Type 형변환하기

document.addEventListener('role:changed', (e: Event) => {
	const { userId, role } = (e as CustomEvent).detail;
	socket.current?.emit('change_role', { userId, role });
});

정의된 listener에 맞게 Event type을 props의 type으로 선언해두되, CustomEvent Type으로 assertion해서 사용할 수 있다. CustomEvent가 Event를 구현한 인터페이스이기 때문에 가능한 방법이다.

2. CustomEventTarget 생성하기

assertion하는 방법 말고 아예 새로운 EventTarget을 만들어서 CustomEvent를 매개변수로 받는 새로운 EventListener를 선언할 수 있게 만드는 방식도 있다.

interface CustomEventMap {
	rolechanged: CustomEvent<CustomType>;
}

interface CustomEventInterface {
	addListener<K extends keyof CustomEventMap>(type: K, listener: (this: Document, ev: CustomEventMap[K]) => void): void;
	dispatch<K extends keyof CustomEventMap>(ev: CustomEventMap[K]): void;
	removeListener<K extends keyof CustomEventMap>(
		type: K,
		listener: (this: Document, ev: CustomEventMap[K]) => void
	): void;
}

export class CustomEventTarget extends EventTarget implements CustomEventInterface {
	addListener<K extends keyof CustomEventMap>(
		type: K,
		listener: (this: Document, ev: CustomEventMap[K]) => void
	): void {
		this.addEventListener(type, listener as EventListener);
	}

	dispatch<K extends keyof CustomEventMap>(ev: CustomEventMap[K]): void {
		document.dispatchEvent(ev);
	}

	removeListener<K extends keyof CustomEventMap>(
		type: K,
		listener: (this: Document, ev: CustomEventMap[K]) => void
	): void {
		this.removeEventListener(type, listener as EventListener);
	}
}

EventTarget을 상속받는 CustomEventTarget가 일반적인 EventTarget처럼 이벤트 리스너 추가, 이벤트 리스너 삭제, 이벤트 발생 메소드를 가질 수 있도록 CustomEventInterface를 구성해두었다.

addListener를 보면 EventTarget의 addEventListener와 다르게 listener의 이벤트 인자가 CustomEvent로 들어갈 수 있도록 매핑된다. dispatchremoveListener에서도 일반 Event가 아닌 CustomEvent를 받을 수 있다.

다음과 같이 CustomEventTarget을 구현하게 되면 document.addEventListener()로 이벤트를 부여하는 것이 아니라 내가 만든 CustomEventTarget의 인스턴스를 사용해서 customEventTarget.addListener()로 이벤트를 부여하고 일반적인 이벤트처럼 사용할 수 있다.

위 두 가지 방식을 비교해보자
새로운 EventTarget을 만들어서 구현하는 방식은 generic과 EventMap을 사용해서 여러 개의 CustomEvent를 효율적으로 관리할 수 있을 것 같고 as 타입 assertion에 비해 안정적이다. 그런데 지금 우리 프로젝트는 커스텀 이벤트를 여러개 사용하는 것이 아니기 때문에 간단하게 assertion하는 첫 번째 방식을 적용해두기로 결정했다.

결론

useEffect(() => {
	document.addEventListener('role:changed', (e: Event) => {
		const { userId, role } = (e as CustomEvent).detail;

		socket.current?.emit('change_role', { userId, role });
	});

	return () => {
		document.removeEventListener('role:changed');
	};
}, []);

위와 같이 CustomEvent를 활용해 모듈 간의 연결을 Observer Pattern으로 구성함으로써 프로젝트의 로직 상의 통일성(Event)을 유지하고 효율적으로 모듈 간의 연결을 구현할 수 있었다🙌

0개의 댓글