3 minute read

개요

모바일의 바텀시트(BottomSheet)와 데스크탑의 모달(Modal)은 각기 다른 UI/UX를 제공하지만, 기존 콘텐츠의 맥락을 유지하면서 추가 정보나 옵션을 제공한다는 본질은 같습니다. 본 포스팅에서는 전략 패턴(Strategy Pattern)의 개념과 합성 컴포넌트(Compound Components)를 활용해, 뷰포트에 따라 최적의 오버레이를 자동으로 전환하는 ResponsiveOverlay 아키텍처 설계 과정에 대해 공유하고자 합니다.

문제점: 반복되는 분기와 파편화되는 UX

서비스 규모가 커지며 채팅 모드 선택, 프로필 설정 등 오버레이를 활용하는 기능이 늘어났습니다. 이때 기기별로 최적화된 UI를 제공하는 과정에서 아래와 같은 고민이 생겼습니다.

  • 코드 중복: 동일한 비즈니스 로직과 컨텐츠를 모달용, 바텀시트용으로 매번 중복하여 작성
  • 상태 관리의 복잡성: 동일한 로직임에도 불구하고 isOpen, onClose 등 동일한 상태값을 각 컴포넌트에 개별 주입
  • 유지보수 효율 저하: 반응형 요구사항 변경 시 모든 페이지의 분기 코드를 수정해야 하며, 실수로 하나라도 누락 시 전반적인 UI/UX의 일관성이 깨질 위험이 큼

이러한 문제점들로 “비즈니스 로직(무엇을 보여줄 것인가)은 유지하되, 렌더링 전략(어떻게 보여줄 것인가)만 뷰포트에 따라 자연스럽게 바꿀 수는 없을까?”하는 고민이 들었고, 이를 해결하기 위한 추상화가 필요하다 생각했습니다.

해결 방안: 전략 패턴 기반의 추상화

단순한 분기 처리를 넘어 환경 감지는 컨테이너 컴포넌트가 담당하고 화면 렌더링은 주입받는 원칙으로 ResponsiveOverlay를 설계했습니다.

1단계: 인터페이스와 타입 설계

먼저, children이 JSX 혹은 상태를 반환받는 함수(Render Props)일 수 있도록 타입을 정의하여 유연성을 더했습니다.

type OverlayVariant = 'bottomSheet' | 'modal';

interface RenderPropsContext {
  variant: OverlayVariant;
  onClose: () => void;
}

type ResponsiveOverlayChildren =
  | React.ReactNode
  | ((context: RenderPropsContext) => React.ReactNode);

interface ResponsiveOverlayProps {
  isOpen: boolean;
  onClose: () => void;
  children: ResponsiveOverlayChildren;
  // ... 스타일 관련 props (modalWidth, zIndex 등)
}

2단계: 컨테이너 추상화

BottomSheetStrategyModalStrategy를 각각 독립된 컴포넌트로 구현하고, 메인 컴포넌트에서는 useMediaQueryContext()isMobile 값에 따라 어떤 전략을 사용할지 결정합니다.

const ResponsiveOverlay = ({ children, ...props }) => {
  const { isMobile } = useMediaQueryContext();
  const variant = isMobile ? "bottomSheet" : "modal";
  const StrategyComponent =
    variant === "bottomSheet" ? BottomSheetStrategy : ModalStrategy;

  const renderedChildren =
    typeof children === "function"
      ? children({ variant, onClose: props.onClose })
      : children;

  return (
    <ResponsiveOverlayContext.Provider
      value=
    >
      <StrategyComponent
        {...props}
        maxWidth={
          variant === "bottomSheet"
            ? props.bottomSheetMaxWidth
            : props.modalMaxWidth
        }
      >
        {renderedChildren}
      </StrategyComponent>
    </ResponsiveOverlayContext.Provider>
  );
};

1,2 단계가 동일한 컨테츠를 다른 그릇에 담는 것과 유사한 방식이라면, 해당 단계는 그릇에 따라 컨텐츠 자체를 바꿔주는 방식입니다. 디자인 시안이 모바일/데스크탑에서 레이아웃까지 완전히 다를 때 유용합니다

3단계: 합성 컴포넌트로 UI 유연성 확보

이미 익숙한 합성 컴포넌트 구조를 적용해 Header, Body, Footer를 나눕니다. 내부에서 Context를 참조해 기기별로 필요한 UI(ex. 모달의 닫기 버튼)를 조건부로 노출합니다.

const Header: React.FC<SubComponentProps> = ({ children, className }) => {
  const { variant } = useResponsiveOverlayContext();

  return (
    <div css={[styles.header, variant === 'modal' && styles.headerModal]} className={className}>
      {children}
    </div>
  );
};

