Modal 만들기

Modal 만들기

리액트 포탈과 컴파운드 컴포넌트 적용해서 Modal 만들어보기

·

10 min read

✋🏻 들어가며

이번에 재남님과 함께하는 UI 요소 만들기 스터디에 합류했다. 평소 관심이 많았던 부분이다보니 뭔가 마음이 설레고.. 그런데 또 마음만 설렐 수는 없으니 셀프 스터디를 위해 레포지토리를 하나 만들어봤다. 스터디에서 다룰 내용들이 나름 순서가 있어서 혼자서 미리 만들어보고 다른 분들이 만든 것들도 비교해가면서 많이 배워봐야겠다.

스터디에서는 아직 모달을 다루려면 멀었지만, 이번에 ReactDOM.createPortal 을 공부를 했었는데 막상 써본 적은 없었어서 이것과 함께 컴파운드 컴포넌트 패턴도 잘 적용해보고 싶어서 모달 컴포넌트를 골라서 한 번 만들어봤다.


🌐 ReactDOM.createPortal()

리액트 공식문서에서는 포탈을 아래와 같이 설명하고 있다.

“Portals provide a first-class way to render children into a DOM node that exists outside the DOM hierarchy of the parent component.”

Portal은 부모 컴포넌트의 DOM 계층 구조 바깥에 있는 DOM 노드로 자식을 렌더링하는 최고의 방법을 제공합니다.

음 근데 이걸 왜 쓰는데 ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ 벨로그에 모달 검색하면 꼭 함께 나오는 친구 중 하나가 이 포탈인데 그래서 왜 쓰는데?ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ를 알아봤다.

왜 쓰냐고?

하지만 뭐 이런게 있다고 하고, 이걸 쓰지 않더라도 css 만으로 충분히 모달을 구현할 수 있다. 내가 어떻게 설계하냐에 따라서 당연히! 하지만 React 라는 녀석은 생겨먹은게 뭔가 불편하다.

React 는 트리 구조로 이루어져 있는데, 그러다보니 원하지 않은 경우에도 스타일이 상속되어 곤란하게 되는 경우가 생긴다. (이게 가장 큰 이유가 아닐까..? 🤦🏻‍♀️ 곤란했던게 한 두번이 아니었다.) 하지만 Portal 을 사용하면 독립적으로 관리가 가능하기 때문에 많이 사용된다.

어떻게 쓰는데!

리액트 프로젝트를 만들면 아래와 같이 생긴 html 파일이 하나 생성된다. 그리고 body 태그 내부에 "root" 라는 아이디를 가진 div 내부에 렌더링될 아이들이 비집고 들어가게된다. 즉 리액트 프로젝트 내부에서 렌더링될 아이들은 모두 "root" 의 하위에 위치한다는 것이다. 그러니 원하지 않은 경우의 스타일 상속이 생길 수가 있겠지.

<!doctype html>
<html lang="en">
  <head>
    //...
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

포탈을 열어줄 createPortal

ReactDOM.createPortal(children, container);
  • children : 렌더링할 자식 컴포넌트

  • container : DOM element

이제 "root" 가 아닌 다른 곳에 포탈을 열어줄텐데 그 때 사용하게 될 녀석이 바로 ReactDOM.createPortal 이다. 첫 번째 인자로는 어떤 것들을 렌더링 시킬건지 전달을 해주면되고, 두번째 인자로는 "root" 와 같은 역할을 해줄 컨테이너를 넣어준다.

적용해볼까?

먼저 "root" 와 같은 역할을 해줄 컨테이너를 html 파일에 작성한다. 나는 모달을 만들어줄 것이니 "root-modal" 이라는 아이디로 div 태그를 넣어줬다. 이제 내가 포탈을 열어주면 "root-modal" 의 하위에 렌더링이 일어나며 "root" 와는 분리가 되어 화면에 표시될 것이다.

<!doctype html>
<html lang="en">
  <head>
    <!-- ... -->
  </head>
  <body>
    <!-- 👉🏻 add div tag (with id "root-modal") -->
    <div id="root-modal"></div>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

이제 포탈을 열어줄 컴포넌트를 만들자. 방법은 아주 간단하다. 아래와 같이 작성만 해주면 끝!

