2025년 4월 19일트러블슈팅조회수 6

Shadcn Dialog 내부에서 Combobox(Popover+Command) 사용 시 스크롤 이슈 해결

Shadcn UI 컴포넌트 조합 시 발생하는 스크롤 전파 문제 해결하기

문제 상황

  • 증상: Dialog 컴포넌트 내부에서 Popover와 Command를 조합한 Combobox 컴포넌트 사용 시, 드롭다운 목록의 스크롤이 작동하지 않음

  • 환경:

    • OS: macOS, Windows, iOS, Android (크로스 플랫폼)
    • 버전: React 19
    • 의존성:
      • @radix-ui/react-dialog: ^1.1.6
      • @radix-ui/react-popover: ^1.1.6
      • cmdk: 1.0.0
      • tailwindcss: ^4.0.13
  • 증상 상세:

    • Dialog 컴포넌트 내부에서 Popover가 열리면 Command 리스트에서 스크롤이 작동하지 않음
    • 상위 컴포넌트인 Dialog로 스크롤 이벤트가 전파되어 Popover 내부 목록의 스크롤이 무시됨
    • 모바일 환경에서는 터치 스크롤도 동일하게 작동하지 않음
    • 요소를 클릭할 수는 있지만 스크롤이 되지 않아 긴 목록을 탐색할 수 없음

원인 분석

  • 문제 원인: React Portal과 이벤트 버블링의 상호작용 문제

  • 이론적 배경:

    1. React Portal과 이벤트 버블링: React Portal은 DOM 계층 구조를 벗어나 렌더링할 수 있지만, 이벤트 버블링은 React 컴포넌트 트리를 따라 일어남
    2. 중첩된 Portal: Dialog, Popover, Command 모두 Portal을 사용하여 렌더링되는데 중첩 사용 시 이벤트 전파 문제 발생
    3. 스크롤 이벤트 캡처: 상위 컴포넌트인 Dialog가 wheel 및 touchmove 이벤트를 캡처하여 하위 Popover의 스크롤 이벤트가 무시됨
  • 기술적 분석:

    1. Shadcn UI의 Popover 컴포넌트는 Radix UI의 Popover 기반으로, 내부적으로 PopoverPrimitive.Portal을 사용함
    2. 실제 소스 코드:
    // popover.tsx (shadcn/ui)
    function PopoverContent({
      className,
      align = "center",
      sideOffset = 4,
      ...props
    }: React.ComponentProps<typeof PopoverPrimitive.Content>) {
      return (
        <PopoverPrimitive.Portal>
          <PopoverPrimitive.Content
            data-slot="popover-content"
            align={align}
            sideOffset={sideOffset}
            className={cn(
              "bg-popover text-popover-foreground ...",
              className
            )}
            {...props}
          />
        </PopoverPrimitive.Portal>
      )
    }
    
    1. Dialog 컴포넌트도 마찬가지로 자체적인 Portal을 사용하여 렌더링됨
    2. 두 Portal이 중첩되면서 Focus 및 이벤트 제어에 충돌 발생
  • 관련 코드:

// 문제가 발생한 코드
<Dialog open={open} onOpenChange={onOpenChange}>
  <DialogContent className="sm:max-w-[660px] max-h-[90vh] overflow-y-auto">
    {/* ... 다른 컴포넌트들 ... */}

    <Popover open={isEnterprisePopoverOpen} onOpenChange={setIsEnterprisePopoverOpen}>
      <PopoverTrigger asChild>
        <Button variant="outline" role="combobox">
          {/* 버튼 내용 */}
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-[300px] p-0">
        <Command>
          <CommandInput placeholder="업체를 검색해주세요" />
          <CommandList className="max-h-[300px] overflow-y-auto">
            {/* 목록 내용 */}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  </DialogContent>
</Dialog>

재현 방법

  1. Dialog 컴포넌트를 열고 내부에 Popover 컴포넌트를 사용
  2. Popover를 클릭하여 Command 리스트 드롭다운이 표시되도록 함
  3. 목록의 항목이 많아 스크롤이 필요한 경우, 스크롤을 시도해보면 동작하지 않음
  4. 대신 Dialog 컴포넌트가 스크롤되는 현상 발생

해결 방법

