Tooltip follow cursor 마우스 이벤트 성능 최적화 여정 🚀

정소현·2025년 6월 3일

프론트엔드 일지

목록 보기
3/10
post-thumbnail

Tooltip follow cursor 마우스 이벤트 성능 최적화 여정 🚀

안녕하세요 개발자 정소현입니다.

단순히 동작만 하는 코드가 아니라,
사용자에게 불편함 없이 매끄러운 경험을 제공하는 것.
실제로 개발하다 보면 이게 얼마나 중요한지 절실히 느끼게 됩니다.

이런 고민, 개발자라면 다들 한 번쯤 해보셨을 거라 생각합니다!! 😊

저도 최근에 회사에서 자체 디자인 시스템 Tooltip 컴포넌트를 개발하면서
마우스 커서를 따라다니는 구조에서 발생하는 성능 이슈를 직접 경험했고,
이를 단계적으로 개선해 나가며 진짜 사용자에게 쾌적한 경험을 주는 코드
가 무엇인지 다시 한 번 고민하게 됐습니다.

🔮 이 글에서는 Tooltip의 성능 병목을 어떻게 찾아내고,
단계적으로 최적화했는지,
그리고 실제 코드와 수치, 삽질(!) 경험까지 모두 정리해봤습니다.
그리고 어떤 코드기술을 적용했는지 이야기 해보려고 합니다.

특히, 이번 성능 저하는 Tooltip이 마우스 커서를 따라다니는 구조라서,
여러 개가 동시에 떠 있을 때 성능 저하가 체감될 정도로 발생했습니다.

1️⃣ 초기 구현의 문제점과 한계

처음엔 그냥 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 });
    }
  };

  // ...생략
};

🤷‍♀️ 성능 측정 결과 (최적화 전)


2. 1차 최적화: 이벤트와 위치 계산 개선

🛠️ 개선 포인트

  • 이벤트 최적화: throttle(16ms) + requestAnimationFrame으로 이벤트 빈도 제어
  • 위치 계산 정확도 향상: Floating UIcomputePosition + 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차 최적화)

(1) 1차 최적화 전후 비교

단계스크립트시스템렌더링페인팅합계
초기1,425ms227ms124ms76ms7,261ms
1차 최적화1,246ms181ms103ms60ms6,567ms
  • 스크립트: 1,425ms → 1,246ms (179ms 감소)
  • 합계: 7,261ms → 6,567ms (694ms 감소)
  • 렌더링/페인팅도 모두 감소

3. ✅ 최종 최적화: 상태 통합, 메모이제이션, 60fps 제어

🛠️ 개선 포인트

  • 상태 통합 관리: 연관된 상태들을 하나의 객체로 통합
  • 성능 메트릭 기반 최적화: 60fps(16.7ms) 기준으로 업데이트 주기 제어, performance.now()로 정확한 시간 측정
  • 메모이제이션 전략 적용: useMemo, useCallback 적극 활용
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까지 줄었습니다.

성능 측정 결과 (최종 최적화)

(2) 최종 최적화 전후 비교

단계스크립트시스템렌더링페인팅합계
1차 최적화1,246ms181ms103ms60ms6,567ms
최종251ms133ms67ms38ms6,398ms
  • 스크립트: 1,246ms → 251ms (무려 995ms 감소!)

  • 합계: 6,567ms → 6,398ms (169ms 감소)

  • 렌더링/페인팅도 더 줄었음


💯 비교 총 정리 !!

🏆 성능 비교 요약

단계스크립트시스템렌더링페인팅합계
초기1,425ms227ms124ms76ms7,261ms
1차 최적화1,246ms181ms103ms60ms6,567ms
최종251ms133ms67ms38ms6,398ms
  • 🥳 스크립트 실행 시간 1,000ms 이상 감소!

  • 🏃‍♂️ 렌더링/페인팅도 30% 이상 감소

  • 실제로 마우스 따라다니는 툴팁이 버벅임 없이 부드럽게 동작

💡 주요 기술/최적화 포인트

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, 마우스 이벤트 성능 고민 중이라면
이 글이 도움이 되셨길 바랍니다!

혹시 궁금한 점/더 좋은 방법 있으면 댓글로 공유해 주세요 🙌

긴 글 읽어주셔서 감사합니다!

profile
기술을 넘어 제품의 가치를 만드는 프론트엔드 엔지니어를 지향합니다.

0개의 댓글