function ModalPortal(props) {
    return ReactDOM.createPortal(props.children, document.getElementById("rood-modal");
}

이론을 배웠으니 이제 직접 이걸 적용해서 실제로 사용할 컴포넌트를 만들어보자.

👩🏻‍🌾 Modal 만들어보기

컴포넌트를 만드는 방법은 굉장히 다양하다.(그래서 늘 고통이다.) 이번에 컴포넌트를 만들면서도 ‘이게 맞는 방법일까?’를 많이 고민했었는데 그 모든 고민의 결과는 방법은 많지는 정답은 없다는 것이었다. 원피스를 찾는 느낌이었을까.

그래서 이번에는 Compound Component 패턴을 사용해서 Modal 을 만들어보기로 했다. 기존에 Modal 을 만들 때는 아래와 같이 만들어서 내부에 여러 조건들을 걸어줄 때가 많았다.

const Modal = ({title, content, onClose}) => {

    return (
        <Layout>
            { title && <Title>{title}</Title> }
            { content && <Content>{content}</Content> }
            <Footer>
                <Button onClick={onClose}>닫기</Button>
            </Footer>
        </Layout>
    )
}

대충 위와 같은 모습이 베이스로 나오고, 필요에 따라서 여러가지 props 가 만들어지고 컴포넌트 내부가 무거워질 것이다. 위의 케이스라면 버튼이 하나만 있는데, 기획이 다양해지면 버튼이 두 개로 늘어날 수도 있고… 그런 것들을 고려하다보면 어느샌가 나도 모르게 비대해진 props에 현기증이 나게 될 것이다.

음 위와 같은 케이스에서 비대해질 수 있는 props을 정리하기 위해서는 컴포넌트를 따로 만들어준다거나 케이스를 타입으로 받아서 타입에 따른 렌더링을 해주는 등의 시도를 해줄 수 있겠다. 아니면 조금 더 자유도를 주기 위해서 render props 를 받는 방법도 있겠고. 예를 들면 아래와 같이 작성해줄 수도 있을 것.

const Page = () => {
    const [open, setOpen] = useState(false);

    return (
        <Layout>
            <Modal
                open={open}
                render={() => (<모달내부/>)}
                />
        </Layout>
    );
};

위 방법은 어느정도 만들어진 큰 틀에 내부를 자유롭게 커스텀할 수 있기 때문에 편할 수 있겠다. 다만 내부에 다시 컴포넌트를 선언해서 불러와주거나 매번 새롭게 작성하는 불편함이 있겠다. 아니면 기본 폼이 있는데 그 폼 자체를 새롭게 커스텀할 때만 저 props 를 이용한다거나? (Antd에서 이런 방법을 많이 쓰던 것 같다.)

🧩 Compound Component Pattern

Compound Component 패턴은 하나의 컴포넌트를 구성하는 암시적 상태 공유 컴포넌트 API 집합을 제공하는 방법이다. 이 친구는 유연성이 좋고 확장성이 뛰어난데, 가장 큰 장점은 “API의 복잡성 감소”라고 할 수 있다.

이 패턴을 적용하면 하나의 부모 컴포넌트에 props 를 이용해 하위로 내려보내는 대신, 각각의 props는 가장 적합한 SubComponent 에 연결되도록 구성된다. 즉 각각의 컴포넌트가 본인이 정말 필요로하는 것들만 받으면 된다는 것이다.

이 패턴을 사용하면서 가장 좋았던 점은 해당 컴포넌트에서 사용되는 state와 handler를 context 로 관리하게 되면서 props 를 줄이면서도 각각의 관심사를 분리시킬 수 있는 점이 좋았다.

다만 단점으로는 JSX 행 수가 굉장히 증가하는 편이며 어느 정도로 유연하게 가지고 가야하는가에 대한 중심을 잡는게 쉽지가 않은 것 같다.

이론적인건 간단하게 알아봤으니 한 번 만들어보자. 조금 더 자세한 내용이나 다양한 리액트의 디자인 패턴과 관련해서는 아래 포스팅을 참고해보자.

🗓️ 컴포넌트 구조 잡아보기

대부분의 모달은 위의 모습으로 생겼다. 만들어야하는 것들을 나열해볼까?

  1. 모달 타이틀

  2. 모달 내용

  3. 모달 하단부

  4. 모달 레이아웃 / 모달 외부 영역 - 클릭하면 모달이 닫혀야함

  5. 모달 버튼

여기에 포탈을 사용하기로 했으니 포탈도 추가해줘야하고 🤔 각각의 컴포넌트들이 상태를 공유해야하니 Context 도 만들어줘야한다.

  1. React Portal

  2. Context Provider

계획이 세워졌으니 하나씩 단계별로 작성해보자. 컴포넌트의 최종 구조는 이런식으로 만들어질 것이다.

<컨텍스트>
    <버튼/>
    <포탈>
        <레이아웃>
            ...내부 원하는 것들 조합
        </레이아웃>
    </포탈>
</컨텍스트>

1️⃣ Context Provider

모달을 열고 닫아줄 상태와 handler 를 모든 컴포넌트가 공유를 해야하니 요것들의 타입을 선언해준다. 그리고 createContext 를 사용해서 context 를 만들어주자.

export type ModalContextType = {
  open: boolean;
  onOpen: () => void;
  onClose: () => void;
};

const ModalContext = createContext<ModalContextType | null>(null);

그리고 ModalRoot 라는 컴포넌트를 만들고 내부에 ContextProvider 를 넣어줬다.

function ModalRoot({children}: PropsWithChildren) {
    const [open, setOpen] = useState<boolean>(false);

  const onOpen = useCallback(() => {
    setOpen(true);
  }, []);

  const onClose = useCallback(() => {
    setOpen(false);
  }, []);

  return <ModalContext.Provider value={{open, onOpen, onClose}}>{props.children}</ModalContext.Provider>;
}

흠 그런데 아마 모달 뿐만 아니라 다른 곳에서도 간단하게 열고 닫는 로직이 많이 쓰일테니 해당로직은 다른 useToggle 훅으로 분리해줬다. Toast 컴포넌트나 다른 비슷한 컴포넌트에서도 유용하게 쓰일 것 같다. (하지만 이런 작업들은 이렇게 미리 할 필요는 사실 없다. ㅋㅋㅋㅋ 나는 이미 쓰일거라는 것이 명확했기에 분리했을 뿐)

export const useToggle = (): ModalContextType => {
    const [open, setOpen] = useState<boolean>(false);

  const onOpen = useCallback(() => {
    setOpen(true);
  }, []);

  const onClose = useCallback(() => {
    setOpen(false);
  }, []);

    return {open, onOpen, onClose}
}
export type ModalContextType = {
  open: boolean;
  onOpen: () => void;
  onClose: () => void;
};

const ModalContext = createContext<ModalContextType | null>(null);

function ModalRoot({children}: PropsWithChildren) {
    const toggle = useToggle();

  return <ModalContext.Provider value={toggle}>{props.children}</ModalContext.Provider>;
}

정리하니 이렇게 만들어졌다. 음.. 그런데 이렇게 만들어주면 ModalRoot 의 상위 컴포넌트에서는 Modal 을 구성하는 컴포넌트를 사용하지 않는다면 이를 열어줄 방법이 없다. 예를 들면, 버튼을 눌러서 모달을 여는 경우가 아니라 서버와의 네트워크 통신 이후에 자동으로 모달이 열리는 등의 케이스에서는 사용하기가 힘들어진다.

이것과 관련해서 다른 상태관리 라이브러리나 useImperativeHandle 을 사용하면 상위 컴포넌트에서 상태를 선언하고 Modal 로 넘겨줄 필요가 없지 않을까? 라는 여러 고민들을 해봤고… 그것에 대한 결론은 지금으로서는 오버스펙이라는 나름의 결론을 내렸다. 그래서 결국은 ModalRoot 컴포넌트에서 사용될 상태와 핸들러는 부모 컴포넌트로 부터 전달 받는 것으로 수정해줬다.

const ParentComponent = () => {
    const context = useToggle();

    return <ModalRoot context={context}></ModalRoot>;
}
export type ModalContextType = {
  open: boolean;
  onOpen: () => void;
  onClose: () => void;
};

const ModalContext = createContext<ModalContextType | null>(null);

interface ModalRootProps extends PropsWithChildren {
    context: ModalContextType;
}
function ModalRoot({context, children}: PropsWithChildren) {

  return <ModalContext.Provider value={context}>{props.children}</ModalContext.Provider>;
}

아마 이 부분이 이번 모달 만들기에서 가장 큰 고민과 시간을 들였던 내용인데.. 🤦🏻‍♀️ 다른 방법에 대해서는 이번 UI요소 만들기 스터디에서 이야기를 해보거나 주변 분들과 많이 토론해봐야겠다. 뭐 그래도 이게 명확한 답이 있는 부분은 아니니, 지금으로서는 가장 최선의 방법 같더라.

<컨텍스트><버튼/>
    <포탈>
        <레이아웃>
            ...내부 원하는 것들 조합
        </레이아웃>
    </포탈>
</컨텍스트>

2️⃣ 모달을 열고 닫아줄 버튼 만들기

이제는 모달을 열고 닫을 버튼을 만들텐데, 버튼을 만들기 전에 버튼 내부에서 context 를 사용해야하기 때문에 이것을 사용할 수 있는 커스텀 훅을 하나 미리 만들었다.

const useModalContext = () => {
  const context = useContext(ModalContext); // ModalContextType | null
  if (!context) {
    throw "value not provided";
  }
  return context;
};

context 관련 예외처리를 간편하게 해주기 위해 훅을 만들었다. 이제 이것을 사용해서 Trigger 라는 컴포넌트를 만들자. 버튼의 경우에는 단순히 열고 닫기만 하는 것이 아니라 특정 로직을 수행하는 경우도 있기 때문에 props 로 전달받은 onClick 함수를 실행할 수 있도록 작성했다.

import Button, { ButtonProps } from "components/@elements/Button";

function ModalTrigger(props: ButtonProps) {
  const { children, ...rest } = props;
  const context = useModalContext();

  const onToggle = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
    const callback = context.open ? context.onClose : context.onOpen;
    if (props.onClick) {
      props.onClick(event);
    }
    callback();
  };
  return (
    <Button onClick={onToggle} {...rest}>
      {children}
    </Button>
  );
}

