컴포넌트 라이프사이클 이해하기
이제 리액트 컴포넌트가 화면에 나타나고, 업데이트되며, 최종적으로 사라지는 전체 과정, 즉 컴포넌트 라이프사이클(Lifecycle, 생명 주기)에 대해 이해해 볼 차례입니다.
컴포넌트 라이프사이클은 마치 사람의 태어나고(mount), 살아가며(update), 죽는(unmount) 과정과 같습니다. 리액트 컴포넌트도 이와 유사한 생명 주기를 가지며, 각 단계에서 특정 작업을 수행할 수 있도록 훅(Hook)을 제공합니다.
이 라이프사이클을 이해하는 것은 컴포넌트의 동작 방식을 더 깊이 이해하고, 필요한 시점에 적절한 로직을 실행하는 데 필수적입니다.
컴포넌트의 주요 라이프사이클 단계
리액트 컴포넌트는 크게 세 가지 주요 생명 주기 단계를 거칩니다.
- 컴포넌트가 처음으로 생성되어 실제 DOM에 삽입되는 단계입니다.
- 예시: 페이지 로딩 시 컴포넌트가 화면에 나타나는 순간
- 컴포넌트의
props나state가 변경되어 UI가 다시 그려지는 단계입니다. - 예시:
useState를 통해 상태가 변경되거나, 부모로부터 새로운props를 받을 때
- 컴포넌트가 더 이상 필요 없어져 실제 DOM에서 제거되는 단계입니다.
- 예시: 페이지 이동 시 컴포넌트가 화면에서 사라지는 순간
함수형 컴포넌트의 useEffect 훅
클래스형 컴포넌트에는 componentDidMount, componentDidUpdate, componentWillUnmount와 같은 생명 주기 메서드들이 존재했습니다. 하지만 함수형 컴포넌트에서는 이 모든 라이프사이클 작업을 useEffect 훅하나로 처리할 수 있습니다.
useEffect 훅은 컴포넌트가 렌더링된 이후에 특정 작업을 수행하도록 하는 훅입니다. 주로 부수 효과(Side Effect)를 처리할 때 사용됩니다.
함수 내에서 함수의 입력(인자) 외에 다른 외부에 영향을 주거나, 외부로부터 영향을 받는 작업을 말합니다. 리액트 컴포넌트에서의 부수 효과는 다음과 같은 것들이 있습니다.
- 데이터 가져오기 (API 호출)
- 구독(Subscription) 설정 및 해제
- DOM 직접 조작 (드물지만 필요할 때)
- 타이머 설정 및 해제 (
setTimeout,setInterval)
useEffect의 기본 구조는 다음과 같습니다.
import React, { useEffect } from 'react';
useEffect(() => {
// 컴포넌트가 렌더링된 후에 실행될 코드 (부수 효과)
return () => {
// 컴포넌트가 언마운트되거나, 다음 이펙트가 실행되기 전에 실행될 '클린업(Clean-up)' 함수
// 구독 해제, 타이머 제거 등 정리 작업
};
}, [의존성 배열]); // (선택 사항) 의존성 배열: 이 배열의 값이 변경될 때만 이펙트가 다시 실행됩니다.useEffect의 두 번째 인자인 의존성 배열(Dependency Array)은 useEffect가 언제 다시 실행될지를 제어하는 데 매우 중요합니다.
useEffect의 다양한 활용 패턴
컴포넌트 라이프사이클 이해하기에서 렌더링 흐름, 상태 경계, 사용자 반응을 정리한 것입니다.
의존성 배열의 값에 따라 useEffect는 마운트, 업데이트, 언마운트 시점에 다르게 동작합니다.
마운트 시에만 실행 (빈 의존성 배열 [])
컴포넌트가 처음 마운트될 때만 한 번 실행되고, 이후에는 다시 실행되지 않도록 하려면 의존성 배열을 빈 배열([])로 전달합니다. 이는 클래스형 컴포넌트의 componentDidMount와 유사합니다.
import React, { useState, useEffect } from 'react';
function MountedComponent() {
const [data, setData] = useState(null);
useEffect(() => {
// 이 코드는 컴포넌트가 처음 화면에 나타날 때 (마운트될 때) 한 번만 실행됩니다.
console.log('MountedComponent가 마운트되었습니다!');
// 페이지 제목 변경 (브라우저 탭 제목)
document.title = 'React 앱 - 마운트됨';
// (가상) API에서 데이터 가져오기 (마운트 시 초기 데이터 로드)
const fetchData = async () => {
console.log('데이터를 불러오는 중...');
await new Promise(resolve => setTimeout(resolve, 2000)); // 2초 지연 시뮬레이션
setData('성공적으로 로드된 데이터입니다!');
console.log('데이터 로드 완료!');
};
fetchData();
// (선택적) 클린업 함수: 컴포넌트가 언마운트될 때 실행됩니다.
return () => {
console.log('MountedComponent가 언마운트될 예정입니다.');
document.title = 'React 앱'; // 제목을 원래대로 복원
};
}, []); // (1) 빈 의존성 배열: 마운트 시 한 번만 실행
return (
<div style={{ border: '1px solid #ff7f50', padding: '20px', margin: '20px', borderRadius: '8px' }}>
<h2>마운트 시 동작하는 컴포넌트</h2>
<p>{data ? data : '데이터 로드 중...'}</p>
</div>
);
}
export default MountedComponent;useEffect내부의 코드는 컴포넌트가 처음 마운트될 때 딱 한 번 실행됩니다.return () => { ... }내의 코드는 컴포넌트가 언마운트될 때(예:MountedComponent가 화면에서 사라질 때) 실행되는 클린업 함수입니다. 타이머 해제, 구독 해제 등 뒷정리 작업을 여기에 작성합니다.
의존성이 변경될 때마다 실행
컴포넌트의 특정 state나 props 값이 변경될 때마다 useEffect를 다시 실행하고 싶다면, 해당 state나 props를 의존성 배열에 포함합니다.
import React, { useState, useEffect } from 'react';
function CounterWithEffect() {
const [count, setCount] = useState(0);
const [message, setMessage] = useState('');
// (1) count state가 변경될 때마다 이펙트 실행
useEffect(() => {
console.log(`카운트가 ${count}로 변경되었습니다.`);
setMessage(`현재 카운트 값은 ${count} 입니다.`);
// (선택적) 클린업: 이전 메시지 초기화
return () => {
console.log('이전 카운트 이펙트 정리');
// setMessage(''); // 필요하다면 메시지 초기화 로직
};
}, [count]); // (2) 의존성 배열에 count 추가
return (
<div style={{ border: '1px solid #4CAF50', padding: '20px', margin: '20px', borderRadius: '8px' }}>
<h2>카운터 (useEffect 활용)</h2>
<p>{message}</p>
<p>현재 값: {count}</p>
<button onClick={() => setCount(prevCount => prevCount + 1)}>
증가
</button>
<button onClick={() => setCount(0)} style={{ marginLeft: '10px' }}>
초기화
</button>
</div>
);
}
export default CounterWithEffect;useEffect의 의존성 배열에[count]를 넣었습니다. 이는countstate의 값이 변경될 때마다 이useEffect내부의 코드를 다시 실행하라는 의미입니다.console.log를 통해count가 변경될 때마다 로그가 찍히는 것을 확인할 수 있습니다.
매 렌더링마다 실행 (의존성 배열 생략)
의존성 배열을 아예 생략하면 useEffect는 컴포넌트가 렌더링될 때마다 (props나 state가 변경될 때마다 포함) 실행됩니다.
import React, { useState, useEffect } from 'react';
function AlwaysRunEffect() {
const [value, setValue] = useState(0);
useEffect(() => {
// 이 코드는 컴포넌트가 렌더링(리렌더링)될 때마다 실행됩니다.
// 무한 루프에 빠지지 않도록 주의해야 합니다. (state 업데이트를 여기서 직접하면 안 됨)
console.log('컴포넌트가 렌더링될 때마다 실행!', value);
}); // (1) 의존성 배열 생략
return (
<div style={{ border: '1px solid #8e44ad', padding: '20px', margin: '20px', borderRadius: '8px' }}>
<h2>항상 실행되는 useEffect</h2>
<p>값: {value}</p>
<button onClick={() => setValue(value + 1)}>값 증가</button>
</div>
);
}
export default AlwaysRunEffect;- 이 패턴은 흔히 사용되지는 않습니다. 주로 DOM 직접 조작이나 외부 라이브러리 연동 등, 컴포넌트의 모든 렌더링 주기마다 특정 작업을 수행해야 할 때 사용됩니다.
- 주의: 의존성 배열을 생략하고
useEffect내부에서state를 업데이트하면 무한 루프에 빠질 수 있습니다. (예:setValue(value + 1)을useEffect내부에서 직접 호출하면value가 변경 ->useEffect재실행 ->value변경 -> ... 무한 반복)
클린업(Clean-up) 함수 활용
useEffect는 선택적으로 return 문 안에 클린업 함수를 반환할 수 있습니다. 이 클린업 함수는 다음 두 가지 시점에 실행됩니다.
컴포넌트가 언마운트될 때 (화면에서 사라질 때)
이전 이펙트가 다시 실행되기 직전 (의존성 배열의 값이 변경되어 이펙트가 재실행될 때)
클린업 함수는 메모리 누수를 방지하거나 불필요한 네트워크 요청 등을 막는 데 사용됩니다. 주로 구독 해제, 타이머 해제, 이벤트 리스너 제거 등의 뒷정리 작업에 활용됩니다.
예제: 타이머 설정 및 해제import React, { useState, useEffect } from 'react';
function TimerComponent() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
console.log('타이머 시작');
// 1초마다 seconds state를 증가시키는 타이머 설정
const interval = setInterval(() => {
setSeconds(prevSeconds => prevSeconds + 1);
}, 1000);
// (1) 클린업 함수 반환: 컴포넌트가 언마운트되거나 이펙트 재실행 전에 타이머 해제
return () => {
console.log('타이머 클린업');
clearInterval(interval); // (2) 타이머 해제
};
}, []); // 빈 의존성 배열: 컴포넌트 마운트 시 한 번만 타이머 설정
return (
<div style={{ border: '1px solid #17a2b8', padding: '20px', margin: '20px', borderRadius: '8px' }}>
<h2>타이머 컴포넌트</h2>
<p>현재 시간: {seconds}초</p>
<p>이 컴포넌트가 화면에서 사라지면 타이머도 멈춥니다.</p>
</div>
);
}
export default TimerComponent;setInterval로 타이머를 시작하고, 반환된intervalID를 변수에 저장합니다.- 클린업 함수에서
clearInterval(interval)을 호출하여 타이머를 해제합니다. 이 컴포넌트가 화면에서 사라지면 타이머가 자동으로 멈춥니다. - 이
TimerComponent를App.js에 추가하고, 조건부 렌더링으로 이 컴포넌트를 나타났다 사라지게 하면 클린업 함수가 실행되는 것을 볼 수 있습니다. (예: 토글 버튼으로TimerComponent의 렌더링 여부 제어)
모든 컴포넌트 한 곳에서 테스트
App.js 파일을 수정하여 위에서 만든 컴포넌트들을 모두 불러와 렌더링하고, 콘솔을 보면서 useEffect의 동작 방식을 직접 확인해 보세요. 특히 MountedComponent나 TimerComponent처럼 조건부 렌더링으로 렌더링 여부를 제어하면 마운트/언마운트 시의 useEffect 동작을 명확히 볼 수 있습니다.
import React, { useState } from 'react';
import './App.css';
import MountedComponent from './components/MountedComponent';
import CounterWithEffect from './components/CounterWithEffect';
import AlwaysRunEffect from './components/AlwaysRunEffect';
import TimerComponent from './components/TimerComponent'; // 추가
function App() {
const [showMounted, setShowMounted] = useState(true);
const [showTimer, setShowTimer] = useState(true); // TimerComponent 제어용
return (
<div className="App">
<h1>React 컴포넌트 라이프사이클과 useEffect</h1>
<button onClick={() => setShowMounted(!showMounted)} style={{ margin: '10px', padding: '10px 20px' }}>
MountedComponent {showMounted ? '숨기기' : '보이기'}
</button>
{showMounted && <MountedComponent />} {/* 조건부 렌더링 */}
<hr />
<CounterWithEffect />
<hr />
<AlwaysRunEffect />
<hr />
<button onClick={() => setShowTimer(!showTimer)} style={{ margin: '10px', padding: '10px 20px' }}>
TimerComponent {showTimer ? '숨기기' : '보이기'}
</button>
{showTimer && <TimerComponent />} {/* 조건부 렌더링 */}
</div>
);
}
export default App;버튼을 클릭하여 컴포넌트의 렌더링 여부를 변경하면서 개발자 도구의 콘솔을 유심히 살펴보면 useEffect와 클린업 함수의 실행 시점을 명확히 이해할 수 있을 것입니다.
라이프사이클을 이해할 때는 언제 실행되는지만 외우기보다, 어떤 외부 자원을 시작하고 언제 정리해야 하는지 함께 봐야 합니다.
3장 4절 컴포넌트 라이프사이클 이해하기는 여기까지입니다. 컴포넌트의 세 가지 주요 생명 주기 단계와 함수형 컴포넌트에서 이 모든 것을 다룰 수 있는 useEffect 훅의 기본 사용법과 다양한 의존성 배열 패턴, 그리고 클린업 함수의 중요성에 대해 상세하게 설명했습니다.
이제는 컴포넌트가 화면에 나타나고 사라지는 시점에 필요한 작업을 수행할 수 있게 되었습니다. 다음 절에서는 지금까지 배운 개념을 묶어 간단한 할 일 목록 앱 만들기 실습을 진행하겠습니다.