(번역) UseEffectEvent : From A to Z

쌍제이(JJVoiture)·2025년 12월 30일
post-thumbnail

원문: UseEffectEvent : From A to Z

UseEffectEvent: A to Z (useEffectEvent의 모든 것)

React의 useEffectEvent 완벽 가이드: useEffectEvent 이해와 코딩 경험의 변화

React는 이벤트 리스너, 구독(subscription), 그리고 오래 실행되는 콜백(long-lived callback) 작업을 단순화하기 위해 useEffectEvent라는 새로운 훅(hook)을 도입했습니다. 이를 통해 불필요한 이펙트(effect) 재실행 없이 최신 상태(state)에 접근할 수 있습니다.

🧠 왜 useEffectEvent가 존재하는가?

React 함수형 컴포넌트는 렌더링될 때마다 클로저를 재생성합니다.

useEffect 내부에서 props나 state를 사용하는 콜백을 정의하면, 그 콜백은 렌더링 시점의 값을 캡처합니다. 이로 인해 이벤트 핸들러가 예전 데이터를 바라보는 오래된 클로저(stale closures) 문제가 자주 발생합니다.

전통적으로 개발자들은 다음 중 하나를 선택해야 했습니다.

  • 모든 상태 변경마다 다시 구독하기 (최신 상태 유지는 좋지만, 성능에 나쁨)
  • Refs를 사용하여 수동으로 데이터를 최신 상태로 유지하기 (코드가 길어지고 에러 발생 쉬움)

useEffectEvent는 이를 사용하는 이펙트의 의존성이 되지 않으면서도, 항상 최신 값을 바라보는 안정적인 콜백을 생성할 수 있게 하여 이 문제를 해결합니다.

🧱 오래된 클로저(Stale Closure) 문제

다음 예시를 봅시다.

function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const handler = () => {
      console.log('Current count:', count);
    };

    window.addEventListener('click', handler);
    return () => window.removeEventListener('click', handler);
  }, []); // ⛔ 빈 의존성 배열
}

count가 변경된 후에 클릭을 해도, 로그에는 항상 초기값만 찍힙니다.
핸들러가 첫 번째 렌더링의 클로저에 묶여있기 때문입니다.

🧰 useEffectEvent 이전에 해결했던 방법들

개발자들은 보통 다음 3가지 패턴 중 하나를 사용했습니다.

1. 의존성이 바뀔 때마다 리스너 다시 등록하기

useEffect(() => {
  const handler = () => {
    console.log('Current count:', count);
  };

  window.addEventListener('click', handler);
  return () => window.removeEventListener('click', handler);
}, [count]); // 👈 deps에 count 추가
  • ✅ 핸들러가 최신 상태를 유지함
  • count가 바뀔 때마다 이벤트 리스너가 제거되고 다시 추가됨 (성능에 나쁨, 특히 빈번한 이벤트의 경우)

2. Ref를 사용하여 최신 값 유지하기

const countRef = useRef(count);

useEffect(() => {
  countRef.current = count;
}, [count]);

useEffect(() => {
  const handler = () => {
    console.log('Current count:', countRef.current);
  };

  window.addEventListener('click', handler);
  return () => window.removeEventListener('click', handler);
}, []);
  • ✅ 리스너가 한 번만 부착됨
  • ✅ 항상 최신 값을 사용함
  • ❌ 추가적인 보일러플레이트 필요: ref 하나와 동기화를 위한 이펙트 하나

이것이 가장 널리 사용되던 우회 방법이었습니다.

3. 커스텀 useEventCallback 훅

많은 팀들이 useEffectEvent를 흉내 낸 커스텀 훅을 사용했습니다.

function useEventCallback(fn) {
  const ref = useRef(fn);
  useEffect(() => {
    ref.current = fn;
  });
  return useCallback((...args) => ref.current(...args), []);
}
  • ✅ 이펙트 내부에서 사용하기 깔끔함
  • ❌ 공식적으로 지원되지 않으며, 미묘한 실수가 발생하기 쉬움

🆕 useEffectEvent 등장

import { useEffectEvent } from 'react';

useEffectEvent를 사용하면 다음과 같은 콜백을 정의할 수 있습니다.

  • 항상 최신 state/props를 가짐
  • 변경되어도 이펙트 재실행을 유발하지 않음
  • ✅ 이벤트 리스너, 구독, 타이머 내부에서 사용하기에 안전함
  • 렌더링 도중에는 사용할 수 없음 — 오직 이펙트나 외부 콜백 내부에서만 사용 가능

예시

function Example({ user }) {
  const onResize = useEffectEvent(() => {
    console.log('Current width:', window.innerWidth);
    console.log('Current user:', user.name);
  });

  useEffect(() => {
    const handler = () => onResize();
    window.addEventListener('resize', handler);
    return () => window.removeEventListener('resize', handler);
  }, []); // 👈 한 번만 실행됨
}
  • ✅ 리스너는 한 번만 부착됨
  • onResize는 항상 최신 user를 바라봄
  • ✅ Ref를 조작하거나 의존성이 폭발하는 문제 없음

⚙️ 작동 원리 (Under the Hood)

