-
React Portal에 대해 알아보기 (Feat. Next.js 예시)React 2024. 11. 16. 15:57728x90반응형
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
반응형'React' 카테고리의 다른 글
React Floating Button 만들기 (with Intersection Observer) (0) 2024.11.09 Compound Component 패턴 (feat. React, Next.js) (0) 2024.09.21 react-virtuoso를 사용한 렌더링 최적화 (0) 2024.03.30 Cypress vs Playwright E2E 테스트 프레임워크 비교 (2) 2024.02.13 고급 React Hooks 사용을 위한 팁과 요령 (0) 2024.02.05