
리액트에서 렌더링 되는 코드는 순수함수여야 하고, 입력 값에 따라 동일한 결과를 반환하고 외부에 영향을 미치지 않아야 합니다. (이전 블로그 글 참조) 하지만 실제 프로젝트에서는 순수함수만으로 모든 기능을 구현하기란 사실상 어렵습니다. 예를 들어, API를 호출하거나, DOM을 직접 조작하는거나 다른 라이브러리를 사용 하는 상황에서는 어느정도의 부수효과를 허용할 수 밖에 없기 때문이죠.
이러한 맥락에서 등장한 것이 useEffect가 않을까 생각합니다. 이름에서도 알 수 있듯이, Effect는 부수효과를 일으키는 side-effect에서 따온 것 입니다. useEffect를 쓰는 행위 자체가 곧 프로젝트에서 부수효과를 도입하는 일이 되는 셈인 것이죠. 외부 세계와의 상호작용을 하기 때문에요.
리액트 공식문서에서는 useEffect를 탈출구라는 표현을 사용합니다. 왜 탈출구 일까요? 저는 이 표현이 리액트의 순수성 제약에서 벗어날 수 있는 지점을 의미한다고 해석했습니다. useEffect는 곧 리액트의 선언적인 패러다임에서 벗어나 명령적인 부수 효과를 처리하기 위함이 아닐까? 하는 생각이 들었습니다.
React의 렌더링 과정은 크게 렌더(render)와 커밋(commit) 두 단계로 나뉩니다. useEffect는 커밋 단계가 완료된 이후에 실행되는데, 이것이 의미하는 바를 자세히 살펴보겠습니다.
렌더 단계
컴포넌트를 렌더링하고 변경 사항을 계산하는 모든 작업을 말합니다. render() 또는 return 결과와 이전 가상 DOM을 비교하는 과정을 거쳐 변경이 필요한 컴포넌트를 체크하는 단계입니다.type, props ,key 셋 중 하나라도 변경되면 변경이 필요한 컴포넌트로 체크해둡니다.
커밋 단계
렌더 단계의 변경 사항을 실제 DOM에 적용해 사용자에게 보여주는 과정을 말한다. 이 단계가 끝나야 비로소 브라우저의 렌더링이 발생한다.
리액트의 렌더링이 일어난다고 무조건 DOM 업데이트가 일어나는 것은 아니다. 렌더링을 수행했으나 커밋단계까지 갈 필요가 없다면 커밋단계는 생략될 수 있습니다.
(렌더 단계에서 변경 사항을 감지할 수 없다면 커밋 단계가 생략되어 브라우저의 DOM 업데이트가 일어나지 않을 수 있습니다.)
useEffect 실행
모든 DOM 변경이 화면에 반영된 후에 실행됩니다. 브라우저가 화면을 그린 후 비동기적으로 실행됩니다. 이는 의도적인 설계로 리액트가 브라우저에게 “화면 업데이트를 먼저 완료하라”고 지시하기 때문입니다.

