애드블럭 종료 후 보실 수 있습니다.

ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • React Portal에 대해 알아보기 (Feat. Next.js 예시)
    React 2024. 11. 16. 15:57
    728x90
    반응형

    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
    반응형

    댓글

Designed by Tistory.