1. 배경
Modal은 UX디자인에서 가장 많이 사용하는 컴포넌트 중 하나로, 사용자의 시선을 집중시킬 때 사용합니다. 예를 들어, 메일을 삭제하는 것과 같이 유저의 특정 행위 이후에 기존의 데이터를 더 이상 복구하거나 볼 수 없는 경우 Modal을 사용하여 사용자에게 다시 한 번 행위에 대한 진행 여부를 물어봅니다.
2. Modal
1. Modal vs Popup
팀원가 대화를 할 때 Modal과 Popup을 혼용하여 사용하는 경우가 많습니다. 이 둘은 분명히 다른 차이점이 존재했습니다.
1. Modal
- 기존에 열려있는 브라우저 페이지 위에 또 다른 브라우저 페이지를 띄우는
- 원래 브라우저와 독립적인 관계
- 브라우저의 옵션을 통해 열리지 않도록 강제할 수 있음
2. Popup
- 기존에 열려있는 브라우저 위에, 새로운 창이 아닌 레이어를 까는 것
- 원래 브라우저와 종속적인 관계, 부모 - 자식의 형태를 가지고 있음.
- 브라우저 옵션과 관계없이 띄울 수 있음
Popup은 이벤트, 사과문, 안내문 등 처음 페이지에 진입했을 때 바로 노출되는 것을 의미합니다. 페이지 내의 메인서비스와 관계없이 뜨는 독립적인 창 등이 팝업에 해당된다고 할 수 있습니다. 반면에 Modal은 서비스와 관련해 다음 단계로 나아가기 위한 필수적인 창입니다. Modal에 진입한 사용자는 서비스의 흐름에서 벗어나, Modal내 컨텐츠에 집중한 후, Modal을 벗어나는 순간 다시 원래 컨텐츠(부모)의 위치로 돌아갈 수 있어야 합니다.
2. Modal vs Page(NonModal)
Modal과 Page의 차이점 또한 여럿 있습니다. Modal이 띄어지면 사용자는 부모 화면을 컨트롤할 수 없습니다. Modal 내 컨텐츠를 완료하거나 닫아야 부모 화면에 접근할 수 있습니다. 그에 반해 Modal이 없는 Page는 부모 화면을 사용자가 컨트롤할 수 있습니다.
프로젝트 이슈 중, 포즈 사진에 대한 상세 정보 페이지를 Modal로 구현할 지, Page로 구현할 지에 대해 고민한 적이 있습니다. 물론 이 사항은 여러 문제들이 복잡하게 얽혔었지만, 상세 정보 페이지같은 경우 Modal이 아닌 페이지로 구성되어야 한다고 생각합니다.
만약 상세 정보페이지가 리뷰나, 구매 같은 다른 작업이 없는 엔드포인트였다면 Modal을 사용해도 아무런 문제가 없을 지도 모릅니다. 하지만 상품 상세 페이지는 사용자가 더욱 깊이 탐색할 수 있으며, 위에 말했듯 다양한 추가 작업등을 수행할 수 있기 때문에, 명확한 엔드포인트가 없고, 서비스 흐름에서 벗어나는 것이 아니며, 주요 서비스 흐름의 일부분입니다. 따라서 Modal이 아닌 Page 단위로 처리하는 것이 올바른 것 같습니다.
3. Modal의 종류
Modal과 Popup, Modal과 NonModal의 차이점에 대해 알아보았습니다. 그렇다면, Modal에는 어떠한 것들이 있을까요?
1. Alert창
'알리다'라는 뜻을 가진 Alert은 단순히 메시지를 전달하는 역할을 합니다. 반환하는 값이 없습니다. 확인을 누르거나 닫기(X) 버튼 혹은 얼럿 창 주변을 클릭하면 사라집니다.얼럿(Alert) 창 예시, 이미지 출처 : Moon's Blog - https://url.kr/rgq1fk

2. Confirm창
'확인하다'란 뜻을 가진 confirm은 사용자가 취한 행동에 대해 다시 한번 확인할 수 있게 하는 역할을 합니다. 대체적으로 실수로 삭제를 눌렀을 때 '정말 삭제하려고 했던 것이 맞아?'라고 되물어보는 역할입니다. true 또는 false 값을 반환하며, 확인에 해당하는 버튼(이미지 상에서는 '삭제')을 누르면 true, 취소를 누르면 false를 반환한다.

