Next.js 에서 ReactPortal 활용하기 (react-tooltip)

Shyuuuuni·2024년 3월 4일
5

📚 Tech-Post

목록 보기
10/10
post-thumbnail

문제 상황

배경

최근 토이 프로젝트로 Next.js를 사용하여 내가 하는 게임의 강화 시스템을 웹에서 할 수 있는 서비스를 개발하고 있다. 서비스 구경하기

빠르게 핵심 기능만 개발해서 게임 커뮤니티로 공유했고, 아래와 같은 사용자 피드백을 받아 추가적인 개선을 진행하고 있었다.

  • 참고: 선조의 가호는 게임 내에서 시뮬레이션 하려는 시스템의 일부

시도

인게임 시스템에서도 해당 선조의 가호 시스템에 마우스를 올리면 툴팁으로 설명을 소개하고 있다.

따라서 사용자인 게이머에게 익숙한 경험을 주기 위해 툴팁을 사용하기로 계획했고, 쉽고 빠르게 개발하기 위해 react-tooltip 라이브러리를 사용했다.

내가 예상한 최종 결과물은 아래 이미지와 같이, [선조의 가호] 텍스트에 마우스를 올리거나, 모바일의 경우 클릭했을 때 툴팁이 보이도록 하는 것을 생각했다.

당시 사용한 코드는 대략적으로 아래와 같다.

export default function AncestorProtectionZone({ className }: Props) {
  // 상태 로직 생략..

  return (
    <div className={clsx(styles.container, className)}>
      <div
        className={styles.label}
        data-tooltip-id="ancestor-protection-information"
      >
        [선조의 가호]
      </div>
      {/* 중간 JSX 생략.. */}
      <Tooltip id="ancestor-protection-information" place={'bottom'}>
        <div className={styles.tooltip}>
          <h3>선조의 가호</h3>
          <p>
            선조의 가호는 상급 재련을 6회 시도할 때 마다 사용할 수 있습니다.
          </p>
          <div>갈라투르의 망치(15%): 상급 재련 경험치 5배 증가</div>
          <div>겔라르의 (35%): 상급 재련 경험치 3배 증가</div>
          <div>
            쿠훔바르의 모루(15%): 상급 재련 경험치 30 추가 증가 및 선조의 가호
            재충전
          </div>
          <div>
            테메르의 (35%): 상급 재련 경험치 10 추가 증가 및 다음 상급 재련 시
            무료
          </div>
        </div>
      </Tooltip>
    </div>
  );
}

코드를 간단히 설명하면, 첫번째 divdata-tooltip-id props로 Tooltipid와 동일한 값을 전달하면 라이브러리에서 해당 엘리먼트에 상호작용 했을 때 툴팁을 표시하도록 지원해준다.

실패

위 코드와 같이 작성하고, 실제로 적용된 모습을 보니 아래와 같이 잘리는 모습을 볼 수 있었다.

툴팁 컴포넌트가 가려지므로, z-index를 부여해서 간단하게 해결하려고 시도했으니 작동하지 않았다. 또한 애초에 컨테이너 자체가 z-index를 사용하지 않는 상태여서 조금 더 찾아보기로 했다.

원인을 찾아보니, 전체적인 레이아웃을 flex로 관리하고 있는데, flex의 경우 position을 따로 사용하지 않더라도(=기본값인 static을 사용하더라도) z-index가 적용된다고 한다.

Flex items paint exactly the same as inline blocks [CSS21], except that order-modified document order is used in place of raw document order, and z-index values other than auto create a stacking context even if position is static (behaving exactly as if position were relative).

Note: Descendants that are positioned outside a flex item still participate in any stacking context established by the flex item.

출처: https://www.w3.org/TR/css-flexbox-1/#painting
참고: https://stackoverflow.com/questions/45398088/z-index-doesnt-work-with-flex-elements

즉, 위 이미지에서 재련 대상 장비가 써있는 flex-item재련 재화가 써있는 flex-item 각각이 z-index를 가질 수 있도록 작동한다.

따라서 첫번째 flex-item 안에 그려지는 툴팁에 z-index를 아무리 높게 주어도, z-index는 그 아이템 내부에서 비교하기 때문에 바깥의 아이템에 가려질 수 밖에 없었다.

성공?

따라서 display: flex가 선언된 페이지 컴포넌트에서 해당 툴팁이 포함된 영역에 z-index를 부여하여 해결할 수 있었다.

export default function AdvancedHoning() {
  return (
    <div className={styles.container}> // *컨테이너 = display: flex
      <StoreInitializer />
      <TitledBox
        title={'상급 재련'}
        light={true}
        className={styles.titledBoxContents}
        style={{ zIndex: 9999 }} // *첫번째 아이템에 z-index 부여
      >
        <ItemZone />
        <AncestorProtectionZone className={styles.gapedContent} />
        <ExpBar className={styles.gapedContent} />
      </TitledBox>
      <TitledBox
        title={'재련 비용'}
        light={true}
        className={styles.titledBoxContents}
      >
        <PercentZone />
        <div className={styles.materialsZone}>
          <RequireMaterials />
          <AuxiliaryMaterials />
        </div>
        <CostZone className={styles.gapedContent} />
      </TitledBox>
      <PrefetchMarketPrice>
        <DetailZone />
      </PrefetchMarketPrice>
      <ControlZone />
    </div>
  );
}

  • 툴팁이 아래 박스에 가려지지 않고 잘 보인다.