왜 커밋 단계 이후에 실행될까요?
- 사용자 경험 향상 DOM 업데이트를 먼저 완료하여 사용자가 UI 변경을 지체 없이 볼 수 있게 합니다.
- DOM 접근의 안정성 Effect 내에서 DOM에 접근할 때, 이미 업데이트 된 DOM을 참조할 수 있습니다.
- 렌더링 블로킹 방지 useEffect가 렌더링 과정을 차단하지 않도록 합니다.
컴포넌트는 마운트, 업데이트, 언마운트 3가지 단계를 거칩니다. 하지만 Effect는 동기화를 시작하는 것, 동기화를 중지하는 것 두가지 작업만 가능합니다. 따라서 Effect는 항상 한번에 하나의 시작/중지 사이클에만 집중해야합니다.
| 구분 | 컴포넌트 생명주기 | useEffect 생명주기 |
|---|---|---|
| 주요 개념 | 마운트 → 업데이트 → 언마운트 | 동기화 시작 → 동기화 중지 |
| 실행 시점 | 렌더링 전후 | 렌더링 후 (커밋 단계 이후) |
| 상태와의 관계 | 상태 변화에 따라 생명주기 메서드 호출 | 의존성 배열에 명시된 값이 변경될 때만 실행 |
useEffect는 React의 커밋단계가 끝난 후, 즉 브라우저에 실제 DOM 변경이 반영된 뒤에 실행됩니다. 이때, Effect는 다음의 두 가지 작업만 수행할 수 있습니다.
동기화 시작 (side effect 시작)
렌더링 이후 외부와 동기화할 작업을 수행합니다. (ex. 이벤트 리스너 등록, 타이머 시작, API 요청 등)
useEffect(() => {
const id = setInterval(() => {
console.log('타이머 작동 중...');
}, 1000);
return () => clearInterval(id);
}, []);
여기서 setInterval 은 외부 브라우저 API와의 동기화를 시작하는 부분입니다.
동기화 중지 (클린업)
이전 Effect가 정리되어야 할 때 실행할 클린업 함수 반환합니다.
useEffect(() => {
const id = setInterval(() => {
console.log('타이머 작동 중...');
}, 1000);
return () => clearInterval(id); //
}, []);
Effect의 핵심은 동기화 시작과 동기화 중지라는 두가지 단계에만 집중하는 것입니다.
해당 방식의 강점은 개발자가 “이 Effect가 언제 실행되는가?”라는 질문 대신 “이 Effect는 무엇을 동기화하는가?”에 집중하게 된다는 점입니다.
Effects from each render are isolated from each other.
React의 useEffect는 각 렌더링 결과와 1:1로 매핑되며, 각각의 Effect는 해당 렌더에서 생성된 변수, 상태 등과만 연결됩니다. 렌더링이 일어날 때마다 이전 Effect는 정리(cleanup) 되고, 새로 생성된 Effect가 등록됩니다.
function Text() {
const [text, setText]= useState('');
useEffect(() => {
console.log("현재 text:", text);
}, [text]);
return (
<input value={text} onChange={()=> setText(e.target.value)} />
)
}
위 코드에서 text는 상태 값입니다. 상태가 a → ab → abc 순으로 업데이트가 된다면, 각각의 렌더링 시점에 대응하는 useEffect를 생성되고, 그 당시의 text값만을 캡쳐(closure)합니다. 이후에 상태가 바뀌더라도 이미 실행중인 Effect는 과거의 상태를 참조합니다.
이 “캡처” 현상은 자바스크립트의 클로저(Closure) 개념과 밀접한 관련이 있습니다.
함수형 컴포넌트는 렌더링될 때마다 새로운 함수 스코프를 생성합니다. 결과적으로 useEffect 내부에서 참조하는 변수는 그 렌더 시점의 변수를 가리키는 클로저에 묶입니다.
즉, 이후 상태가 바뀌어도 해당 Effect는 자신이 렌더링될 당시의 상태에만 접근합니다. 이것이 “Effect는 각 렌더와 격리되어 있다”는 의미입니다.
클린업은 앞에서 동기화 중지를 하는데 사용한다고 했습니다. 그렇다면 해당 함수는 항상 필요한 필요조건일까요? 그렇지만은 않습니다. 다음 설명은 클린업 함수가 필요한 경우와 불필요한 경우를 나눠 설명해보겠습니다.
구독 취소가 필요한 경우
useEffect(() => {
const subscription = dataSource.subscribe();
return () => {
subscription.unsubscribe();
};
}, [dataSource]);
구독 취소로 메모리 누수를 방지할 수 있습니다.
타이머나 인터벌을 설정한 경우
useEffect(() => {
const timerId = setInterval(() => {
}, 1000);
return () => {
clearInterval(timerId);
};
}, []);
타이머 정리로 불필요한 작업을 방지할 수 있습니다.
이벤트 리스너를 등록한 경우
useEffect(() => {
const handleResize = () => {
setWindowSize(getWindowSize());
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
이벤트 리스너 제거로 메모리 누수를 방지할 수 있습니다.
애니메이션 프레임 요청을 한 경우
useEffect(() => {
let frameId;
const animate = () => {
frameId = requestAnimationFrame(animate);
};
frameId = requestAnimationFrame(animate);
return () => {
cancelAnimationFrame(frameId);
};
}, []);
애니메이션 프레임 취소를 요청해야 합니다.
비동기 작업의 결과를 무시해야 하는 경우
useEffect(() => {
let isMounted = true;
fetchData().then(data => {
if (isMounted) {
setData(data);
}
});
return () => {
isMounted = false;
};
}, []);
컴포넌트가 여전히 마운트된 상태인 경우에만 상태를 업데이트해야합니다. 컴포넌트가 언마운트된 후에 비동기 작업의 결과가 도착하는 경우, 이를 무시하기 위해 클린업함수를 사용합니다.
useEffect(() => {
setIsLoaded(true);
}, []);useEffect(() => {
const savedValue = localStorage.getItem('key');
if (savedValue) {
setValue(savedValue);
}
}, []);useEffect(() => {
logComponentMount();
}, []);useEffect(() => {
window.addEventListener('resize', handleResize);
}, []);clean up 함수 없이 구독이나 이벤트 리스너를 등록하면, 컴포넌트가 언마운트된 후에도 이러한 리소스가 계속 존재하게 됩니다. 이는 메모리 누수로 이어져 애플리케이션 성능 저하와 예기치 않은 동작을 유발할 수 있습니다
useEffect(() => {
fetchUserData(userId).then(data => {
setUserData(data);
});
}, [userId]);만약 사용자가 빠르게 userId를 A → B → C로 변경한다면?
비동기 작업이 완료되기 전에 컴포넌트가 언마운트되거나 의존성이 변경되면, 이전 작업의 결과가 새로운 상태를 덮어쓸 수 있습니다. 이는 경쟁 상태라 불리며, 예측할 수 없는 버그를 초래합니다.
3. 중복 실행 및 리소스 낭비
useEffect(() => {
const intervalId = setInterval(checkNotifications, 1000);
}, [user]);
클린업 함수 없이 Effect가 여러 번 실행되면, 동일한 리소스(타이머, 구독 등)가 여러 번 생성될 수 있습니다. 이는 성능 저하와 예기치 않은 동작을 유발합니다.
useEffect(() => {
fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => {
setData(data); // 경고: Can't perform a React state update on an unmounted component
});
}, []); 클린업 함수 없이 비동기 작업이 완료되면, 이미 언마운트된 컴포넌트의 상태를 업데이트하려 할 수 있습니다. 이는 리액트 경고를 발생시키고 메모리 누수로 이어질 수 있습니다.결론적으로 useEffect에서 cleanup 함수는 다음 조건에 해당하는 경우에만 필요합니다.
이처럼 React에서는 모든 useEffect에 클린업함수가 필요한 것이 아니라, Effect의 특성과 수행하는 작업의 유형에 따라 선택적으로 사용해야 합니다.
React 18.3에서 실험적으로 도입된 useEffectEvent는 useEffect의 클로저 문제와 과도한 재실행 이슈를 해결하기 위해 설계된 새로운 Hook입니다. Effect 내부에서 항상 최신 상태나 props를 안전하게 참조할 수 있도록 돕는 것이 핵심 목적입니다.
useEffect는 React의 핵심 Hook이지만 몇 가지 제한사항이 있습니다.
function UserStatus({ username }) {
const [isOnline, setIsOnline] = useState(false);
useEffect(() => {
console.log(`${username}님이 ${isOnline ? '온라인' : '오프라인'} 상태가 되었습니다.`);
saveUserStatus(username, isOnline);
// 필요 없는 클린업 함수지만 예시를 위해 포함
return () => {
console.log(`${username}의 이전 상태 기록 정리 중...`);
};
}, [username, isOnline]);
return (
<div>
<p>{username}님은 현재 {isOnline ? '온라인' : '오프라인'} 상태입니다.</p>
<button onClick={() => setIsOnline(!isOnline)}>
상태 변경
</button>
</div>
);
}
function UserStatus({ username }) {
const [isOnline, setIsOnline] = useState(false);
const logStatusChange = useEffectEvent(() => {
console.log(`${username}님이 ${isOnline ? '온라인' : '오프라인'} 상태가 되었습니다.`);
saveUserStatus(username, isOnline);
});
// isOnline 상태가 변경될 때만 실행
useEffect(() => {
logStatusChange();
return () => {
console.log(`${username}의 이전 상태 기록 정리 중...`);
};
}, [isOnline]);
return (
<div>
<p>{username}님은 현재 {isOnline ? '온라인' : '오프라인'} 상태입니다.</p>
<button onClick={() => setIsOnline(!isOnline)}>
상태 변경
</button>
</div>
);
}
useEffectEvent는 다음과 같은 핵심 차이점을 제공합니다.
usename 이 변경되어도 effect는 다시 실행되지 않습니다.오래된 값 또는 렌더링 시점에 고정된 값을 의미합니다.
리액트 컴포넌트는 렌더링될 때마다 함수 전체가 다시 실행됩니다. 이때 렌더링 시점의 변수 값이 클로저에 저장되며, 이후에도 해당 값을 계속 참조하게 되는 문제가 발생할 수 있습니다.
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // 👈 항상 0만 출력됨
setCount(count + 1); // 👈 count는 고정된 0이라 1만 반복됨
}, 1000);
return () => clearInterval(id);
}, []);
return <div>{count}</div>;
}
콘솔에는 항상 0이 출력됩니다. count 값은 고정된 0이라 setCount(count+1) 값은 항상 1만 반복되게 됩니다.
과정 설명
👉 이렇게 최신 상태를 참조하지 못하고 고정된 값만 참조하는 현상을 stale value라고 부릅니다.
상태 업데이터 함수 사용
setCount(prev => prev + 1);
useRef로 최신 값 저장
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
useEffectEvent사용
Object.is 로 비교하는 이유리액트에서는 상태나 props의 변화를 감지할 때 === 연산자 대신 Object.is() 를 사용합니다. 왜일까요?
=== 연산자와 Object.is() 의 주요 차이점NaN === NaN *// false - NaN은 자기 자신과도 동등하지 않음.*
Object.is(NaN,NaN) *// true - NaN을 자기 자신과 동등하게 처리*+0 === -0 *// true - 두 값을 같다고 판단*
Object.is(+0, -0) *// false - 두 값을 다르다고 판단*더 정밀한 비교
React는 Object.is()를 사용하여 이전 상태와 새 상태를 비교합니다. 이는 useState나 useReducer 같은 훅에서 상태 업데이트 시 특히 중요합니다.
function Counter() {
const [count, setCount] = useState(0);
function handleReset() {
setCount(NaN);
}
return (
<div>
Count: {count}
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={handleReset}>Reset</button>
</div>
);
}
count가 NaN일 경우, 서로 다른 값으로 인지하고 리렌더링을 발생시킬 것 입니다. 하지만 Object.is(NaN, NaN)는 true이므로 리렌더링을 유도하지 않습니다.
부동 소수점 계산의 정확성
function TemperatureConverter() {
const [celsius, setCelsius] = useState(0);
return (
<div>
<input
value={celsius}
onChange={e => setCelsius(Number(e.target.value<))}
/>
<p>화씨: {celsius * 9/5 + 32}</p>
</div>
);
}
물리학이나 공학 계산에서는 +0과 -0의 구분이 의미가 있을 수 있습니다. 이를 같다고 해버리는 순간 물리학이나 공학 관련 관계자들은 잘못된 예측 결과에 당황스러울 것입니다. Object.is(+0, -0)는 false이므로 상태 변화로 인식할 수 있습니다.
리액트의 내부 구현 일관성
function shallowEqual(objA: mixed, objB: mixed): boolean {
if (Object.is(objA, objB)) {
return true;
}
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
// Test for A's keys different from B.
for (let i = 0; i < keysA.length; i++) {
const currentKey = keysA[i];
if (
!hasOwnProperty.call(objB, currentKey) ||
!is(objA[currentKey], objB[currentKey])
) {
return false;
}
}
return true;
}
export default shallowEqual;
React에서는 shallowEqual 함수를 사용해 객체의 얕은 비교를 수행합니다. 이 함수는 내부적으로 Object.is()를 기반으로 합니다.
function is(x: any, y: any) {
return (
(x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
);
}
const objectIs: (x: any, y: any) => boolean =
typeof Object.is === 'function' ? Object.is : is;
export default objectIs;
+0 vs -0, NaN vs NaN처럼 ===로 비교할 수 없는 엣지 케이스를 보완하고 Object.is를 안전하게 사용할 수 있도록 리액트에서 구현한 코드입니다.
(++ 내용 추가 예정입니다)
역시 세라