useEffect는 함수형 컴포넌트에서 부수 효과(side effect)를 수행할 수 있게 해주는 React 훅으로, 클래스 컴포넌트의 생명주기 메서드인 componentDidMount, componentDidUpdate 및 componentWillUnmount를 대체합니다. React Documentation (2025)에 따르면, useEffect는 React가 DOM에 변경 사항을 커밋한 후에 실행되어 실제 DOM 트리에 접근할 수 있음을 보장합니다. 이 훅은 효과 함수와 실행 빈도를 제어하는 선택적 의존성 배열을 받습니다.
핵심 요점
useEffect는 React 16.8에 추가된 훅으로, 함수형 컴포넌트에서 부수 효과를 수행합니다. 부수 효과는 UI 렌더링과 직접적으로 관련되지 않은 작업입니다: API에 HTTP 요청, 이벤트 구독, 타이머 작업, DOM 조작, 로깅 및 타사 라이브러리와의 통합 등이 있습니다.
훅 이전에는 이러한 모든 작업을 클래스 컴포넌트의 생명주기 메서드에 배치해야 했습니다: 초기화를 위한 componentDidMount, prop 변경에 대응하기 위한 componentDidUpdate, 정리를 위한 componentWillUnmount입니다. useEffect는 세 가지 시나리오를 단일 API로 통합했으며, 의존성 배열이 효과가 실행되어야 하는 시기를 결정합니다. 이로 인해 로직이 간소화되고, 특히 구독 시나리오에서 코드 중복이 줄었습니다.
React DevTools Usage Survey (2024)에 따르면, useEffect는 useState 다음으로 가장 인기 있는 훅이며 React 애플리케이션의 89%에서 사용됩니다. 대부분의 개발자는 데이터 가져오기, 외부 시스템과의 동기화, DOM 이벤트 구독 관리에 이를 사용합니다.
import { useEffect } from 'react';
function UserProfile({ userId }) {
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
}
useEffect는 React가 렌더링을 완료하고 DOM을 업데이트한 후에 전달된 효과 함수를 실행합니다. 이는 렌더링 시간 계산과의 주요 차이점입니다: 효과는 렌더링을 차단하지 않으며, 이는 UX 성능에 매우 중요합니다. 효과가 동기적으로 실행된다면 사용자는 데이터 로딩 중에 멈춘 인터페이스를 보게 될 것입니다.
일반적인 효과의 생명주기는 세 단계로 구성됩니다. 컴포넌트 마운트 시 React가 효과를 실행합니다. 각 업데이트 시 배열의 의존성 중 하나라도 변경되면 React는 먼저 이전 효과의 정리 함수를 실행한 다음 새 효과를 실행합니다. 컴포넌트 언마운트 시에는 정리 함수만 실행됩니다.
React Team — useEffect RFC (2024)에 따르면, useEffect의 내부 구현은 파이버 트리에서 부수 효과 큐를 사용합니다. 변경 사항을 커밋한 후(커밋 단계), React는 이 큐를 순회하며 컴포넌트에서 선언된 순서대로 효과 함수를 호출합니다. 각 파이버 노드는 올바른 정리 및 재실행을 위해 이전 효과에 대한 참조를 저장합니다.
| 단계 | React 작업 | 실행 시점 |
|---|---|---|
| 마운팅 | 효과 함수 호출 | 첫 번째 렌더링 후 |
| 업데이트 | 정리 → 효과 | 의존성 변경 시 |
| 언마운팅 | 정리만 | 컴포넌트 제거 시 |
의존성 배열 — useEffect의 두 번째 인수 — 은 효과가 다시 실행되어야 하는 시기를 결정합니다. React는 Object.is를 사용하여 배열의 각 값을 이전 렌더링과 비교합니다. 하나라도 값이 변경되면 효과가 다시 실행됩니다. 배열이 비어 있으면([]), 효과는 마운트 후에 한 번만 실행됩니다.
올바른 의존성을 선택하는 것은 useEffect를 사용할 때 가장 어려운 부분입니다. 배열에는 효과 내에서 사용되고 렌더링 간에 변경될 수 있는 모든 변수와 함수가 포함되어야 합니다. 의존성을 누락하면 스테일 클로저(stale closure)가 발생합니다 — 효과가 이전 렌더링의 오래된 값을 보게 됩니다. 불필요한 의존성을 포함하면 과도한 재실행과 잠재적 버그가 발생합니다.
// 의존성이 효과의 재실행 시기를 제어합니다
useEffect(() => {
document.title = `User: ${user.name}`;
}, [user.name]); // user.name이 바됐을 때만 재실행
// eslint-disable-next-line react-hooks/exhaustive-deps
// 의존성을 생랽하면 구 데이터를 얻습니다
React는 eslint-plugin-react-hooks를 exhaustive-deps 규칙과 함께 제공하며, 이는 의존성 배열의 완전성을 자동으로 확인합니다. Meta Engineering Blog(2024)에 따르면, 이 플러그인을 활성화하면 훅 관련 버그가 72% 감소합니다. 사용자 정의 로직이 있는 드문 경우를 제외하고, 주석으로 억제하는 대신 모든 exhaustive-deps 경고를 수정하는 것이 좋습니다.
의존성 배열을 전혀 전달하지 않으면 useEffect는 모든 렌더링 후에 실행됩니다. 이는 DOM 동기화 또는 로깅에 유용할 수 있지만, 대부분의 경우 오류입니다: 효과가 너무 자주 실행되어 성능 저하로 이어집니다. 대부분의 경우 빈 배열(마운트 시 한 번)이나 특정 props/state가 있는 배열을 전달해야 합니다.
빈 배열([])은 효과가 어떤 값에도 의존하지 않으며 엄격히 한 번만 실행됨을 의미합니다. 이는 클래스 컴포넌트의 componentDidMount와 동일합니다. 하지만 주의할 점: 효과가 의존성 배열에 나열되지 않은 props나 state를 사용하는 경우, 효과는 초기 값만 사용하고 업데이트를 인식하지 못합니다. 이를 스테일 캡처(stale capture)라고 하며, 종종 찾기 어려운 버그의 원인이 됩니다.
| 의존성 배열 | 동작 | 클래스 동등 |
|---|---|---|
| 인수 없음 | 모든 렌더링 후 | componentDidUpdate |
| [] | 마운트 시 한 번 | componentDidMount |
| [a, b] | a 또는 b 변경 시 | ComponentWillReceiveProps 유사 |
| return cleanup | 언마운팅 관리 | componentWillUnmount |
정리 함수는 useEffect가 콜백에서 반환할 수 있는 함수입니다. React는 컴포넌트 언마운트 시와 의존성 변경 시 효과를 재실행하기 전에 이를 호출합니다. 정리는 구독, 타이머, 요청 및 해제해야 하는 리소스를 취소하는 데 필요합니다.
일반적인 예는 WebSocket 구독입니다. 마운트 시 연결이 생성되고, 의존성 업데이트 시 재생성되며(정리가 이전 것을 닫고, 효과가 새 것을 염), 언마운트 시 닫힙니다. 정리가 없으면 컴포넌트가 다시 마운트될 때마다 새 WebSocket 연결이 생성되어 메모리 누수와 여러 연결이 발생합니다.
useEffect(() => {
const socket = new WebSocket('wss://api.example.com');
socket.onmessage = event => setData(event.data);
// 정리 함수 — 언마운트 시 및 재실행 전에 실행
return () => {
socket.close();
};
}, []);
React Documentation (2025)에 따르면, AbortController는 정리에서 fetch 요청을 취소하는 현대적인 접근 방식입니다. 효과가 HTTP 요청을 하고 컴포넌트가 완료 전에 언마운트되면 요청이 계속 실행되고, 언마운트 후 setState가 오류를 발생시킵니다. 효과 내부에 AbortController를 만들고 정리에서 controller.abort()를 호출하여 요청을 취소합니다.
가장 흔한 실수는 의존성 누락입니다. 예를 들어, 효과가 userId prop을 사용하지만 의존성 배열이 비어 있는 경우입니다. 결과적으로 효과는 초기 userId 값으로 한 번 실행되고 변경 사항에 반응하지 않습니다. 개발자는 컴포넌트가 새 userId를 받는 것을 보지만 데이터는 업데이트되지 않습니다. exhaustive-deps 규칙이 있는 eslint-plugin-react-hooks는 이러한 버그를 자동으로 감지합니다.
// ❌ 경쟁 상태 — 취소 없음
useEffect(() => {
fetch(`/api/user/${userId}`).then(res => setUser(res));
}, [userId]);
// ✅ AbortController로 해결
useEffect(() => {
const controller = new AbortController();
fetch(`/api/user/${userId}`, { signal: controller.signal })
.then(res => setUser(res));
return () => controller.abort();
}, [userId]);
무한 루프 문제를 해결하려면 이전 상태를 기반으로 상태를 업데이트하는 로직을 useEffect에 배치하지 마세요. setState의 함수형 형태를 사용하거나 계산을 효과 외부로 이동하세요. 효과가 storage나 브라우저 이벤트를 구독하는 경우, 리스너 인스턴스가 매 렌더링마다가 아니라 한 번만 생성되도록 하세요.
자주 묻는 질문
직접적으로는 사용할 수 없습니다. useEffect는 동기 함수 또는 undefined를 반환할 것을 기대하기 때문입니다. 콜백이 async로 선언되면 Promise를 반환하고 React는 이를 무시하며, 정리 메커니즘이 작동을 멈춥니다. 해결책: 효과 내부에서 async 함수를 호출하세요: useEffect(() => { async function load() { ... }; load(); }, []).
제한은 없습니다. React는 동일한 의존성 배열을 가지더라도 관련 없는 로직을 개별 useEffect로 분리할 것을 권장합니다. 각 효과는 명확하게 정의된 하나의 부수 작업을 담당해야 합니다: 하나는 구독용, 다른 하나는 데이터 로딩용, 세 번째는 탭 제목 동기화용입니다. 이렇게 하면 이해와 디버깅이 쉬워집니다.
React Strict Mode(개발 모드만)에서는 모든 효과가 마운트, 언마운트, 그리고 다시 마운트됩니다. 이는 기능이지 버그가 아닙니다 — React가 정리가 올바르게 작동하는지 확인합니다. 언마운트 후 다시 마운트했을 때 효과가 잘못 작동하면(예: 중복 구독), 정리가 불완전한 것입니다. 프로덕션에서는 효과가 한 번만 실행됩니다.
AbortController를 사용하세요. 효과 내부에서 컨트롤러를 만들고 controller.signal을 fetch에 전달한 후 정리에서 controller.abort()를 호출합니다. 요청이 완료되기 전에 컴포넌트가 언마운트되면 fetch가 취소되고 setState가 호출되지 않습니다. 이는 경쟁 상태와 “Can't perform a React state update on an unmounted component” 오류를 방지합니다.
useEffect는 예외 없이 모든 렌더링 후에 실행됩니다. 즉, 효과 내의 setState가 새 렌더링 → 새 효과 → 무한 루프를 일으킵니다. 실제로 의존성 배열이 없는 효과는 거의 항상 오류입니다. 예외는 모든 렌더링에 동기화가 필요한 로깅이나 외부 시스템과의 동기화입니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.