고급 프로젝트 Coworkers의 시작❗
저는 Modal 공통 컴포넌트를 맡게 되었습니다.
작업을 시작하기 전에 지난 프로젝트에서 팀원분께서 만들어주신 Modal 컴포넌트를 사용할 때 좋았던 점들과 불편했던 점들을 생각해 봤어요🤔
좋은 점
- Modal의 Container만 만들어두고 Content는 children으로 받아 사용할 수 있어 편했음
- Modal의 dim 영역을 클릭했을 때 닫히는 기능이 있어 편했음
불편한 점
- Modal 컴포넌트를 사용하는 페이지에서 모달의 open 관련 State를 선언하고 관리해야 해서 불편했음
- DOM 계층의 Depth가 깊어지면 z-index 관련해서 신경쓸 부분이 많아 불편했음
- Modal의 dim과 컨텐츠가 각각 Dim, Modal 컴포넌트로 분리되어 있어 불편했음
위 내용을 고려하면서 작업 목표를 아래처럼 작성했습니다.
useModal 커스텀 훅
import { useState } from 'react';
interface ModalHookProps {
initialState?: boolean;
}
function useModal({ initialState = false }: ModalHookProps = {}) {
const [isOpen, setIsOpen] = useState(initialState);
const openModal = () => setIsOpen(true);
const closeModal = () => setIsOpen(false);
return { isOpen, openModal, closeModal };
}
export default useModal;
- initialState : 모달의 초기 상태가 꼭 닫혀있는 것은 아닌 것 같아서 추가했습니다. 웹이나 앱을 이용하다 보면 광고나 공지 같은 모달이 열려있는 상태로 진입하는 경우를 많이 볼 수 있잖아요😀
Modal 컴포넌트
'use client';
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import IconClose from '@/app/components/icons/IconClose';
interface ModalProps {
hasCloseBtn?: boolean;
portalRoot?: HTMLElement;
closeModal: () => void;
}
function Modal({
hasCloseBtn = false,
portalRoot,
closeModal,
children,
}: PropsWithChildren<ModalProps>) {
const [isMounted, setIsMounted] = useState(false);
const modalRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setIsMounted(true);
// 모달 외부 클릭 시 닫히도록 이벤트 처리
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && modalRef.current.contains(event.target as Node)) {
closeModal();
}
};
// 마운트 시 이벤트 리스너 추가
document.addEventListener('mousedown', handleClickOutside);
// clean-up : 언마운트 시 이벤트 리스너 제거
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [closeModal]);
if (!isMounted) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center tablet:justify-end">
{/* 모달 dim 영역 - 클릭 시 모달 닫힘 */}
<div ref={modalRef} className="absolute inset-0 bg-black opacity-50" />
<div className="relative flex w-96 flex-col items-center rounded-xl bg-background-secondary pb-8 pt-12 tablet:w-full tablet:rounded-b-none">
{hasCloseBtn && (
<button
type="button"
className="absolute right-4 top-4 h-6 w-6"
title="모달 닫기"
onClick={closeModal}
>
<IconClose />
</button>
)}
{children}
</div>
</div>,
portalRoot || document.body,
);
}
export default Modal;
Props
- hasCloseBtn : 닫기 버튼(X) 유무 설정
- portalRoot : Modal을 렌더링할 상위 DOM 노드, body 하위가 아닌 다른 곳에 렌더링 할 경우를 고려해 prop으로 추가했습니다.
- closeModal : Modal 닫기 함수
- children : Modal 내부에 렌더링될 컨텐츠
CreatePortal()
- portalRoot || document.body : portalRoot prop을 지정하지 않으면 기본적으로 body 하위에 모달을 렌더링 하도록 설정했습니다.
ModalRef
- 기존에는 dim 영역 <div>의 onClick 이벤트 핸들러에 closeModal()을 사용했습니다.
- dropdown 작업을 하신 팀원분이 useRef를 이용해 닫기 이벤트를 처리하셔서, 공통으로 뺄 수 있을 것 같아 논의 후 수정했습니다.
if (!isMounted) return null;
- SSR 환경에서 internal server error(500)이 발생하는 문제를 해결하기 위해, modal이 마운트 되기 전에 null을 리턴하도록 했습니다.
🌈 작업 완료 & 테스트
Modal이 body 하위에 잘 렌더링 되고 dim 영역 클릭 시 닫히는 기능도 잘 동작합니다.
이렇게 마무리하나 싶었는데, 스크럼 회의 때 애니메이션을 추가했으면 좋겠다는 의견이 나왔어요👀
의견을 듣고 Modal을 동작시켜 보니 좀 심심해 보이긴 하네요😅😅
수정 전
export function Home() {
const {isOpen, openModal, closeModal} = useModal();
const handleClose = () => {
/* TODO */
closeModal();
}
return (
<div>
{isOpen && (<Modal closeModal={handleClose}>모달 컨텐츠</Modal>)}
</div>
)
}
기존 Modal은 아래 코드처럼 조건부로 렌더링을 관리했어요.
그런데 이 방법으로는 애니메이션을 추가해도 적용이 안되네요.. 다른 방법을 찾아야 할 것 같습니다😭
여러 프레임워크의 Modal 컴포넌트를 찾아보다가 Bootstrap의 Modal이 가장 적합한 것 같아 참고하기로 했습니다❗
수정 후
export function Home() {
const {isOpen, openModal, closeModal} = useModal();
const handleClose = () => {
/* TODO */
closeModal();
}
return (
<div>
<Modal isOpen={isOpen} closeModal={handleClose}>모달 컨텐츠</Modal>
</div>
)
}
'use client';
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import IconClose from '@/app/components/icons/IconClose';
interface ModalProps {
hasCloseBtn?: boolean;
portalRoot?: HTMLElement;
isOpen: boolean;
closeModal: () => void;
}
function Modal({
hasCloseBtn = false,
portalRoot, // Modal 렌더링할 상위 DOM 노드
closeModal,
isOpen,
children,
}: PropsWithChildren<ModalProps>) {
const [renderModal, setRenderModal] = useState(isOpen);
const modalRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (isOpen) {
setRenderModal(true); // isOpen이 true면 렌더링 활성화
}
}, [isOpen]);
const handleAnimationEnd = () => {
if (!isOpen) {
setRenderModal(false); // 애니메이션 종료 후 렌더링 중단
}
};
useEffect(() => {
// 모달 외부 클릭 시 닫히도록 이벤트 처리
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && modalRef.current.contains(event.target as Node)) {
closeModal();
}
};
// 마운트 시 이벤트 리스너 추가
document.addEventListener('mousedown', handleClickOutside);
// clean-up : 언마운트 시 이벤트 리스너 제거
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [closeModal]);
if (!renderModal) return null;
return createPortal(
<div
className={`tablet:justify-center fixed inset-0 z-50 flex flex-col items-center justify-end transition-opacity ${
isOpen ? 'opacity-100' : 'pointer-events-none hidden opacity-0'
}`}
style={{ display: renderModal ? 'flex' : 'none' }}
>
<div ref={modalRef} className="absolute inset-0 bg-black opacity-50" />
<div
className={`tablet:w-96 tablet:rounded-b-xl relative flex max-h-[80%] w-full transform flex-col items-center overflow-y-hidden rounded-t-xl bg-background-secondary pb-8 pt-12 transition-transform ${isOpen ? 'translate-y-0' : 'translate-y-4'}`}
onTransitionEnd={handleAnimationEnd}
>
{hasCloseBtn && (
<button
type="button"
className="absolute right-4 top-4 h-6 w-6"
title="모달 닫기"
onClick={closeModal}
>
<IconClose />
</button>
)}
{children}
</div>
</div>,
portalRoot || document.body,
);
}
export default Modal;
Props
- isOpen : Modal 렌더링과 애니메이션 관리를 위해 prop으로 추가
renderModal
- Modal의 렌더링 여부를 결정하기 위한 State로 isOpen prop의 값에 따라 토글 됩니다.
onTransitionEnd={handleAnimationEnd}
- 애니메이션이 종료된 후 렌더링을 중단하기 위해 추가
const handleAnimationEnd = () => { if (!isOpen) { setRenderModal(false); // 애니메이션 종료 후 렌더링 중단 } };
🌈 애니메이션 동작 화면
tailwind CSS를 처음 접하다 보니 작업 속도가 많이 느리고, 애니메이션이나 UX 적인 부분을 작업 초반에 고려하지 못해 많은 수정을 했네요..😅
그래도 여러 번 수정 끝에 좋은 결과물을 만들어 내고, 팀원들에게 긍정적인 코드 리뷰를 받아서 기분이 좋네요🎉🎉
저도 많이 부족한데 하나 배워 가신다니 머쓱하지만 더 열심히 해야겠다는 의지가 불타오르네요🔥🔥
'프로젝트 > Next+TypeScript' 카테고리의 다른 글
[맛길] 데이터 채우기 - Youtube Data API (2) | 2025.01.18 |
---|---|
[맛길] 프로젝트 주제 및 기술 스택 선정 (0) | 2025.01.17 |
[Taskify] Trouble Shooting - 동적 페이지 라우팅 (5) | 2024.12.28 |
[Taskify] 1차 배포 테스트(2) - 사용자 편의성 개선 (5) | 2024.12.26 |
[Taskify] 1차 배포 테스트 - Trouble shooting (4) | 2024.12.24 |