Skip to content
Go back

React useEffectEvent 완벽 가이드: Effect 안의 비반응형 로직을 우아하게 다루기

Published:  at  12:56 PM

useEffect 안에서 최신 props를 읽고 싶은데, 의존성 배열에 넣으면 Effect가 불필요하게 재실행됩니다. 의존성에서 빼면 린터가 경고하고, useRef로 우회하면 보일러플레이트가 늘어납니다. React 개발자라면 한 번쯤 겪어본 딜레마입니다.

useEffectEvent 는 이 문제를 근본적으로 해결합니다. React 19.2에서 stable로 전환된 이 Hook은 Effect 내부의 비반응형 로직을 깔끔하게 분리하여, 의존성 배열을 정직하게 유지하면서도 항상 최신 값에 접근할 수 있게 해줍니다.

이 글에서는 useEffectEvent의 탄생 배경부터 API, 실전 예제, 주의사항까지 하나씩 살펴보겠습니다.


🤔 왜 useEffectEvent가 필요한가?

useEffect 의존성 배열의 딜레마

채팅방 연결 기능을 구현한다고 가정해 보겠습니다. roomId가 바뀌면 새로운 방에 재연결해야 하고, 연결 성공 시 현재 테마에 맞는 알림을 표시해야 합니다.

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on("connected", () => {
      showNotification("Connected!", theme); // theme을 읽어야 함
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, theme]); // 🔴 theme 변경마다 재연결됨!
}

theme을 의존성 배열에 포함하면, 사용자가 다크 모드로 전환할 때마다 채팅 연결이 끊겼다가 다시 연결됩니다. 테마 변경과 채팅 연결은 아무 관계가 없는데 말이죠.

기존 해결책들의 한계

이 문제를 해결하기 위해 다양한 패턴이 시도되었습니다.

방법 1: 의존성 배열에서 제거 (린터 경고 무시)

useEffect(() => {
  // ...
  showNotification("Connected!", theme);
}, [roomId]); // eslint-disable-line react-hooks/exhaustive-deps

린터를 무시하면 당장은 동작하지만, theme이 stale closure에 갇혀 초기 값만 참조하는 버그가 발생합니다.

방법 2: useRef로 최신 값 유지

const themeRef = useRef(theme);
themeRef.current = theme; // 매 렌더링마다 수동 업데이트

useEffect(() => {
  const connection = createConnection(serverUrl, roomId);
  connection.on("connected", () => {
    showNotification("Connected!", themeRef.current);
  });
  connection.connect();
  return () => connection.disconnect();
}, [roomId]);

동작은 하지만, 매번 ref를 선언하고 .current를 업데이트하는 보일러플레이트가 필요합니다. 값이 여러 개면 ref도 여러 개 만들어야 합니다. 린터도 이 패턴의 정확성을 검증하지 못합니다.

Stale Closure, 정확히 무엇인가

JavaScript의 클로저는 함수가 생성된 시점의 변수를 캡처합니다. useEffect의 콜백도 마찬가지입니다.

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

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // 항상 0을 출력!
    }, 1000);
    return () => clearInterval(id);
  }, []); // count를 의존성에 넣지 않음
}

의존성 배열이 []이므로 Effect는 마운트 시 한 번만 실행됩니다. 이때 콜백이 캡처한 count0이고, 이후 count가 아무리 변경되어도 콜백은 여전히 0을 참조합니다. 이것이 stale closure 문제입니다.

useEffectEvent는 이 근본적인 문제를 해결합니다.


🔍 useEffectEvent란?

핵심 아이디어

useEffectEvent는 Effect 내부에서 발생하는 비반응형(non-reactive) 로직을 별도의 “Effect Event”로 분리하는 Hook입니다.

핵심 특성은 두 가지입니다.

  1. 항상 최신 props/state에 접근합니다. 클로저에 갇히지 않습니다.
  2. 의존성 배열에 포함되지 않습니다. Effect의 재실행을 트리거하지 않습니다.

