Skip to content

CustomEvent 적용기

Myeongseong Choe edited this page Dec 11, 2022 · 1 revision

CustomEvent를 사용하게된 계기

BooCrum을 구현하면서 Fabric.js의 이벤트, Socket의 이벤트를 다루게 되었다. 현재 실시간 공유 편집을 주로 작업하고 있기 때문에 모든 Socket 이벤트는 Fabric.js의 이벤트를 거쳐서 가게 되는 구조이다.

(Fabric.js 변경 발생 → Fabric.js 이벤트 수신 → Socket 이벤트 발생 → Socket 이벤트 수신 → Fabric.js 변경 발생)

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

구조가 설명하기 애매하지만,,

image

지금 대충 이런 구조이다. hook을 사용해서 socket 관련 로직을 숨겨두었고, hook에서 socket을 리턴해주고 있기 때문에 canvas 컴포넌트에서는 socket을 사용 가능하다.

그런데 우리가 socket을 사용해야 하는 부분은 저기, layout 컴포넌트이다. socket을 가져다 쓸 수가 없다.

대안을 생각해보자

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

    → 지금은 저렇게 간단하게 도식화되어서 가능한 문제처럼 보이나, 실제 코드를 보면 현재 canvas 컴포넌트에서 socket을 사용해서 몇 가지 다른 hook들을 호출하고 있다. 결국 useSocket hook을 workspace 컴포넌트로 빼게되면 해당 hook들도 다같이 빼야하고, 그 모든 리턴값들을 canvas 컴포넌트에 props로 넘겨줘야 한다. 지금 세어보니까 대략 12개 정도의 props가 넘어가야 한다,,,😱 포기.

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

    → 일단 관심사가 어긋난다는 점에서 그닥 마음에 들지 않았다. 그래도 구현이 가능하다면 질척거려 보려고 했으나, 얘도 로직적으로 불가능하다. 현재 권한 수정 modal은 헤더에서 아이콘을 클릭하면 뜨게 되어있다. 결국 header에 종속적인 modal이라는 것,,, 포기.

  3. socket을 전역 상태로?!

    → 인스턴스라서 recoil로 상태관리를 할 수 없다. 굳이 전역으로 뺀다면 모듈로 빼볼 수 있겠으나, 현재 socket 코드가 많이 구현된 상태로 다른 파트 코드 수정이 필요한지라 최후의 수단으로 남겨두고 더 생각해보기로,,,

이정도 생각하고 나니까 머리가 아팠다.

어떻게 해야하나,,, 코드 뒤적거리다가 Fabric.js 이벤트를 보고 번뜩 생각이 났다. Fabric.js에서 이벤트를 발생시키는 것처럼 나도 내맘대로 이벤트를 발생시키고 socket이 있는 hook에서 이벤트를 수신하면 되는게 아닌가?!

그렇게 나오게된 방안이 CustomEvent이다.

서론이 굉장히 길었다.

CustomEvent란?

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

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

  readonly attribute any detail;
};

다음과 같이 Event 인터페이스를 상속한 인터페이스라고 한다.

detail 속성을 가지고 있어 이벤트 세부 정보에 접근할 수 있다.

Event 조금 더 알아보기

EventTarget

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

CustomEvent 적용하기

image

수정된 구조는 다음과 같다. 수정 modal에서 CustomEvent를 발생시키면 useCustomEvent hook에서 이를 감지하고 socket 이벤트를 요청할 수 있다.

const roleChangeEvent = new CustomEvent<RoleChangeEvent>('role:changed', { detail: { userId, role } });
document.dispatchEvent(roleChangeEvent)
useEffect(() => {
	document.addEventListener('role:changed', (e) => {
		const { userId, role } = (e as CustomEvent).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 });
});

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

image

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

image

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

현재 매개변수를 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해서 사용할 수 있다.

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하는 첫 번째 방식을 적용해두기로 결정했다🙌

📚 그라운드 룰

✏️ 컨벤션

🧑‍🏫 멘토링

📁 애자일 프로세스

기획
데일리 스크럼
스프린트 리뷰
스프린트 회고
트러블 슈팅
기타 산출물

📖 기술문서

Week2
Week3
Week4
Week5

🗂 참고문서

Clone this wiki locally