처음에는 잘 작동한다고 생각했는데, 코드를 리팩토링하면서 다른 기능들을 개발하다보니 몇가지 문제점이 있다고 느껴졌다.

  1. z-index 범위: 위 코드에서 결국 TitleBox 라는 영역 전체에 z-index를 부여했기 때문에 네비게이션 바 등 다른 z-index를 사용하는 영역과 충돌하는 경우가 있다.
  2. 리팩토링 불편함: 툴팁이 포함된 컴포넌트의 위치가 변하면 그 컨테이너까지 올라와서 z-index를 수정해야 하는 불편함이 있다.
  3. 2번과 연계하여 코드가 직관적이지 않다. (툴팁을 위한 z-index가 컨테이너에 있음)

그래서 이를 해결하기 위해 방법을 찾아보기로 했다.

React Portal을 이용한 해결

위 상황에서 느낀 문제점은 툴팁의 position이 컨테이너에 종속되어 있다 는 점이었다. 이를 해결하기 위해 이전에 모달을 구현해본 경험을 살려서 리액트의 portal을 활용하기로 했다.

참고: https://ko.react.dev/reference/react-dom/createPortal

툴팁을 선언한 위치 외의 DOM 위치에 툴팁을 그릴 수 있다.
= 현재 컨테이너나 계층 구조와 관계 없이 독립된 위치에서 포탈을 그릴 수 있다.
= 스타일 충돌과 같, 내가 느낀 문제점들을 해결할 수 있다.

라고 생각했고, 적용하기로 결정했다.

portal 적용하기

layout.tsx (RootLayout)

먼저 최상위 layout.tsx 파일의 RootLayoutchildren 부분 바깥에 툴팁을 그려줄 엘리먼트를 선언한다.

// layout.tsx
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={notoSansKr.className}>
        <ReactQueryProvider>{children}</ReactQueryProvider>
        <SpeedInsights />
        <div id="tooltips-portal" /> // 툴팁을 그릴 엘리먼트를 선언
      </body>
      <GoogleAnalytics gaId="G-NJ6JT3JXPP" />
    </html>
  );
}

그리고 global.css 파일에 해당 부분의 position을 선언해주었다.

/* 리액트 렌더링 영역과 독립된 영역 */
#tooltips-portal {
    position: absolute;
    z-index: var(--z-index-tooltip);
}

TooltipPortal.tsx

다음으로 위에서 선언한 엘리먼트로 내용물을 옮겨주는 TooltipPortal.tsx 파일을 선언했다.

클라이언트 컴포넌트 여도 서버에서 사전 렌더링 될 수 있기 때문에, 클라이언트 사이드로 마운트가 된 후를 보장하기 위해 useEffect를 통해 마운트 되었을 때에만 포탈을 열도록 선언했다.

  • 이를 보장하지 않으면 아래와 같이 컨테이너를 찾을 수 없다고 오류가 발생한다.
'use client';

import { createPortal } from 'react-dom';
import { ReactNode, useEffect, useState } from 'react';

type Props = {
  children: ReactNode;
};

export default function TooltipPortal({ children }: Props) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, [setMounted]);

  // 전역 레이아웃(layout.ts) 참조
  return mounted
    ? createPortal(children, document.getElementById('tooltips-portal')!)
    : null;
}

withTooltipPortal.tsx (Optional)

위 Tooltip을 그대로 사용해도 기능은 작동한다.

<TooltipPortal>
  <Tooltip id={...} place={...}>
    {/* tooltip 내용 */}
  </Tooltip>
</TooltipPortal>

그대로 마무리 해도 좋았지만, 개인적으로 포탈을 사용하는 곳 마다 위와 같이 한 depth를 더 늘려서 코드를 보여주는 것이 조금 아쉬웠다.

나는 아래와 같이 간단히 HOC를 이용해서 컴포넌트를 포탈로 감싸주도록 구현해서 적용했는데, 나쁘지 않다고 느껴졌다.

import TooltipPortal from '@/app/_business/TooltipPortal';
import IntrinsicAttributes = React.JSX.IntrinsicAttributes;

export default function withTooltipPortal<P extends IntrinsicAttributes>(
  TooltipComponent: React.ComponentType<P>,
) {
  const Component = (props: P) => {
    return (
      <TooltipPortal>
        <TooltipComponent {...props} />
      </TooltipPortal>
    );
  };

  return Component;
}

최종적으로 적용된 코드

코드상으로 바뀐 부분은 react-tooltip에서 가져온 Tooltip 컴포넌트 대신 HOC로 감싼 PortalTooltip 컴포넌트를 사용하도록 바뀌었다.

const PortalTooltip = withTooltipPortal(Tooltip);

export default function AncestorProtectionZone({ className }: Props) {
  // 상태 로직 생략..

  return (
    <div className={clsx(styles.container, className)}>
      <div
        className={styles.label}
        data-tooltip-id="ancestor-protection-information"
      >
        [선조의 가호]
      </div>
      <PortalTooltip id="ancestor-protection-information" place={'bottom'}>
        <div className={styles.tooltip}>
          <h3>선조의 가호</h3>
          <p>
            선조의 가호는 상급 재련을 6회 시도할 때 마다 사용할 수 있습니다.
          </p>
          <div>갈라투르의 망치(15%): 상급 재련 경험치 5배 증가</div>
          <div>겔라르의 (35%): 상급 재련 경험치 3배 증가</div>
          <div>
            쿠훔바르의 모루(15%): 상급 재련 경험치 30 추가 증가 및 선조의 가호
            재충전
          </div>
          <div>
            테메르의 (35%): 상급 재련 경험치 10 추가 증가 및 다음 상급 재련 시
            무료
          </div>
        </div>
      </PortalTooltip>
    </div>
  );
}

결론

  • Next.js에서도 portal을 사용할 수 있다.
  • react-tooltip 라이브러리를 사용할 때, 모달처럼 portal을 사용하여 조금 더 독립적으로 스타일을 관리할 수 있다.
profile
배짱개미 개발자 김승현입니다 🖐

0개의 댓글