사실 이렇게 만들고 보니 그냥 기능을 수행하는 버튼과 단순히 열고 닫기만 하는 버튼을 분리해주는게 맞을지 고민이 드는데, 아직은 크게 중요한 부분은 아닌 것 같아서 하나로 퉁쳤다. (귀찮..)

3️⃣ Portal 맨들기.

이제 처음 공부했던 Portal 을 만들어줄 것이다. 우선 .html 파일을 찾아서 아래와 같이 "root-modal" 을 만들어줬다. 내가 포탈을 만들어주면 이제 그 컴포넌트는 "root" 내부가 아닌 "root-modal" 내부에서 렌더링 될 것이다.

<!doctype html>
<html lang="en">
  <head>
    <!-- ... -->
  </head>
  <body>
    <div id="root-modal"></div>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

모달을 띄워줄 포탈을 만들어볼까? 핵심은 두 가지다.

  • children : 렌더링할 자식 컴포넌트

  • container : DOM element

function ModalPortal({ children }: PropsWithChildren) {
  const container = document.getElementById("root-modal");

  if (container === null) {
    return null;
  }
  return ReactDom.createPortal(children, container);
}

이제 포탈이 만들어졌으니, 아래처럼 작성하면 화면에 렌더링이 될 것이다.

<ModalPortal>
    // ...렌더링할 컴포넌트 야무지게 작성해주기
