react.js로 프론트엔드 개발을 하다 보면 필수적으로 개발을 해야할게 생깁니다.
바로 Modal UI 이죠.
항상 이 시점에서 모달을 ContextAPI로 개발을 해야하나 redux나 zustand와 같은 전연관리 툴을 써야하나 고민을 하게 됩니다.
어떻게 개발을 해야 좀 더 효율적이게 개발을 할 수 있을까요?
위 두개의 방법에 공통된 단점은 아래와 같습니다.
그렇다면 위와 같은 단점들을 보완하고 좀 더 유연하고 재사용에 용이한 Modal 시스템을 구현하려면 어떻게 할 수 있을까요?
의존성을 끊고 커스텀 훅에서만이 아닌 여러 서비스 및 비지니스 로직에서 Modal을 열고 닫으며 독립적으로 관리 할 수 있게끔 구현하려면 사실 Class를 이용한 Modal 모듈을 만들면 가능합니다.
하지만 Class로 구현한다면 react.js가 추구하는 불변성을 지키며 모달 정보가 달라지면 그에 맞게 UI를 리랜더링 해줘야 하는 문제가 생깁니다.
react.js는 useState를 활용한 상태 관리를 통해 불변성을 유지하고 리랜더링을 진행합니다.
저희는 Modal Class가 갖고 있는 modal 정보가 업데이트 될때마다 observe function을 호출하여 모달 UI를 리랜더링을 하는 방식의 Observing Pattern을 사용하여 Modal 시스템을 구현하고자 합니다.
./modal/Modal.js
import { uniqueId } from 'lodash-es';
class Modal {
/**
* 모달로 노출될 모달 정보를 담은 배열
* 여러개의 모달을 연달아서 띄울 수도 있기 때문에 배열로 구현
*/
private _modalList = [];
/**
* _modalList가 업데이트 될때마다 실행할 observe 함수
*/
public observeFunction = null;
set modalList(value) {
const newModalList = [...value];
this._modalList = newModalList;
// 업데이트할 새로운 모달 배열을 observe 함수에 전달인자로 전달하여 함수 호출
this.observeFunction && this.observeFunction(newModalList);
}
get modalList() {
return this._modalList;
}
/**
* 모달을 닫을때 호출하는 함수
*/
closeModal(id) {
// 닫고자 하는 모달이 현재 노출 되고 있는 모달 리스트에 존재하는지 확인
const findModal = this.modalList.find((m) => m.id === id);
if (!findModal) return;
// 모달 리스트 업데이트
this._modalList = this.modalList.filter((m) => m.id !== id);
}
/**
* 비동기 결과값을 반환하고 closeModal 함수를 호출하는 함수
*/
resolveModal({ modalId, result }) {
modal.resolve(result);
this.closeModal(modal.id);
}
resetModal() {
this.modalList = [];
}
openModal({ component, props = {}, options = {} }) {
return new Promise((resolve, reject) => {
// 유니크한 모달 id를 생성
const modal = {
id: options.id ?? uniqueId(), // options에 id로 modalId 직접 지정 가능
component // 모달로 랜더링할 Component 함수
props, // 모달로 랜더링할 Component의 props
resolve, // 비동기로 모달의 결과값을 받을 수 있게 하는 resolve 함수
reject
};
this.modalList = [...this.modalList, modal];
});
}
observe(observeFunction) {
this.observeFunction = observeFunction;
}
unobserve() {
this.observeFunction = null;
}
}
const modal = new Modal();
export default modal;
./modal/components/ModalContainer.jsx
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import modal from '../Modal';
const ModalContainer = () => {
const prevPathname = useRef('');
const { pathname } = useLocation();
const [modals, setModals] = useState(modal.modalList);
/**
* 1. Modal 클래스로 생성된 modal 객체의 modalList가 변경될때 호출된 옵저버 함수를 등록
*/
useEffect(() => {
modal.observe(setModals);
return () => {
modal.unobserve();
};
}, []);
// pathname이 바뀔때 즉, 페이지가 변경될때 모든 모달을 reset
useEffect(() => {
if (prevPathname.current && prevPathname.current !== pathname) {
modal.resetModal();
}
prevPathname.current = pathname;
}, [pathname]);
return (
<>
{modals.map((m) => createElement(m.component, {
key: m.id,
onCancel: () => modal.deleteModal(m.id),
onOk: (result) => modal.resolve({ modalId: m.id, result }),
...m.props
}))}
</>
);
};
export default ModalContainer;
./components/ModalTemplate.jsx
import React from 'react';
import styled from 'styled-components';
const ModalTemplateStyle = styled.div`
position: fixed; top: 0; left:0; bottom: 0; right: 0; z-index: 1; display: flex; justify-content: center; align-items: center;
.dim{ position: absolute; top: 0; left:0; bottom: 0; right: 0; z-index: 1; background-color: rgba(0,0,0,0.5); }
.modal-cont{ position: relative; z-index: 2; width: 500px; height: 500px; padding: 20px; border-radius: 8px; background-color: white; }
`;
const ModalTemplate = ({ className, children, onClose }) => {
return (
<ModalTemplateStyle>
<div tabIndex={-1} role="button" aria-label="modal-dim" className="dim" onClick={onClose} />
<div className="modal-cont">
{children}
</div>
</ModalTemplateStyle>
);
};
export default ModalTemplate;
./components/TestModal.jsx
const TestModal = ({ className, text, onCancel, onOK }) => {
return (
<ModalTemplate className={className}>
TestModal {text}
<div className="btn-cont">
<button onClick={onCancel}>취소</button>
<button onClick={() => onOK && onOK('확인을 눌렀네요!')}>확인</button>
</div>
</ModalTemplate>
)
}
export default TestModal;
App.jsx
import modal from './modal/Modal';
import ModalContainer from './modal/components/ModalContainer';
import TestModal from './components/TestModal';
const App = () => {
const openTestModal = async () => {
const result = await modal.openModal({
component: TestModal,
props: {
text: '모달 컨텐츠 입니다.'
},
options: {
id: 'TestModal' // 직접 지정하지 않으면 랜덤하게 자동 생성 됩니다.
}
});
console.log(result); // '확인을 눌렀네요!'
}
// TestModal 컴포넌트를 강제로 모달에서 닫게 하도록 하는 함수
const forceCloseTestModal = () => {
modal.closeModal('TestModal');
}
return (
<div>
<ModalContainer />
/** 모달을 테스트 하기 위한 button */
<button onClick={openTestModal}>Open Modal</button>
<button onClick={forceCloseTestModal}>Close Modal</button>
</div>
)
}
지금까지 Observer Pattern을 이용하여 Modal 시스템을 간단하게 구현해보았습니다.
사실 실제로 제가 구현한 Modal 시스템은 위 내용에 모달 애니메이션, modalId 직접 지정, 동적 임포트한 컴포넌트 Suspense로 처리 등 여러 옵션을 넣어서 Modal 시스템을 확장하여 개발하였습니다.
또한 제네릭 타입을 이용하여 openModal 함수를 호출할때 component의 따른 props 타입 동적 지정 등 타입스크립트를 활용하여 개발자가 좀 더 직관적이고 유용하게 사용할 수 있도록 개발하였습니다.
그 모든 시스템을 녹인 소스 코드를 공유하기 보다는 Observer Pattern을 이용한 Modal 시스템 구현에 관련된 핵심적인 소스만을 간추려서 글을 작성하였습니다.
해당 아키텍처를 참고하여 여러분이 현재 개발하고 있는 프로젝트에 좀 더 잘 맞는 효율적이고 멋진 모달 시스템을 구현하는데에 조금이나마 도움이 되기를 바라겠습니다.
위 글에 대해 궁금점이 있거나 피드백을 주실게 있으시다면 언제든지 아래의 메일 주소로 문의 부탁드리겠습니다~!
contact me: dfd11233@gmail.com