React useEffectEvent 정리 - 왜 등장했고 무엇을 해결하나?

이명진·2026년 2월 11일

react - 이론

목록 보기
12/12

React를 쓰다 보면 useEffect를 작성하면서 항상 마주치는 문제가 있다.

  • effect 내부에서 state를 참조했는데 값이 최신이 아니다 (stale closure)
  • dependency 배열을 넣으면 effect가 너무 자주 실행된다
  • dependency를 빼면 lint 경고가 뜬다
  • 이벤트 핸들러에서 최신 state를 쓰고 싶은데, effect 의존성이 꼬인다

이 문제는 React 개발자들이 오랫동안 겪어온 대표적인 "Effect 설계 문제"이고,
이 문제를 해결하기 위해 React 팀에서 제안한 훅이 바로 useEffectEvent이다.

이 글에서는 useEffectEvent의 정의, 등장 배경, RFC/역사, 그리고 실제로 어떤 문제를 해결하는지 정리한다.


useEffectEvent란?

useEffectEvent는 React에서 제공하는 Hook으로,
Effect에서 사용되는 함수가 최신 props/state를 읽을 수 있도록 보장하면서도 dependency 배열에는 포함되지 않는 안정적인 함수를 만든다.
( React 19.2버전 부터 사용가능하다!! )

즉 다음을 동시에 만족하는 것이 핵심이다.

  • 함수는 안정적인 identity(참조)가 유지된다
  • 내부에서 읽는 값은 항상 최신이다
  • dependency 배열을 복잡하게 만들지 않는다

왜 useEffectEvent가 필요했을까?

React에서 가장 흔한 문제는 오래된 클로저(stale closure) 이다.

예를 들어 다음 코드를 보자.

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

  React.useEffect(() => {
    const id = setInterval(() => {
      console.log(count);
    }, 1000);

    return () => clearInterval(id);
  }, []);

  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

여기서 count는 0에서 증가하지만, 콘솔에는 계속 0만 출력된다.
왜냐하면 effect가 처음 실행될 때의 count=0을 closure로 캡처했고,
dependency 배열이 []라서 다시 실행되지 않기 때문이다.
이 stale closure 문제는 단순한 버그를 넘어, React 개발자들이 "Effect를 어떻게 설계해야 하는가"라는 구조적인 고민을 만들었다.

위에 방법을 해소하는 방법에는 무엇이 있을까 ?

해소 방법

1) dependency 배열에 넣기

React.useEffect(() => {
  console.log(count);
}, [count]);

이건 최신 값을 보장하지만, 문제는 effect가 계속 재실행된다는 점이다.

  • interval, websocket, subscription 같은 코드는 매번 다시 연결됨
  • 불필요한 cleanup과 setup이 반복됨
  • 성능 문제가 생길 수 있음

2) useRef로 최신 값 유지하기


const countRef = React.useRef(count);

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

그리고 effect 안에서는

console.log(countRef.current);

이렇게 해결할 수 있다.
하지만 이 방식은 단점이 있다.

  • 코드가 지저분해진다
  • "왜 ref를 쓰는지" 이해가 어렵다
  • 실수로 .current 업데이트를 빼먹으면 다시 stale bug 발생
  • TypeScript에서 타입 관리도 귀찮다

3) useCallback으로 감싸기


const handler = React.useCallback(() => {
  console.log(count);
}, [count]);

하지만 이 방식은 결국 count가 바뀌면 callback이 새로 만들어지고,
effect dependency 문제는 그대로 남는다.

useEffectEvent가 해결하려는 핵심 문제

React 팀이 보기에 stale closure는 단순 버그가 아니라 React의 선언형 모델과 effect의 관계에서 생기는 구조적 문제였다.
useEffectEvent가 해결하려는 핵심은 다음이다.

  • Effect는 "외부 시스템과 동기화"하는 코드여야 한다.
  • 하지만 Effect 안에서 이벤트 핸들러처럼 동작하는 로직이 섞이면 dependency가 꼬인다.
  • "Effect는 setup/cleanup만 담당하고, 이벤트 성격의 로직은 별도로 분리"해야 한다.
    이 분리를 공식적으로 지원하는 것이 useEffectEvent이다.

역사 / RFC 배경

React 팀이 꾸준히 강조한 것
React 팀은 오랫동안 다음을 강조해왔다.

  • useEffect는 "값이 변할 때마다 실행되는 함수"가 아니다
  • useEffect는 "외부 시스템과 동기화"하는 목적이다
  • dependency 배열은 최적화 옵션이 아니라 correctness를 위한 것이다
    하지만 현실에서는 많은 개발자들이 effect 안에서 다음을 해버린다.
  • 클릭 이벤트 처리
  • 상태 변경
  • 최신 state 읽기
  • 로그 찍기
  • analytics 이벤트 전송
    즉 effect가 "동기화"가 아니라 "이벤트 처리기" 역할을 하게 된다.

