[번역] useEffectEvent의 즐거움

I
Inkyu Oh

Front-End2026.01.22

Jack Herrington - 2026-01-07


React에서 버그가 가장 많이 발생하는 원인이 무엇이냐고 묻는다면 여러분은 무엇이라고 답하시겠습니까? 아마도 모든 사람이 말하는 그것, 바로 useEffect일 것입니다. 비동기 작업을 수행할 수 있게 해주지만 이름부터 난해한 이 Hook은 훌륭한 기능을 제공하지만, 동시에 많은 문제를 일으키기도 합니다. 특히 서버에서 데이터를 계속해서 가져오는 무한 루프 현상이 대표적입니다.
jack herrington useeffectevent

이제 인정할 것은 인정해야 합니다. React 팀은 이 문제를 인지했고, useEffectEvent라는 새로운 Hook을 고안해냈습니다. 이름이 다소 길고 복잡하게 느껴질 수 있지만, 여러분의 React 앱을 안정화하는 데 있어 구세주와 같은 존재입니다.
매우 흔하게 발생하는 문제 하나를 예로 들어 설명해 보겠습니다. 먼저 현재 우리가 사용하는 Hook들로 시작하여 문제점을 살펴본 다음, useEffectEvent가 이를 어떻게 해결하는지 보여드리겠습니다.

이것이 실제로 중요한 이유: Cloudflare의 잘못된 useEffect 사용 사례

Cloudflare는 지구상에서 가장 큰 배포 서비스 제공업체 중 하나이며 훌륭한 엔지니어링 팀을 보유하고 있습니다. 하지만 그런 그들조차 useEffect와 관련해서는 실수를 할 수 있습니다. 최근 그들은 의존성 배열(dependency array)에 객체를 잘못 넣는 바람에 자신들의 대시보드에 분산 서비스 거부(DDOS) 공격을 가하는 꼴이 되었습니다. 해당 객체는 리렌더링될 때마다 정체성(identity)이 바뀌었고, 이로 인해 무한 루프가 발생하여 대시보드 전체가 다운되었습니다.
누구나 저지르기 쉬운 당혹스러운 실수입니다. 이것이 바로 React 컴파일러와 같은 개선 사항과 useEffectEvent 같은 새로운 Hook이 중요한 이유입니다. 컴파일러의 경우 객체 참조를 안정화하여 객체 정체성과 관련된 잠재적 버그를 줄여줍니다. 그리고 useEffectEvent는 의존성 배열에서 객체를 완전히 제거해 버립니다!

상황 설정