API

import { useEffectEvent } from "react";

const onEvent = useEffectEvent(callback);

파라미터

반환값

동작 원리

useEffectEvent로 래핑된 함수는 호출 시점에 가장 최근 커밋된 렌더링의 값에 접근합니다. 마치 “이 함수를 호출할 때, 지금 현재의 props와 state를 봐줘”라고 말하는 것과 같습니다.

function ChatRoom({ roomId, theme }) {
  // theme이 바뀌어도 Effect는 재실행되지 않음
  const onConnected = useEffectEvent(() => {
    showNotification("Connected!", theme); // 항상 최신 theme
  });

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on("connected", () => {
      onConnected(); // Effect Event 호출
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ✅ roomId만 의존성 - theme 변경은 재연결하지 않음
}

이제 theme이 변경되어도 채팅 연결은 유지되면서, 실제로 연결이 성공했을 때는 그 시점의 최신 theme 값으로 알림을 표시합니다.

탄생까지의 여정

useEffectEvent는 하루아침에 만들어진 것이 아닙니다. 7년에 걸친 논의와 설계를 거쳤습니다.

시기이벤트
2018년 11월Dan Abramov가 GitHub Issue #14099 제기 — useCallback()이 너무 자주 무효화되는 문제
2022년 5월useEvent RFC 제안 (reactjs/rfcs#220)
2022년 9월useEffectEvent로 이름 변경, 범위를 Effect 내부 전용으로 축소
2024년 12월React 19 출시 (experimental 상태)
2025년 10월React 19.2에서 stable로 전환

처음에는 useEvent라는 이름으로, Effect뿐 아니라 이벤트 핸들러에서도 사용할 수 있는 범용 Hook으로 설계되었습니다. 하지만 Concurrent Mode에서 렌더링 중간에 호출하면 잘못된 값을 참조하는 문제가 발견되어, Effect 내부 전용으로 범위가 축소되면서 useEffectEvent로 이름이 바뀌었습니다.


🛠️ 실전 예제

예제 1: 타이머와 최신 값

setInterval에서 최신 state를 참조하는 전형적인 패턴입니다.

function Timer() {
  const [count, setCount] = useState(0);
  const [increment, setIncrement] = useState(1);

  const onTick = useEffectEvent(() => {
    setCount(count + increment); // 항상 최신 count, increment를 읽음
  });

  useEffect(() => {
    const id = setInterval(() => {
      onTick();
    }, 1000);
    return () => clearInterval(id);
  }, []); // ✅ 빈 의존성 - 타이머가 재시작되지 않음

  return (
    <div>
      <p>Count: {count}</p>
      <label>
        Increment:
        <input
          type="number"
          value={increment}
          onChange={e => setIncrement(Number(e.target.value))}
        />
      </label>
    </div>
  );
}

useEffectEvent 없이 이 코드를 작성하려면, countincrement를 모두 의존성에 넣어야 합니다. 그러면 값이 바뀔 때마다 타이머가 초기화되어 간격이 불규칙해집니다.

예제 2: 분석(Analytics) 로깅

페이지 방문 시 URL과 장바구니 아이템 수를 로깅하는 상황입니다. URL이 바뀔 때만 로깅해야 하고, 장바구니 변경은 새로운 로그를 트리거하면 안 됩니다.

function ProductPage({ url, cartItems }) {
  const onVisit = useEffectEvent((visitedUrl: string) => {
    logAnalytics(visitedUrl, cartItems.length);
  });

  useEffect(() => {
    onVisit(url);
  }, [url]); // ✅ url 변경 시에만 로깅, cartItems 변경은 무시
}

여기서 주목할 점은 url을 Effect Event의 인자로 전달한다는 것입니다. url은 반응형으로 동작해야 하므로(URL이 바뀌면 로깅해야 하므로) 의존성 배열에 남기고, cartItems는 비반응형이므로 Effect Event 내에서 직접 읽습니다.

예제 3: 메시지 구독과 사용자 설정

채팅 메시지를 구독하되, 알림 스타일은 사용자 설정에 따라 달라지는 경우입니다.

function ChatNotifications({ roomId, userPreference }) {
  const onMessage = useEffectEvent((msg: Message) => {
    showToast(msg.text, { variant: userPreference });
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    const unsubscribe = connection.subscribe(onMessage);
    return () => unsubscribe();
  }, [roomId]); // ✅ roomId 변경 시에만 재구독
}

userPreference가 “compact”에서 “detailed”로 바뀌어도 채팅 연결은 끊기지 않습니다. 새 메시지가 도착하면 그 시점의 최신 userPreference 값을 읽어 알림을 표시합니다.

예제 4: 커스텀 Hook — useInterval

useEffectEvent는 커스텀 Hook 내부에서 특히 빛을 발합니다. 재사용 가능한 useInterval Hook을 만들어 보겠습니다.

function useInterval(callback: () => void, delay: number | null) {
  const onTick = useEffectEvent(callback);

  useEffect(() => {
    if (delay === null) return;

    const id = setInterval(() => {
      onTick();
    }, delay);
    return () => clearInterval(id);
  }, [delay]); // ✅ delay만 의존성
}

사용하는 쪽에서는 callback의 의존성을 신경 쓸 필요가 없습니다.

function AutoSave({ data }) {
  const [savedAt, setSavedAt] = useState<Date | null>(null);

  useInterval(() => {
    saveToServer(data); // 항상 최신 data를 저장
    setSavedAt(new Date());
  }, 30000);

  return <p>Last saved: {savedAt?.toLocaleTimeString() ?? "Not yet"}</p>;
}

예제 5: 이벤트 리스너와 조건부 로직

마우스 이동에 반응하되, canMove prop에 따라 동작을 제어하는 캔버스 컴포넌트입니다.

function Canvas({ canMove }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  const onMove = useEffectEvent((e: PointerEvent) => {
    if (canMove) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
  });

  useEffect(() => {
    window.addEventListener("pointermove", onMove);
    return () => window.removeEventListener("pointermove", onMove);
  }, []); // ✅ canMove 변경이 리스너 재등록을 트리거하지 않음

  return (
    <div
      style={{
        position: "absolute",
        left: position.x,
        top: position.y,
        width: 20,
        height: 20,
        borderRadius: "50%",
        background: "blue",
      }}
    />
  );
}

canMove를 의존성에 넣으면 truefalse 전환 시마다 이벤트 리스너가 해제되었다가 다시 등록됩니다. useEffectEvent를 사용하면 리스너는 한 번만 등록하고, 호출 시점에 canMove의 최신 값을 확인합니다.

예제 6: 폴링 대시보드

주기적으로 데이터를 가져오되, 필터 옵션이 다음 폴링에 자동 반영되어야 하는 대시보드입니다.

function Dashboard({ teamId, includeArchived }) {
  const [data, setData] = useState(null);

  const fetchData = useEffectEvent(async () => {
    const response = await fetch(
      `/api/team/${teamId}/tasks?archived=${includeArchived}`
    );
    setData(await response.json());
  });

  useEffect(() => {
    fetchData(); // 즉시 첫 fetch
    const intervalId = setInterval(fetchData, 10_000);
    return () => clearInterval(intervalId);
  }, [teamId]); // ✅ teamId 변경 시에만 폴링 재시작
}

includeArchived 체크박스를 토글해도 폴링 타이머는 초기화되지 않습니다. 다음 10초 주기가 되면 자동으로 최신 includeArchived 값이 반영된 요청이 나갑니다.


⚖️ useEffect vs useEffectEvent 비교

두 Hook의 역할과 차이를 정리합니다.

측면useEffectuseEffectEvent
목적의존성 변경 시 사이드 이펙트 실행Effect 내 비반응형 로직 래핑
의존성모든 반응형 값을 나열해야 함의존성 배열에서 제외됨
최신 값 접근의존성 배열을 통해 업데이트호출 시점에 항상 최신 값
함수 정체성렌더링 간 안정적 (cleanup/setup 기준)매 렌더링마다 변경 (의도적 설계)
호출 위치React가 자동 호출Effect 또는 다른 Effect Event 내부에서만
재실행 조건의존성 변경 시해당 없음 (Effect 내부에서 명시적으로 호출)

useCallback과 무엇이 다른가?

useCallback과 혼동하기 쉽지만 근본적으로 다릅니다.

// useCallback: 의존성이 변경되면 새 함수 생성 → 반응형
const handleClick = useCallback(() => {
  console.log(count);
}, [count]);

// useEffectEvent: 의존성 없음, 항상 최신 값 → 비반응형
const onTick = useEffectEvent(() => {
  console.log(count);
});
측면useCallbackuseEffectEvent
반응성반응형 (의존성 변경 시 새 참조)비반응형 (의존성에서 제외)
사용 범위어디서든 사용 가능Effect 내부에서만 호출 가능
주요 용도자식 컴포넌트에 콜백 전달 시 리렌더링 최적화Effect 내 비반응형 로직 분리

기존 useRef 패턴과의 비교

useEffectEvent 이전에는 useRef로 최신 값을 유지하는 패턴이 일반적이었습니다.

// 🔴 기존 useRef 패턴 — 보일러플레이트가 많고 린터 지원 없음
function ChatRoom({ roomId, theme }) {
  const themeRef = useRef(theme);
  themeRef.current = theme;

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on("connected", () => {
      showNotification("Connected!", themeRef.current);
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
}

// ✅ useEffectEvent — 깔끔하고 린터가 정확성 검증
function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification("Connected!", theme);
  });

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on("connected", () => onConnected());
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
}

useEffectEvent의 이점은 명확합니다.


⚠️ 주의사항과 규칙

호출 위치 제한

useEffectEvent로 만든 함수는 Effect(useEffect, useLayoutEffect, useInsertionEffect) 또는 다른 Effect Event 내부에서만 호출할 수 있습니다.

// 🔴 렌더링 중 호출 — 런타임 에러
function Component({ theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification("Connected!", theme);
  });

  onConnected(); // ❌ 렌더링 중 직접 호출

  return <div>...</div>;
}

// 🔴 이벤트 핸들러에서 호출 — 허용되지 않음
function Component() {
  const onEvent = useEffectEvent(() => {
    /* ... */
  });

  const handleClick = () => {
    onEvent(); // ❌ Effect 밖에서 호출
  };
}

// ✅ Effect 내부에서 호출
function Component() {
  const onEvent = useEffectEvent(() => {
    /* ... */
  });

  useEffect(() => {
    onEvent(); // ✅
  }, []);
}

다른 컴포넌트에 전달 금지

Effect Event는 다른 컴포넌트나 Hook에 props로 전달할 수 없습니다.

// 🔴 잘못된 사용
function Parent() {
  const onTick = useEffectEvent(() => {
    /* ... */
  });
  return <Child onTick={onTick} />; // ❌ props로 전달 금지
}

// ✅ 올바른 사용 — 같은 컴포넌트의 Effect에서 사용
function Component() {
  const onTick = useEffectEvent(() => {
    /* ... */
  });

  useEffect(() => {
    const id = setInterval(onTick, 1000);
    return () => clearInterval(id);
  }, []);
}

의존성 배열에 포함 금지

Effect Event는 의도적으로 매 렌더링마다 함수 정체성이 변경됩니다. 의존성 배열에 넣으면 매 렌더링마다 Effect가 재실행되어, useEffectEvent를 사용하는 의미가 사라집니다.

// 🔴 의존성 배열에 포함 — 무한 재실행
const onTick = useEffectEvent(() => {
  /* ... */
});

useEffect(() => {
  const id = setInterval(onTick, 1000);
  return () => clearInterval(id);
}, [onTick]); // ❌ 매 렌더링마다 Effect가 재실행됨

안티패턴: 의존성 회피 목적으로 사용

useEffectEvent진정한 비반응형 로직을 분리하기 위한 도구입니다. 반응해야 할 값의 의존성을 회피하는 용도로 사용하면 버그가 됩니다.

// 🔴 안티패턴: 반응해야 할 값을 숨김
const logVisit = useEffectEvent(() => {
  log(pageUrl); // pageUrl 변경에 반응해야 하는데 무시됨!
});
useEffect(() => {
  logVisit();
}, []); // pageUrl이 바뀌어도 로깅되지 않음 — 버그!

// ✅ 올바른 패턴: 반응형 값은 인자로, 비반응형 값만 Effect Event 내부에서
const logVisit = useEffectEvent((visitedUrl: string) => {
  log(visitedUrl, theme); // theme만 비반응형
});
useEffect(() => {
  logVisit(url);
}, [url]); // ✅ url 변경에 정상 반응

핵심 판단 기준은 간단합니다. “이 값이 바뀌면 Effect가 다시 실행되어야 하는가?”


💡 실전 팁

선언 위치

Effect Event는 관련 useEffect 바로 위에 선언하면 가독성이 좋습니다. 로직의 흐름이 위에서 아래로 자연스럽게 읽힙니다.

function ChatRoom({ roomId, theme, soundEnabled }) {
  // Effect Event들을 관련 Effect 바로 위에 선언
  const onConnected = useEffectEvent(() => {
    showNotification("Connected!", theme);
    if (soundEnabled) playSound("connect");
  });

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on("connected", () => onConnected());
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
}

네이밍 컨벤션

Effect Event 이름은 on 접두사를 사용하여 이벤트 핸들러와 유사하게 짓는 것이 관례입니다. “이 Effect에서 어떤 이벤트가 발생하는가?”를 생각하면 자연스러운 이름이 나옵니다.

필요 환경

useEffectEvent를 사용하려면 다음 환경이 필요합니다.


🧭 useEvent에서 useEffectEvent로: 범위 축소의 이유

useEffectEvent의 역사를 이해하면 설계 의도가 더 명확해집니다.

원래 2022년에 제안된 useEvent RFC는 세 가지를 동시에 제공하려 했습니다.

  1. 의존성 배열 불필요
  2. 안정적인 함수 참조 (메모이제이션)
  3. 항상 최신 값 접근

이 세 가지를 모두 만족하면 useCallback을 완전히 대체할 수 있는 꿈의 Hook이 됩니다. 하지만 React의 Concurrent Mode에서 문제가 발견되었습니다.

Concurrent Mode에서 React는 렌더링을 중단하고 재시작할 수 있습니다. 만약 useEvent가 렌더링 중에 호출되면, 중단된 렌더의 stale한 값을 참조할 위험이 있었습니다. “항상 최신 값”이라는 약속을 렌더링 도중에는 보장할 수 없었던 것입니다.

React 팀은 세 가지 목표를 모두 달성하는 대신, 안전하게 보장할 수 있는 범위로 축소하는 결정을 내렸습니다.

덕분에 Effect 내부에서의 동작은 완벽하게 예측 가능해졌습니다.


📝 마무리

useEffectEvent는 React의 Effect 모델에서 오랫동안 존재했던 빈 공간을 채웁니다.

핵심 요약

다음 단계

참고 자료



Previous Post
Claude Code Agent Teams 완벽 가이드: 에이전트 협업으로 생산성 극대화하기
Next Post
Mac에서 Ollama + EXAONE 3.5 설치 및 사용 가이드: 로컬에서 한국어 LLM 돌리기