-
Notifications
You must be signed in to change notification settings - Fork 0
간편하고 확장성 있는 모달 관리를 위한 노력
저희 프로젝트는 모달이 많아요. 인터랙션 모달부터 시작해서, 본인인증 모달, 확인 모달, 알러트 모달 등 많은 모달이 있어요. 프론트엔드를 개발하면서 빠질 수 없는 게 모달 관리이듯이, 저희도 모달을 관리해야 했어요. 모달이라는 UI 컴포넌트에 대한 관건은 다음의 2가지에요.
- 계층이 완전히 다른 상황에서, 모달을 어떻게 띄울 것인가?
- 모달 내부의 내용이 다른 상황에서, 모달을 어떻게 닫을 것인가?
첫 번째 문제는, 모달이 실제로 모달을 부르는 컴포넌트와 완전히 다른 계층에 있다는 속성에서 기인해요. 모달은 여러 곳에서 불러질 수 있지만, 모달의 위계는 모달을 부른 곳에 존재하지 않아요. 일반적으로, 모달은 실제 앱 레이어의 형제 레이어에 존재해요.
두 번째 문제는, 모달의 스타일이 다양하다는 속성에서 기인해요. 모달은 공통적으로 같은 열고 닫는다(더 추가하자면 모달 밖을 누르면 모달이 닫히는 동작과 모달이 열린 동안에는 스크롤을 할 수 없다)는 관심사를 가지지만, 그 외 모달의 내용은 '모달'이 관리할 필요가 없어요.
그래서, 저희는 다음을 만족시키는 모달 관리 시스템을 만들고 싶었어요.
- 함수만 호출하면 간편하게 모달을 띄울 수 있는 모달 표시 인터페이스
- 모달 내부의 내용이 무엇이든 상관없이 모달의 닫기 동작을 호출할 수 있는 인터페이스
추가로, 저희의 경우는 모달의 레이어가 2개이고, 같은 레이어의 모달은 2개 이상 동시에 띄워지면 안 된다는 조건이 붙어요. 이 조건이 붙은 이유는 각각의 모달이 순차적인 비즈니스 로직을 갖고 있는데, 서로 상충되는 비즈니스 로직을 가진 모달을 동시에 띄우면 문제가 생기기 때문이에요. 예를 들면, 같은 로그인 모달을 2개 띄운 뒤 한쪽에서 로그인을 시도한 뒤 다른 쪽에서 로그인을 시도하는 등의 문제가 생길 수 있어요.
또한, 모달이 안 띄워져 있을 때는 dom에 아예 노출되면 안 된다는 조건도 있어요. 저희는 시간에 따라 인터랙션이 개방되는 서비스를 갖고 있는데, 사용자가 개발자 도구를 켜고 브라우저에서 스타일을 바꿔서 강제로 인터랙션을 시도하면 안 되기 때문이에요.
첫 번째 관건은 모달을 함수 호출만을 열 수 있는 시스템을 만드는 것이었어요. 이상적으로는, 다음과 같은 인터페이스를 갖기를 원했어요.
openModal(<ModalComponent />, "alert");
모달로 띄워질 컴포넌트와 모달의 레이어를 받아서, 해당 레이어에 컴포넌트를 모달 식으로 띄우는 구조에요.
간편한 함수 호출이라는 조건이 붙기 때문에, Context API는 단계가 1개 더 추가되어서 좋지 않았어요. 또한, Context API를 최적화하려면 memo로 하위 컴포넌트들을 감싸는 번거로운 작업이 필요했어요. 그래서 리액트 밖에서 모달로 띄워질 컴포넌트들의 상태를 관리하고, 모달들의 상태를 모달을 띄울 모달 컴포넌트에서 구독하는 걸 생각했어요. 하지만, 저희가 사용하던 zustand는 리액트가 아닌 함수에서 상태를 변경시키려면 상당히 번거로운 동작을 수행해야 한다는 문제점이 있었어요. 추가로 상태를 변경시키려면 상태가 불변해야 하기 때문에 모달을 여는 동작을 반복할수록 버려지는 상태가 많이 생길 것이라는 우려도 있었어요.
그래서 저희는 useSyncExternalStore와, 커스텀 상태 관리 객체를 통해 이 문제를 해결했어요.
class ModalStore {
constructor() {
this.callback = new Set();
this.modalChildren = new Map();
}
subscribe(callback) {
this.callback.add( callback );
return ()=>this.callback.delete( callback );
}
changeModal(component, layer) {
if(this.modalChildren.get(layer) === component) return;
this.modalChildren.set(layer, component);
this.#update();
}
removeModal(layer) {
if(this.modalChildren.get(layer) === null) return;
this.modalChildren.set(layer, null);
this.#update();
}
#update() {
this.callback.forEach( (update)=>update() );
}
getSnapshot(layer) {
return ()=>{
return this.modalChildren.get(layer) ?? null;
}
}
}
먼저, 모달 컴포넌트 상태를 관리하는 ModalStore 객체를 만들었어요. 이 객체는 callback을 담는 Set과 모달 컴포넌트를 담는 modalChildren Map을 프로퍼티로 갖고 있어요. modalChildren의 경우, 각 레이어에 따라 하나의 모달만 띄우는 걸 보장하기 위해, Map 구조로 만들었어요. 추가로 레이어가 더 늘어나는 것에 따라 확장성도 챙길 수 있어요. ModalStore는 subscribe 함수로 리액트 업데이터 함수를 등록시키고, changeModal 함수와 deleteModal 함수로 모달을 등록시킴과 동시에, 등록된 리액트 업데이터 함수를 호출해서 리렌더링을 발생시켜요. 마지막으로, getSnapshot 함수는 레이어에 따라 현재 레이어에 맞는 모달 컴포넌트를 반환하는 함수랍니다.
이렇게 만들어진 ModalStore 객체는 useSyncExternalStore 훅으로 리액트 컴포넌트와 연동시킬 수 있어요. useSyncExternalStore는 리액트 18에서 처음 생긴 훅으로, 외부의 상태를 구독하고 변경에 따라 상태를 변경시키는 API를 제공해요. 일종의 발행 구독 패턴이죠.
const store = new ModalStore();
function useModalStore(layer) {
return useSyncExternalStore(store.subscribe.bind(store), store.getSnapshot(layer), ()=>null);
}
이 훅을 Modal 컴포넌트에서 사용해서 렌더링하면 돼요.
마지막으로, openModal 함수를 만들어서, 모달 상태를 변경시키는 법을 외부에 노출했어요.
export default function openModal(component, layer="alert") {
modalStore.changeModal(component, layer);
}
이렇게 구성하면, 모달을 호출하는 쪽에서 함수 import만으로 쉽고 간편하게 모달을 불러오는 상태를 변경시킬 수 있어요. 추가로, ModalStore가 map을 변경시키는 형태의 동작을 하고 있으며, 감지되는 변경을 직접 호출하기 때문에 버려지는 객체가 적어져요.
다음 관건은, 모달을 닫는 동작을 어떻게 모달의 자식 컴포넌트가 알게 하는지였어요. 일반적인 상황에서는 props를 이용해서 넘겨주면 되지만, 모달의 종류가 여러 종류이며, props도 다르게 갖고 있기 때문에, 일반적인 방식으로 해결할 수는 없었어요.
다음의 4가지 해결법을 찾아보았어요.
- cloneElement를 이용하는 방법
- render props를 이용하는 방법
- 전역 상태관리 라이브러리/useSyncExternalStore를 이용하는 방법
- Context API를 이용하는 방법
cloneElement는 리액트 엘리먼트에 props를 주입하는 리액트의 API에요. cloneElement(element, props, ...children)
의 형태로 사용할 수 있어요.
하지만 이 API는 공식적으로 deprecated되었고, 불안정한 코드를 낳을 수 있다는 치명적인 문제가 있어요.
render props를 이용한 방법은 리액트에서 props를 주입하는 방법 중 cloneElement 대신 권장하는 방법 중 하나에요. 리액트 엘리먼트를 반환하는 함수 자체를 넘겨주는 방식이에요. 하지만, render props를 이용하면 실제 모달의 생김새를 표현하는 걸 함수 하나로 더 감싸야 해서 가독성이 떨어진다는 단점이 있어요.
전역 상태관리를 이용하는 것도 하나의 방법이지만, 모달을 닫는 동작은 모달 컴포넌트 내부의 애니메이션 상태 등을 조작해야 하기 때문에, 닫기 동작을 전역 상태로 관리하면 컴포넌트 단 하나만 쓰는 내부 상태가 밖으로 노출될 수 있다는 문제가 있어서 해당 방법은 쓰지 않도록 했어요.
오랜 고민 끝에, Context API를 사용하기로 결정했어요. Context API는 리액트 내 지역적인 상태(애니메이션 상태 등)를 관리하기에 아주 좋았으며, 모달의 실제 내용 컴포넌트가 모달 컴포넌트를 몰라도 되고, 향후 다른 모달 컴포넌트에서 다른 닫기 동작을 제공해도 자신의 상위 모달 컴포넌트의 닫기 동작을 참조하기 때문에, 유연성 있게 동작한다는 이점이 있었어요. 즉, 결합성을 줄일 수 있다는 것이죠!
function Modal({layer}) {
const child = useModalStore(layer);
const close = useCallback( ()=>{
closeModal(layer);
// 그 외 애니메이션 상태 조작 로직이 들어감
}, [layer] );
return <ModalCloseContext.Provider value={ close }>
{child !== null ?
<div className="modal">
{child}
<div className="backdrop" onClick={ close }></div>
</div>
:
null
}
</ModalCloseContext.Provider>
}
먼저, 모달 컴포넌트에서 다음과 같이 정의해요. ModalCloseContext 컨텍스트를 정의한 뒤, Modal 컴포넌트에서는 닫기 동작을 정의한 뒤, ModalCloseContext.Provider 컴포넌트로 자식 컴포넌트가 렌더링될 부분을 감싸줬어요. 닫기 함수는 useCallback으로 감싸줘서, 재생성을 막아서 필요 없는 함수가 재생성되는 현상을 방지해주었어요.
function MyModal({name}) {
const close = useContext(ModalCloseContext);
const secondModal = <MyModal name="hello, second!" />;
return <div className="w-72 h-48 bg-white">
<p>안녕 난 테스트 모달: {name}이야</p>
<div onClick={()=>openModal(secondModal)}>모달2 오! 픈!</div>
<div onClick={close}>닫아버렷!</div>
</div>
}
모달 내부 컴포넌트가 모달의 닫기 동작을 원한다면, useContext를 이용해서 닫기 동작을 가져오면 돼요. MyModal 컴포넌트는 자신을 둘러싼 Modal이 무엇인지, 어떤 동작을 하고 있는지 몰라도 되며, 모달을 호출하는 함수가 온전히 jsx를 쓸 수 있어서 호출할 때에도 어떤 구조의 엘리먼트를 렌더링하는지 확실히 알 수 있어서 가독성과 유지보수에도 도움이 되어요.
프로젝트가 진행되면서, 모달이 닫힌 후 반드시 무언가가 실행되어야 하는 로직이 필요했어요. 구체적으로는, 어드민 페이지에서 이벤트를 추가한 뒤, 이벤트 추가 완료 모달을 닫은 시점에 다른 페이지로 넘어가야 하는 것과, 메인 페이지의 선착순 이벤트 섹션에서, 비로그인 사용자가 카드를 뒤집을 때 로그인 모달이 뜨면서 카드가 흔들리는데, 사용자가 모달을 닫으면 카드의 흔들림이 멈춰야 하는 요구사항이 있었어요.
하지만, 현재 시점에서는 모달의 닫기 동작은 순전히 Modal 컴포넌트 자체에서 관리하고 있고, 모달을 여는 쪽에서는 모달의 내용만 정의할 수 있기 떄문에, 모달의 커스텀 닫기 동작 자체를 주입하는 것이 불가능했어요, 그렇다고, 백드롭과 닫기 동작까지 모달을 여는 쪽에서 정의하도록 넘기면, Modal 컴포넌트로 분리한 이유가 퇴색될 것이라고 생각했어요.
그러던 중, 모달을 여는 상태를 비동기 함수로 관리하는 아이디어를 보게 되었어요. 모달을 여는 동안에는 Promise가 pending 상태로 기다렸다가, 모달이 닫히는 이벤트가 발생하면 모달 열기 Promise를 resolved하는 원리에요. 예시의 코드는 확인 버튼과 취소 버튼을 양식화한 모달이지만, 모달을 여는 동작을 Promise로 관리하는 것은 저희의 방식대로 적용하기로 했어요.
function openModal(component, layer = "alert") {
modalStore.changeModal(component, layer);
return new Promise((resolve) => {
function observe() {
if (modalStore.getSnapshot(layer) !== component) {
resolve();
clear();
}
}
const clear = modalStore.subscribe(observe);
});
}
저희는 "모달로 띄워질 컴포넌트를 전역 상태에 등록하는 로직"을 모달을 여는 것으로 정의하고 있어요. 그래서, 모달이 닫히는 것을 "설정한 레이어에 해당하는 리액트 엘리먼트의 상태가 열릴 때의 상태와 달라지는 것"이라고 정의했어요. 우선, modalStore에 현재 설정한 모달의 상태를 변경시킨 뒤, promise를 반환해요. 이 때, 이 promise 내부에서는 다음과 같은 동작을 해요.
- observe 함수를 정의해요. observe 함수는 modalStore가 변경될 때 실행되는 함수에요. 이 함수는 현재 설정한 레이어에 띄워진 모달이 열었을 때 설정한 모달의 값과 다르면, Promise를 resolve한 뒤, modalStore에 대한 자기 자신의 구독을 해제해요(
clear()
). - observe 함수를 구독한 뒤, clear 변수에 구독 해제 함수를 할당해요. clear 함수는 모달이 닫히거다 다른 모달이 될 때 호출되어요. 결론적으로, observe 함수는 모달이 닫힐 때 단 1번만 실행해요.
openModal(<AlertModal title="등록 완료" description="이벤트가 성공적으로 등록되었습니다!"/>)
.then(() => navigate("/events"));
실제로 사용할 때에는 openModal 함수의 then 메소드를 이용해서 모달이 닫혔을 때 반드시 실행되어야 하는 로직을 정의할 수 있어요. 결과적으로, 어떤 모달이 띄워져야 하는지, 그 모달이 닫힐 때 무엇을 해야 하는지를 선언적으로 구성할 수 있어서, 코드를 읽기 쉬워졌어요.
-
🎯 기술적 선택 이유
-
✨ UX 및 접근성
-
#️⃣ 코드 퀄리티
-
🛠️ 구현