사용자 이름을 수정할 수 있는 간단한 컴포넌트가 여기 있습니다.
function MyUserInfo() {
const [userName, setUserName] = useState("Bob");

return (
<div>
<input
value={userName}
onChange={(evt) => setUserName(evt.target.value)}
/>
</div>
);
}
여기까지는 좋습니다. 사용자 이름을 변경할 수 있습니다. 이제 사용자가 로그인한 시간을 추적하여 표시하고 싶다고 가정해 봅시다.
function MyUserInfo() {
const [userName, setUserName] = useState("Bob");

useEffect(() => {
let loggedInTime = 0;
const interval = setInterval(() => {
loggedInTime++;
}, 1000);
return () => clearInterval(interval);
}, []);

return (
<div>
<input
value={userName}
onChange={(evt) => setUserName(evt.target.value)}
/>
</div>
);
}
그래서 우리는 이 사람이 로그인한 초 단위 시간을 추적하기 위해 타이머를 설정하는 [useEffect](https://blog.logrocket.com/useeffect-react-hook-complete-guide/)를 추가했습니다. (네, 실제로 로그인된 시간을 엄밀히 측정하는 것은 아니며 데모용 코드일 뿐입니다.)
이 코드는 실제로 잘 작동하며 버그도 없습니다. 빈 의존성 배열 덕분에 컴포넌트 마운트 시에만 한 번 실행됩니다. 그리고 인터벌을 해제하는 클린업(cleanup) 함수를 반환하여 타이머를 종료함으로써 스스로 뒷정리도 잘 수행합니다.
하지만 기능적인 측면에서 보면, 그 숫자를 어디에도 표시하지 않기 때문에 실제로 작동하는 것처럼 보이지는 않습니다. 이를 해결하기 위해 해당 숫자를 보여줄 수 있는 loginMessage 문자열을 추가해 보겠습니다.
function MyUserInfo() {
const [userName, setUserName] = useState("Bob");
const [loginMessage, setLoginMessage] = useState("");

useEffect(() => {
let loggedInTime = 0;
const interval = setInterval(() => {
loggedInTime++;
setLoginMessage(
`${userName} has been logged in for ${loggedInTime} seconds`
);
}, 1000);
return () => clearInterval(interval);
}, []);

return (
<div>
<div>{loginMessage}</div>
<input
value={userName}
onChange={(evt) => setUserName(evt.target.value)}
/>
</div>
);
}
겉보기에는 잘 작동할 것 같습니다. 실제로 어느 정도는 그렇습니다. 시작하자마자 "Bob has been logged in for 1 second"라고 표시되고, 매초 성실하게 숫자가 올라갑니다. 대성공이네요!

오래된 클로저(Stale closure) 문제

아차, 사실 버그가 있습니다. useEffect에 전달한 함수가 "오래된(stale)" 상태가 될 수 있기 때문입니다.
gif of useEffect going stale

사용자 이름을 변경하면 어떻게 될까요? input은 변경되지만, 로그인 메시지는 계속해서 사용자 이름이 "Bob"이라고 말합니다. 하지만 실제로는 이름이 바뀌었죠.
우리가 useEffect에 보낸 함수는 당시의 userName 값("Bob")을 캡처한 "클로저(closure)"를 생성했습니다. 그리고 이 값은 절대 변하지 않을 것입니다. 실제 값과 동기화되지 않기 때문에 우리는 이를 "오래된(stale)" 상태라고 부릅니다. 즉, "오래된 클로저"가 발생한 것입니다.
다행히 React에는 이에 대한 해결책이 있습니다 (useEffectEvent는 아니니 조금만 기다려 주세요). 의존성 배열에 userName을 추가하기만 하면 됩니다.
useEffect(() => {
let loggedInTime = 0;
const interval = setInterval(() => {
loggedInTime++;
setLoginMessage(
`${userName} has been logged in for ${loggedInTime} seconds`
);
}, 1000);
return () => clearInterval(interval);
}, [userName]);
짜잔! 문제가 해결되었습니다. 이제 userName을 수정하면 로그인 메시지도 바뀝니다! 멋지네요. 아, 잠깐만요. 이게 뭐죠? 변경할 때마다 로그인 시간이 다시 1초로 돌아가 버립니다.
gif of useEffect going stale

아하, 새로운 userName 값으로 새로운 클로저를 생성할 때마다 이전 타이머를 죽이고 있습니다(이건 좋습니다). 하지만 새로운 loggedInTime을 생성하고 다시 0부터 시작하고 있습니다. 이건 확실히 좋지 않네요.
물론 이 문제의 쉬운 해결책 중 하나는 loggedInTime을 상태(state)로 관리하고 JSX에서 문자열 포맷을 맞추는 것입니다. 알겠습니다. 하지만 그렇게 할 수 없는 상황이라고 가정해 봅시다.

useRef가 구원해 줄 것입니다

이걸 어떻게 고칠 수 있을까요? useEffectEvent가 나오기 전이라면 아마도 ref를 사용했을 것입니다.
const nameRef = useRef(userName);
nameRef.current = userName;

useEffect(() => {
let loggedInTime = 0;
const interval = setInterval(() => {
loggedInTime++;
setLoginMessage(
`${nameRef.current} has been logged in for ${loggedInTime} seconds`
);
}, 1000);
return () => clearInterval(interval);
}, []);
여기서 몇 가지 작업을 했습니다. 먼저 userName의 현재 값을 저장하는 참조(reference)를 만들고, 매 렌더링마다 현재 값을 업데이트합니다. React는 상태처럼 ref를 모니터링하지 않기 때문에 렌더링 중에 ref의 current 값을 설정해도 괜찮습니다.
다음으로, 템플릿 문자열에서 userName 대신 nameRef.current를 사용합니다. 매 렌더링마다 업데이트되므로 항상 userName의 최신 값을 가져올 수 있습니다. 마지막으로 의존성 배열에서 userName을 제거하여 리셋 버그를 해결했습니다.

이제 실제로 잘 작동합니다. 예외 상황도 없죠! 다만 코드가 좀 투박하다는 점이 아쉬운데, 바로 이 지점에서 useEffectEvent가 등장합니다.

useEffectEvent가 훨씬 낫습니다

이 버전을 확인해 보세요.
const getName = useEffectEvent(() => userName);

useEffect(() => {
let loggedInTime = 0;
const interval = setInterval(() => {
loggedInTime++;
setLoginMessage(
`${getName()} has been logged in for ${loggedInTime} seconds`
);
}, 1000);
return () => clearInterval(interval);
}, []);
우리는 새로운 useEffectEvent Hook을 사용하여 userName의 현재 값을 반환하는 게터(getter) 함수를 만들었습니다. 이 함수는 useEffect 내에서 호출될 수 있으며 절대 오래된 상태가 되지 않습니다. 정말 깔끔합니다. useRef 버전보다 훨씬 더 깔끔하고 명확합니다.
하지만 더 좋은 점은 useEffect를 더 일반적인 관점에서 생각할 수 있게 해준다는 것입니다. 생각해보면, 우리는 해당 useEffect를 통해 일종의 범용적인 "타이머"를 갖게 된 셈입니다.
const onTick = useEffectEvent((tick: number) =>
setLoginMessage(`${userName} has been logged in for ${tick} seconds`)
);

useEffect(() => {
let ticks = 0;
const interval = setInterval(() => onTick(++ticks), 1000);
return () => clearInterval(interval);
}, []);
이제 모든 상태 관련 로직을 useEffectEvent로 옮겼습니다. useEffect가 얼마나 더 깔끔해졌는지 보이시나요? useEffect는 단지 타이머만 관리합니다. 그리고 onTick은 그 타이머로 무엇을 할지에 대한 모든 로직을 처리합니다.

useEffectEvent는 게임 체인저입니다

더 좋은 점은 useEffect가 상태에 대한 의존성을 전혀 갖지 않는다는 것입니다. 우리가 보았듯이 useEffect가 문제를 일으키는 지점은 바로 상태 의존성입니다. 잘못된 상태에 의존하는 잘못된 의존성 배열은 오래된 클로저 문제, 부적절한 리셋, 심지어 무한 루프까지 유발할 수 있습니다. useEffectEvent를 사용하면 의존성 배열에서 상태를 제거할 수 있습니다. 이는 우리가 더 나은 useEffect를 작성하도록 도와줍니다.
심지어 이를 더 범용적으로 만들어 커스텀 Hook으로 바꿀 수도 있습니다.
function useInterval(onTick: (tick: number) => void) {
const onTickEvent = useEffectEvent(onTick);
useEffect(() => {
let ticks = 0;
const interval = setInterval(() => onTickEvent(++ticks), 1000);
return () => clearInterval(interval);
}, []);
}
이제 매우 깔끔하고 버그가 없는 완전한 useInterval 구현체를 갖게 되었습니다.

작은 도전 과제

재미있는 도전 과제를 하나 드리자면, 밀리초 단위(현재 1000)를 조정할 수 있는 버전을 어떻게 구현하시겠습니까?
function useInterval(onTick: (tick: number) => void, timeout: number = 1000) {
// ????
}
제가 생각해낸 방법은 다음과 같습니다.
function useInterval(onTick: (tick: number) => void, timeout: number = 1000) {
const onTickEvent = useEffectEvent(onTick);
useEffect(() => {
let ticks = 0;
const interval = setInterval(() => onTickEvent(++ticks), timeout);
return () => clearInterval(interval);
}, [timeout]);
}
아, 잠깐만요, 이건 틀렸습니다. timeout이 바뀔 때마다 카운터가 다시 0부터 시작되므로 또다시 오래된 클로저 문제가 발생합니다. 이런. 아, 맞다, 또 다른 useEffectEvent를 사용하면 되겠네요.
function useInterval(onTick: (tick: number) => void, timeout: number = 1000) {
const onTickEvent = useEffectEvent(onTick);
const getTimeout = useEffectEvent(() => timeout);

useEffect(() => {
let ticks = 0;
let mounted = true;
function onTick() {
if (mounted) {
onTickEvent(++ticks);
setTimeout(onTick, getTimeout());
}
}
setTimeout(onTick, getTimeout());
return () => {
mounted = false;
};
}, []);
}
이번에 취한 접근 방식은 조금 다릅니다. setInterval 대신 setTimeout을 사용하고 매 반복마다 타임아웃을 조정하고 있습니다. 이 코드를 더 줄일 수 있는 방법이 있다면 알려주세요.
그동안, 이 생소한 이름의 새로운 Hook이 작은 개선처럼 보일지 몰라도 실제로는 React에 얼마나 큰 승리인지 이해하셨기를 바랍니다. React 팀은 useEffect 코드가 통제 불능으로 실행되는 문제가 있음을 인정했습니다. 그들은 상태와 연결된 useEffect라는 문제의 원인을 명확히 파악했습니다. 그리고 useEffect를 상태로부터 분리하는 우아한 해결책을 제시했습니다.

결론: 안전벨트를 맨 React

React 앱에서 수많은 문제를 일으키는 이슈들을 React 팀이 직접 해결해 나가는 모습은 매우 고무적입니다. 컴파일러와 같은 새로운 도구와 useEffectEvent 같은 개선 사항 덕분에 우리는 훨씬 더 신뢰할 수 있고 견고한 React 코드를 작성할 수 있게 되었습니다. React는 결코 죽지 않았으며, 매 릴리스마다 더 좋아지고 있습니다!
여러분도 직접 useEffectEvent를 사용하여 useEffect Hook이 얼마나 더 깔끔하고 안전해질 수 있는지 확인해 보시기 바랍니다.
0
18

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글