useEvent 제안 -> useEffectEvent로 발전
React 팀은 stale closure 문제를 해결하기 위해 예전부터 useEvent라는 이름으로 API를 고민해왔다.

  • "함수 identity는 고정"
  • "함수 내부에서 최신 state/props 읽기 가능"
    이 개념이 내부적으로 여러 차례 논의되었고,
최종적으로 React 문서에서 설명하던 "Effect Event" 개념을 Hook 형태로 제공하기 위해
이름이 useEffectEvent로 정리되었다.
    즉 useEffectEvent는 "Effect 안에서 이벤트 성격 로직을 분리하기 위한 공식 API"이다.

useEffectEvent의 정의를 한 문장으로 정리하면
useEffectEvent는
Effect 내부에서 최신 state/props를 안전하게 읽을 수 있도록 만들어진, dependency 배열에 포함되지 않는 안정적인 callback을 생성하는 Hook이다.

useEffectEvent 사용 예시

문제 상황: effect 안에서 최신 값 읽기

ChatRoom 컴포넌트는 채팅방에 접속하는 상황을 가정한 예시다.

  • roomId가 바뀌면 새로운 채팅방에 연결해야 한다.
  • 사용자가 input에 입력하는 message는 계속 변한다.
  • 채팅 서버는 connection.onMessage() 같은 이벤트 콜백을 등록해두고, 메시지가 오면 콜백을 실행한다.

즉 이 코드는 "외부 시스템(WebSocket 같은 서버 연결)"을 React 컴포넌트가 관리하는 대표적인 패턴을 보여준다.

function ChatRoom({ roomId }: { roomId: string }) {
  const [message, setMessage] = React.useState("");

  React.useEffect(() => {
    const connection = createConnection(roomId);

    connection.onMessage(() => {
      console.log("latest message:", message);
    });

    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  return (
    <input value={message} onChange={e => setMessage(e.target.value)} />
  );
}

여기서 message는 최신이 아닐 수 있다.
message를 dependency에 넣으면:

  • input 입력할 때마다 connect/disconnect가 반복된다
    이건 말이 안 된다.

해결: useEffectEvent로 이벤트 로직 분리


function ChatRoom({ roomId }: { roomId: string }) {
  const [message, setMessage] = React.useState("");

  const onMessage = React.useEffectEvent(() => {
    console.log("latest message:", message);
  });

  React.useEffect(() => {
    const connection = createConnection(roomId);

    connection.onMessage(onMessage);
    connection.connect();

    return () => connection.disconnect();
  }, [roomId]);

  return (
    <input value={message} onChange={e => setMessage(e.target.value)} />
  );
}

핵심 변화는 이거다.

  • Effect는 외부 시스템(connection) setup/cleanup만 담당
  • 이벤트 핸들러 역할은 useEffectEvent가 담당
  • onMessage는 dependency에 넣지 않아도 된다
  • 하지만 최신 message를 항상 읽는다

useEffectEvent의 특징

1) 함수 identity가 안정적이다
useCallback과 다르게 dependency에 따라 함수가 새로 생성되지 않는다.

2) 최신 state/props를 읽을 수 있다
stale closure 문제가 해결된다.

3) effect dependency가 단순해진다
setup/cleanup을 위한 dependency만 남게 된다.

4) "Effect는 동기화"라는 React 철학을 강화한다
Effect 안에 섞여 있던 이벤트성 로직을 분리함으로써
React 팀이 의도한 올바른 설계 패턴을 자연스럽게 유도한다.

useEffectEvent 는 실무에서 언제 사용 할까 ?

useEffectEvent는 "신뢰할 수 없는 외부 시스템"과 연결되는 코드에서 특히 유용하다.
대표적인 상황은 다음과 같다.

  • setInterval, setTimeout
  • WebSocket 이벤트 핸들러
  • DOM 이벤트(addEventListener)
  • subscription 패턴
  • analytics 이벤트 전송
  • 외부 라이브러리 callback 등록

결론

useEffectEvent는 단순히 "stale closure를 해결하는 훅"이 아니다.
React 팀이 오랫동안 고민했던 문제,

  • Effect는 무엇을 위한 것인가?
  • Effect에서 이벤트성 로직이 섞일 때 dependency는 어떻게 관리할 것인가?
    이 문제를 해결하기 위해 나온 새로운 구조적 도구다.
    Effect는 setup/cleanup만 담당하고,
Effect 내부에서 사용되는 이벤트성 로직은 useEffectEvent로 분리한다.
    이 패턴이 정착되면 dependency 배열 때문에 생기는 대부분의 스트레스가 줄어들게 된다.

참고 자료 (출처)

  • React 공식 문서 (Effect Events 관련 문서)
  • React RFC / Discussions (useEvent, useEffectEvent 관련 논의)
  • Dan Abramov, React Team 발표 및 글들
profile
프론트엔드 개발자 초보에서 고수까지!

0개의 댓글