useEffectEvent(fn)는 마치 다음과 같이 동작합니다.

const ref = useRef(fn);
ref.current = fn;
const stableCallback = useCallback((...args) => ref.current(...args), []);

하지만 React가 타이밍, 업데이트, 그리고 렌더링 모델과의 통합을 대신 처리해주어 안전하고 일관성 있게 동작합니다.

🧭 완벽한 사용 사례

  • DOM 이벤트 (resize, scroll 등) =WHY=> 리스너는 한 번만 추가하고, 콜백은 항상 최신 상태 유지
  • 구독 (WebSocket, SignalR) =WHY=> 이벤트 핸들러가 최신 상태를 필요로 함
  • 타이머 (setTimeout, setInterval) =WHY=> 콜백이 나중에 실행되지만 최신 상태를 사용해야 함
  • 서드파티 SDK 콜백 =WHY=> 한 번만 등록하고 오래된 클로저 문제 방지

🧪 실제 예제: SignalR Hook

useEffect(() => {
  const handleVisibilityChange = () => {
    if (document.visibilityState === 'visible' && connectionState === 'disconnected' && enabled) {
      if (!connectionRef.current) {
        createAndStartConnection();
      } else {
        connectionRef.current
          .start()
          .then(() => {
            setConnectionState('connected');
            resetInactivityTimer();
          })
          .catch((err) => {
            console.error('Reconnect failed:', err);
            setConnectionState('error');
          });
      }
    }
  };

  window.addEventListener('visibilitychange', handleVisibilityChange);
  return () => window.removeEventListener('visibilitychange', handleVisibilityChange);
}, [connectionState, enabled, createAndStartConnection, resetInactivityTimer]);

문제점:

  • 의존성이 바뀔 때마다 이펙트가 실행됨
  • 불필요한 재구독 발생
  • 이벤트 중복이나 깜빡임 발생 가능성

✅ useEffectEvent로 리팩토링:

const handleVisibilityChangeEvent = useEffectEvent(() => {
  if (document.visibilityState === 'visible' && connectionState === 'disconnected' && enabled) {
    if (!connectionRef.current) {
      createAndStartConnection();
    } else {
      connectionRef.current
        .start()
        .then(() => {
          setConnectionState('connected');
          resetInactivityTimer();
        })
        .catch((err) => {
          console.error('Reconnect failed:', err);
          setConnectionState('error');
        });
    }
  }
});

useEffect(() => {
  const handler = () => handleVisibilityChangeEvent();
  window.addEventListener('visibilitychange', handler);
  return () => window.removeEventListener('visibilitychange', handler);
}, []); // 👈 한 번만 실행됨
  • ✅ 리스너가 한 번만 부착됨
  • ✅ 콜백이 최신 상태를 유지함
  • ✅ 의존성이 단순화됨

🌍 백그라운드 동작 방식

useEffectEvent는 콜백을 내부 ref에 저장합니다. 매 렌더링마다 해당 ref를 최신 콜백으로 업데이트합니다. 이벤트 리스너를 부착하는 이펙트는 새로운 의존성을 보지 못하므로 다시 실행되지 않습니다. 이벤트가 발생하면 React는 최신 콜백을 조회하여 실행합니다. 이를 통해 백그라운드 시스템(DOM이나 WebSocket 이벤트 등)은 안정적으로 유지되면서 UI 상태는 자유롭게 변경될 수 있습니다.

⚠️ 흔히 할 수 있는 실수

  • 렌더링 도중에 useEffectEvent 사용하기
  • ✅ 오직 이펙트와 외부 콜백을 위한 것입니다.
  • 컴포넌트를 리렌더링한다고 가정하기
  • ✅ 그렇지 않습니다. 반응형(reactive)이 아니라 단지 "최신(fresh)" 상태일 뿐입니다.
  • 같은 리스너 내에서 오래된 클로저와 useEffectEvent 섞어 쓰기
  • ✅ 모든 이벤트 로직을 useEffectEvent 콜백 안으로 옮기세요.

🧠 핵심 요약

useEffectEventuseEffect의 대체제가 아니라 동반자입니다. 이펙트 재실행을 유발하지 않으면서 이벤트 핸들러 내에서 최신 상태를 얻을 수 있게 해줍니다.

이벤트 리스너, 구독, 타이머와 같은 백그라운드의 오래 지속되는 동작에 이상적입니다.

이는 ref + callback 편법을 깔끔한 공식 API로 대체합니다. 성능, 가독성, 정확성을 향상시킵니다.

useEffectEvent 이전에는 개발자들이 콜백을 최신으로 유지하기 위해 ref 우회법이나 의존성 조작에 의존했습니다. 이제 React는 이를 깔끔하게 처리할 수 있는 일급(first-class) API를 제공합니다.

👉 만약 당신이:

  • 이펙트에 콜백을 추가하고 있거나,
  • 오래된 값(stale values)을 걱정하고 있거나,
  • 의존성 배열에 무언가를 추가해야 할지 고민하고 있다면,

useEffectEvent가 바로 당신에게 필요한 것입니다.

profile
안녕하세요. 중구난방 개발자 쌍제이입니다.

0개의 댓글