1. 들어가며
웹 개발을 하다 보면 하나의 UI 컴포넌트로 여러 환경을 지원해야 하는 상황이 자주 발생합니다. 특히 모바일과 데스크톱을 동시에 고려해야 할 때가 그렇죠.
이번 글에서는 입력 방식에 따라 자동으로 Tooltip 또는 Popover를 렌더링하는 적응형 컴포넌트를 구현한 경험을 공유하려고 합니다. 마우스가 있는 환경에서는 hover 기반 Tooltip을, 터치만 가능한 환경에서는 클릭 기반 Popover를 자동으로 선택하는 컴포넌트입니다.
2. 문제 상황: Tooltip이 모바일에서 동작하지 않는다
PWA로 모바일과 PC를 동시 지원하는 과정에서
제 경우는 PWA(Progressive Web App)를 개발하면서 이 문제를 마주쳤습니다. PWA는 하나의 웹 코드로 모바일 앱처럼 설치도 가능하고, 데스크톱 브라우저에서도 동작하는 크로스 플랫폼 앱을 만들 수 있는 기술입니다.
문제는 UI 컴포넌트였습니다. 가격 정보 안내 아이콘에 Tooltip을 달아두었는데, PC 브라우저에서는 잘 작동하지만 스마트폰에 설치한 PWA에서는 아무리 눌러도 반응이 없는 거죠.
// 기존 코드 - PC에서만 작동
<Tooltip>
<TooltipTrigger>
<InfoIcon />
</TooltipTrigger>
<TooltipContent>
가격 정보 안내 내용
</TooltipContent>
</Tooltip>
이유는 간단했습니다. Tooltip은 기본적으로 hover 이벤트에 반응하는데, 터치 기기에는 hover라는 개념 자체가 없기 때문입니다. 손가락으로는 화면 위에 "올려둘" 수가 없으니까요.
모바일에서는 클릭(터치) 기반으로 열리고 닫히는 Popover가 필요했습니다. 하지만 PC에서 Popover를 쓰면 매번 클릭해야 해서 불편하고요. 결국 환경에 따라 다른 컴포넌트를 보여줘야겠다는 결론에 도달했습니다.
3. 잘못된 접근: 화면 크기로 분기하기
처음에는 가장 직관적인 방법을 시도했습니다. 화면 크기로 분기하는 것이죠.
// 잘못된 접근
const isMobile = window.innerWidth < 768;
return isMobile ? <Popover>...</Popover> : <Tooltip>...</Tooltip>;
이 방식의 문제점은 화면 크기와 입력 방식이 일치하지 않는 경우가 생각보다 많다는 것입니다.
실제로 테스트해보니 이런 상황들이 있더군요:
- 마우스가 연결된 태블릿: 화면은 작지만 마우스로 조작 가능. Tooltip이 필요함
- 작은 노트북: 화면은 768px 미만이지만 마우스/트랙패드 사용. Tooltip이 적합함
- 터치스크린 노트북: 화면은 크지만 터치로도 조작 가능. Popover가 필요한 상황도 있음
화면 크기는 결국 간접적인 지표일 뿐, 실제 입력 방식을 정확히 알려주지 못합니다. 다른 방법이 필요했습니다.
4. 해결책: (hover: none) 미디어 쿼리
찾아보니 CSS 미디어 쿼리에 딱 맞는 기능이 있었습니다. hover 미디어 쿼리입니다.
/* 호버가 가능한 기기 */
@media (hover: hover) {
/* 마우스, 트랙패드 등 */
}
/* 호버가 불가능한 기기 */
@media (hover: none) {
/* 터치스크린 */
}
이 미디어 쿼리는 화면 크기가 아니라 기기가 실제로 hover를 지원하는지를 감지합니다. 브라우저가 직접 입력 방식을 파악해서 알려주는 거죠.
hover: hover- 포인터를 정확하게 올릴 수 있음 (마우스, 트랙패드)hover: none- 호버가 불가능함 (터치스크린)
이제 이 미디어 쿼리를 React에서 어떻게 활용할지 고민했습니다.
5. 구현: AdaptiveTooltip 컴포넌트
useMediaQuery 훅 만들기
먼저 미디어 쿼리 상태를 추적하는 커스텀 훅을 만들었습니다.
// hooks/use-media-query.ts
"use client";
import { useState, useEffect } from "react";
export const useMediaQuery = (query: string): { matches: boolean; mounted: boolean } => {
const [matches, setMatches] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const media = window.matchMedia(query);
setMatches(media.matches);
const listener = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
// Modern browsers
if (media.addEventListener) {
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}
// Fallback for older browsers
else {
media.addListener(listener);
return () => media.removeListener(listener);
}
}, [query]);
return { matches, mounted };
};
이 훅은 두 가지 값을 반환합니다:
matches: 미디어 쿼리가 매칭되는지 여부mounted: 클라이언트에서 마운트가 완료되었는지 여부 (이건 6번에서 설명하겠습니다)
AdaptiveTooltip 컴포넌트 구현
이제 이 훅을 활용해서 적응형 컴포넌트를 만들었습니다. Tooltip과 Popover는 shadcn/ui 라이브러리를 사용했습니다.
// components/adaptive-tooltip.tsx
"use client";
import { useState, ReactNode } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useMediaQuery } from "@/hooks/use-media-query";
interface AdaptiveTooltipProps {
trigger: ReactNode;
content: ReactNode;
side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end";
className?: string;
contentClassName?: string;
}
export const AdaptiveTooltip = ({
trigger,
content,
side = "bottom",
align = "center",
className = "",
contentClassName = "",
}: AdaptiveTooltipProps) => {
const [isOpen, setIsOpen] = useState(false);
const { matches: isTouchDevice, mounted } = useMediaQuery("(hover: none)");
// 마운트 전에는 트리거만 렌더링
if (!mounted) {
return <div className={className}>{trigger}</div>;
}
// 터치 기기: Popover 사용 (클릭 기반)
if (isTouchDevice) {
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild className={className}>
{trigger}
</PopoverTrigger>
<PopoverContent
side={side}
align={align}
className={`bg-gray-900 text-white border-gray-800 ${contentClassName}`}
>
{content}
</PopoverContent>
</Popover>
);
}
// 마우스 기기: Tooltip 사용 (호버 기반)
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild className={className}>
{trigger}
</TooltipTrigger>
<TooltipContent
side={side}
align={align}
className={`bg-gray-900 text-white ${contentClassName}`}
>
{content}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
동작 방식은 간단합니다:
useMediaQuery("(hover: none)")로 터치 기기인지 확인- 터치 기기면 클릭으로 열리는 Popover 렌더링
- 마우스 기기면 호버로 열리는 Tooltip 렌더링
사용 예시
실제로 사용할 때는 이렇게 쓰면 됩니다:
import { AdaptiveTooltip } from "@/components/adaptive-tooltip";
import { InfoIcon } from "lucide-react";
function MyComponent() {
return (
<div>
<h2>
설정
<AdaptiveTooltip
trigger={<InfoIcon className="w-5 h-5 ml-2 cursor-pointer" />}
content={<p>설정을 변경할 수 있습니다</p>}
side="right"
/>
</h2>
</div>
);
}
PC에서는 아이콘에 마우스를 올리면 Tooltip이 뜨고, 모바일에서는 아이콘을 터치하면 Popover가 열립니다. 개발자는 환경을 신경 쓸 필요 없이 하나의 컴포넌트만 사용하면 되는 거죠.
6. SSR/Hydration 이슈 해결
여기서 중요한 부분이 하나 있습니다. mounted 상태를 왜 사용하는지 설명드리겠습니다.
Next.js 같은 SSR(Server-Side Rendering) 환경에서는 서버에서 먼저 HTML을 렌더링한 다음, 클라이언트에서 React가 다시 마운트되면서 이벤트를 연결합니다(Hydration). 이 과정에서 서버와 클라이언트의 렌더링 결과가 다르면 에러가 발생합니다.
문제는 window.matchMedia는 브라우저 API라서 서버에서는 실행할 수 없다는 것입니다. 그래서 이런 에러가 발생할 수 있죠:
Warning: Expected server HTML to contain a matching <div> in <div>
해결 방법은 마운트가 완료될 때까지 최소한의 UI만 렌더링하는 것이었습니다.
// 마운트 전에는 트리거만 보여줌
if (!mounted) {
return <div className={className}>{trigger}</div>;
}
이렇게 하면:
- 서버: 트리거만 렌더링
- 클라이언트 첫 렌더링: 트리거만 렌더링 (서버와 일치)
- useEffect 실행 후:
mounted가true가 되고 미디어 쿼리 결과에 따라 Tooltip/Popover 렌더링
여기서 중요한 판단이 하나 있었는데, Tooltip/Popover는 보조 정보를 제공하는 요소라 SEO에 크게 영향을 주지 않는다는 점이었습니다. 검색 엔진이 크롤링할 때 중요한 건 트리거 요소(예: 아이콘, 버튼)이지, 호버나 클릭 시 나타나는 부가 설명이 아니니까요. 그래서 mounted 전에는 트리거만 렌더링해도 괜찮다고 판단했습니다.
실제로 적용해보니 Hydration 에러가 완전히 사라지더군요. 사용자 입장에서는 화면이 로드되는 순간부터 제대로 동작하는 컴포넌트를 보게 됩니다.
7. 실전 적용 및 테스트
다양한 환경에서 테스트
실제로 여러 환경에서 테스트해봤습니다:
| 환경 | 입력 방식 | 렌더링 결과 |
|---|---|---|
| iPhone (Safari) | 터치 | Popover ✅ |
| Android (Chrome) | 터치 | Popover ✅ |
| MacBook (Chrome) | 트랙패드 | Tooltip ✅ |
| Windows (Edge) | 마우스 | Tooltip ✅ |
| iPad + 마우스 | 마우스 | Tooltip ✅ |
| Surface (터치) | 터치 | Popover ✅ |
모든 환경에서 예상대로 동작했습니다. 특히 iPad에 마우스를 연결했을 때 Tooltip으로 전환되는 것을 확인했을 때는 뿌듯하더군요.
접근성 고려사항
Tooltip과 Popover 모두 shadcn/ui를 사용했는데, 이미 접근성이 잘 구현되어 있습니다:
- 키보드 네비게이션 지원 (Tab, Escape)
aria-describedby등 적절한 ARIA 속성- 포커스 관리
추가로 트리거 요소에는 항상 의미있는 레이블을 달아줬습니다:
<AdaptiveTooltip
trigger={
<button aria-label="가격 정보 안내">
<InfoIcon />
</button>
}
content="가격 정보 안내 내용"
/>
8. 마치며
입력 방식에 따라 자동으로 적응하는 UI 컴포넌트를 구현해봤습니다. 핵심은 화면 크기가 아니라 실제 입력 기능을 감지하는 것이었습니다.
이 패턴은 Tooltip/Popover 외에도 다양하게 응용할 수 있습니다:
- Dropdown 메뉴 (호버 vs 클릭)
- Context Menu (우클릭 vs 롱프레스)
- 네비게이션 메뉴 (hover submenu vs 클릭)
하나의 코드로 모든 환경에서 최적의 UX를 제공하는 것, 생각보다 어렵지만 (hover: none) 미디어 쿼리를 알고 나면 충분히 구현 가능합니다.
PWA나 크로스 플랫폼 웹 앱을 개발하고 계신다면 한번 적용해보시길 추천드립니다!
참고 자료
'Front-end > NextJS' 카테고리의 다른 글
| NextJS API Routes Caching (1) | 2024.09.13 |
|---|