PUBLISHED

useEffectEvent

작성일: 2025.10.08

useEffectEvent

React를 사용하면서 useEffect 안에서 상태를 참조할 때 의외로 자주 겪는 문제가 있다. 바로 stale closure 문제다. 예를 들어 어떤 값이 변경되었는데, useEffect 내부의 콜백에서는 여전히 이전 값을 읽고 있는 현상이다. 이는 React의 클로저 구조상 자연스러운 현상이지만, 종종 예기치 않은 버그로 이어진다. 기존에는 ref를 사용하거나 의존성 배열을 세밀하게 조정하는 방식으로 이를 해결했지만, 코드가 복잡해지고 유지보수가 어려워지는 단점이 있었다.

아래의 코드는 표면적으로는 문제가 없어 보이지만, 실제로 실행하면 count가 0에서 더 이상 증가하지 않는다. 그 이유는 useEffect의 콜백이 초기 렌더 시점의 count 값을 클로저로 캡처하기 때문이다. 즉, setInterval 내부의 함수는 매번 같은 count(=0)을 기반으로 계산하고 있기 때문에 값이 갱신되지 않는다.

untitled
JSX
import { useState, useEffect } from 'react';
 
function Timer() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    const interval = setInterval(() => {
      // ❌ stale closure 발생: count는 항상 0으로 고정되어 있음
      console.log('Current count:', count);
      setCount(count + 1);
    }, 1000);
 
    return () => clearInterval(interval);
  }, []); // 의존성 배열이 비어 있어 count는 갱신되지 않음
 
  return <h1>Count: {count}</h1>;
}

이 문제를 해결하려면 count를 의존성 배열에 추가하거나, setCount(prev => prev + 1) 형태의 함수형 업데이트를 사용해야 한다. 그러나 이러한 방식은 상황에 따라 불필요한 재실행을 유발하거나 의존성 관리가 복잡해지는 단점이 있다.

 

바로 이런 상황을 해결하기 위해 React의 19.2 버전에서 useEffectEvent가 새로 도입되었다. useEffectEvent는 Effect 내부 로직을 “비반응적 함수(non-reactive function)”로 분리할 수 있게 해준다. 쉽게 말해, effect 안에서 호출되는 특정 콜백이 항상 최신 props와 state를 참조하게 하면서도, effect 자체가 불필요하게 다시 실행되지 않도록 도와준다.

이 훅은 React 공식 문서에서 “Effect Event”라는 개념으로 설명되며, 함수형 컴포넌트 내부에서 “최신 상태를 읽되, 의존성 배열을 복잡하게 관리하지 않아도 되는 안전한 방법”을 제공한다. 즉, useEffectEvent는 일반적인 의존성 배열 기반의 useEffect와 달리, 함수의 정의 시점이 아니라 호출 시점의 최신 상태를 보장한다는 점이 핵심이다.

 

useEffectEvent는 Effect가 특정 값의 변화에만 반응하도록 엄격하게 제어하면서도, 그 내부에서는 항상 최신의 상태나 props를 참조해야 하는 딜레마 상황에서 가장 빛을 발한다. 예를 들어, 채팅방에 입장할 때 roomId가 변경되면 서버에 단 한 번만 연결해야 하지만, 연결 성공 후 보내는 환영 메시지에는 사용자의 최신 nickname이 포함되어야 하는 경우를 생각해보자. 기존 방식대로라면 nickname을 의존성 배열에 추가해야 하고, 이는 사용자가 닉네임을 바꿀 때마다 불필요한 재연결을 유발한다.

바로 이럴 때 useEffectEvent를 사용하면, Effect의 재실행은 roomId에만 의존하게 만들고, 메시지를 보내는 로직은 언제나 최신 nickname을 안전하게 가져다 쓸 수 있게 되는 것이다. 아래의 예시에서 볼 수 있는 것처럼 useEffectEvent를 통해 코드의 의존 관계가 명확해지고 불필요한 부수 효과를 막아 성능 최적화까지 자연스럽게 이어진다. 이처럼 '언제 실행할지'와 '어떤 데이터로 실행할지'를 분리해야 할 때 useEffectEvent는 가장 이상적인 해결책이 된다.

untitled
JSX
import { useEffect, useEffectEvent } from 'react';
 
