Dev Notes
Web Vitals
Web Vitals
Dev Notes
DocsuseEffectEvent: Effect 의존성 딜레마의 종착점
Web Vitals
Web Vitals
GitHub
React30분

useEffectEvent: Effect 의존성 딜레마의 종착점

React 19.2 useEffectEvent 훅의 동작 원리, ref 트릭 대체, 실무 적용 전략

2026년 2월 4일
reacthooksuseEffectuseEffectEventreact-19best-practices

적용 환경: React 19.2+, TypeScript 5+

들어가며

useEffect를 작성하다 보면 묘한 딜레마에 빠지는 순간이 있습니다. Effect 안에서 최신 props나 state 값이 필요한데, 의존성 배열에 넣으면 Effect가 원치 않는 시점에 다시 실행되는 상황입니다. 채팅 앱에서 서버 연결을 관리하는 Effect가 대표적인 사례입니다. roomId가 바뀌면 재연결해야 하지만, 연결 시점에 현재 theme 값을 로깅하고 싶다면 어떻게 해야 할까요?

TSX
function ChatRoom({ roomId, theme }: ChatRoomProps) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('connected', () => {
      // theme의 최신 값이 필요하다
      logVisit(roomId, theme);
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, theme]); // theme이 바뀔 때마다 재연결?
}

theme을 의존성 배열에 포함하면 테마를 전환할 때마다 서버 연결이 끊겼다가 다시 맺어집니다. 사용자가 다크 모드로 전환했을 뿐인데 채팅이 끊기는 것은 분명히 의도한 동작이 아닙니다. 반대로 theme을 배열에서 제거하면 린터가 경고를 내보내고, 실제로 콜백 안의 theme은 처음 렌더링 시점의 값에 고정되어 stale closure 문제가 발생합니다.

이 딜레마의 본질은 하나의 Effect 안에 반응해야 하는(reactive) 로직과 반응할 필요 없는(non-reactive) 로직이 뒤섞여 있다는 데 있습니다. roomId 변경에 따른 재연결은 반응형이고, theme 값을 읽는 로깅은 반응할 필요가 없습니다. 이 글에서는 후자를 줄여 비반응형 로직이라 부르겠습니다. React 19.2에서 정식 출시된 useEffectEvent는 바로 이 경계를 명확히 분리하기 위한 훅입니다.

ref 트릭: 기존 해결책과 한계

useEffectEvent가 등장하기 전까지, 이 문제에 대한 사실상의 표준 해결책은 useRef를 활용한 트릭이었습니다. 최신 값을 ref에 저장하고, Effect 안에서는 ref를 통해 간접적으로 접근하는 방식입니다.

