React Portal에 대해 알아보기 (Feat. Next.js 예시)
React를 사용하다 보면 컴포넌트 트리 구조 내에서 DOM 구조와 시각적인 표현이 일치하지 않는 상황을 마주할 때가 있습니다.
이런 경우에 React Portal은 강력한 도구로 활용될 수 있습니다.
이번 글에서는 React Portal의 작동 원리부터 실제로 어떻게 활용할 수 있는지 살펴보겠습니다.
1. React Portal이란 무엇인가?
React Portal은 React 16부터 도입된 기능으로, 현재의 컴포넌트 계층 구조 밖의 DOM 노드로 자식을 렌더링 할 수 있게 해 줍니다.
일반적으로 React 컴포넌트는 부모 컴포넌트의 DOM 노드 내에 렌더링 되지만, Portal을 사용하면 컴포넌트를 DOM 트리의 다른 위치에 렌더링 할 수 있으면서, 그리하여 논리적으로는 기존의 컴포넌트 트리 구조를 유지할 수 있습니다.
사용 방법은 무척 간단합니다.
// child: 렌더링할 React 노드입니다.
// container: child를 렌더링할 DOM 요소입니다.
ReactDOM.createPortal(child, container)
2. 왜 React Portal이 필요한가?
2.1. CSS z-index 문제 해결
복잡한 레이아웃에서 모달이나 툴팁을 구현할 때, 부모 요소의 overflow: hidden이나 z-index 때문에 원하는 대로 컴포넌트가 표시되지 않을 수 있습니다.
Portal을 사용하면 이러한 컴포넌트를 DOM 트리의 최상단에 렌더링 하여 이런 문제를 우회할 수 있습니다.
2.2. 이벤트 버블링 관리
Portal을 통해 렌더링 된 컴포넌트에서도 이벤트는 기존의 React 컴포넌트 트리에서 버블링 됩니다.
이는 이벤트 처리를 일관성 있게 유지할 수 있다는 장점이 있습니다.
2.3. 콘텍스트 공유
Portal 내부의 컴포넌트에서도 상위 컴포넌트의 콘텍스트에 접근할 수 있습니다.
3. Next.js에서 React Portal 사용 시 고려 사항
React Portal은 강력한 기능이지만, Next.js와 같은 서버 사이드 렌더링(SSR) 프레임워크에서 사용할 때는 몇 가지 주의해야 할 점이 있습니다.
3.1. 서버 사이드 렌더링과 브라우저 API
Next.js는 페이지를 서버 측에서 렌더링 하기 때문에, 브라우저에서만 사용할 수 있는 document, window와 같은 전역 객체에 직접 접근하면 오류가 발생합니다.
Portal은 보통 document.body나 특정 DOM 노드에 렌더링 되는데, 이 DOM 노드는 브라우저 환경에서만 존재합니다.
3.2. 일관성 있는 렌더링
서버와 클라이언트의 렌더링 결과가 일치하지 않으면 경고 메시지가 나타나거나 예기치 않은 동작이 발생할 수 있습니다.
Portal을 사용하는 컴포넌트가 서버에서는 렌더링 되지 않고 클라이언트에서만 렌더링 되도록 처리해야 합니다.
4. 해결 방법
4.1. 동적 임포트 사용하기
Next.js의 next/dynamic을 사용하여 클라이언트 측에서만 컴포넌트를 렌더링 할 수 있습니다.
import dynamic from 'next/dynamic';
const NoSSRComponent = dynamic(() => import('./NoSSRComponent'), { ssr: false });
4.2. 조건부 렌더링
컴포넌트 내에서 useEffect 훅과 상태를 사용하여 클라이언트 측에서만 Portal을 렌더링 하도록 할 수 있습니다.
import { useState, useEffect } from 'react';
const MyComponent = () => {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
return isMounted ? <PortalComponent /> : null;
};
4.3. _document.js 파일 수정(Page router 한정)
// pages/_document.js
import Document, { Html, Head, Main, NextScript } from 'next/document'
class MyDocument extends Document {
render() {
return (
<Html>
<Head />
<body>
<Main />
<div id="portal-root" /> {/* Portal을 위한 컨테이너 */}
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument
5. Next.js에서 Portal 사용 예시
5.1. Portal을 활용한 툴팁 구현
- useClient 커스텀 훅을 만들어 클라이언트에서만 렌더링 하도록 처리합니다.
- useEffect를 사용하여 마운트 여부를 확인합니다.
- Tooltip 컴포넌트는 마운트 된 경우에만 document.body에 접근합니다.
// hooks/useClient.ts
import { useState, useEffect } from 'react';
export const useClient = () => {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return isClient;
};
// components/Tooltip.tsx
'use client';
import React from 'react';
import ReactDOM from 'react-dom';
import { useClient } from '../hooks/useClient';
interface TooltipProps {
children: React.ReactNode;
position: { top: number; left: number };
}
const Tooltip: React.FC<TooltipProps> = ({ children, position }) => {
const isClient = useClient();
if (!isClient) return null;
return ReactDOM.createPortal(
<div
className="tooltip"
style={{ top: position.top, left: position.left, position: 'absolute' }}
>
{children}
</div>,
document.body
);
};
export default Tooltip;
// components/ButtonWithTooltip.tsx
'use client';
import React, { useState } from 'react';
import Tooltip from './Tooltip';
const ButtonWithTooltip: React.FC = () => {
const [showTooltip, setShowTooltip] = useState(false);
const [position, setPosition] = useState<{ top: number; left: number }>({
top: 0,
left: 0,
});
const handleMouseEnter = (e: React.MouseEvent<HTMLButtonElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
setPosition({ top: rect.bottom + window.scrollY, left: rect.left + window.scrollX });
setShowTooltip(true);
};
return (
<div>
<button
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setShowTooltip(false)}
>
Hover me
</button>
{showTooltip && <Tooltip position={position}>This is a tooltip</Tooltip>}
</div>
);
};
export default ButtonWithTooltip;
5.2. 글로벌 모달 구현
- app/layout.tsx에서 modal-root를 추가합니다.
- useClient 훅을 사용하여 클라이언트 측에서만 document.getElementById를 호출합니다.
// components/Modal.tsx
'use client';
import React from 'react';
import ReactDOM from 'react-dom';
import { useClient } from '../hooks/useClient';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, children }) => {
const isClient = useClient();
if (!isOpen || !isClient) return null;
const modalRoot = document.getElementById('modal-root');
if (!modalRoot) return null;
return ReactDOM.createPortal(
<div className="modal-overlay">
<div className="modal-content">
<button onClick={onClose}>닫기</button>
{children}
</div>
</div>,
modalRoot
);
};
export default Modal;
// app/layout.tsx
export const metadata = {
title: 'Next.js App',
description: 'Generated by create next app',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
{children}
<div id="modal-root" />
</body>
</html>
);
}
// app/modal/page.tsx
'use client';
import React, { useState } from 'react';
import Modal from '../../components/Modal';
const ModalPage: React.FC = () => {
const [isModalOpen, setModalOpen] = useState(false);
return (
<main>
<h1>모달 예제</h1>
<button onClick={() => setModalOpen(true)}>모달 열기</button>
<Modal isOpen={isModalOpen} onClose={() => setModalOpen(false)}>
<h1>모달 내용</h1>
</Modal>
</main>
);
};
export default ModalPage;
3.3 알림 시스템 구현
- app/layout.tsx에서 notification-root를 추가합니다.
- useClient 훅을 사용하여 클라이언트 측에서만 document.getElementById를 호출합니다.
// components/NotificationPortal.tsx
interface Notification {
id: string;
message: string;
type: 'success' | 'error' | 'info';
}
const NotificationPortal = () => {
const [notifications, setNotifications] = useState<Notification[]>([]);
const isClient = useClient();
if (!isClient) return null;
const addNotification = (notification: Omit<Notification, 'id'>) => {
const id = Math.random().toString(36);
setNotifications(prev => [...prev, { ...notification, id }]);
setTimeout(() => {
removeNotification(id);
}, 3000);
};
const removeNotification = (id: string) => {
setNotifications(prev => prev.filter(note => note.id !== id));
};
return createPortal(
<div className="notification-container">
{notifications.map(note => (
<div key={note.id} className={`notification ${note.type}`}>
{note.message}
</div>
))}
</div>,
document.getElementById('notification-root')!
);
};
// app/layout.tsx
export const metadata = {
title: 'Next.js App',
description: 'Generated by create next app',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
{children}
<div id="notification-root" />
</body>
</html>
);
}
Portal을 올바르게 사용하면 Next.js 환경에서도 복잡한 UI 컴포넌트를 효율적으로 구현할 수 있습니다.
하지만 Portal을 과도하게 사용하면 애플리케이션의 성능에 영향을 줄 수 있습니다.
특히 많은 수의 Portal을 동시에 사용할 경우, 메모리 사용량과 렌더링 성능을 모니터링해야 합니다
오늘 소개한 툴팁, 모달, 알림 이외에도 콘텍스트 메뉴, 드래그 앤 드롭 등의 컴포넌트를 구현할 때 이번 글에서 소개한 방법을 적용해 보세요.
참고 자료
- Next.js 공식 문서: App Router
- React 공식 문서: Portals
- React 공식 문서: useEffect Hook