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과 이벤트 버블링의 상호작용 문제
-
이론적 배경:
- React Portal과 이벤트 버블링: React Portal은 DOM 계층 구조를 벗어나 렌더링할 수 있지만, 이벤트 버블링은 React 컴포넌트 트리를 따라 일어남
- 중첩된 Portal: Dialog, Popover, Command 모두 Portal을 사용하여 렌더링되는데 중첩 사용 시 이벤트 전파 문제 발생
- 스크롤 이벤트 캡처: 상위 컴포넌트인 Dialog가 wheel 및 touchmove 이벤트를 캡처하여 하위 Popover의 스크롤 이벤트가 무시됨
-
기술적 분석:
- Shadcn UI의 Popover 컴포넌트는 Radix UI의 Popover 기반으로, 내부적으로
PopoverPrimitive.Portal
을 사용함 - 실제 소스 코드:
// 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> ) }
- Dialog 컴포넌트도 마찬가지로 자체적인 Portal을 사용하여 렌더링됨
- 두 Portal이 중첩되면서 Focus 및 이벤트 제어에 충돌 발생
- Shadcn UI의 Popover 컴포넌트는 Radix UI의 Popover 기반으로, 내부적으로
-
관련 코드:
// 문제가 발생한 코드
<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>
재현 방법
- Dialog 컴포넌트를 열고 내부에 Popover 컴포넌트를 사용
- Popover를 클릭하여 Command 리스트 드롭다운이 표시되도록 함
- 목록의 항목이 많아 스크롤이 필요한 경우, 스크롤을 시도해보면 동작하지 않음
- 대신 Dialog 컴포넌트가 스크롤되는 현상 발생
해결 방법
시도한 접근법
-
[포털 구조 변경]:
- 결과: 부분적으로 효과가 있었지만 모든 환경에서 일관되게 작동하지 않음
- 한계점: 컴포넌트 구조를 크게 변경해야 하며, Shadcn UI의 기본 구조와 충돌 가능성
// 포털 구조 변경 시도 const portalContainer = document.createElement('div'); document.body.appendChild(portalContainer); return ReactDOM.createPortal( <CustomPopoverContent> {/* ... */} </CustomPopoverContent>, portalContainer );
-
[Portal 사용 제거]:
- 결과: 스크롤 문제는 해결되지만 Popover 포지셔닝과 스타일링에 다른 문제 발생
- 한계점: Radix UI의 핵심 기능인 자동 포지셔닝 기능 손실
// PopoverPrimitive.Portal을 제거한 커스텀 컴포넌트 function CustomPopoverContent({ className, ...props }) { return ( <PopoverPrimitive.Content className={cn("z-50 w-72 ...", className)} {...props} /> ); }
-
[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>
핵심 변경사항:
PopoverContent
에onWheel={(e) => e.stopPropagation()}
추가: 마우스 휠 이벤트가 상위로 전파되는 것을 방지onTouchMove={(e) => e.stopPropagation()}
추가: 모바일 터치 스크롤 이벤트가 상위로 전파되는 것을 방지CommandList
에className="max-h-[300px] overflow-y-auto"
유지: 스크롤 가능한 영역 정의
이 해결책의 장점:
- Shadcn UI 및 Radix UI의 원래 구조를 그대로 유지
- Portal 구조를 변경하지 않고도 이벤트 전파만 제어하여 문제 해결
- 데스크톱 및 모바일 환경 모두에서 정상 작동
- 적은 코드 변경으로 최대 효과
성능 개선 사항
- 변경 전 성능: 스크롤 이벤트가 상위 컴포넌트로 전파되어 중첩된 컴포넌트의 UX 저하
- 변경 후 성능: 이벤트 전파 차단으로 예상대로 작동하는 스크롤 UX 제공
- 추가 최적화: 불필요한 리렌더링 없이 이벤트 핸들링만 개선하여 성능 오버헤드 최소화
- 네이티브 DOM 이벤트 핸들링 활용으로 React 렌더링 사이클에 영향 없음
교훈 및 예방책
-
배운 점:
- React Portal을 사용하는 컴포넌트를 중첩할 때는 이벤트 전파에 주의해야 함
- DOM 구조와 React 컴포넌트 트리 구조의 차이를 이해하는 것이 중요함
- 이벤트 전파 메커니즘을 활용하여 문제를 해결할 수 있음
- React 19에서는 중첩된 Portal 간의 상호작용이 더 복잡해질 수 있음
-
유사 문제 예방법:
- UI 컴포넌트 라이브러리 사용 시 중첩 컴포넌트의 이벤트 전파 패턴 확인
- 복잡한 중첩 구조에서는 이벤트 핸들러에
stopPropagation()
을 적절히 사용 - 디버깅 시 React DevTools와 브라우저 이벤트 리스너 탭을 활용하여 이벤트 흐름 추적
- 컴포넌트 설계 시 포털 중첩을 최소화하거나 명시적으로 이벤트 전파 제어하기
-
참고할 문서/자료:
- React 공식 문서의 이벤트 전파와 Portal 섹션
- Radix UI의 컴포넌트 조합 가이드라인
- GitHub Issue: Popover cannot be worked properly inside Dialog with React 19
관련 참고 자료
태그
#React#Shadcn UI#React Portal#이벤트 버블링#UI 컴포넌트