안녕하세요.
팀 디스턴스의 프론트엔드 개발자 노주영입니다.
디스턴스 앱에서는 사용자가 한 화면에서 여러 가지 작업을 처리할 수 있게 모달 컴포넌트를 사용합니다. 특히 모바일을 기준으로 디자인된 디스턴스 특성상, 새 창이 열려서 집중을 흐트러뜨릴 수 있는 팝업보다 같은 화면에서 열리는 모달을 적극 활용하고 있습니다.
디스턴스 앱에서 사용되는 팝업의 종류는 총 11가지입니다. 그중에는 상대방과의 통화를 요청할 때 표시되는 모달, 서비스 이용 약관에 동의하기 모달 등이 포함되어 있습니다. 이렇게 많은 모달 종류를 어떻게 잘 관리할 수 있는지에 대해 고민한 내용을 이야기하려고 합니다.
이야기에 들어가기에 앞서, 모달과 팝업이 어떤 차이가 있는지 간략하게 말씀드리려고 합니다.
인터넷 화면에서 새로운 창을 띄우는 방식의 그래픽 요소입니다. 이러한 점은 팝업 UI와 유사하게 느껴지기도 하는데요. 팝업은 브라우저 자체에서 새 창을 띄우는 방식이지만, 모달은 같은 화면에서 새로운 층을 띄우는 방식으로 두 방식에 차이가 있습니다.
모달 창. 브라우저 내에서 새로운 층을 표시합니다.
앞서 말씀드린 것처럼, 디스턴스에는 총 11가지의 모달 종류가 있습니다. 최대한 일관된 룩을 유지하기 위해 일부 모달들은 서로 비슷한 디자인을 공유하고 있는데요. 컴포넌트 기반 개발이 가능한 리액트로 구성되어 있는 만큼, 비슷한 디자인을 하나로 묶어서 코드로 구현하고자 했습니다.
위 이미지에서 비슷해 보이는 디자인을 하나로 묶어서 <Modal />이라는 컴포넌트를 만들고, 그 외에 모달들도 유연하게 사용할 수 있게 <BlankModal />이라는 컴포넌트를 추가로 만들었습니다. 이 두 컴포넌트의 생김새는 다음과 같습니다.
디스턴스에서 정의한 모달의 특징 중 하나는 화면의 최상위에 있는 것입니다. 이는 다른 요소에 의해 모달이 가려져서는 안 된다는 것을 의미합니다. 이러한 요구사항을 고려하여 react-dom에서 제공하는 createPortal()을 사용하여 구현했습니다. createPortal()을 사용하면 HTML에서 원하는 위치를 선택해 렌더링할 수 있어 화면의 쌓임 맥락과 부모 요소로부터 받는 스타일 영향으로부터 상대적으로 자유로워질 수 있습니다. 그리고 부모 컴포넌트에서 모달을 여닫을 수 있도록 useImperativeHandle 훅을 사용하여 핸들을 외부로 노출시켰습니다.
const Modal = forwardRef(
(
{ children, buttonLabel, buttonClickHandler, buttonColor = '#FF625D' },
ref
) => {
const dialog = useRef();
// useImperativeHandle을 사용하여 외부로 ref 핸들을 노출합니다.
useImperativeHandle(ref, () => {
return {
open() {
dialog.current.showModal();
document.body.style = `overflow: hidden`;
},
close() {
dialog.current.close();
document.body.style = `overflow: auto`;
};
});
const handleCloseModal = () => {
dialog.current.close();
document.body.style = `overflow: auto`;
};
// createPortal()을 사용하여 div#modal이라는 요소의 자식으로 렌더링합니다.
return createPortal(
<Dialog ref={dialog}>
<WrapContent>
<CloseButton onClick={handleCloseModal} />
{children}
<Button
onClick={buttonClickHandler}
backgroundColor={buttonColor}
>
{buttonLabel}
</Button>
</WrapContent>
</Dialog>,
document.getElementById('modal')
);
}
);
위와 같이 모달 컴포넌트의 기초를 만들어놓고 페이지나 컴포넌트에서 직접 렌더링하는 방식으로 구현했습니다. 아래 코드는 어떻게 작성되어 있었는지에 대한 예시입니다.
const HomePage = ({ pageContents, member }) => {
const modalRef = useRef();
const openModal = () => modalRef.current.open();
const closeModal = () => modalRef.current.close();
// 페이지에서 모달 내용을 직접 렌더링!
return (
{pageContents}
<Modal
ref={modalRef}
buttonLabel="메시지 보내기"
buttonClickHandler={() => {}}
buttonColor="FF625D"
>
<CloseButton onClick={closeModal}>X</CloseButton>
<Character>{member.character}</Character>
<Department>{member.department}</Department>
<Tag>{member.tag}</Tag>
</Modal>
)
}
프로젝트의 규모가 커지다 보니 요구 사항과 코드 크기도 덩달아 늘어났습니다. 특히 저희 앱의 메인 기능인 채팅 페이지의 코드 길이가 한때 800줄에 달할 만큼 커지다 보니 유지보수 또한 어려워지는 문제가 있었습니다.
기존 코드에서 느꼈던 불편한 부분들을 정리하면 다음과 같습니다.
1. 모달을 수정하려면 해당 페이지를 찾아야 했습니다.
모달을 수정하기 위해서는 해당 모달이 어떤 페이지에 있는지 먼저 찾아야하는 불편함이 있었습니다. 그리고 페이지에서 모달의 JSX와 스타일링은 물론, 모달을 열고 닫는 코드까지 포함하다 보니 코드가 많이 길어졌습니다.
2. 모달이 컴포넌트 안에서 렌더링 된다는 개념이 어색했습니다.
프론트엔드 코드에는 상단바 역할을 하는 Header라는 컴포넌트가 있습니다.
이 컴포넌트에서 오른쪽 동물 캐릭터를 터치하면 모달이 열리게 되는데요. 모달이 열리는 이유 때문에 Header 컴포넌트의 JSX 안에 모달 내용이 포함된다는 점이 어색하게 느껴졌습니다. 한 컴포넌트가 두 가지 이상의 역할을 수행하는 것 같아서 말이죠.
3. ref 사용을 피하고 싶었습니다.
props를 가지고도 충분히 표현할 수 있는 내용이라고 생각했기 때문에 ref와 ref를 사용하는데 수반되는 여러 훅의 사용을 최소화하고 싶었습니다. 리액트 공식 문서에서도 props로 표현할 수 없는 필수적인 행동에만 ref를 사용하라고 나와 있기도 하구요.
먼저 기존에 사용하던 <Modal />, <BlankModal /> 컴포넌트를 제거하고, 11가지의 모달을 각각 개별 컴포넌트화했습니다. 기존에는 모달의 내용이나 로직을 수정하기 위해 해당 페이지를 방문해야 했다면, 이제는 그 역할을 분리해 해당하는 모달을 찾아서 수정할 수 있게 되었습니다.
다음으로는 페이지마다 공통으로 반복되는 모달을 열고 닫는 기능을 커스텀 훅으로 만드는 일을 했는데요. 커스텀 훅을 만드는 김에 페이지와 모달을 분리하고자 하였습니다. 모달은 모달대로 화면에 보이고, 페이지는 모달을 여닫는 동작만 수행할 수 있게 말이죠. 이러한 로직을 구현하고자 모달을 전역 상태로 관리하기로 했습니다. 저희 프론트엔드 코드에서는 이미 전역 상태 관리 라이브러리로 Recoil을 사용하고 있어서 그것을 그대로 활용하기로 했습니다.
먼저 반복되는 로직을 useModal이라는 커스텀 훅으로 감쌌습니다. 모달을 여는 동작은 전역 상태에 모달 컴포넌트를 업데이트하는 동작으로 이루어지며, 반대로 닫는 동작은 전역 상태를 초기화하는 것으로 끝납니다. 추가로 모달이 열렸을 때 모달 뒤의 요소들이 스크롤 되지 않도록 overflow 스타일을 적용했습니다.
// 모달을 관리하는 전역 상태
export const modalState = atom({
key: 'modalState',
default: null,
});
// 반복되는 로직을 감싼 커스텀 훅 useModal
const useModal = (modal) => {
const setActiveModal = useSetRecoilState(modalState);
const resetState = useResetRecoilState(modalState);
const openModal = () => {
setActiveModal(modal);
document.body.style.overflow = 'hidden';
};
const closeModal = () => {
resetState();
document.body.style.overflow = 'auto';
};
return { openModal, closeModal };
};
페이지에서는 모달의 JSX를 직접 렌더링하지 않고도 모달을 여닫을 수 있게 됩니다. 다음은 위에서 만든 useModal 커스텀 훅을 사용해 페이지에서 모달을 사용하는 예시 코드입니다.
const HomePage = ({ profile }) => {
// 모달 컴포넌트를 리턴하는 익명 함수를 인자로 전달합니다.
// 모달 자체를 전달하면 closeModal 함수를 모달의 props로 넘겨줄 수 없기 때문입니다.
const { openModal, closeModal } = useModal(() => (
<ProfileModal closeModal={closeModal} selectedProfile={profile} />
));
// 이제 모달을 직접 렌더링하지 않아도 됩니다.
return (
<div>
<h1>Home Page</h1>
<button onClick={openModal}>모달 열기</button>
</div>
);
}
전역 상태에 올려진 모달은 GlobalModalContainer라는 프로바이더에서 렌더링 됩니다. 이 프로바이더는 단순히 전역 상태에 올라와 있는 모달 컴포넌트를 리턴 시켜주는 역할만 수행합니다.
const GlobalModalContainer = () => {
const modal = useRecoilValue(modalState);
return modal;
};
이 프로바이더는 index.js에 <App /> 하단에 배치됩니다.
<React.StrictMode>
<RecoilRoot>
<BrowserRouter>
<App />
<GlobalModalContainer /> {/* 여기! */}
</BrowserRouter>
</RecoilRoot>
</React.StrictMode>
전체적인 구조도를 그리면 다음과 같습니다. 전역 상태에 모달 컴포넌트 자체를 올려놓고, GlobalModalContainer에서 모달을 그대로 화면에 렌더링시켜주는 방식입니다. 프로젝트의 index.js에서 <App /> 컴포넌트 바로 하단에 GlobalModalContainer를 위치시켜 루트 요소 바로 하위에서 모달이 렌더링 됩니다. 그렇기 때문에 createPortal을 사용하지 않고도 부모 요소로부터 CSS의 영향을 받지 않을 수 있습니다.
프론트엔드 개발 공부를 하면서 전역 상태의 사용처를 딱히 몰라서, 단순히 로그인 상태를 관리해주는 기능 원툴 정도로만 생각했습니다. 하지만 이번에 컴포넌트 자체를 전역 상태로 관리하는 사용 사례를 발견하면서 전역 상태를 가지고 더 재밌는 걸 만들어 볼 수 있겠다는 생각이 들었습니다.
이번에 작업한 내용은 사용자에게 성능상 이점을 가져다준다거나 심미적으로 큰 변화를 주는 것은 아닙니다. 하지만 이번 작업 덕분에 코드를 분산시켜 유지보수할 때 긴 코드를 왕복하느라 개발자의 주의가 분산되는 문제를 조금이나마 덜었다고 생각합니다. 특히 모달이 3개가 사용되는 회원가입 페이지에서는 전체 코드의 길이를 3/4 수준으로 줄일 수 있었습니다.
이제는 단순히 기능을 구현하는 단계를 넘어서 유지보수가 용이한 코드, 동료 개발자들이 이해하기 쉬운 코드를 작성하는데 노력해야겠다는 생각이 들었습니다.
긴 글 읽어주셔서 감사합니다.
즉석으로 이미지를 줄여주는 CDN 구축하기 (feat. Lambda@edge) (4) | 2024.10.27 |
---|---|
슬라이드 업 메뉴 구현하기 (with React) (0) | 2024.08.09 |
토스트 메세지 전역 컴포넌트로 관리하기 (0) | 2024.08.02 |