문제 상황
우리 프로젝트는 현재 지도 페이지에서 특정 버튼 또는 클릭의 여부에 따라 사전에 구현해둔 바텀 시트에 children 컴포넌트를 각각 분기처리하여 그때 그때 필요한 컴포넌트를 바텀시트로 띄우곤 한다.
현재 프로젝트의 코드 구조는 간략히 아래와 같다.
// CorkageMap.tsx 의 일부. 각 컴포넌트의 props는 생략
..
..
..
{/* 바텀시트: 저장한 매장 or 멀티핀 리스트 */}
<BottomSheet >
{sheetView === 'list' && (
<List />
)}
{sheetView === 'store' && <MyStore />}
{sheetView === 'multipin' && <MultipinList />}
{/* [추가] Detail 컴포넌트 렌더링 (ID 전달) */}
{sheetView === 'detail' && selectedRestaurantId && (
<Detail />
)}
</BottomSheet>
위 BottomSheet가 지도에서 처음으로 등장하는 바텀시트이다. 여기에 Detail 컴포넌트 속에서 한번더 BottomSheet가 존재한다.
즉,
외부 바텀시트 : CorkageMap.tsx 지도 페이지로부터 팝업된 BottomSheet(Detail.tsx 등)
내부 바텀시트 : 외부 바텀시트의 Children 컴포넌트로 전달된 Detail.tsx 에서 사용되는 또한번의 BottomSheet
이로인해, 바텀시트 이중구조를 띄고있다.
현재 개별식당 클릭 시 아래와 같이 식당 정보에 관한 바텀시트가 등장한다.
여기서 저장(스크랩) 버튼을 클릭하면 오른쪽 사진과 같이 또한번 바텀시트가 등장한다.
여기서 문제가 발생한다. 바텀시트 안에서 또하나의 바텀시트가 동작한다는 것이다.
이로인해 바텀시트안의 바텀시트는 topSnapVh(바텀시트를 띄울 최대높이) 계산을 뷰포트 전체를 기준으로 하는 것이 아니라, 본인의 바로 외부 컴포넌트. 즉 사용자 관점에서 봤을 때, 첫번째 바텀시트에 표시된 화면을 기준으로 topSnapVh를 계산하게 된다.


이로인해 오른쪽 사진에서 볼 수 있듯이 그룹에 저장하고 싶어도 바텀시트가 더이상 끌어올려 지지도 않는 등 제대로 동작하지 못하고있다.
이를 해결하기 위해 다음과 같은 방법을 생각했다.
중첩된 BottomSheet 구조 내에서 자식 컴포넌트(GroupSelector)가 부모의 제약(높이, 위치, 쌓임 맥락)을 무시하고 전체 뷰포트를 기준으로 동작하게 만드는 가장 간단하고 확실한 방법인 React Portal을 사용하는 것
방법은 아래와 같다.
import { createPortal } from 'react-dom'; // createPortal을 import 한다.
.
.
.
// return 문을 createPortal로 감싼다.
return createPortal(
<>
//내용물
</>,
document.body
);
- Portal의 첫 번째 인자: 화면에 보여줄 JSX 전체(<> ... </>)가 들어간다.
- Portal의 두 번째 인자: 이 UI가 실제로 붙을 DOM 위치(document.body) 이다.
보통 리액트 컴포넌트는 부모 컴포넌트의 자식 위치(DOM 상에서도 안쪽)에 렌더링하지만 포털을 사용하면 "논리적인 위치는 부모-자식 관계를 유지하고, 물리적인 렌더링 위치는 전혀 다른 곳으로 순간이동" 시키는 것과 같음.
- 기존 방식: CorkageMap > BottomSheet > Detail > GroupSelector 순으로 겹겹이 쌓여있어, 부모가 작으면 자식인 GroupSelector도 부모의 크기에 갇혀버림
- Portal 방식: Detail 컴포넌트가 GroupSelector를 호출하는 코드는 그대로 두어 데이터(Props)는 자유롭게 주고받되, 브라우저가 화면을 그릴 때는 <body> 태그 바로 아래에 독립적으로 배치.
document.body를 인자로 준 것은, 이 바텀시트를 HTML의 가장 뿌리가 되는 <body> 태그 바로 아래에 붙이겠다는 뜻.
- 레이아웃 독립성: body 바로 아래에 있으면 주변에 방해되는 부모 요소가 없음. 따라서 width: 100%, height: 100vh 같은 설정이 부모의 크기가 아닌 사용자의 전체 브라우저 화면(Viewport)을 기준으로 동작하게 되며 이 문제를 해결할 수 있게됨.
구조적 변화 시각화
[수정 전 DOM 트리]
<body>
<div id="root">
<main>
<div class="bottom-sheet">
<div class="detail">
<div class="group-selector">...</div>
</div>
</div>
</main>
</div>
</body>
[React Portal 적용 후 DOM 트리]
<body>
<div id="root"></div>
<div class="group-selector-portal"> </div>
</body>