TSX
function ChatRoom({ roomId, theme }: ChatRoomProps) {
  const themeRef = useRef(theme);
 
  // 매 렌더마다 최신 theme으로 갱신
  useEffect(() => {
    themeRef.current = theme;
  }, [theme]);
 
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('connected', () => {
      // ref를 통해 최신 theme 접근
      logVisit(roomId, themeRef.current);
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // theme을 의존성에서 제외
}

이 패턴은 동작은 하지만 몇 가지 구조적 한계를 안고 있습니다. 우선 비반응형으로 다뤄야 할 값마다 ref 선언과 동기화용 Effect를 추가해야 하므로 보일러플레이트가 누적됩니다. Custom Hook 설계 원칙에서 다룬 useInterval 구현에서도 동일한 패턴이 사용되는데, 콜백 하나를 최신 상태로 유지하기 위해 매번 같은 구조를 반복해야 합니다.

더 근본적인 문제는 eslint-plugin-react-hooks가 ref를 reactive dependency로 인식하지 못한다는 점입니다. 린터 관점에서 themeRef.current는 그저 뮤터블 값이므로, 개발자가 실수로 진짜 반응형이어야 할 값을 ref로 감싸더라도 경고가 발생하지 않습니다. 즉, ref 트릭은 린터의 보호망 바깥에서 작동하며, "이 값은 의도적으로 비반응형입니다"라는 의미를 코드 자체가 전달하지 못합니다.

useEffectEvent: 반응형과 비반응형의 경계

useEffectEvent는 이 문제를 언어 수준에서 해결합니다. 이 훅으로 선언한 함수는 항상 최신 props/state를 참조하면서, 의존성 배열에서 자동으로 제외됩니다. React가 이 함수를 "Effect Event"로 인식하기 때문에 린터도 이를 의존성에 포함하라고 요구하지 않습니다.

TSX
import { useEffect, useEffectEvent } from "react";
 
function ChatRoom({ roomId, theme }: ChatRoomProps) {
  const onConnected = useEffectEvent((connectedRoomId: string) => {
    // 항상 최신 theme에 접근 가능
    logVisit(connectedRoomId, theme);
  });
 
  // onConnected는 의존성 배열에 포함하지 않아도 됨
  useEffect(() => { /* ... */ }, [roomId]);
}

앞서 살펴본 채팅방 예제를 useEffectEvent로 다시 작성하면, ref 트릭에서 느꼈던 불편함이 사라집니다.

TSX
function ChatRoom({ roomId, theme }: ChatRoomProps) {
  const themeRef = useRef(theme);
 
  useEffect(() => {
    themeRef.current = theme;
  }, [theme]);
 
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('connected', () => {
      logVisit(roomId, themeRef.current);
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
}

useEffectEvent 버전에서는 ref 선언, 동기화 Effect, .current 접근이 모두 사라졌습니다. 코드만 봐도 onConnected가 "연결 시 실행되는 이벤트 핸들러"이며 비반응형 로직을 담당한다는 의미가 드러납니다. 린터 역시 이 함수를 Effect의 의존성으로 취급하지 않으므로, // eslint-disable-next-line 주석 없이 깔끔하게 동작합니다.

실무에서 useEffectEvent가 빛나는 순간

애널리틱스 로깅

페이지 방문을 추적하는 로직은 useEffectEvent의 대표적인 적용 대상입니다. URL 변경에만 반응하면서 현재 사용자 정보를 함께 전송해야 하는데, user 객체를 의존성에 포함하면 사용자 프로필이 업데이트될 때마다 방문 이벤트가 중복 발생합니다.

TSX
function PageTracker({ url, user }: PageTrackerProps) {
  const onPageVisit = useEffectEvent((visitedUrl: string) => {
    // user의 최신 값을 참조하되 Effect를 재실행하지 않음
    analytics.track('page_visit', {
      url: visitedUrl,
      userId: user.id,
      role: user.role,
      timestamp: Date.now(),
    });
  });
 
  useEffect(() => {
    onPageVisit(url);
  }, [url]); // url이 바뀔 때만 실행
}

url이 바뀔 때만 Effect가 실행되고, user 정보는 onPageVisit 내부에서 항상 최신 값으로 읽힙니다. ref 트릭이었다면 userRef를 별도로 관리하면서 동기화 Effect까지 추가해야 했을 코드입니다.

타이머와 인터벌

setInterval 내부에서 최신 콜백을 실행해야 하는 패턴도 useEffectEvent로 깔끔해집니다. Custom Hook 설계 원칙에서 useRef로 구현했던 useInterval을 현대화하면 다음과 같습니다.

TSX
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]);
}

savedCallback ref 선언과 동기화 Effect가 사라지고, onTick이라는 Effect Event 하나로 "매 틱마다 최신 콜백을 실행한다"는 의도가 명확히 표현됩니다.

사용하면 안 되는 경우

useEffectEvent는 편의를 위해 의존성을 생략하는 도구가 아닙니다. 값이 바뀌었을 때 Effect가 실제로 다시 실행되어야 하는 상황이라면, 해당 값은 의존성 배열에 남아 있어야 합니다. 예컨대 roomId가 바뀌면 채팅방 연결을 다시 맺어야 하는 것은 정당한 반응형 동작이므로, roomId를 Effect Event 안으로 숨기면 오히려 버그가 됩니다.

의존성 생략 도구가 아닙니다

useEffectEvent는 "이 값의 변경이 Effect의 재실행을 유발해서는 안 된다"고 확신할 수 있는 비반응형 로직에만 사용해야 합니다. 린터 경고를 피하기 위한 수단으로 남용하면, ref 트릭보다 더 은밀하게 버그를 숨기는 결과를 낳습니다.

제약 사항과 도입 전략

useEffectEvent에는 의도적인 제약이 존재하며, 이를 이해하면 훅의 설계 철학이 더 명확해집니다.

제약설명
Effect 내부에서만 호출useEffect, useLayoutEffect, useInsertionEffect 내부에서만 호출해야 합니다. 이벤트 핸들러나 렌더링 중 호출은 린터가 감지하여 경고합니다.
다른 컴포넌트나 훅에 전달 금지Effect Event는 선언된 컴포넌트나 훅에 귀속됩니다. props로 넘기거나 다른 훅의 인자로 전달하면 안 됩니다. 같은 훅 안에서 선언하고 사용하는 것은 허용됩니다.
eslint-plugin-react-hooks v6.1.1+ 필요린터가 Effect Event를 인식하려면 플러그인을 업데이트해야 합니다. 이전 버전에서는 의존성 누락 경고가 발생할 수 있습니다.

기존 코드베이스에 도입할 때는 일괄 마이그레이션보다 점진적 접근이 적합합니다. useRef와 동기화 useEffect 조합으로 최신 값을 유지하는 패턴을 검색한 뒤, 각 사례가 진짜 비반응형 로직인지 확인하고 useEffectEvent로 교체하는 방식입니다. 모든 ref 트릭이 교체 대상은 아닙니다. ref가 DOM 요소를 참조하거나, Effect 바깥에서도 사용되는 경우에는 기존 패턴을 유지해야 합니다.

한 가지 주의할 점은, 커뮤니티에서 널리 사용되어 온 useEventCallback 커스텀 훅과 useEffectEvent의 차이입니다. useEventCallback은 범용 안정 참조 함수를 만드는 훅으로, 이벤트 핸들러 props 전달 등 다양한 곳에서 활용됩니다. 반면 useEffectEvent는 이름이 말해주듯 Effect 내부 전용으로, 사용 범위가 의도적으로 제한되어 있습니다. 두 훅은 해결하는 문제의 범위가 다르므로, useEffectEvent가 useEventCallback을 완전히 대체하지는 않습니다.

정리

핵심 정리

  1. useEffectEvent는 Effect 내부의 비반응형 로직을 분리하는 React 19.2의 공식 API입니다. 항상 최신 props/state를 참조하면서 의존성 배열에서 제외됩니다.

  2. useRef + 동기화 useEffect 조합의 보일러플레이트와 린터 사각지대를 해결하며, 코드 자체가 "이 로직은 비반응형입니다"라는 의도를 명시합니다.

  3. 모든 의존성을 생략하기 위한 편의 도구가 아닙니다. reactive/non-reactive 경계가 명확한 경우에만 사용해야 하며, 남용하면 오히려 버그를 은폐합니다.

  4. useEffect 사용 판단의 전반적인 멘탈 모델은 useEffect를 남용하고 있다는 신호들에서 다룹니다.

참고 자료

  • React 공식 문서 - useEffectEvent
  • React 공식 문서 - Separating Events from Effects
  • React 19.2 릴리즈 노트
  • Dan Abramov - A Complete Guide to useEffect
  • LogRocket - React useEffectEvent
Written by

Mirunamu (Park Geonwoo)

Software Developer

관련 글 더보기

React

SSR Hydration Mismatch의 원리와 해결 패턴

React SSR에서 서버/클라이언트 렌더링 불일치가 발생하는 근본 원인과 체계적 해결 전략

읽기
React

Next.js App Router 렌더링 전략 완전 정리

Server Component와 Client Component, SSG·SSR·ISR의 두 축으로 이해하는 Next.js 렌더링 모델

읽기
React

Custom Hook 설계 원칙

재사용 가능하고 테스트하기 쉬운 Custom Hook을 설계하는 실전 원칙과 패턴

읽기
React

useEffect를 남용하고 있다는 신호들

useEffect 과사용의 근본 원인과 올바른 멘탈 모델

읽기
이전 글useEffect를 남용하고 있다는 신호들
목차
  • 들어가며
  • ref 트릭: 기존 해결책과 한계
  • useEffectEvent: 반응형과 비반응형의 경계
    • 타이머와 인터벌
    • 사용하면 안 되는 경우
  • 제약 사항과 도입 전략
  • 정리
  • 참고 자료