
Intro

리액트 공식 문서의 Escape Hatches메뉴에 5가지의 effect에 대한 문서가 있습니다.
이 개념을 위해 이렇게 많은 설명이 필요한걸까요? useEffect에서 effect는 무엇을 의미할까요? 제대로 사용하기 위한 첫 걸음은 정확한 멘탈 모델을 세우는 것입니다. 많은 문제가 여기에서 출발합니다. 그리고 이 문제들을 해결하는 쉬운 방법은 lint의 경고를 준수하는 것입니다.
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
useEffect(() => {
logVisit(url, numberOfItems);
// 🔴 린트 경고를 무시하면 잠재적 오류가 발생할 수 있습니다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
// ...
}
하지만 실무에서 위와 같은 상황을 마주하게 됩니다.
numberOfItems가 변경될 때 logVisist가 실행되면 안 됩니다. 이처럼 lint의 경고를 그대로 따르려 했을 때 발생하는 예외적 상황을 마주했을 때 많은 리액트 개발자들이 공식 문서의 권장과 예외 상황 사이에서 혼란을 느끼며 린트를 억제하는 주석(eslint-disable-next-line react-hooks/exhaustive-deps)을 추가하고 넘어갔습니다. 이 문제를 해결하기 위해 react팀에서는 useEffectEvent라는 새로운 개념을 소개했습니다. (^react@19.2 부터 사용가능)
이 글에서는 Effect를 ‘렌더링의 부수 효과’라는 관점에서 왜 lint 경고를 억제하지 말아야 하며,
그 대안으로 useEffectEvent가 어떤 역할을 하는지 설명합니다.
우선 리액트에서 말하는 Effect는 무엇일까요?
// 리액트 순수 컴포넌트
function Recipe({ drinkers }) {
return (
<ol>
<li>Boil {drinkers} cups of water.</li>
<li>Add {drinkers} spoons of tea and {0.5 * drinkers} spoons of spice.</li>
<li>Add {0.5 * drinkers} cups of milk to boil and sugar to taste.</li>
</ol>
);
}
리액트 코드는 크게 두가지 로직 유형으로 볼 수 있습니다. 렌더링 코드(UI를 표현하기 위한 코드)와 이벤트 핸들러입니다.
state와 props의 의한 UI표현과 이벤드 핸들러만으로 이뤄진 컴포넌트를 리액트에서 순수 컴포넌트 라고 정의하며 모든 컴포넌트가 기본적으로는 순수할 것이라 가정합니다. 그런데 순수한 컴포넌트만으로는 분명 한계가 있고 예외적 상황이 발생합니다. 이런 예외적 상황을 렌더링의 부수효과로 보고 부수효과를 처리하기 위한 기능이 useEffect입니다.
여기서 Effect는 렌더링에 의해 발생하는 부수 효과를 의미합니다. 특정 이벤트가 아닌 렌더링에 의해 발생합니다.
채팅 서비스를 예로 들어보겠습니다. 사용자가 메시지를 보내는 것은 이벤트입니다. 사용자의 인터렉션에 의해서 발생했기 때문입니다. 그러나 채팅 서버 연결은 Effect입니다. 이는 어떠한 상호작용에 의한 결과가 아닌 렌더링 이후 발생하는 렌더링의 부수 효과이기 때문입니다. 리액트는 화면 업데이트가 된 렌더링 이후 시점에 useEffect를 통해 외부 시스템과 동기화합니다.
UI = f(state)
순수 컴포넌트는 동일한 props, state, context를 제공할 때 항상 동일한 UI를 렌더링하는 것을 보장한다.
리액트에서 말하는 Effect는 "렌더링에 의한 부수 효과"로 의미를 제한합니다.
즉, 렌더링의 부수 효과를 처리하기 위해 외부 시스템과 props과 state를 동기화하는 API
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
// ...
}
이제 위 로직을 "ChatRoom컴포넌트 렌더링의 부수 효과로 roomId을 채팅 서버 연결을 동기화한다."라고 설명할 수 있습니다.
반응형 값(reactive value)
컴포넌트 본문에서 참조되는 props, state, 그리고 그로부터 파생된 값은 모두 리렌더링에 따라 변경될 수 있으며, 이를 반응형 값이라 부릅니다. 리액트에서 다루는 Effect 내부 로직 역시 반응형입니다. 따라서 Effect에서 반응형 값을 참조하는 경우 그 값을 의존성으로 지정해야 합니다. 그렇게 해야 리렌더링시에 그 바뀐 값을 참조하여 리액트가 새로운 effect로직을 실행합니다.
아래 채팅 로직을 통해 설명해보겠습니다.
roomId가 변경되면 새로운 채팅방에 연결되어야 합니다. 따라서 의존성 배열에 roomId가 추가되고 이 값에 의해 새로운 채팅방에 연결됩니다. useEffect는 컴포넌트와 외부 시스템의 동기화를 수행한다고 했습니다. 이 관점에서 생각해볼 때 현재 선택된 채팅방(roomId)과 채팅서버 연결을 동기화했다라고 할 수 있습니다.
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId]);
반응형 값(rective values은 useEffect의 의존성 배열에 들어가야 한다.
Effect Event
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('연결됨!', theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]);
return <h1>{roomId} 방에 오신 것을 환영합니다!</h1>
}
Effect event에 대한 설명에 앞서 위 코드의 상황을 한번 살펴보겠습니다.
의존성 배열의 항목을 보면 roomId와 theme가 변경될 때 마다 채팅방이 다시 연결되는 것을 확인할 수 있습니다.
roomId는 유효한 의존성입니다. 선택된 방이 변경되면 다시 연결되어야 하니까요. 하지만 theme는 어떨까요?
선택된 방이 동일한데 테마가 변경됐을 때 이전 방의 연결이 끊어졌다가 다시 방이 연결되는 것은 어색한 동작입니다. 문제는 반응형값(props)이면서 의존성 배열에서는 빠져야한다는 것입니다.
props와 state는 반응형값이고
반응형 값은 useEffect 의존성 배열에 넣어야 한다면서요? 😵💫
Lastest Ref Pattern
이런 고민을 하다가 아래와 같은 접근 방식을 고민하셨을지 모릅니다. ref를 이용해 최신 값을 참조하면서 lint 의존성을 우회하는 방법입니다. remix의 공동 창시자 Kent C.Dodds는 이 패턴을 Lastest Ref Pattern이라고 불렀습니다.
뒤에 소개할 useEffectEvent도 이 아이디어에서 출발했습니다.
function ChatRoom({ roomId, theme }) {
const onConnected = useRef(() => showNotification('연결됨!', theme));
useLayoutEffect(() => {
onConnected.current = () => showNotification('연결됨!', theme);
}, [theme]);
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => onConnected.current('연결됨!'));
connection.connect();
return () => connection.disconnect();
}, [roomId]);
useEffectEvent
이제 effect event가 등장할 시점입니다.
💡 useEffectEvent
useEffect에서 반응형이 아닌 로직을 분리하는 리액트 훅
출처: https://ko.react.dev/reference/react/useEffectEvent
effect event은 지금 상황처럼 effect 함수의 반응형 코드에서 비반응형 코드(non-reactive logic)을 분리하는 방법입니다.
useEffectEvent를 통해 비반응형 코드를 분리해보겠습니다. useEffectEvent에 정의된 함수는 항상 props와 state의 최근 값을 바라봅니다. 아래 코드에서 선언된 onConnected 함수를 Effect 이벤트라고 부릅니다.
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('연결됨!', theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ 모든 의존성이 선언됨
useEffectEvent를 활용할 수 있는 또 다른 예시입니다.
아래 코드는 초당 일정 증가량(increment)씩 증가하는 카운터 로직입니다. 이 코드의 문제는 의도하지 않은 debounce가 발생한다는 것입니다. 즉 카운팅 간격(1000ms)내에 여러번 increment가 증가하게 되면 clearInterval에 의해 타이머가 멈춰 버립니다. 비 반응형 코드를 분리할 필요가 있습니다.
import { useState, useEffect } from 'react';
import { useEffectEvent } from 'react';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + increment);
}, 1000);
return () => {
clearInterval(id);
};
}, [increment]);
return (
<>
<h1>
카운터: {count}
<button onClick={() => setCount(0)}>재설정</button>
</h1>
<hr />
<p>
초당 증가량:
<button disabled={increment === 0} onClick={() => {
setIncrement(i => i - 1);
}}>–</button>
<b>{increment}</b>
<button onClick={() => {
setIncrement(i => i + 1);
}}>+</button>
</p>
</>
);
}
아래 코드에서 어떤 변화가 생겼는지 잠시 살펴보시길 바랍니다.
이제 increment의 변화와 상관없이 초당 count가 상승이 보장됩니다. 의도 하지 않은 debounce로직이 사려졌습니다.
import { useState, useEffect } from 'react';
import { useEffectEvent } from 'react';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
const onTick = useEffectEvent(() => {
setCount(c => c + increment);
});
useEffect(() => {
const id = setInterval(() => {
onTick();
}, 1000);
return () => {
clearInterval(id);
};
}, []);
return (
<>
<h1>
카운터: {count}
<button onClick={() => setCount(0)}>재설정</button>
</h1>
<hr />
<p>
초당 증가량:
<button disabled={increment === 0} onClick={() => {
setIncrement(i => i - 1);
}}>–</button>
<b>{increment}</b>
<button onClick={() => {
setIncrement(i => i + 1);
}}>+</button>
</p>
</>
);
}
useEffectEvent의 한계
Effect 이벤트는 useEffect와 같은 실행 맥락에서 선언되어야 합니다.
아래 코드에서는 Timer에서 선언한 Effect이벤트를 useTimer에 전달합니다. 즉 Effect이벤트를 매개변수로 전달하여 다른 실행맥락에 실행하는 코드입니다. 이 경우 lint는 올바르게 Effect이벤트를 구분하지 못합니다. 이게 Effect이벤트의 한계이자, 같은 실행 맥락에서 선언되어야 하는 이유입니다.
function Timer() {
const [count, setCount] = useState(0);
const onTick = useEffectEvent(() => {
setCount(count + 1);
});
useTimer(onTick, 1000); // 🔴 금지: Effect 이벤트 전달하기
return <h1>{count}</h1>
}
function useTimer(callback, delay) {
useEffect(() => {
const id = setInterval(() => {
callback();
}, delay);
return () => {
clearInterval(id);
};
}, [delay, callback]); // 의존성에 "callback"을 지정해야 함
}
function Timer() {
const [count, setCount] = useState(0);
useTimer(() => {
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>
}
function useTimer(callback, delay) {
const onTick = useEffectEvent(() => {
callback();
});
useEffect(() => {
const id = setInterval(() => {
onTick(); // ✅ 바람직함: Effect 내부에서 지역적으로만 호출됨
}, delay);
return () => {
clearInterval(id);
};
}, [delay]); // "onTick"(Effect 이벤트)를 의존성으로 지정할 필요 없음
}
린트의 경고를 무시하면 안되는 이유
지금까지 리액트에서의 Effect의 정의와 반응형 값, useEffectEvent까지 다뤄봤습니다.
이렇게까지 해서 왜 useEffect 의존성 배열을 유지해야 할까요? 왜 린트의 경고를 무시하면 안될까요? 그냥 하던대로 주석달고 넘어가면 어떤 문제가 있을까요?
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
useEffect(() => {
logVisit(url, numberOfItems);
// 🔴 린트 경고 무시
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
// ...
}
useEffectEvent가 등장하기 이전에 린트의 경고를 억제하기 위해 위와 같은 방법을 사용했을지 모릅니다.
얼핏 타당해 보이는 위 코드의 문제는 무엇일까요? 새로운 반응형 변수가 effect내부에서 사용됐을 때 린트를 통해서 알 수 없다는 것은 버그로 이어질 수 있는 구조적 문제입니다. (새로운 반응형 값이 effect에서 사용됐을 때 경고하지 않음)
다음 예시가 린트를 억제했을 때 발생할 수 있는 상황을 설명합니다.
마우스의 위치를 랜더링하는 기능입니다. canMove 상태를 통해 위치 트래킹 여부를 결정짓는 것이 의도입니다.
하지만 아래 로직에서 canMove의 값과 무관하게 항상 위치가 트래킹됩니다. 원인은 스냅샷(클로져)에 의해 canMove의 최신값이 아닌 최초의 값(true)를 참조하기 때문입니다.
import { useState, useEffect } from 'react';
export default function App() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [canMove, setCanMove] = useState(true);
function handleMove(e) {
if (canMove) {
setPosition({ x: e.clientX, y: e.clientY });
}
}
useEffect(() => {
window.addEventListener('pointermove', handleMove);
return () => window.removeEventListener('pointermove', handleMove);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<label>
<input type="checkbox"
checked={canMove}
onChange={e => setCanMove(e.target.checked)}
/>
점 움직이게 하기
</label>
<hr />
<div style={{
position: 'absolute',
backgroundColor: 'pink',
borderRadius: '50%',
opacity: 0.6,
transform: `translate(${position.x}px, ${position.y}px)`,
pointerEvents: 'none',
left: -20,
top: -20,
width: 40,
height: 40,
}} />
</>
);
}
canMove가 항상 최신 상태를 참조하도록 수정하기 위해 아래와 같이 코드를 수정할 수 있습니다.
import { useState, useEffect } from 'react';
import { useEffectEvent } from 'react';
export default function App() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [canMove, setCanMove] = useState(true);
const onMove = useEffectEvent(e => {
if (canMove) {
setPosition({ x: e.clientX, y: e.clientY });
}
});
useEffect(() => {
window.addEventListener('pointermove', onMove);
return () => window.removeEventListener('pointermove', onMove);
}, []);
return (
<>
<label>
<input type="checkbox"
checked={canMove}
onChange={e => setCanMove(e.target.checked)}
/>
점 움직이게 하기
</label>
<hr />
<div style={{
position: 'absolute',
backgroundColor: 'pink',
borderRadius: '50%',
opacity: 0.6,
transform: `translate(${position.x}px, ${position.y}px)`,
pointerEvents: 'none',
left: -20,
top: -20,
width: 40,
height: 40,
}} />
</>
);
}
마무리
Lifecycle of Reactive Effects
Effects have a different lifecycle from components. Components may mount, update, or unmount. An Effect can only do two things: to start synchronizing something, and later to stop synchronizing it. This cycle can happen multiple times if your Effect depends on props and state that change over time. React provides a linter rule to check that you’ve specified your Effect’s dependencies correctly. This keeps your Effect synchronized to the latest props and state.
출처: https://react.dev/learn/lifecycle-of-reactive-effects
리액트 공식문서에서 제공하는 자료를 바탕으로 리액트에서의 부수 효과와 린트 경고를 억제하면 안되는 이유에 대해 알아봤습니다.
useEffect에 대한 가장 큰 오해는 이를 컴포넌트 라이프사이클로 해석하는 것입니다.
Effect는 특정 시점에 실행되는 로직이 아니라,
렌더링 결과를 외부 시스템과 동기화하는 선언입니다.
이 관점에 서면 lint 경고를 무시할 이유도, 의존성을 임의로 제거할 이유도 사라집니다.
이 사고의 전환을 통해 리액트를 바라보는 관점을 개선할 수 있습니다.
참고
why-is-suppressing-the-dependency-linter-so-dangerous
is-it-okay-to-suppress-the-dependency-linter-instead
https://ko.react.dev/learn/keeping-components-pure
컴포넌트를 순수하게 유지하기 – React
The library for web and native user interfaces
ko.react.dev
React Effect의 생명주기 – React
The library for web and native user interfaces
ko.react.dev
https://ko.react.dev/learn/removing-effect-dependencies
Effect의 의존성 제거하기 – React
The library for web and native user interfaces
ko.react.dev
https://ko.react.dev/reference/react/useEffectEvent
useEffectEvent – React
The library for web and native user interfaces
ko.react.dev
https://ko.react.dev/learn/separating-events-from-effects
Effect에서 이벤트 분리하기 – React
The library for web and native user interfaces
ko.react.dev
https://ko.react.dev/learn/you-might-not-need-an-effect
Effect가 필요하지 않은 경우 – React
The library for web and native user interfaces
ko.react.dev
https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md#internal-implementation
rfcs/text/0000-useevent.md at useevent · reactjs/rfcs
RFCs for changes to React. Contribute to reactjs/rfcs development by creating an account on GitHub.
github.com
https://www.epicreact.dev/the-latest-ref-pattern-in-react
The Latest Ref Pattern in React
How to improve your custom hook APIs with a simple pattern.
www.epicreact.dev
'react' 카테고리의 다른 글
| 함수 컴포넌트와 클로저 (0) | 2025.12.23 |
|---|---|
| React에서 상태를 초기화하는 기준 (0) | 2025.12.22 |
| useReducer를 이용한 상태 모델링의 이점 (0) | 2025.12.19 |
| [설계] 실무에서 자주 겪는 잘못된 추상화 사례 (0) | 2025.12.19 |
| [설계] 모듈 응집도와 단방향 의존성을 통한 유지보수 비용 줄이기 (0) | 2025.12.18 |