useEffect 심화
지난 장에서는 useState
훅의 심화 개념들을 다루며 상태를 더욱 견고하고 효율적으로 관리하는 방법을 알아보았습니다. 이제 리액트 함수형 컴포넌트에서 부수 효과(Side Effect)를 다루는 핵심 훅인 useEffect
에 대해 더 깊이 파고들 시간입니다.
우리는 이미 3장에서 useEffect
의 기본적인 역할과 마운트/업데이트/언마운트 시점에서의 동작 방식을 간략하게 살펴보았습니다. 이번 장에서는 useEffect
의 의존성 배열(deps
배열)의 역할과 중요성, 그리고 useEffect
의 꽃이라고 할 수 있는 클린업(Clean-up) 함수의 다양한 활용 사례들을 실제 예제와 함께 자세히 다룰 것입니다.
useEffect
의 핵심: 의존성 배열 (deps
배열)
useEffect
훅의 두 번째 인자인 의존성 배열(Dependency Array) 은 useEffect
의 동작 방식을 결정하는 가장 중요한 요소입니다. 이 배열에 어떤 값을 넣느냐에 따라 useEffect
콜백 함수가 언제 다시 실행될지가 결정됩니다.
useEffect(() => {
// 이펙트 콜백 함수: 부수 효과 로직
console.log('이펙트 실행!');
return () => {
// 클린업 함수: 부수 효과 정리 로직
console.log('클린업 실행!');
};
}, [dep1, dep2, ...]); // 의존성 배열 (deps)
deps
배열의 역할:
deps
배열에 있는 값들 중 어떤 하나라도 이전 렌더링 이후 변경되었다면,useEffect
콜백 함수가 다시 실행됩니다.deps
배열은 리액트에게 "이 이펙트가 의존하는 값들은 여기에 있으니, 이 값들이 변할 때만 다시 실행해줘"라고 알려주는 역할을 합니다.
deps
배열이 없는 경우
deps
배열을 아예 생략하면, useEffect
는 컴포넌트가 렌더링될 때마다 (모든 props
나 state
의 변경 포함) 실행됩니다.
사용 시점
- 컴포넌트의 모든 렌더링 주기마다 특정 로직을 실행해야 할 때 (매우 드뭄).
- 주의: 이 안에서
state
를 직접 변경하면 무한 루프에 빠질 수 있습니다. 예를 들어,setState(newValue)
를 직접 호출하면state
변경 $\rightarrow$ 리렌더링 $\rightarrow$useEffect
재실행 $\rightarrow$state
변경 ... 무한 반복.
빈 deps
배열
deps
배열을 빈 배열([]
) 로 전달하면, useEffect
는 컴포넌트가 처음 마운트(Mount)될 때 단 한 번만 실행됩니다. 이후 컴포넌트가 리렌더링되어도 다시 실행되지 않습니다. 이는 클래스 컴포넌트의 componentDidMount
와 유사합니다.
사용 시점
- 초기 데이터 로딩 (API 호출).
- 이벤트 리스너 등록 (클린업에서 해제 필요).
- 구독 설정 (클린업에서 해제 필요).
- DOM 직접 조작 (차트 라이브러리 초기화 등).
예제: 사용자 정보 초기 로딩 (빈 deps
배열)
// src/components/UserFetcher.js
import React, { useState, useEffect } from 'react';
function UserFetcher() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
console.log('UserFetcher 컴포넌트가 마운트되었습니다. 사용자 정보 로딩 시작!');
setLoading(true);
const fetchUser = async () => {
try {
// (가상) API 호출 시뮬레이션
await new Promise(resolve => setTimeout(resolve, 1500)); // 1.5초 지연
const fetchedUser = { id: 1, name: '김리액트', email: 'react@example.com' };
setUser(fetchedUser);
setError(null);
} catch (err) {
setError('사용자 정보를 불러오는 데 실패했습니다.');
setUser(null);
} finally {
setLoading(false);
}
};
fetchUser();
// 클린업 함수: 컴포넌트 언마운트 시 실행될 로직
return () => {
console.log('UserFetcher 컴포넌트가 언마운트될 예정입니다. 정리 작업 수행.');
// 이 경우 특별히 정리할 것은 없지만, 예를 들어 외부 구독을 해제하는 등의 작업을 할 수 있습니다.
};
}, []); // ⭐️ 빈 배열: 마운트 시 한 번만 실행
if (loading) return <p style={{ color: '#555' }}>사용자 정보 로딩 중...</p>;
if (error) return <p style={{ color: 'red' }}>에러: {error}</p>;
return (
<div style={{ border: '1px solid #673AB7', padding: '20px', margin: '20px', borderRadius: '8px' }}>
<h2>사용자 프로필</h2>
{user && (
<div>
<p>이름: {user.name}</p>
<p>이메일: {user.email}</p>
</div>
)}
</div>
);
}
export default UserFetcher;
특정 값들을 deps
배열에 포함하는 경우
가장 일반적인 사용 패턴입니다. deps
배열에 포함된 값(prop, state, 함수 등) 중 하나라도 변경될 때마다 useEffect
콜백이 재실행됩니다. 이는 클래스 컴포넌트의 componentDidUpdate
와 유사합니다.
사용 시점
- 특정
prop
이나state
가 변경될 때마다 데이터를 다시 로드해야 할 때 (예: 검색어 변경 시 검색 결과 업데이트). - 특정
prop
이나state
에 따라 다른 부수 효과를 실행해야 할 때.
예제: 검색어에 따른 검색 결과 표시
// src/components/SearchComponent.js
import React, { useState, useEffect } from 'react';
function SearchComponent() {
const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [loading, setLoading] = useState(false);
// (1) searchTerm이 변경될 때마다 이펙트 실행
useEffect(() => {
if (searchTerm.length < 2) { // 2글자 미만일 땐 검색하지 않음
setSearchResults([]);
return;
}
setLoading(true);
console.log(`"${searchTerm}"으로 검색 시작...`);
// (가상) 검색 API 호출 시뮬레이션
const timer = setTimeout(() => { // 0.5초 디바운싱 (입력 중에는 바로 검색하지 않도록)
const mockResults = [
`검색 결과: ${searchTerm} - 아이템 A`,
`검색 결과: ${searchTerm} - 아이템 B`,
`검색 결과: ${searchTerm} - 아이템 C`,
].filter(item => item.includes(searchTerm)); // 실제로는 서버에서 필터링
setSearchResults(mockResults);
setLoading(false);
console.log(`"${searchTerm}" 검색 완료.`);
}, 500);
// ⭐️ (2) 클린업 함수: 이전 타이머 해제
return () => {
console.log(`"${searchTerm}"에 대한 이전 검색 취소.`);
clearTimeout(timer); // 이전 검색 요청(타이머)이 완료되기 전에 새로운 검색이 시작되면 이전 타이머를 해제
};
}, [searchTerm]); // ⭐️ searchTerm이 변경될 때마다 이펙트 재실행
return (
<div style={{ border: '1px solid #FFC107', padding: '20px', margin: '20px', borderRadius: '8px' }}>
<h2>실시간 검색</h2>
<input
type="text"
placeholder="검색어를 입력하세요..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{ padding: '8px', fontSize: '16px', borderRadius: '5px', border: '1px solid #ddd', width: '80%', marginBottom: '15px' }}
/>
{loading && <p>검색 중...</p>}
{!loading && searchResults.length === 0 && searchTerm.length >= 2 && <p>일치하는 결과가 없습니다.</p>}
{!loading && searchResults.length > 0 && (
<ul style={{ listStyle: 'none', padding: 0 }}>
{searchResults.map((result, index) => (
<li key={index} style={{ padding: '5px 0', borderBottom: '1px dotted #eee' }}>{result}</li>
))}
</ul>
)}
</div>
);
}
export default SearchComponent;
[searchTerm]
을deps
배열에 넣었기 때문에searchTerm
state
가 변경될 때마다useEffect
가 다시 실행됩니다.setTimeout
과clearTimeout
을 활용하여 디바운싱(Debouncing) 을 구현했습니다. 사용자가 타이핑을 멈춘 후 0.5초가 지나야 실제 검색이 시작되도록 하여 불필요한 API 호출을 줄입니다. (이전 타이머를clearTimeout
으로 해제하는 것이 클린업의 중요한 역할 중 하나입니다.)
useEffect
의 클린업(Clean-up) 함수 심화
클린업 함수는 useEffect
콜백 함수가 반환하는 함수로, 리소스 정리 작업을 수행합니다.
클린업이 실행되는 시점
- 컴포넌트가 언마운트될 때:
deps
배열이 빈 배열([]
)이거나, 컴포넌트가 화면에서 사라질 때 마지막으로 한 번 실행됩니다. - 이전 이펙트가 다시 실행되기 직전:
deps
배열에 값이 있고, 그 값이 변경되어useEffect
가 재실행될 때, 새로운 이펙트가 실행되기 전에 이전 이펙트의 클린업 함수가 먼저 실행됩니다. (위SearchComponent
예시에서clearTimeout(timer)
가 이런 경우입니다.)
클린업의 중요성 및 사용 사례
클린업 함수를 사용하는 주된 이유는 메모리 누수(Memory Leak) 를 방지하고, 불필요한 작업을 중단하여 애플리케이션의 성능과 안정성을 확보하기 위함입니다.
주요 클린업 사용 사례
- 타이머 해제:
setTimeout
,setInterval
과 같은 타이머를 설정했을 때, 컴포넌트가 사라지면 타이머도 함께 멈춰야 합니다.useEffect(() => { const timer = setInterval(() => console.log('tick'), 1000); return () => clearInterval(timer); // 타이머 해제 }, []);
- 이벤트 리스너 제거:
window
,document
, 또는 특정 DOM 요소에 이벤트 리스너를 직접 추가했을 때, 컴포넌트가 언마운트되면 해당 리스너를 제거해야 합니다.useEffect(() => { const handleResize = () => console.log('창 크기 변경'); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); // 리스너 제거 }, []);
- 구독 해제: WebSocket, RxJS 등 외부 라이브러리를 통해 데이터 스트림을 구독했을 때, 더 이상 필요 없을 때 구독을 해제해야 합니다.
useEffect(() => { const subscription = someService.subscribe(data => console.log(data)); return () => subscription.unsubscribe(); // 구독 해제 }, []);
- 진행 중인 비동기 요청 취소:
SearchComponent
예제처럼 API 호출과 같은 비동기 작업이 이전 요청이 완료되기 전에 새로운 요청으로 대체되어야 할 때 이전 요청을 취소하여 불필요한state
업데이트나 에러를 방지할 수 있습니다. (AbortController 사용 등)// 예시: API 요청 취소 (더 복잡한 구현 필요) useEffect(() => { const controller = new AbortController(); const signal = controller.signal; fetch('/api/data', { signal }) .then(response => response.json()) .then(data => console.log(data)) .catch(error => { if (error.name === 'AbortError') { console.log('Fetch aborted'); } else { console.error('Fetch error:', error); } }); return () => { controller.abort(); // 이펙트 재실행 또는 언마운트 시 요청 취소 }; }, [someDependency]);
모든 컴포넌트 한 곳에서 테스트 (App.js)
App.js
파일을 수정하여 위에서 만든 UserFetcher
와 SearchComponent
를 모두 불러와 렌더링하고, 콘솔을 보면서 useEffect
의 동작 방식과 클린업 함수가 실행되는 시점을 직접 확인해 보세요. 특히 SearchComponent
에서 빠르게 타이핑하면서 콘솔 메시지를 보면 클린업이 얼마나 중요한지 체감할 수 있을 것입니다.
// src/App.js
import React, { useState } from 'react';
import './App.css';
import UserFetcher from './components/UserFetcher';
import SearchComponent from './components/SearchComponent';
function App() {
const [showUserFetcher, setShowUserFetcher] = useState(true);
return (
<div className="App">
<h1>useEffect 심화 학습</h1>
<button onClick={() => setShowUserFetcher(!showUserFetcher)} style={{ margin: '10px', padding: '10px 20px' }}>
UserFetcher {showUserFetcher ? '숨기기' : '보이기'}
</button>
{showUserFetcher && <UserFetcher />} {/* 조건부 렌더링으로 마운트/언마운트 테스트 */}
<hr />
<SearchComponent />
</div>
);
}
export default App;
UserFetcher
의 '숨기기/보이기' 버튼을 클릭하면서 UserFetcher
컴포넌트의 마운트/언마운트 및 그에 따른 useEffect
와 클린업의 실행을 콘솔에서 확인해 보세요. SearchComponent
에서는 입력 필드에 빠르게 타이핑하면서 검색 요청이 어떻게 취소되고 새로 시작되는지 콘솔에서 확인해 보세요.
"useEffect 심화: 라이프사이클과 클린업"은 여기까지입니다. 이 장에서는 useEffect
훅의 의존성 배열(deps
배열)의 다양한 활용 패턴과 그 의미를 명확히 하고, 클린업 함수의 중요성 및 메모리 누수 방지를 위한 활용 사례들을 상세하게 다루었습니다.
이제 여러분은 컴포넌트의 생명 주기 동안 발생하는 복잡한 부수 효과들을 useEffect
와 클린업 함수를 사용하여 안전하고 효율적으로 관리할 수 있게 되었습니다. 다음 장에서는 useRef
훅을 이용하여 DOM 요소에 직접 접근하는 방법과 그 활용 사례에 대해 알아보겠습니다.