3. Prompt창
'즉각적인'이란 뜻을 가진 Prompt는 컴퓨터 언어로 사용자의 명령을 받아들일 준비가 되었음을 모니터에 나타내는 표시라고 구글 사전에서 안내하고 있습니다. 주로 사용자가 입력한 값을 받아오기 위한 용도로 사용합니다. 값을 받아야 하는 입력 필드와 함께 확인/취소 버튼이 존재하며 취소를 누르면 null을 반환합니다.

3. Portal
Portal는 ReactDOM에서 제공하는 컴포넌트를 렌더링할 때, 부모 컴포넌트의 DOM 외부에 존재하는 DOM 노드에 렌더링할 수 있게 해주는 것을 말합니다. Modal 컴포넌트를 Portal을 이용하여 만드는 가장 큰 이유는 css상속 구조로부터 벗어나기 위함입니다. 만약 부모 컴포넌트가 hidden 혹은 높은 z-index를 가지고 있다면 자식에 있는 Modal 또한 이의 영향을 받을 수 있습니다.
하지만, Portal이 다른 컴포넌트에 영향을 주지 않는 것은 아닙니다. 왜냐하면 portal이 DOM트리의 어디에도 존재할 수 있다 하더라도 모든 다른 면에서 일반적인 React 자식처럼 동작합니다. context와 같은 기능은 자식이 poratal이든지 아니든지, 위치에 상관 없이 여전히 React 트리에 존재하기 때문입니다. 이는 이벤트 버블링도 포함되어 있습니다. portal 내부에서 발생한 이벤트는 React 트리에 포함된 상위로 전파될 것입니다.
Portal은 주로 Modal, Popup, Loading 화면 등 다른 화면 위에 무언가를 띄울 때 사용합니다.
1. react에서 Portal을 만드는 방법 : createPortal
react-dom에서는 createPortal메소드를 제공합니다. createPortal은 2개의 인자를 받습니다.
ReactDOM.createPortal(child, container)
첫 번째 인자(child)는 엘리먼트, 문자열, 혹은 fragment와 같은 어떤 종류이든 렌더링할 수 있는 React 자식입니다. 두 번째 인자(container)는 DOM 엘리먼트입니다.
4. 실제 구현
1. 최상단 컴포넌트에서 container 설정
ReactDOM에서 제공하는 ReactDOM.createPortal(child, container)을 사용하기 위해서는 가장 먼저, 최상단 컴포넌트로 넘겨줄 container가 필요합니다. 그래서 루트 컴포넌트에 <div id=”portal” />을 추가하여 Modal 컴포넌트에서 이 태그를 찾을 수 있도록 합니다. 이 <div id="portal" />은 추후에 ReactDOM.createPortal의 두 번째 인자로 들어갈 녀석입니다. Next.js 13의 경우 app/layout.tsx에 있는 html이 가장 루트 노드입니다. 저희는 html>body>div 안에서 모든 컴포넌트(children)를 라우팅을 하므로 이 안에 container를 넣었습니다.
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<div>
{children}
<div id="portal" />
</div>
</body>
</html>
);
}
2. Portal 컴포넌트
1번에서 만든 <div id=”portal” />에 Portal을 생성해보겠습니다.
1. 컴포넌트가 mount될 때까지 기다린다.
mount가 되었는 지에 대한 여부를 return하는 useIsMounted훅을 사용합니다. useIsMounted훅은 useEffect를 이용하여 mount되었는지 여부를 판단합니다. 여기서 다른 함수가 아니라 useEffect를 사용하는 이유는, useEffect가 생명 주기 함수이기 때문입니다. useEffect는 컴포넌트가 mount 되었을 때, unmount되었을 때, update되었을 때 특정 작업을 처리할 수 있습니다. 여기서 우리가 활용하는 경우는 mount되었을 때입니다.
function useIsMounted() {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
return isMounted;
}
const isMounted = useIsMounted();
2. ref.current에 <div id="portal" /> 태그 등록
컴포넌트가 마운트되었다면, 마운트된 컴포넌트의 부모 컴포넌트에서 ‘portal’이라는 id를 가진 요소를 찾습니다.
자바스크립트서 특정 DOM을 선택하는 경우 getElementById, querySelector같은 DOM Selector 함수를 사용합니다. React에서는ref를 사용하여 DOM Selector 함수를 이용하여 찾은 DOM요소에 대한 참조를 저장합니다. 이렇게 하면 이후에 해당 요소를 직접 조작할 수 있습니다.
const ref = useRef<Element | null>(null);
useEffect(() => {
ref.current = findWrapperElement('portal');
}, [isMounted, documentId]);
해당 요소를 찾는 findWrapperElement함수입니다. document.getElementById를 이용하여 인자로 넘겨준 documentId를 찾아서 wrapper에 등록하여 return하고, 만약에 없다면 에러를 띄웁니다. 이렇게 에러가 발생하는 경우는 root노드에 해당 documentId 노드를 추가하지 않은 경우입니다.
const findWrapperElement = (documentId: string): Element | null => {
const wrapper = document.getElementById(documentId);
if (wrapper) {
return wrapper;
} else {
console.warn(`Element with ID '${documentId}'가 root layout에 없어요....추가해주세요.`);
return null;
}
};
3. Portal 생성
return createPortal(children, ref.current);
3. AnimatedPortal 컴포넌트
Modal에 애니메이션이 없다면, Modal이 뜨는 것이 다소 부자연스러워 보일 수 있습니다. 저희는 framer-motion 라이브러리를 이용하여 애니메이션을 구현했습니다.
export default function AnimatedPortal({
children,
motionProps,
}: StrictPropsWithChildren<AnimatedPortalProps>) {
return (
<Portal>
<AnimatePresence>
<motion.div {...motionProps}>{children}</motion.div>
</AnimatePresence>
</Portal>
);
}
4. ModalWrapper 컴포넌트
Modal컴포넌트를 감싸는 ModalWrapper 컴포넌트입니다. Modal이 뜨게 되면 뒷 배경은 어둡게 처리를 해야합니다. 이 때, 단순히 검은색으로 하여 뒷배경을 완전히 어둡게 하는 것이 아니라 bg-opacity-90을 통해 뒷 배경을 투명도를 낮추었습니다.
export default function ModalWrapper({
isOpen,
onClose = () => {},
children,
}: StrictPropsWithChildren<ModalWrapperProps>) {
const modalRef = useRef<HTMLDivElement>(null);
useOnClickOutside(modalRef, onClose);
if (!isOpen) return null;
return (
<AnimatedPortal>
<div>
{children}
</div>
</AnimatedPortal>
);
}
1. useOnClickOutside훅
뒷 배경을 클릭했을 때 이벤트를 발생시키는 훅입니다. Modal컴포넌트 DOM을 가리키는 ref와 props로 전달받은 onClose함수를 useOnClickOutside의 인자로 넘겨줍니다.
export function useOnClickOutside<T extends HTMLElement = HTMLElement>(
ref: React.RefObject<T>,
handler: (event: MouseEvent | TouchEvent) => void
) {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
event.stopPropagation();
if (!ref.current || ref.current.contains(event.target as Node)) {
return;
}
handler(event);
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}
useEffect를 이용하여 컴포넌트가 mount되었을 때 listener함수를 추가하고, unmount되었을 때 listener함수를 제거합니다.listener함수는 다음과 같은 역할을 한다.
event.stopPropagation()를 이용하여 이벤트 버블링을 막는다.- 만약 클릭 또는 터치한 대상이
ref가 참조하는 요소, 또는 그 요소 내부가 아니라면, 인자로 전달받은handler함수를 호출한다.
5. Modal 구현
ModalWrapper를 활용하여, 디자인에 맞는 Modal컴포넌트를 구현합니다.
위와 같이 네/아니오 버튼이 있는 컴포넌트가 있고, 없는 컴포넌트가 있습니다. 이를 어떻게 구분하는 것이 좋을지에 대해 고민을 했습니다.
1. props를 두 가지의 타입으로 구분할 경우
- ModalProps
- 네/아니오 버튼이 있는 경우
- 이 경우, props로 variant(warning/success), children을 props로 받으면 됩니다.
- ModalWithConfirmProps
- 네 버튼만 있는 경우
- 이 경우, props로 받을 것이 children외에 없습니다.
- 두 props에서 공통적으로 받는 props는
children,isOpen,onOkClick등입니다. 두 경우의 차이점이 variant뿐입니다.ModalProps가ModalWithConfirmProps의 상위호환이여서 extends로 interface를 구현할 수 있습니다.
2. props를 한 가지 타입으로 통일할 경우
- 넘겨준 variant가 있는 경우
- variant === ‘success’
- variant === ‘warning’
- 넘겨준 variant가 없는 경우
- 넘겨준 children만을 렌더링한다.
variant가 있는 경우와 없는 경우를 넘겨주는 variant props로 판단할 수 있기에 props를 두 개 나누는 것은 불필요하다고 생각하였습니다. 따라서 2번으로 구현을 하였습니다.
variantMap 객체를 만들어, 넘겨준 variant props가 warning인 경우와 success인 경우로 나누었습니다. 각 경우에 따라 다른 스타일을 적용하였습니다.
const variantMap = {
warning: {
ok: 'solid-warning',
cancel: 'outline-warning',
},
success: {
ok: 'solid-primary',
cancel: 'solid-default',
},
} as const;
export default function Modal({
// 생략
}) {
return (
<ModalWrapper isOpen={isOpen} onClose={onCancelClick}>
<div className="~">
{children}
{variant && (
<div className="~">
<Button
variant={variantMap[variant].ok}
onClick={onOkClick}
>
{okText}
</Button>
<Button
variant={variantMap[variant].cancel}
onClick={onCancelClick}
>
{cancelText}
</Button>
</div>
)}
</div>
</ModalWrapper>
);
}
6. 사용 예시
Modal 안에 Modal을 구성할 컴포넌트들로 구현하였습니다.
export default function EmailModal() {
return (
<Modal
variant="warning"
onOkClick={~}
onCancelClick={~}
>
<Spacing size={32} />
<Image src="/icons/48/warning.svg" width={48} height={48} alt="warning" />
<Spacing size={12} />
<p className="text-subtitle-1">재학생 인증을 건너뛰시겠습니까?</p>
<Spacing size={4} />
<p className="text-sign-tertiary">
회원가입 후 개인 프로필에서
<br />
재학생 인증을 진행할 수 있어요.
</p>
<Spacing size={16} />
</Modal>
);
}
5. Modal 컨트롤하기
위에서 만든 Modal은 두 가지 상태가 있습니다.
- isOpen = true
- isOpen = false
이 상태값 isOpen상태값에 따라 Modal이 열리고, 닫힙니다. isOpen상태값을 관리하는 방법에는 수없이 많은 방법이 있겠지만 가장 먼저 떠오르는 방법은 useState를 이용하여 상태값을 관리하는 방법입니다.
1. Modal을 컨트롤하는 첫 번째 방법 : useState로 상태관리
가장 단순한 방법은 useState를 이용하여 각 페이지에서 Modal상태값을 관리하는 것입니다.
export default function app () {
const [isModalOpen, setIsModalOpen] = useState(false);
const handleCancelClick = () => setIsModalOpen(false);
const handleOkClick = () => {
{/* Ok버튼을 클릭했을 때 */}
}
return (
<div>
<Modal isOpen={isModalOpen}>
{/* Modal 내부 */}
</Modal>
{/* 생략 */}
</div>
}
위와 같이 useState로 상태를 관리값을 경우 여러가지 불편함이 있습니다. 가장 먼저, Modal을 사용하는 컴포넌트에서 매번 상태값을 선언해줘야한다는 점입니다. Modal 상태값을 관리하는 코드와 부모 컴포넌트의 코드가 섞이게 되어 가독성을 좋지 않게 만듭니다. 이러한 Modal이 여러개일 경우 Modal상태값을 여러개 만들어야 하고 그만큼 코드의 난잡함은 더욱 심해질 것입니다.
2. Modal을 컨트롤하는 두 번째 방법 : 전역상태 관리
전역 상태 관리 방법에는 여러 가지가 있습니다. Context API 또는 전역 상태를 관리할 수 있는 서드파티 라이브러리들이 있습니다. Modal을 관리하는 상태는 true/false 두가지로 매우 단순하기 때문에, React에 내장된 Context API를 사용하기로 결정하였습니다.
Context API를 사용할 경우, 루트 노드에 있는 자식 요소들을 ModalContext로 감쌀 것입니다. 이렇게 함으로써 위에서 만든 Portal은 필요하지 않습니다. Portal은 부모 컴포넌트의 DOM 계층 구조 바깥에 있는 DOM 노드로 자식을 렌더링하는 방법인데, Context Provider로 루트 노드를 감싼다면 Provider의 위치가 바로 부모 컴포넌트의 DOM 계층 구조 바깥에 있는 DOM 노드이기 때문입니다.
1. ModalProvider 컴포넌트
Modal에 대한 상태(modalById)를 {id : 컴포넌트} 구조를 가진 Map 타입에 저장을 할 것입니다. 각 Modal을 id값으로 상태 관리를 함으로써 추가하거나 제거할 때 Modal을 구분할 수 있도록 합니다. 자바스크립트의 map타입을 사용하여 각 id값에 해당하는 컴포넌트를 기억해둡니다. 이 컴포넌트는 Modal로 렌더링하게 될 컴포넌트입니다.
Object가 아니라 Map을 이유는 여러가지가 있습니다. 가장 먼저, 순서를 보장한다는 점입니다. Object는 키의 순서를 보장하지 않지만 Map은 삽입 순서를 보장합니다. 이는 Modal의 렌더링 순서에 영향을 줄 수 있습니다. 두 번째로 Map은 Object와 달리 set, get, delete같은 메소드를 기본적으로 제공하여 코드의 가독성을 높여준다는 점 입니다. 마지막으로, {id:컴포넌트} 같은 단순한 구조의 경우에서는 Map을 사용하여 연산을 더 빨리 할 수 있다는 점입니다.
mount는 id와 컴포넌트를 인자로 받아 modalById에 Modal을 추가하는 함수입니다. unmount는 id를 인자로 받아 modalById에서 id에 해당하는 Modal을 제거하는 함수입니다.
// components/ModalProvider.tsx
const [modalById, setModalById] = useState<Map<string, ReactNode>>(new Map());
const mount = useCallback((id: string, element: ReactNode) => {
setModalById((modalById) => {
const cloned = new Map(modalById);
cloned.set(id, element);
return cloned;
});
}, []);
const unmount = useCallback((id: string) => {
setModalById((modalById) => {
const cloned = new Map(modalById);
cloned.delete(id);
return cloned;
});
}, []);
ModalProvider라는 새로운 컨텍스트를 생성합니다. 이 컨텍스트는 모달을 추가하거나 제거하는 두 가지 함수(**mount**와 unmount)를 제공합니다. 전달받은 children과 같은 depth에 Modal을 렌더링합니다.
// components/ModalProvider.tsx
export const ModalContext = createContext<{ mount(id: string, element: ReactNode): void; unmount(id: string): void; } | null>(null);
export default function ModalProvider({children}:PropsWithChildren){
{/* 생략 */}
const context = useMemo(() => ({mount,unmount}),[mount,unmount]);
return (
<ModalContext.Provicer value={context}>
{children}
{
Array.from(modalById.entries()).map(([id,element]) => (
<React.Fragment key={id}>{element}</React.Fragment>
))
}
</ModalContext.Provicer>
)
}
ModalProvider는 최상단 루트를 감쌈으로써 어느 페이지 및 컴포넌트에서도 접근할 수 있도록 합니다.
// app/layout.tsx
export default function Layout({children}){
return (
<ModalProvider>
{children}
</ModalProivder>
)
}
mount함수가 발동되면 다음과 같이 modalById(Map)에 요소가 추가됩니다.
2. useModal 훅
위에서 만든 useModalContext훅으로부터 mount와 unmount함수를 받습니다. 그리고 이들을 이용한 open함수와 close함수를 return합니다. open은 넘겨준 인자(컴포넌트)를 바탕으로 Modal을 modalById상태에 추가하는 함수입니다. close는 modalById에서 Modal을 제거하는 함수입니다. 훅은 여러 번 호출하여 선언하면 각 훅 별로 값을 가지고 있고, 따로 동작하기에 id는 인자로 넘겨받지 않아도 됩니다.
또한, 컴포넌트가 unmount되면 Modal 또한 unmount함수를 실행하여 modalById에서 제거합니다. elementId 초기 값은 1이며, useModal이 호출될 때마다 elementId는 1씩 증가합니다.
let elementId = 1;
export default function useModal() {
const { mount, unmount } = useModalContext();
const [id] = useState(() => String(elementId++));
useDidUnMount(() => unmount(id));
return {
open: (modalElement: ReactElement) => {
mount(id, modalElement);
},
close: () => {
unmount(id);
},
};
}
맺으며
Modal을 직접 만들고 Modal을 관리하는 Context와 훅을 만들어보았습니다. Portal, Context Provider를 이용하여 노드 계층을 넘나들며 컴포넌트를 구현하는 것이 한 층 더 리액트를 잘 다룰 수 있는 기분을 들게 했습니다. 리액트의 핵심 개념인 컴포넌트 기반의 아키텍처를 바탕으로 그 구현의 유연성과 확장성을 느낄 수 있었습니다.
Portal, Context Provider를 이용하여 상상하는 대부분의 것들을 구현할 수 있다는 것을 깨달았습니다. 아직 알지 못하는 많은 패턴들이 있을 것으로 생각합니다. 개발자로서, 우리는 항상 새로운 패턴과 접근법을 탐구해야 합니다. 그것이 프로그래밍의 무궁한 재미와 도전의 원동력이 되기 때문입니다. 이번 경험을 통해 리액트의 다양한 가능성을 경험하였으며, 이를 바탕으로 더욱 창의적이고 효율적인 웹 애플리케이션을 구축하는 데 도움이 될 것으로 기대합니다.