useEvent 패턴 — 부모 리렌더링이 자식 effect를 폭주시키는 문제 해결하기
리액트로 외부 라이브러리나 SDK를 붙이다 보면 한 번쯤 마주치는 함정이 있다. 부모에서 콜백을 prop으로 넘겼을 뿐인데, 부모가 리렌더링될 때마다 자식의 useEffect가 통째로 다시 실행되는 현상이다. 이 글에서는 그 원인을 짚고, useEvent라는 한 줄짜리 훅으로 깔끔하게 해결하는 방법을 정리한다.
어떤 문제인가
리액트에서 자식 컴포넌트(특히 외부 라이브러리)에 콜백을 prop으로 넘길 때, 자식이 그 콜백을 useEffect의 deps 배열에 잡고 있으면 문제가 생긴다. 부모가 리렌더링될 때마다 새 콜백 reference가 만들어지고, 그 reference 변화가 자식의 effect를 매번 재실행시킨다.
// 자식 (외부 라이브러리라고 가정)
function ThirdPartyWidget({ onResult }) {
useEffect(() => {
const handle = expensiveSetup(onResult); // 무거운 초기화
return () => handle.cleanup();
}, [onResult]); // ← 콜백 reference가 바뀔 때마다 setup/cleanup 반복
// ...
}
// 부모
function Parent() {
const { user } = useAuth();
const onResult = async (result) => {
// user 같은 값을 클로저로 캡처해서 써야 한다
await sendResult({ ...result, userId: user.id });
};
return <ThirdPartyWidget onResult={onResult} />;
// ↑ 매 렌더마다 새 함수 → 자식 effect가 무한히 재트리거
}
핵심 딜레마는 이렇다. 콜백은 항상 최신 user를 봐야 하므로 매 렌더에 다시 만들어지는 게 자연스럽다. 그런데 그러면 reference가 매번 바뀌어서 자식 effect가 폭주한다. "최신 클로저"와 "안정적인 reference"를 동시에 원하는 것이다.
기존 해결책과 그 한계
방법 1 — useCallback(fn, [deps])
const onResult = useCallback(async (result) => {
await sendResult({ ...result, userId: user.id });
}, [user.id]);
- ❌ deps에 들어간 값이 바뀌면 결국 reference가 바뀐다 → 자식 effect 재실행은 못 막는다.
- ❌ deps를 빠뜨리면 stale closure 버그. 옛날 값을 캡처한 함수가 호출된다.
deps 누락은 react-hooks/exhaustive-deps ESLint 규칙이 잡아주긴 하지만, 그 규칙을 따를수록 reference가 더 자주 바뀌는 모순에 빠진다.
방법 2 — useCallback(fn, []) + 값마다 useRef
reference를 영구히 고정하고 싶으니 deps를 비운다. 대신 최신값이 필요한 변수들을 ref로 따로 들고 있는다.
const userLatest = useRef(user);
userLatest.current = user;
const dataLatest = useRef(data);
dataLatest.current = data;
// ... 캡처할 값마다 이 두 줄을 반복
const onResult = useCallback(async (result) => {
await sendResult({ ...result, userId: userLatest.current.id });
}, []);
- ✅ reference는 영구 안정. 자식 effect를 재트리거하지 않는다.
- ✅ stale closure도 없다. ref를 통해 항상 최신값을 읽는다.
- ❌ 보일러플레이트가 너무 많다. 캡처할 값을 추가/제거할 때마다 ref를 손봐야 한다.
- ❌ 새 변수를 쓰기 시작했는데 ref 등록을 깜빡하면 그 변수만 조용히 stale해진다. 가장 찾기 어려운 종류의 버그다.
동작은 완벽하지만 사람이 실수하기 좋은 구조다. 이걸 한 줄로 줄이는 게 useEvent다.
해결: useEvent 훅
아이디어는 단순하다. 변수마다 ref를 만드는 대신, 콜백 함수 자체를 하나의 ref에 저장한다. 그리고 그 ref를 호출하기만 하는 안정적인 wrapper를 바깥에 노출한다.
// hooks/useEvent.ts
import { useCallback, useLayoutEffect, useRef } from 'react';
export function useEvent<TArgs extends unknown[], TReturn>(
fn: (...args: TArgs) => TReturn,
): (...args: TArgs) => TReturn {
const ref = useRef(fn);
useLayoutEffect(() => {
ref.current = fn; // 매 렌더 직후 최신 fn으로 갱신
});
return useCallback((...args: TArgs) => ref.current(...args), []);
// ↑ 영구히 동일한 reference. 내부에서 최신 fn을 호출.
}
사용
import { useEvent } from './hooks/useEvent';
const onResult = useEvent(async (result: Result) => {
// user, data 등을 그냥 평범하게 클로저로 접근 — 항상 최신값
await sendResult({ ...result, userId: user.id });
});
한 줄. ref 보일러플레이트가 전부 사라진다. 함수 안에서는 평소처럼 부모 스코프 변수를 쓰면 되고, 바깥으로 나가는 reference는 컴포넌트 수명 내내 고정된다.
동작 원리
세 가지 사실이 맞물려 돌아간다.
- 컴포넌트 lifetime 동안 동일 reference
useCallback(..., [])이 한 번 만든 wrapper 함수를 영원히 재사용한다. 이 wrapper가 자식 effect의 deps에 들어가도 절대 바뀌지 않으니 effect를 재실행시키지 않는다. - 호출 시점에는 항상 최신 클로저
useLayoutEffect가 매 렌더 직후 ref.current를 최신 fn으로 덮어쓴다. wrapper가 호출되면 ref.current(...)를 통해 가장 최신 함수를 부른다. 그 함수 안에서는 평범하게 부모 스코프의 변수에 접근하므로 항상 최신값이 보인다. - useEffect가 아니라 useLayoutEffect인 이유useEvent에서 굳이 useLayoutEffect를 쓰는 이유는 타이밍 안전성 때문이다.
- useEffect는 commit 이후 비동기라, ref가 갱신되기 전에 wrapper가 호출되는 짧은 윈도우가 존재한다 → 그 사이에 호출되면 옛 fn이 실행될 수 있다.
- useLayoutEffect는 commit 직후 동기라 그 윈도우가 없다 → wrapper가 호출되는 시점에는 항상 최신 fn이 보장된다.
useLayoutEffect란?
useEffect와 거의 같지만 실행 타이밍이 다른 훅이다. 리액트가 DOM을 업데이트(commit)한 직후, 브라우저가 화면을 그리기(paint) 전에 동기적으로 실행된다. useEffect는 반대로 브라우저가 그린 뒤에 비동기로 실행된다.
| 단계 | render | commit (DOM 업데이트) | useLayoutEffect | paint (브라우저 그리기) | useEffect |
| 타이밍 | 동기 | 동기 | 동기, paint 직전 | 비동기 | paint 이후 |
보통은 useEffect가 디폴트다 — paint를 막지 않아 더 빠르다. useLayoutEffect는 DOM 측정/조작이 paint 전에 끝나야 하는 경우(레이아웃 깜빡임 방지)에 쓴다.
한눈에 비교
| 방식 | 코드량 | stale 위험 | 자식 재트리거 |
| 평범한 함수 | 적음 | 없음 | 있음 |
| useCallback([deps]) | 보통 | deps 누락 시 있음 | deps 변화 시 있음 |
| useCallback([]) + 값마다 useRef | 많음 | ref 빠뜨리면 있음 | 없음 |
| useEvent | 한 줄 | 없음 | 없음 |
리액트 공식 API: useEffectEvent (React 19.2)
사실 리액트 팀도 똑같은 컨셉을 정식 API로 내놓았다. 이름은 useEffectEvent, React 19.2부터 안정 API다. (초기 RFC에서는 useEvent라는 이름이었는데, "Effect 안에서 호출하는 게 주된 용도"라는 의미를 분명히 하려고 useEffectEvent로 바뀌었다.)
기본 사용
import { useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme); // theme 항상 최신
});
useEffect(() => {
const conn = createConnection(roomId);
conn.on('connected', () => onConnected());
conn.connect();
return () => conn.disconnect();
}, [roomId]); // ← theme도, onConnected도 deps에 안 넣어도 된다
}
핵심 특성
- 항상 최신 props/state 캡처 — userland useEvent와 동일하다.
- deps 배열에 넣지 않아야 한다 — 오히려 넣으면 ESLint가 경고한다.
- Effect 내부 호출이 권장 패턴 — 자식 컴포넌트에 prop으로 직접 넘기는 건 제약이 있을 수 있다.
- 렌더 중 호출 금지 — 컴포넌트 render body에서 직접 부르면 안 된다.
그렇다면 userland useEvent는 이제 필요 없을까?
useEffectEvent는 본래 effect 내부에서 호출하는 non-reactive 이벤트 핸들러를 추출하기 위해 설계됐다. 이 글에서 다룬 "외부 컴포넌트에 콜백 prop으로 직접 넘기는" 케이스는 결이 조금 다르다.
| 사용처 | userland useEvent | 공식 useEffectEvent |
| useEffect 내부 호출 | ✅ 동작 | ✅ 권장 |
| 자식 컴포넌트 prop | ✅ 동작 | ⚠️ 동작하지만 ESLint 경고 |
| 이벤트 핸들러 prop (onClick 등) | ✅ 동작 | ⚠️ 동작하지만 권장 안 함 |
| 외부 SDK / setTimeout 콜백 | ✅ 동작 | ⚠️ 동작은 함 |
즉 React 19.2 이상이라면 effect 내부에서 호출하는 케이스는 공식 useEffectEvent로 옮기는 게 좋다. 반면 외부 라이브러리에 콜백 prop으로 넘기는 케이스는 userland useEvent가 여전히 유용하다. 의미적으로 "effect 전용"이라는 시그널이 없는 게 오히려 자연스럽고, ESLint 경고도 피할 수 있다. 두 API가 같은 패턴을 공유하므로, 나중에 권장 방식이 바뀌더라도 마이그레이션이 매끄럽다.
어디에 쓰면 좋은가
- 외부 컴포넌트에 콜백을 넘기는데 그 콜백이 자식의 effect deps에 들어가는 경우
- WebView 메시지 핸들러, 외부 SDK 콜백, 차트/지도 라이브러리의 이벤트 핸들러 등
- 결과 전송처럼 "최신 상태를 캡처해야 하지만 reference는 고정돼야 하는" 비동기 콜백
반대로 일반적인 이벤트 핸들러(버튼 onClick/onPress 등)는 굳이 쓸 필요 없다. 리액트가 알아서 처리해주는 영역이라 과한 추상화가 된다.
실전 적용 예시
외부 위젯 컴포넌트가 onResult prop을 effect deps로 잡고 있어서, 부모의 data·locale·deviceInfo 같은 값이 바뀔 때마다 결과 전송이 반복 트리거되는 문제가 있었다고 하자.
Before — 값마다 useRef
const sendResultLatest = useRef(sendResult);
sendResultLatest.current = sendResult;
const routineIdLatest = useRef(data?.routineId);
routineIdLatest.current = data?.routineId;
const localeLatest = useRef(locale);
localeLatest.current = locale;
const onResult = useCallback(async (result) => {
const body = {
routineId: routineIdLatest.current ?? 0,
preferredLang: localeLatest.current,
deviceId: deviceInfo?.deviceId, // ← ref 등록을 빠뜨림! stale closure
// ...
};
await sendResultLatest.current(body);
}, []);
deviceInfo만 ref로 감싸는 걸 깜빡했고, 그 결과 이 필드만 옛날 값으로 전송되는 버그가 숨어 있다. 코드만 봐서는 눈에 잘 띄지 않는다.
After — useEvent
const onResult = useEvent(async (result: Result) => {
const body = {
routineId: data?.routineId ?? 0,
preferredLang: locale,
deviceId: deviceInfo?.deviceId, // ← 자동으로 최신값
// ...
};
await sendResult(body);
});
코드량은 절반 이하로 줄고, 어떤 변수를 추가하든 자동으로 최신값을 본다. stale closure가 구조적으로 발생할 수 없다.
마치며
useEvent는 "최신 클로저"와 "안정적인 reference"라는, 평소엔 양립하기 어려운 두 요구를 한 줄로 충족시킨다. 외부 라이브러리를 자주 붙이는 코드베이스라면 공용 훅으로 하나 만들어두는 것을 추천한다. React 19.2 이상이라면 effect 내부 호출은 공식 useEffectEvent로, 콜백 prop 전달은 useEvent로 — 두 패턴을 상황에 맞게 함께 쓰면 된다.
참고 링크
- React 공식 문서: useEffectEvent
- "Separating Events from Effects" 가이드: https://react.dev/learn/separating-events-from-effects
- 원본 RFC: https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md
'Engineering > FrontEnd' 카테고리의 다른 글
| [Vue-Cal] Vue.js 캘린더 라이브러리 (0) | 2023.07.03 |
|---|---|
| [Vue] requestAnimationFrame (0) | 2023.04.25 |
| [Vue-Query] persistQueryClient (0) | 2023.04.24 |
| [Swagger Codegen] swagger-typescript-api (0) | 2023.04.20 |
| [Vue-Query] useMutation (0) | 2023.04.20 |