웹뷰 환경에서 사용할 리액트 공통 모달 컴포넌트 만들기

Modal(Dialog) Image

웹뷰 환경에서 팀 공통 모달 컴포넌트 만들기

시대생 앱을 웹뷰로 리빌딩하게 되면서 공통으로 사용할 모달 컴포넌트를 새로 만들었다.
특히 웹뷰 환경을 고려해 최대한 심플하고, 커스터마이징 가능한 구조로 설계했다.

이번 글에서는 모달을 만들면서 어떤 고민을 했고, 어떤 선택지를 두고 결정했는지, 또 아쉬운 점과 앞으로 더 발전시킬 수 있는 부분까지 기록해보려고 한다.


1. 모달 설계에서 고민했던 점

처음에는 단순히 isOpen, title, description, primaryButton, secondaryButton 같은 props만 받는 구조로 시작했다.

하지만 바로 몇 가지 고민이 들었다.

  • description을 문자열(string)만 받을까? 아니면 ReactNode로 받을까?
  • Footer 버튼 2개만 고정할까? 아니면 자유롭게 추가할 수 있도록 열어둘까?
  • 모달 외부를 클릭했을 때 닫을 수 있게 할까? 옵션으로 둘까?
  • 모달이 열릴 때 스크롤은 막아야 할까?

이런 세부적인 고민들을 해결하면서 모달을 조금 더 유연하게 설계하고 싶었다.


2. 하나씩 결정해나갔던 과정

description: string vs ReactNode

처음엔 단순히 description: string으로 받았다.
하지만 실무에서는 단순 문구뿐 아니라 링크, 강조 텍스트, 이미지 등 다양한 형태가 들어갈 수 있다.

그래서 최종적으로 description: ReactNode로 설계했다.

ReactNode로 타입을 지정하면 문자열뿐만 아니라 JSX, 배열, Fragment 등도 모두 받을 수 있다. 덕분에 단순 텍스트부터 복잡한 UI까지 유연하게 표현할 수 있다.

(Tip) string으로 타입을 강제하면 단순하지만, 확장성이 떨어진다. ReactNode로 열어두는 게 장기적으로 훨씬 유리하다.

Footer: 버튼 2개 고정 vs 자유롭게 받기

처음엔 primaryText, secondaryText props를 각각 받아서 버튼을 두 개 고정으로 구성했다.

하지만 이러면 만약 버튼이 1개만 필요한 경우나, 버튼 외 다른 요소를 Footer에 넣고 싶은 경우 매우 불편해진다.

그래서 Footer를 ReactNode로 완전히 열어두었다.

<footer>
  {footer}
</footer>

이렇게 하면 버튼이 0개, 1개, 2개, N개가 되든 자유롭게 조합할 수 있다.
(버튼 대신 다른 UI를 넣을 수도 있다)

(Tip) Footer를 ReactNode로 받으면, 미래에 기획 변경이나 디자인 변경에도 쉽게 대응할 수 있다.

모달 외부 클릭(closeOnOverlayClick)

모달을 열었을 때 Overlay(배경)를 클릭하면 모달을 닫게 할지 고민했다.

  • 어떤 경우는 바깥 클릭으로 닫히는 게 UX적으로 편하다.
  • 반대로 중요한 작업을 다루는 모달은 바깥 클릭으로 닫히지 않게 해야 한다.

그래서 closeOnOverlayClick?: boolean 옵션을 만들어서 선택할 수 있도록 했다.

if (e.target === e.currentTarget && closeOnOverlayClick && onClose) {
  onClose();
}

모달 열릴 때 스크롤 막기

모달이 열려 있는 동안 배경 스크롤이 되면 UX가 깨진다.

그래서 모달이 열릴 때 document.body.style.overflow = 'hidden'으로 스크롤을 막고,
모달이 닫히거나 unmount될 때 원래대로 복구하는 코드를 추가했다.

useEffect(() => {
  if (isOpen) document.body.style.overflow = 'hidden';
  return () => {
    document.body.style.overflow = '';
  };
}, [isOpen]);