시도한 접근법

  1. [포털 구조 변경]:

    • 결과: 부분적으로 효과가 있었지만 모든 환경에서 일관되게 작동하지 않음
    • 한계점: 컴포넌트 구조를 크게 변경해야 하며, Shadcn UI의 기본 구조와 충돌 가능성
    // 포털 구조 변경 시도
    const portalContainer = document.createElement('div');
    document.body.appendChild(portalContainer);
    
    return ReactDOM.createPortal(
      <CustomPopoverContent>
        {/* ... */}
      </CustomPopoverContent>,
      portalContainer
    );
    
  2. [Portal 사용 제거]:

    • 결과: 스크롤 문제는 해결되지만 Popover 포지셔닝과 스타일링에 다른 문제 발생
    • 한계점: Radix UI의 핵심 기능인 자동 포지셔닝 기능 손실
    // PopoverPrimitive.Portal을 제거한 커스텀 컴포넌트
    function CustomPopoverContent({ className, ...props }) {
      return (
        <PopoverPrimitive.Content
          className={cn("z-50 w-72 ...", className)}
          {...props}
        />
      );
    }
    
  3. [CSS로 해결 시도]:

    • 결과: 특정 상황에서만 작동하고 모든 브라우저에서 일관되지 않음
    • 한계점: 이벤트 전파 자체가 해결되지 않아 근본적인 해결이 되지 않음
    .popover-content {
      position: fixed;
      z-index: 9999;
      isolation: isolate;
    }
    

최종 해결책

// 수정된 코드
<Popover open={isEnterprisePopoverOpen} onOpenChange={setIsEnterprisePopoverOpen}>
  <PopoverTrigger asChild>
    <Button 
      id="enterprise-select"
      variant="outline"
      role="combobox"
      aria-expanded={isEnterprisePopoverOpen}
      className="h-[56px] flex-7 justify-between border-gray-300 text-muted-foreground font-normal hover:text-muted-foreground"
    >
      {/* 버튼 내용 */}
      <ChevronsUpDown className="w-4 h-4 text-gray-400" />
    </Button>
  </PopoverTrigger>
  <PopoverContent 
    className="w-[300px] p-0"
    onWheel={(e) => e.stopPropagation()}
    onTouchMove={(e) => e.stopPropagation()}
  >
    <Command>
      <CommandInput placeholder="업체를 검색해주세요" />
      <CommandList className="max-h-[300px] overflow-y-auto">
        <CommandEmpty>일치하는 업체가 없습니다.</CommandEmpty>
        <CommandGroup>
          {/* 목록 내용 */}
        </CommandGroup>
      </CommandList>
    </Command>
  </PopoverContent>
</Popover>

핵심 변경사항:

  1. PopoverContentonWheel={(e) => e.stopPropagation()} 추가: 마우스 휠 이벤트가 상위로 전파되는 것을 방지
  2. onTouchMove={(e) => e.stopPropagation()} 추가: 모바일 터치 스크롤 이벤트가 상위로 전파되는 것을 방지
  3. CommandListclassName="max-h-[300px] overflow-y-auto" 유지: 스크롤 가능한 영역 정의

이 해결책의 장점:

  • Shadcn UI 및 Radix UI의 원래 구조를 그대로 유지
  • Portal 구조를 변경하지 않고도 이벤트 전파만 제어하여 문제 해결
  • 데스크톱 및 모바일 환경 모두에서 정상 작동
  • 적은 코드 변경으로 최대 효과

성능 개선 사항

  • 변경 전 성능: 스크롤 이벤트가 상위 컴포넌트로 전파되어 중첩된 컴포넌트의 UX 저하
  • 변경 후 성능: 이벤트 전파 차단으로 예상대로 작동하는 스크롤 UX 제공
  • 추가 최적화: 불필요한 리렌더링 없이 이벤트 핸들링만 개선하여 성능 오버헤드 최소화
  • 네이티브 DOM 이벤트 핸들링 활용으로 React 렌더링 사이클에 영향 없음

교훈 및 예방책

  • 배운 점:

    1. React Portal을 사용하는 컴포넌트를 중첩할 때는 이벤트 전파에 주의해야 함
    2. DOM 구조와 React 컴포넌트 트리 구조의 차이를 이해하는 것이 중요함
    3. 이벤트 전파 메커니즘을 활용하여 문제를 해결할 수 있음
    4. React 19에서는 중첩된 Portal 간의 상호작용이 더 복잡해질 수 있음
  • 유사 문제 예방법:

    1. UI 컴포넌트 라이브러리 사용 시 중첩 컴포넌트의 이벤트 전파 패턴 확인
    2. 복잡한 중첩 구조에서는 이벤트 핸들러에 stopPropagation()을 적절히 사용
    3. 디버깅 시 React DevTools와 브라우저 이벤트 리스너 탭을 활용하여 이벤트 흐름 추적
    4. 컴포넌트 설계 시 포털 중첩을 최소화하거나 명시적으로 이벤트 전파 제어하기
  • 참고할 문서/자료:

    1. React 공식 문서의 이벤트 전파와 Portal 섹션
    2. Radix UI의 컴포넌트 조합 가이드라인
    3. GitHub Issue: Popover cannot be worked properly inside Dialog with React 19

관련 참고 자료