조건부 분기형(Render Props)

모바일과 데스크탑에서 완전히 다른 UI를 렌더링해야 할 때 사용합니다. 1단계에서 정의한 함수형 children 타입이 이 패턴을 위한 것이라 할 수 있습니다.

<ResponsiveOverlay isOpen={isOpen} onClose={onClose}>
  {({ variant }) => {
    const headerPadding =
      variant === "bottomSheet" ? "24px 20px 16px 20px" : "24px 24px 16px 24px";
    const bodyMaxHeight =
      variant === "bottomSheet" ? "297px" : "min(297px, 50vh)";

    return (
      <>
        <ResponsiveOverlay.Header css=>
          <Text variant="h16">프로필 선택</Text>
        </ResponsiveOverlay.Header>
        <ResponsiveOverlay.Body maxHeight={bodyMaxHeight}>
          <ProfileList />
        </ResponsiveOverlay.Body>
      </>
    );
  }}
</ResponsiveOverlay>

실제 활용 패턴: 상황에 따른 두가지 대응

Responsiveoverlay는 디자인 요구사항의 복잡도에 두 가지 방식으로 활용됩니다.

패턴1: 컨텐츠 주입형 (Encapsulated Content)

비즈니스 로직과 UI가 이미 캡슐화된 경우입니다. 호출부 코드가 간결해지며 여러 곳에서 재사용할 때 유지보수 효율이 극대화됩니다.

// 특정 도메인(PlayMode)의 지식이 응집된 컴포넌트를 주입
<ResponsiveOverlay isOpen={isPlayModeOpen} onClose={onClose}>
  <ExampleContent contentId={content.id} />
</ResponsiveOverlay>

패턴2: 원자적 조립형 (Atomic Composition)

Header, Body, Footer를 직접 조합하여 사용하는 방식입니다. 각 하위 요소에 직접적인 스타일 주입이 필요할 때 사용할 수 있습니다.

<ResponsiveOverlay isOpen={isOpen} onClose={onClose}>
  <ResponsiveOverlay.Header>
    <div>
      <p>채팅을 계속하시겠어요?</p>
    </div>
  </ResponsiveOverlay.Header>
  <ResponsiveOverlay.Body>
    <ContentList data={data} />
  </ResponsiveOverlay.Body>
  <ResponsiveOverlay.Footer>
    <Button onClick={continueContent} primary>
      이어하기
    </Button>
  </ResponsiveOverlay.Footer>
</ResponsiveOverlay>

추가로 고민한 부분

1. 모바일 뒤로가기 대응 (History API)

모바일 웹에서 바텀시트가 열린 상태에서 뒤로가기를 누르면 사용자는 “바텀시트 닫힘”를 기대하지만 실제로는 페이지 이탈이 발생합니다. 이를 해결하기 위해 가상 히스토리를 활용했습니다.

useEffect(() => {
  if (isOpen) {
    history.pushState({ page: "modal" }, document.title); // 가상 히스토리 추가
    window.addEventListener("popstate", goBack); // 뒤로가기 시 닫기
  } else {
    // 닫기 버튼으로 닫은 경우, 추가했던 히스토리를 정리
    if (!isGoBackClicked.current && history.state?.page === "modal") {
      history.back();
    }
  }
}, [isOpen]);

핵심은 isGoBackClicked ref입니다. 뒤로가기로 닫혔을 때는 이미 히스토리가 pop된 상태이므로 history.back()을 또 호출하면 의도치 않은 이중 이탈이 발생합니다. 이 플래그로 “누가 닫았는가”를 구분해 정확히 한 번만 히스토리를 정리합니다.

2. 전역 모달 스택 관리 (ModalProvider 연동)

오버레이가 하나일 때는 문제가 없지만, 바텀시트 위에 확인 모달이 뜨는 등 중첩 오버레이 상황에서는 닫기 순서와 포커스 관리가 꼬일 수 있습니다. ModalProvider에 고유 ID로 등록/해제함으로써 스택 기반의 순서 보장과 cleanup 누락 방지(언마운트 시 자동 해제)를 처리합니다.

마치며

이번 설계를 통해 파편화된 코드를 통합하여 개발자 경험(DX)을 개선하고, 뷰포트에 따라 일관적인 UI/UX 제공으로 두가지를 동시에 챙길 수 있는 경험이었습니다. 합성 컴포넌트의 유연함을 유지하면서도 반응형을 대응함으로써 비즈니스 로직 자체에만 더 집중할 수 있는 계기가 되었습니다.