이렇게 하면 모달이 열리는 동안 자연스럽게 스크롤이 비활성화된다.


3. 최종 구조

최종적으로 모달은 다음과 같은 구조로 완성했다.

  • Modal 컴포넌트
    • Modal.Header
    • Modal.Description
    • Modal.Footer
    • Modal.FooterButtons

컴파운드 컴포넌트 패턴을 참고해서, Modal 하위에 Header, Description, Footer를 조립할 수 있게 설계했다.

컴파운드 패턴은 부모 컴포넌트가 자식들을 Context로 관리하면서, 필요한 부분만 조립해서 사용할 수 있도록 하는 설계 방식이다.
덕분에 유연성과 일관성을 모두 잡을 수 있다.

사용 예시:

<Modal isOpen={isOpen} onClose={closeModal}>
  <Modal.Header>타이틀</Modal.Header>
  <Modal.Description>간단한 설명 또는 커스텀 UI</Modal.Description>
  <Modal.Footer>
    <Modal.FooterButtons>
      <button onClick={closeModal}>닫기</button>
      <button>추가 작업</button>
    </Modal.FooterButtons>
  </Modal.Footer>
</Modal>

4. 아쉬운 점과 더 발전할 수 있는 부분

1) JSX.Element vs ReactNode

이번에는 description, footer 등의 타입을 ReactNode로 설정했다.
만약 JSX.Element로 타입을 좁혔다면, 단일한 요소만 받을 수 있게 되었을 것이다.

  • JSX.Element: 하나의 React 요소만 받을 수 있다.
  • ReactNode: 문자열, 숫자, JSX, 배열 등 거의 모든 렌더링 가능한 것을 받을 수 있다.

이번 설계에서는 더 유연함을 위해 ReactNode를 선택했지만,
만약 "반드시 하나의 컴포넌트만 넘겨야 한다"는 강제성이 필요했다면 JSX.Element가 더 나았을 수도 있다.

2) 포커스 트랩 처리 없음

현재는 모달이 열렸을 때 키보드 포커스가 모달 내부에만 머무는 처리는 하지 않았다.
(예를 들어 Tab 키를 눌러도 모달 안에서만 이동해야 하는 UX)

어쨌든 앱 위의 웹뷰이다 보니 키보드 사용 등은 크게 고려하지 않았는데,
추후 접근성을 강화하려면 포커스 트랩 로직을 추가해야 한다.

3) 다중 모달 관리

여러 모달이 겹쳐서 뜨는 상황은 아직 고려하지 않았다.
나중에 여러 모달이 동시에 뜰 가능성이 있다면, 모달 스택 관리나 z-index 충돌 방지도 추가해야 한다.

4) 모달 애니메이션

모달이 열릴 때와 닫힐 때 페이드 인/아웃 같은 부드러운 애니메이션을 추가하면 더 자연스럽다.
현재는 열리고 닫힐 때 DOM 추가/제거만 하고 있어서 이 부분도 개선 포인트다.


5. 마치며

이번 모달 컴포넌트를 만들면서 작은 디테일까지도 신경을 많이 썼다.

처음에는 간단하게 시작했지만, 실제 서비스를 생각하면서

  • 다양한 UI를 지원해야 하고
  • 커스터마이징 가능해야 하고
  • 유지보수하기 편해야 하고
  • 웹뷰 환경도 고려해야 한다는
    고민을 반복했다.

덕분에 훨씬 더 탄탄하고 유연한 모달 컴포넌트를 만들 수 있었던 것 같다.

앞으로 팀에서도 이 모달 컴포넌트를 기반으로,
다른 다양한 공통 컴포넌트들을 컴파운드 패턴처럼 유연하게 설계할 수 있으면 좋겠다.


실제 사용하는 사람 입장에서, 서비스 전체 일관성과 유지보수를 함께 고려한 설계
좋은 공통 컴포넌트를 만드는 첫걸음이라고 느꼈던 작업이었다.