React

React Portal에 대해 알아보기 (Feat. Next.js 예시)

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