최근 토이 프로젝트로 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>
);
}
코드를 간단히 설명하면, 첫번째 div
의 data-tooltip-id
props로 Tooltip
의 id
와 동일한 값을 전달하면 라이브러리에서 해당 엘리먼트에 상호작용 했을 때 툴팁을 표시하도록 지원해준다.
위 코드와 같이 작성하고, 실제로 적용된 모습을 보니 아래와 같이 잘리는 모습을 볼 수 있었다.
툴팁 컴포넌트가 가려지므로, 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>
);
}
처음에는 잘 작동한다고 생각했는데, 코드를 리팩토링하면서 다른 기능들을 개발하다보니 몇가지 문제점이 있다고 느껴졌다.
z-index
범위: 위 코드에서 결국 TitleBox
라는 영역 전체에 z-index
를 부여했기 때문에 네비게이션 바 등 다른 z-index
를 사용하는 영역과 충돌하는 경우가 있다.z-index
를 수정해야 하는 불편함이 있다.z-index
가 컨테이너에 있음)그래서 이를 해결하기 위해 방법을 찾아보기로 했다.
위 상황에서 느낀 문제점은 툴팁의 position이 컨테이너에 종속되어 있다
는 점이었다. 이를 해결하기 위해 이전에 모달을 구현해본 경험을 살려서 리액트의 portal
을 활용하기로 했다.
툴팁을 선언한 위치 외의 DOM 위치에 툴팁을 그릴 수 있다.
= 현재 컨테이너나 계층 구조와 관계 없이 독립된 위치에서 포탈을 그릴 수 있다.
= 스타일 충돌과 같, 내가 느낀 문제점들을 해결할 수 있다.
라고 생각했고, 적용하기로 결정했다.
먼저 최상위 layout.tsx
파일의 RootLayout
내 children
부분 바깥에 툴팁을 그려줄 엘리먼트를 선언한다.
// 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
파일을 선언했다.
클라이언트 컴포넌트
여도 서버에서 사전 렌더링 될 수 있기 때문에, 클라이언트 사이드로 마운트가 된 후를 보장하기 위해 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;
}
위 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
을 사용하여 조금 더 독립적으로 스타일을 관리할 수 있다.