life-cycle
React 생명주기의 진화: 클래스에서 훅까지
들어가며
React는 2013년 Facebook에서 공개된 이후 지속적으로 진화해왔습니다. 그 중에서도 가장 혁신적인 변화 중 하나는 React 16.8에서 도입된 훅(Hooks)입니다. 이 글에서는 React의 생명주기 관리가 어떻게 발전해왔는지, 그리고 현대적인 함수형 컴포넌트에서 생명주기를 어떻게 다루는지 깊이 있게 알아보겠습니다.
React 생명주기의 역사
초기 React: 클래스 컴포넌트의 시대 (2013-2018)
React는 처음부터 컴포넌트의 생명주기를 중심으로 설계되었습니다. 초기 React에서 상태를 가진 컴포넌트를 만들려면 클래스 컴포넌트를 사용해야 했고, 이는 명확한 생명주기 메서드를 제공했습니다.
전통적인 생명주기 단계:
- Mounting (마운팅): 컴포넌트가 DOM에 삽입되는 과정
- Updating (업데이팅): props나 state가 변경되어 컴포넌트가 다시 렌더링되는 과정
- Unmounting (언마운팅): 컴포넌트가 DOM에서 제거되는 과정
이 시기의 개발자들은 componentDidMount
, componentDidUpdate
, componentWillUnmount
등의 메서드를 통해 각 단계에서 필요한 로직을 구현했습니다.
훅의 등장: 패러다임의 전환 (2018~)
React 16.8에서 훅이 도입되면서 함수형 컴포넌트도 상태와 생명주기를 다룰 수 있게 되었습니다. 이는 단순한 문법적 변화가 아닌, React의 철학적 변화였습니다.
훅이 가져온 변화:
- 로직 재사용의 용이성
- 관련 로직의 집중화
- 더 작고 테스트하기 쉬운 함수들
- 클래스의 복잡성 제거
useEffect: 모든 생명주기를 아우르는 훅
useEffect
는 함수형 컴포넌트에서 생명주기를 관리하는 핵심 훅입니다. 클래스 컴포넌트의 여러 생명주기 메서드를 하나로 통합한 강력한 도구입니다.
기본 사용법
import React, { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 이 부분은 componentDidMount와 componentDidUpdate에 해당
console.log('Effect 실행됨');
return () => {
// 이 부분은 componentWillUnmount에 해당
console.log('Cleanup 실행됨');
};
});
return loading ? <div>Loading...</div> : <div>{user?.name}</div>;
}
의존성 배열(Dependency Array)의 이해
의존성 배열은 useEffect의 실행 조건을 정의하는 핵심 개념입니다.
// 1. 의존성 배열 없음: 매번 렌더링 후 실행
useEffect(() => {
console.log('매번 실행됩니다');
});
// 2. 빈 배열: 마운트시에만 실행 (componentDidMount와 동일)
useEffect(() => {
console.log('마운트시에만 실행됩니다');
}, []);
// 3. 특정 값들을 의존성으로: 해당 값들이 변경될 때만 실행
useEffect(() => {
console.log('userId가 변경될 때만 실행됩니다');
}, [userId]);
// 4. 여러 의존성
useEffect(() => {
console.log('userId나 theme이 변경될 때 실행됩니다');
}, [userId, theme]);
실전 생명주기 관리 패턴
1. 데이터 페칭 (Data Fetching)
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isCancelled = false; // cleanup을 위한 플래그
async function fetchUsers() {
try {
setLoading(true);
const response = await fetch('/api/users');
const userData = await response.json();
// 컴포넌트가 언마운트된 경우 상태 업데이트 방지
if (!isCancelled) {
setUsers(userData);
setError(null);
}
} catch (err) {
if (!isCancelled) {
setError(err.message);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
}
fetchUsers();
// Cleanup function: 컴포넌트 언마운트 시 실행
return () => {
isCancelled = true;
};
}, []); // 마운트시에만 실행
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{/* 사용자 목록 렌더링 */}</div>;
}
2. 이벤트 리스너 관리
function WindowSizeTracker() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
// 이벤트 리스너 등록
window.addEventListener('resize', handleResize);
// Cleanup: 이벤트 리스너 제거
return () => {
window.removeEventListener('resize', handleResize);
};
}, []); // 마운트/언마운트시에만
return <div>화면 크기: {windowSize.width} x {windowSize.height}</div>;
}
3. 타이머 관리
function Timer() {
const [seconds, setSeconds] = useState(0);
const [isActive, setIsActive] = useState(false);
useEffect(() => {
let interval = null;
if (isActive) {
interval = setInterval(() => {
setSeconds(seconds => seconds + 1);
}, 1000);
}
// Cleanup function
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [isActive]); // isActive가 변경될 때마다 실행
return (
<div>
<div>시간: {seconds}초</div>
<button onClick={() => setIsActive(!isActive)}>
{isActive ? '정지' : '시작'}
</button>
</div>
);
}
고급 생명주기 패턴
1. 커스텀 훅으로 로직 분리
생명주기 관련 로직을 커스텀 훅으로 분리하면 재사용성과 테스트 용이성이 향상됩니다.
// 커스텀 훅: 데이터 페칭 로직 분리
function useApi(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isCancelled = false;
async function fetchData() {
try {
setLoading(true);
setError(null);
const response = await fetch(url);
const result = await response.json();
if (!isCancelled) {
setData(result);
}
} catch (err) {
if (!isCancelled) {
setError(err);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
}
fetchData();
return () => {
isCancelled = true;
};
}, [url]);
return { data, loading, error };
}
// 사용법
function UserProfile({ userId }) {
const { data: user, loading, error } = useApi(`/api/users/${userId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{user?.name}</div>;
}
2. useLayoutEffect: 동기적 실행이 필요한 경우
function ScrollToTop() {
useLayoutEffect(() => {
// DOM 변경 후, 브라우저가 화면을 그리기 전에 동기적으로 실행
window.scrollTo(0, 0);
}, []); // 컴포넌트가 마운트될 때
return <div>페이지 상단으로 스크롤됩니다</div>;
}
3. 조건부 Effect
function ConditionalEffect({ shouldTrack, userId }) {
useEffect(() => {
if (!shouldTrack) return;
// 조건이 만족될 때만 실행되는 로직
console.log('사용자 활동 추적 시작:', userId);
return () => {
console.log('사용자 활동 추적 종료:', userId);
};
}, [shouldTrack, userId]);
return <div>조건부 추적 컴포넌트</div>;
}
성능 최적화와 생명주기
1. 불필요한 Effect 실행 방지
function OptimizedComponent({ user, theme }) {
const [data, setData] = useState(null);
// 잘못된 예: 매번 실행됨
useEffect(() => {
fetchUserData(user.id);
}); // 의존성 배열 없음
// 올바른 예: user.id가 변경될 때만 실행
useEffect(() => {
fetchUserData(user.id);
}, [user.id]); // user 전체가 아닌 필요한 속성만
// theme 변경시에만 실행되는 별도 Effect
useEffect(() => {
applyTheme(theme);
}, [theme]);
}
2. 복잡한 계산 최적화
function ExpensiveComponent({ items, filter }) {
// useMemo로 비싼 계산 최적화
const filteredItems = useMemo(() => {
return items.filter(item => item.category === filter);
}, [items, filter]);
// Effect에서 최적화된 값 사용
useEffect(() => {
// filteredItems가 변경될 때만 실행
updateAnalytics(filteredItems.length);
}, [filteredItems]);
return <ItemList items={filteredItems} />;
}
클래스 컴포넌트 vs 함수형 컴포넌트: 생명주기 비교
마이그레이션 가이드
클래스 컴포넌트의 각 생명주기 메서드를 함수형 컴포넌트로 변환하는 방법:
// 클래스 컴포넌트
class ClassComponent extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
}
componentDidMount() {
document.title = `카운트: ${this.state.count}`;
this.timer = setInterval(() => {
this.setState({ count: this.state.count + 1 });
}, 1000);
}
componentDidUpdate() {
document.title = `카운트: ${this.state.count}`;
}
componentWillUnmount() {
clearInterval(this.timer);
}
render() {
return <div>{this.state.count}</div>;
}
}
// 함수형 컴포넌트로 변환
function FunctionComponent() {
const [count, setCount] = useState(0);
// componentDidMount + componentDidUpdate
useEffect(() => {
document.title = `카운트: ${count}`;
}, [count]);
// componentDidMount + componentWillUnmount
useEffect(() => {
const timer = setInterval(() => {
setCount(prevCount => prevCount + 1);
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
return <div>{count}</div>;
}
모던 React의 생명주기 패턴
1. Concurrent Features와의 호환성
React 18의 Concurrent Features를 고려한 생명주기 관리:
function ConcurrentSafeComponent() {
const [data, setData] = useState(null);
useEffect(() => {
let cancelled = false;
// startTransition으로 긴급하지 않은 업데이트 처리
startTransition(() => {
fetchData().then(result => {
if (!cancelled) {
setData(result);
}
});
});
return () => {
cancelled = true;
};
}, []);
return <div>{data ? '데이터 로드됨' : '로딩 중...'}</div>;
}
2. Suspense와 Error Boundary 활용
function SuspenseAwareComponent() {
const [error, setError] = useState(null);
useEffect(() => {
// 에러 상태 초기화
setError(null);
}, []);
if (error) {
throw error; // Error Boundary가 처리
}
return (
<Suspense fallback={<div>Loading...</div>}>
<AsyncComponent />
</Suspense>
);
}
실무에서의 베스트 프랙티스
1. Effect 분리 원칙
관련없는 로직은 별도의 Effect로 분리:
function UserDashboard({ userId }) {
const [user, setUser] = useState(null);
const [notifications, setNotifications] = useState([]);
// 사용자 데이터 페칭
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
// 알림 데이터 페칭 (별도 Effect)
useEffect(() => {
fetchNotifications(userId).then(setNotifications);
}, [userId]);
// 페이지 제목 업데이트 (별도 Effect)
useEffect(() => {
if (user) {
document.title = `${user.name}의 대시보드`;
}
}, [user]);
return <div>대시보드 내용</div>;
}
2. 메모리 누수 방지
function SafeComponent() {
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(response => response.json())
.then(data => {
// 데이터 처리
})
.catch(error => {
if (error.name !== 'AbortError') {
console.error('Fetch error:', error);
}
});
return () => {
controller.abort(); // 요청 취소
};
}, []);
}
3. 의존성 배열 최적화
function OptimizedDependencies({ config }) {
const { apiUrl, retryCount } = config;
useEffect(() => {
fetchData(apiUrl, retryCount);
}, [apiUrl, retryCount]); // config 전체가 아닌 필요한 속성만
// 또는 useCallback으로 함수 최적화
const fetchDataCallback = useCallback(() => {
fetchData(apiUrl, retryCount);
}, [apiUrl, retryCount]);
useEffect(() => {
fetchDataCallback();
}, [fetchDataCallback]);
}
마무리
React의 생명주기 관리는 클래스 컴포넌트에서 함수형 컴포넌트로 진화하면서 더욱 유연하고 강력해졌습니다. useEffect
를 중심으로 한 훅 기반 접근법은 다음과 같은 장점을 제공합니다:
- 로직의 집중화: 관련된 로직을 한 곳에 모을 수 있습니다
- 재사용성: 커스텀 훅으로 로직을 쉽게 재사용할 수 있습니다
- 테스트 용이성: 작은 단위로 분리된 함수들을 개별적으로 테스트할 수 있습니다
- 성능 최적화: 세밀한 의존성 관리로 불필요한 실행을 방지할 수 있습니다
현대적인 React 개발에서는 함수형 컴포넌트와 훅을 활용한 생명주기 관리가 표준이 되었습니다. 이러한 패턴을 잘 이해하고 활용한다면, 더욱 효율적이고 유지보수하기 쉬운 React 애플리케이션을 개발할 수 있을 것입니다.