function ChatRoom({ roomId, nickname }) {
  // 1. nickname의 변화에 반응하지 않는 함수를 만듭니다.
  // 이 함수는 항상 최신 nickname 값을 참조합니다.
  const sendWelcomeMessage = useEffectEvent(() => {
    sendMessage(`Welcome to the room, ${nickname}!`);
  });
 
  useEffect(() => {
    // 2. 이 Effect는 오직 `roomId`가 변경될 때만 재실행됩니다.
    const connection = createConnection(serverUrl, roomId);
    
    connection.on('connected', () => {
      // 3. 연결 성공 시, 최신 nickname으로 환영 메시지를 보냅니다.
      sendWelcomeMessage();
    });
 
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // `nickname`은 의존성 배열에 포함되지 않습니다.
 
  return <h1>Welcome to {roomId}</h1>;
}

 

또 다른 예시를 보자. 이번에는 사용자가 페이지의 어느 곳에서든 단축키(예: Ctrl+S)를 눌렀을 때 현재 작업 내용을 저장하는 기능을 생각해 볼 수 있다. 키보드 이벤트를 감지하는 useEffect는 페이지가 로드될 때 한 번만 등록하는 것이 가장 효율적이다. 하지만 저장할 내용(content)은 사용자가 입력함에 따라 계속 변하는 state다.

만약 content를 의존성 배열에 넣으면, 사용자가 타자를 칠 때마다 이벤트 리스너가 끊임없이 제거되고 다시 등록되는 비효율이 발생한다. 반대로 의존성 배열에서 제외하면, 저장 기능은 항상 맨 처음의 비어있는 내용만 기억하게 될 것이다. useEffectEvent는 이 문제를 완벽하게 해결한다.

저장 로직을 useEffectEvent로 감싸 onSave와 같은 함수로 분리하면, 이 함수는 항상 최신 content를 바라보게 된다. 그리고 키보드 이벤트 리스너를 등록하는 useEffect는 의존성 배열을 비워둔 채([]) 안전하게 이 onSave 함수를 호출하기만 하면 된다. 이를 통해 이벤트 리스너의 등록은 최소화하면서도, 데이터의 정합성은 최상으로 유지하는 두 마리 토끼를 모두 잡을 수 있다.

untitled
JSX
import { useState, useEffect, useEffectEvent } from 'react';
 
function DocumentEditor() {
  const [content, setContent] = useState('');
 
  // 1. 저장 로직을 useEffectEvent로 분리합니다.
  // 이 onSave 함수는 항상 최신 `content` 상태를 알고 있습니다.
  const onSave = useEffectEvent(() => {
    // 실제 애플리케이션에서는 이 부분에 API 호출이 들어갑니다.
    console.log('Saving content:', content);
    alert(`Saved: ${content.substring(0, 20)}...`);
  });
 
  // 2. 키보드 이벤트 리스너를 등록하는 Effect입니다.
  useEffect(() => {
    const handleKeyDown = (e) => {
      // Ctrl+S 또는 Cmd+S를 감지합니다.
      if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
        e.preventDefault(); // 브라우저의 기본 저장 동작을 막습니다.
        onSave(); // 최신 내용을 저장하는 함수를 호출합니다.
      }
    };
 
    window.addEventListener('keydown', handleKeyDown);
 
    // 컴포넌트가 언마운트될 때 이벤트 리스너를 제거합니다.
    return () => {
      window.removeEventListener('keydown', handleKeyDown);
    };
  }, []); // 3. 의존성 배열이 비어있어, 이 Effect는 단 한 번만 실행됩니다.
 
  return (
    <div>
      <h2>Document Editor 📝</h2>
      <p>아래 칸에 내용을 입력하고 <b>Ctrl+S</b> (또는 Mac에서 <b>Cmd+S</b>)를 눌러 저장하세요.</p>
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        rows={10}
        style={{ width: '100%', padding: '10px' }}
        placeholder="여기에 입력하세요..."
      />
    </div>
  );
}

useEffectEvent는 React의 오랜 숙제였던 stale closure 문제를 해결하면서, effect와 콜백의 책임을 명확히 분리할 수 있도록 돕는다. 단순한 API 추가가 아니라, “언제 실행할지”와 “어떤 데이터로 실행할지”를 구분하려는 React의 철학이 담겨 있다. 앞으로 useEffectEvent는 복잡한 의존성 관리나 이벤트 핸들링 패턴을 단순화하는 핵심 도구로 자리 잡게 될 것이다.