</ModalPortal>
<컨텍스트><버튼/><포탈><레이아웃>
            ...내부 원하는 것들 조합
        </레이아웃>
    </포탈>
</컨텍스트>

4️⃣ Layout 잡기

우선 레이아웃 내에 크게 두 개의 div 를 넣어줬다. 전체 화면을 덮을 배경영역이 될 부분과 팝업의 내용을 담아줄 컨테이너 영역이다. 그리고 공유받고 있는 context 를 불러와서 상태에 따라 렌더링이 될 수 있게 조건문을 추가해줬다.

const ModalLayout = (props: ModalProps) => {
  const { children } = props;
  const context = useModalContext();

    if (!context.open) return null;
  return (
        {/* 가장 바깥 영역 - 배경 */}
    <div css={dimmed}>
            {/* 팝업 컨테이너 */}
            <div css={layout}>
                // ...컴포넌트
      </div>
    </div>
  );
};

그런데 이제 조금의 디테일을 추가해줘봤다. 마우스로 굳이 닫기 버튼까지 찾아가는 수고를 덜어주면 좋을 것 같아서 배경을 클릭하면 모달이 닫히게끔 구현해주면 편할 것 같아서!

이 경우에는 외부 영역 자체에 클릭이벤트를 걸어줄 수 있을 것 같은데, 이번에는 ref 를 사용해봤다. ref 를 만들어서 클릭 이벤트가 일어나면 ‘안되는’ 곳에 심어줬다. 이제 이 부분의 외부영역을 클릭했을 때 onClose 함수가 불리게끔 로직을 작성해주면 되는데 이벤트를 다루는 것이니 addEventListener 를 걸어줄 것이다.

