

안녕하세요 개발자 정소현입니다.
단순히 동작만 하는 코드가 아니라,
사용자에게 불편함 없이 매끄러운 경험을 제공하는 것.
실제로 개발하다 보면 이게 얼마나 중요한지 절실히 느끼게 됩니다.
이런 고민, 개발자라면 다들 한 번쯤 해보셨을 거라 생각합니다!! 😊
저도 최근에 회사에서 자체 디자인 시스템 Tooltip 컴포넌트를 개발하면서
마우스 커서를 따라다니는 구조에서 발생하는 성능 이슈를 직접 경험했고,
이를 단계적으로 개선해 나가며 진짜 사용자에게 쾌적한 경험을 주는 코드
가 무엇인지 다시 한 번 고민하게 됐습니다.
🔮 이 글에서는 Tooltip의 성능 병목을 어떻게 찾아내고,
단계적으로 최적화했는지,
그리고 실제 코드와 수치, 삽질(!) 경험까지 모두 정리해봤습니다.
그리고 어떤 코드와 기술을 적용했는지 이야기 해보려고 합니다.
특히, 이번 성능 저하는 Tooltip이 마우스 커서를 따라다니는 구조라서,
여러 개가 동시에 떠 있을 때 성능 저하가 체감될 정도로 발생했습니다.
처음엔 그냥 mousemove 이벤트에서 마우스 좌표만 받아서
툴팁 위치를 setState로 계속 업데이트하는 방식이었습니다.
mousemove 이벤트가 발생할 때마다 상태가 계속 업데이트 → 불필요한 리렌더링 폭발
isOpen, isMounted, tooltipPosition 등 상태가 각각 관리되어 연관된 상태 업데이트가 따로따로 발생
위치 계산도 단순히 마우스 좌표만 사용해서, 화면 경계나 스크롤 위치 등이 고려되지 않음
🥲 실제로 마우스를 빠르게 움직이면 툴팁이 따라오지 못하고,
CPU(스크립트, 렌더링, 페인팅) 사용량이 급증하는 현상이 발생했습니다.
export const TooltipInitial = ({
content,
children,
placement,
}: TooltipProps) => {
const [isOpen, setIsOpen] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null);
const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
if (!placement) {
setTooltipPosition({ x: e.clientX, y: e.clientY });
}
};
// ...생략
};

throttle(16ms) + requestAnimationFrame으로 이벤트 빈도 제어Floating UI의 computePosition + VirtualElement 활용const handleMouseMove = useCallback(
throttle((e: MouseEvent<HTMLDivElement>) => {
if (!placement) {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
updateTooltipPosition(e.clientX, e.clientY);
});
}
}, 16),
[placement, updateTooltipPosition]
);
const updateTooltipPosition = useCallback(
async (x: number, y: number) => {
if (!refs.floating.current || placement) return;
const virtualEl: VirtualElement = {
getBoundingClientRect() {
return {
width: 0,
height: 0,
x,
y,
top: y,
right: x,
bottom: y,
left: x,
};
},
};
const { x: computedX, y: computedY } = await computePosition(
virtualEl,
refs.floating.current,
{
placement: "bottom-start",
middleware: [offset(10)],
}
);
setTooltipPosition({ x: computedX, y: computedY });
},
[placement, refs.floating]
);
→ 마우스가 움직일 때마다 무작정 setState하지 않고,
→ 브라우저가 렌더링할 타이밍에만 위치를 계산해서
→ 불필요한 연산을 확 줄였습니다!
1차 최적화 성능 측정 결과 이벤트 처리 횟수가 줄고, 툴팁이 훨씬 부드럽게 따라오게 됐어요.

| 단계 | 스크립트 | 시스템 | 렌더링 | 페인팅 | 합계 |
|---|---|---|---|---|---|
| 초기 | 1,425ms | 227ms | 124ms | 76ms | 7,261ms |
| 1차 최적화 | 1,246ms | 181ms | 103ms | 60ms | 6,567ms |
const [state, setState] = useState({
isOpen: false,
isMounted: false,
tooltipPosition: null,
});
const lastUpdateTime = useRef(0);
const UPDATE_INTERVAL = 1000 / 60;
const updateTooltipPosition = useCallback(async (x, y) => {
if (!refs.floating.current || placement) return;
const now = performance.now();
if (now - lastUpdateTime.current < UPDATE_INTERVAL) return;
lastUpdateTime.current = now;
const virtualEl: VirtualElement = {
getBoundingClientRect() {
return {
width: 0,
height: 0,
x,
y,
top: y,
right: x,
bottom: y,
left: x,
};
},
};
const { x: computedX, y: computedY } = await computePosition(
virtualEl,
refs.floating.current,
{
placement: "bottom-start",
middleware: [offset(10)],
}
);
setState((prev) => ({
...prev,
tooltipPosition: { x: computedX, y: computedY },
}));
}, [placement, refs.floating]);
→ 상태를 한 번에 묶어서 관리하고,
→ 60fps에 맞춰 위치 업데이트를 제한!
→ useMemo, useCallback으로 불필요한 함수/스타일 재생성도 방지!
최종 최적화 성능 측정 결과 상태 업데이트가 한 번에 일어나고,
불필요한 렌더링이 줄어들면서
스크립트 실행 시간이 251ms까지 줄었습니다.

| 단계 | 스크립트 | 시스템 | 렌더링 | 페인팅 | 합계 |
|---|---|---|---|---|---|
| 1차 최적화 | 1,246ms | 181ms | 103ms | 60ms | 6,567ms |
| 최종 | 251ms | 133ms | 67ms | 38ms | 6,398ms |
스크립트: 1,246ms → 251ms (무려 995ms 감소!)
합계: 6,567ms → 6,398ms (169ms 감소)
렌더링/페인팅도 더 줄었음
🏆 성능 비교 요약
단계 스크립트 시스템 렌더링 페인팅 합계 초기 1,425ms 227ms 124ms 76ms 7,261ms 1차 최적화 1,246ms 181ms 103ms 60ms 6,567ms 최종 251ms 133ms 67ms 38ms 6,398ms
requestAnimationFrame / cancelAnimationFrame
→ 브라우저 리페인트 주기에 맞춰 위치 업데이트, 불필요한 연산 방지
performance.now()
→ 60fps(16.7ms) 기준으로 업데이트 주기 제어
VirtualElement
→ 실제 DOM 없이 마우스 위치 기준으로 툴팁 위치 계산
rafRef.current = requestAnimationFrame(() => {
updateTooltipPosition(e.clientX, e.clientY);
});
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
performance.now()
const now = performance.now();
if (now - lastUpdateTime.current < UPDATE_INTERVAL) {
return;
}
VirtualElement
const virtualEl: VirtualElement = {
getBoundingClientRect() {
return {
width: 0,
height: 0,
x,
y,
top: y,
right: x
마우스 이벤트는 자칫 잘못 다루면 성능 저하의 주범이 될 수 있습니다.
툴팁처럼 자주 등장하는 UI 요소에서 마우스가 느려지거나, 원하는 대로 동작하지 않으면 사용자 경험에 직접적인 불편함이 생기죠.
여러분도 Tooltip, 마우스 이벤트 성능 고민 중이라면
이 글이 도움이 되셨길 바랍니다!
혹시 궁금한 점/더 좋은 방법 있으면 댓글로 공유해 주세요 🙌
긴 글 읽어주셔서 감사합니다!