React로 Modal 컴포넌트 만들기 - 2편
Modal의 기능 구현
이전 포스트에서 다룬 Modal의 동작 특성을 다시 한번 상기해보자. 이번 포스트에서는 이러한 동작을 구현하는 방법을 다룬다.
- 사용자가 현재 페이지를 떠나게 되면 Modal 인스턴스도 같이 제거된다.
- Modal을 열면 스크롤이 잠기고 닫으면 해제된다.
- 오버레이 배경 영역을 클릭하거나 ESC키를 입력하면 닫는다.
- 하나의 Modal을 표시하고 있는 상태에서 추가로 표시하는 경우 더 높은 레이어에서 표시된다.
모달을 열고 닫는 상태 제어하기
Modal 컴포넌트를 열고 닫는 상태가 제어될 수 있도록 구현해보자.
방법은 간단하다. 우선 Modal 컴포넌트를 사용하는 부분부터 시작하자.
useState훅을 사용하여 제어한다.
Page.tsx
1export default function Page() {
2 const [isModalOpen, setIsModalOpen] = useState(false);
3
4 const openModal = () => {
5 setIsModalOpen(true);
6 };
7
8 const closeModal = () => {
9 setIsModalOpen(false);
10 };
11
12 return (
13 <div>
14 {/* Content of page */}
15 <button type="button" onClick={openModal}>
16 모달 열기
17 </button>
18 <Modal open={isModalOpen} onClose={closeModal}>
19 {/* Content of modal */}
20 <button type="button" onClick={closeModal}>
21 모달 닫기
22 </button>
23 </Modal>
24 </div>
25 );
26}
Modal 컴포넌트로 이동하여 기능을 고도화해보자.
open, onClose 프로퍼티가 추가된 것에 주목하자.
open 프로퍼티는 부모 컴포넌트 쪽에서 모달을 열고 닫는 상태를 제어할 수 있도록 하기 위해 추가되었다.
onClose 프로퍼티는 모달을 닫을 때 호출될 함수를 부모 컴포넌트로부터 전달받기 위해 추가되었다.
이 함수는 모달의 기능중 하나인 오버레이 배경 영역을 클릭하거나 ESC키를 입력하면 닫는 기능에서 사용된다.
Modal.tsx
1import { PropsWithChildren } from 'react';
2import { createPortal } from 'react-dom';
3import ModalLayer from './ModalLayer.tsx';
4import ModalPanel from './ModalPanel.tsx';
5
6interface Props {
7 open: boolean;
8 onClose: () => void;
9}
10
11export default function Modal({
12 open,
13 onClose,
14 children,
15}: PropsWithChildren<Props>) {
16 return open
17 ? createPortal(
18 <ModalLayer>
19 <ModalPanel>{children}</ModalPanel>
20 </ModalLayer>,
21 document.body,
22 )
23 : null;
24}
이제 페이지에서 버튼을 클릭하면 모달이 표시되고 모달에서 버튼을 클릭하면 모달이 닫히게 되는 것을 확인할 수 있다.
페이지를 떠나면 Modal도 제거하기
위의 단락에서 우리는 이미 이 작업을 완료했다.
페이지를 떠나면 Page 컴포넌트가 언마운트 된다.
자연스럽게 Modal 컴포넌트도 언마운트 된다.
우리는 이 부분을 고려할 필요가 없다.
스크롤을 잠그는 방법
모달을 열었을 때 스크롤을 잠글 수 있는 방법은 여러가지가 있다.
첫번째 방법은 바로 document.body에 overflow: hidden 스타일 속성을 부여하는 방법이다.
React 의 useLayoutEffect 훅을 사용하여 이 처리를 적용해보자.
왜 useLayoutEffect 훅을 사용하나요?
useEffect 훅은 Modal을 DOM에 업데이트 및 Paint한 이후에 실행됩니다. 반면 useLayoutEffect 훅은 Modal을 DOM에 업데이트 및 Paint하기 직전에 실행됩니다.
그러므로 유저가 시각적으로 Modal을 확인하기 전에 스크롤을 잠그는 처리가 적용되어 더 적절한 시점에 처리됩니다.
ModalLayer.tsx 컴포넌트에 이 처리를 추가했다.
하지만 무언가 2% 부족하다.
ModalLayer.tsx
1import { PropsWithChildren, useLayoutEffect } from 'react';
2import { twMerge } from 'tailwind-merge';
3
4interface Props {
5 className?: string;
6}
7
8export default function ModalLayer({
9 className,
10 children,
11}: PropsWithChildren<Props>) {
12 useLayoutEffect(() => {
13 document.body.style.overflow = 'hidden';
14 return () => {
15 document.body.style.overflow = 'visible';
16 };
17 }, []);
18
19 return (
20 <div
21 className={twMerge(
22 'fixed inset-0 bg-black/50 flex items-center justify-center',
23 className,
24 )}
25 >
26 {children}
27 </div>
28 );
29}
모달을 하나만 열고 닫는식으로 어플리케이션에서 동작한다면 이 처리는 문제가 없다.
하나의 모달을 열었을 때 스크롤이 잠길 것이고 모달을 닫았을 때 스크롤 잠금 처리가 해제될 것이기 때문이다.
하지만 모달을 중첩하여 여러개를 열고 닫는 상황을 가정해보자.
첫번째 모달을 열고 그 다음 두번째 모달을 연다. 이때는 스크롤이 잠겨 있는 상태이다.
두번째 모달을 닫았을 때 useLayoutEffect 훅에서 반환한 cleanup 함수가 실행되어 스크롤이 잠금 해제된다.
아직 첫번째 모달은 열려있는 상태이기 때문에 스크롤 잠금이 해제되어 버리면 문제가 된다.
어떻게 해야 할까?
하나의 아이디어는 cleanup 함수가 동작할 때 모달이 스스로 DOM상에서 마지막으로 하나 남은 모달인지 확인하는 것이다.
다음의 처리를 추가하여 보자.
ModalLayer.tsx
1import { PropsWithChildren, useLayoutEffect } from 'react';
2import { twMerge } from 'tailwind-merge';
3
4interface Props {
5 className?: string;
6}
7
8export default function ModalLayer({
9 className,
10 children,
11}: PropsWithChildren<Props>) {
12 useLayoutEffect(() => {
13 document.body.style.overflow = 'hidden';
14 return () => {
15 if (!checkHasAnyOtherModal()) {
16 document.body.style.overflow = 'visible';
17 }
18 };
19 }, []);
20
21 return (
22 <div
23 className={twMerge(
24 MODAL_LAYER_SELECTOR,
25 'fixed inset-0 bg-black/50 flex items-center justify-center',
26 className,
27 )}
28 >
29 {children}
30 </div>
31 );
32}
33
34const checkHasAnyOtherModal = () => {
35 const livingModalsOnDOM = document.querySelectorAll(
36 `.${MODAL_LAYER_SELECTOR}`,
37 );
38 return livingModalsOnDOM.length > 1;
39};
40
41const MODAL_LAYER_SELECTOR = 'modal-layer'; // 클래스 충돌을 피하기 위해 좀더 유니크한 값으로 지정하길 권합니다.
이제 모달을 중첩하여 열고 닫더라도 적절한 시점에 스크롤 잠금 처리가 적용 및 해제 되게 되었다.
여기서 추가로 알아두면 좋을 내용이 있다.
document.body 에 overflow: hidden 스타일 속성을 부여하여 스크롤을 잠그는 방법은 PC와 AOS 모바일 환경의 브라우저에서도 잘 동작한다. 하지만 IOS 모바일 Safari 브라우저에서는 이 처리가 동작하지 않는다.
IOS 대응을 위해 다음의 처리를 같이 적용하는 것을 권장한다. bodyScrollLock.js#L110-L152
이 처리는 document.body 에 적용되어 있던 기존의 스타일을 저장한 후 position: fixed 스타일 속성을 부여하여 스크롤을 비활성화 하는 것이다. 이후 스크롤 잠금을 해제할 때 저장했던 스타일로 다시 복원시킨다.
스크롤 잠금에 대해 이 정도 내용을 알아두면 앞으로 삽질할 일이 없을 것이다.
오버레이 배경 영역을 클릭하거나 ESC키를 입력하면 닫는 방법
모달에서 자주 사용되는 두가지 옵션 기능은 오버레이 배경 영역을 클릭하거나 ESC키를 입력하면 닫는 기능이다.
오버레이 배경 영역을 클릭시 모달을 닫는 작업부터 진행해보자.
closeOnLayerClick 프로퍼티가 추가된 것에 주목하자.
이 프로퍼티는 오버레이 배경 영역을 클릭시 모달을 닫을지 여부를 결정한다.
onClickLayer 핸들러는 closeOnLayerClick 프로퍼티가 true일 때만 모달을 닫는 작업을 수행하도록 한다.
ModalLayer.tsx
1import {
2 MouseEventHandler,
3 PropsWithChildren,
4 useLayoutEffect,
5 useRef,
6} from 'react';
7import { twMerge } from 'tailwind-merge';
8
9interface Props {
10 className?: string;
11 closeOnLayerClick?: boolean;
12 onClose: () => void;
13}
14
15export default function ModalLayer({
16 className,
17 closeOnLayerClick = true,
18 onClose,
19 children,
20}: PropsWithChildren<Props>) {
21 const onClickLayer: MouseEventHandler<HTMLDivElement> = () => {
22 if (closeOnLayerClick) {
23 onClose();
24 }
25 };
26
27 return (
28 <div
29 className={twMerge(
30 MODAL_LAYER_SELECTOR,
31 'fixed inset-0 bg-black/50 flex items-center justify-center',
32 className,
33 )}
34 onClick={onClickLayer}
35 >
36 {children}
37 </div>
38 );
39}
이렇게 간단히 기능 구현이 끝난 것처럼 보인다. 하지만 문제가 있다.
클릭 이벤트는 Event Bubbling이 발생한다.
ModalPanel 컴포넌트에서 발생시킨 클릭 이벤트가 ModalLayer에 등록해둔 onClickLayer핸들러를 실행시킬 수 있다.
사용자 입장에서는 모달을 닫으려는 의도가 없었지만 모달을 닫게 되는 것이다.
이 문제를 해결하기 위해 onClickLayer 핸들러에서 클릭한 요소가 ModalLayer 컴포넌트인지 확인하도록 한다.
이제 배경 영역에서 클릭 이벤트를 발생시켜야지만 모달을 닫을 수 있게 되었다.
ModalLayer.tsx
1export default function ModalLayer({
2 className,
3 closeOnLayerClick = true,
4 onClose,
5 children,
6}: PropsWithChildren<Props>) {
7 const modalLayerRef = useRef<HTMLDivElement>(null);
8
9 const onClickLayer: MouseEventHandler<HTMLDivElement> = (event) => {
10 if (closeOnLayerClick && event.target === modalLayerRef.current) {
11 onClose();
12 }
13 };
14
15 return (
16 <div
17 className={twMerge(
18 MODAL_LAYER_SELECTOR,
19 'fixed inset-0 bg-black/50 flex items-center justify-center',
20 className,
21 )}
22 ref={modalLayerRef}
23 onClick={onClickLayer}
24 >
25 {children}
26 </div>
27 );
28}
다음은 ESC키를 입력하면 모달을 닫는 기능을 구현한다.
어플리케이션 전역 레벨에서 발생하는 키 입력을 감지해야 하기 때문에 window 또는 document 객체에 이벤트 리스너를 등록해야 한다.
closeOnEscape 프로퍼티가 추가된 것에 주목하자.
이 프로퍼티는 ESC키를 입력시 모달을 닫을지 여부를 결정한다.
onKeydown 핸들러는 closeOnEscape 프로퍼티가 true일 때만 모달을 닫는 작업을 수행하도록 한다.
ModalLayer.tsx
1interface Props {
2 className?: string;
3 closeOnLayerClick?: boolean;
4 closeOnEscape?: boolean;
5 onClose: () => void;
6}
7
8export default function ModalLayer({
9 className,
10 closeOnLayerClick = true,
11 closeOnEscape = true,
12 onClose,
13 children,
14}: PropsWithChildren<Props>) {
15 const onKeyDown = (event: KeyboardEvent) => {
16 if (closeOnEscape && event.key === 'Escape') {
17 onClose();
18 }
19 };
20
21 useEffect(() => {
22 if (closeOnEscape) {
23 document.addEventListener('keydown', onKeydown);
24 }
25 return () => {
26 document.removeEventListener('keydown', onKeydown);
27 };
28 }, [closeOnEscape]);
29
30 return (
31 <div
32 className={twMerge(
33 MODAL_LAYER_SELECTOR,
34 'fixed inset-0 bg-black/50 flex items-center justify-center',
35 className,
36 )}
37 ref={modalLayerRef}
38 onClick={onClickLayer}
39 >
40 {children}
41 </div>
42 );
43}
이제 ESC키를 입력하면 모달이 닫히는 것을 확인할 수 있다.
그러나 모달이 중첩된 상태에서 예상치 못한 문제가 발생한다.
각각의 모달 인스턴스는 독립적인 onKeyDown 이벤트 및 리스너를 document에 등록해둔 상태이다.
한번의 ESC키 입력이 중첩되어 있는 모든 모달 인스턴스의 onClose 핸들러를 실행시켜 모든 모달이 닫히게 된다.
이 문제를 해결하려면 가장 마지막으로 열린 모달 인스턴스부터 차례대로 닫히도록 구현해야 한다.
한가지 아이디어는 키 입력이 발생했을 때 각각의 모달 인스턴스가 자신이 가장 마지막으로 열린 모달 인스턴스인지 확인하게 하는 것이다.
ModalLayer.tsx
1export default function ModalLayer({
2 className,
3 closeOnLayerClick = true,
4 closeOnEscape = true,
5 onClose,
6 children,
7}: PropsWithChildren<Props>) {
8 const modalLayerRef = useRef<HTMLDivElement>(null);
9
10 const onKeydown = (event: KeyboardEvent) => {
11 if (
12 closeOnEscape &&
13 event.key === 'Escape' &&
14 modalLayerRef.current &&
15 checkIsLastModal(modalLayerRef.current)
16 ) {
17 onClose();
18 }
19 };
20
21 useEffect(() => {
22 if (closeOnEscape) {
23 document.addEventListener('keydown', onKeydown);
24 }
25 return () => {
26 document.removeEventListener('keydown', onKeydown);
27 };
28 }, [closeOnEscape]);
29
30 return (
31 <div
32 className={twMerge(
33 MODAL_LAYER_SELECTOR,
34 'fixed inset-0 bg-black/50 flex items-center justify-center',
35 className,
36 )}
37 ref={modalLayerRef}
38 onClick={onClickLayer}
39 >
40 {children}
41 </div>
42 );
43}
44
45const checkIsLastModal = (el: HTMLDivElement) => {
46 const livingModalsOnDOM = document.querySelectorAll(
47 `.${MODAL_LAYER_SELECTOR}`,
48 );
49
50 return livingModalsOnDOM.item(livingModalsOnDOM.length - 1) === el;
51};
52
53const MODAL_LAYER_SELECTOR = 'modal-layer';
이제 ESC키 입력시에 마지막으로 열린 모달부터 순차적으로 닫히는 것을 확인할 수 있다.
모달을 스택으로 처리하기
Modal 컴포넌트 구현부로 돌아가보자.
ReactDOM.createPortal 함수를 사용하여 모달을 렌더링했던 부분을 기억할 것이다.
이 함수는 모달 엘리먼트를 document.body 엘리먼트의 자식 요소로 추가하는 역할을 한다.
재미있는 점은 여러 모달 인스턴스를 발생시키면 순서대로 자식요소로 추가된다는 점이다.
Modal.tsx
1import { createPortal } from 'react-dom';
2
3export default function Modal({
4 open,
5 onClose,
6 children,
7}: PropsWithChildren<Props>) {
8 return open
9 ? createPortal(
10 <ModalLayer onClose={onClose}>
11 <ModalPanel>{children}</ModalPanel>
12 </ModalLayer>,
13 document.body,
14 )
15 : null;
16}
이러한 동작은 다음과 같은 이점이 있다.
모달마다 쌓임 맥락을 갖고 있기 때문에 모달 인스턴스를 발생시킨 순서대로 화면상의 제일 높은 레이어에 표시된다.
모달마다 z-index 스타일을 일일히 부여하여 높낮이를 관리할 필요가 없어지는 것을 의미한다.
페이지의 다른 요소가 모달을 뚫고 표시되는 이슈도 원천적으로 발생하지 않게 된다.
document.body의 자식요소로 추가하기 때문에 다음과 같은 DOM Tree 구조가 형성되기 때문이다.
1<body>
2 <div id="app" class="relative z-0">{...}</div> // Stack Context Level 0
3 <Modal class="fixed"></Modal> // Stack Context Level 1
4 <Modal class="fixed"></Modal> // Stack Context Level 2
5 <Modal class="fixed"></Modal> // Stack Context Level 3
6</body>
이상으로 모달 컴포넌트 구현이 끝났다.
개인적으로 사내 업무에서 모달을 직접 구현하여 사용하고 있어서 노하우를 공유해보고 싶었다.
더 나은 방법이 있으면 얼마든지 연락주길 바란다.