const ModalLayout = (props: ModalProps) => {
  // ...
  const context = useModalContext();
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    // ✅ 이벤트가 생길 때 불러줄 콜백 함수
    const closeModal = (event: MouseEvent) => {
                         // ✅ 모달 영역이 포함되지 않은 곳일 때!
      if (ref.current && !ref.current.contains(event.target as Node | null)) {
        context.onClose();
      }
    };

    // ✅ 마우스를 눌렀을 때 closeModal 함수가 불림
    window.addEventListener("mousedown", closeModal);
    return () => {
      // ✅ 알잘딱으로 해제시켜주기 
      window.addEventListener("mousedown", closeModal);
    };
  }, [context]);

  // ... 
  return (
    <div css={dimmed}>
      <div css={layout} ref={ref}>
        {children}
      </div>
    </div>
  );
};

아주 롸스굿하게 잘 작동이 된다. 이것도 자주 사용하게되면 따로 분리해줘도 좋을 것 같긴하다.

5️⃣ etc (내부 레고 조각들)

<컨텍스트><버튼/><포탈><레이아웃> ✅
            ...내부 원하는 것들 조합
        </레이아웃>
    </포탈>
</컨텍스트>

이제 뭐 핵심은 다 나왔으니 알잘딱으로 필요한 레고들을 만들어주면 된다. 나는 간단하게 Title, Body, Footer 로 나눠서 작성해줬다.

function ModalHeader({ children }: PropsWithChildren) {
  return (
    <div css={headerStyle}>
      {children}
    </div>
  );
}

function ModalBody({ children }: PropsWithChildren) {
  return (
    <div css={bodyStyle}>
      {children}
    </div>
  );
}

function ModalFooter({ children }: PropsWithChildren) {
  return (
    <div css={footerStyle}>
      {children}
    </div>
  );
}

이제 필요에 따라서 파츠(?ㅋㅋ)들을 만들어서 원하는대로 조합해서 사용해주면 된다. 이렇게!

export function Page() {
  const toggle = useToggle();

  return (
    <div css={layout}>
            <Button onClick={toggle.onOpen}>이렇게도 열리지</Button>

      <Modal.Root context={modal}>
        <Modal.Portal>
          <Modal.Layout modalType="ALERT">
            <Modal.Header>제목</Modal.Header>
            <Modal.Body>
                            <ul>
                                <li>주의사항은 이렇고 저렇고</li>
                                <li>그렇고 저렇고 이렇고</li>
                            <ul>
                        </Modal.Body>
            <Modal.Footer>
              <Modal.Trigger btnType="default">닫기</Modal.Trigger>
            </Modal.Footer>
          </Modal.Layout>
        </Modal.Portal>
      </Modal.Root>
    </div>
  );
}

포탈이 제대로 먹히고 있는지도 확인해볼까?

오우 내가 작성한 모달 컴포넌트가 의도한대로 "root-modal" 내부에서 렌더링되고 있다. 닥터 트루레인지 성공?ㅋㅋㅋㅋㅋ

이렇게 모달 컴포넌트를 컴파운드 컴포넌트 패턴과 포탈을 사용해서 만들어봤는데, 확실히 return 문 내부의 모시껭이가 길어져서 무거워보인다. 하지만 한 편으로는 각각이 어떤 의미를 가지고 어떤 역할을 하는지가 명확하게 파악이 가능해서 편리하기도 하다. 해당 페이지의 추상화 단계에 따라서 따로 큰 뭉티기로 만들어서 사용해주는 방법도 괜찮을 것 같다. 그건 그 페이지 내부에서의 추상화 단계를 잘 고려해서 판단하면 될 몫인 것 같군.. 🤔


👩🏻‍🌾 마무리

컴파운드 컴포넌트 패턴의 경우는 지금까지 개발을 하면서 가장 선호하는 방법이어서 이번에도 요 패턴으로 모든 UI 컴포넌트들을 만들어보려 하는데, 더 많이 만들어보면서 장단점에 대해 많이 느껴봐야겠다. 아직은 그 경험이 많다고는 할 수가 없어서 내가 느끼고 있는 것들을 자신있게 말하기는 힘들 것 같다.

그래도 뭔가 props 결벽증이 언제부턴가 생겨버려서 ㅋㅋㅋㅋㅋㅋㅋ 진짜 필요한 경우에만 넘겨주고 싶은 욕심이 그득한 인간이라 아직은 요 패턴을 가장 선호하는 편이다. 필요한 것들만 조립해서 사용하는 것도 편리하고!

어쨌든 이렇게 만들어봤고.. 다음은 아코디언을 한 번 만들어봐야겠다. 간바레 트루쟝

참고