컴포넌트 라이프사이클
컴포넌트의 마운트·업데이트·언마운트 단계를 구분하고 useEffect의 의존성과 정리 함수 실행 시점을 익힙니다.
이제 React 컴포넌트가 화면에 나타나고, 갱신되며, 마지막에 사라지는 전체 과정인 컴포넌트 라이프사이클(lifecycle, 생명주기)을 이해할 차례입니다. 컴포넌트의 생명주기는 사람이 태어나고(mount), 살아가며(update), 생을 마치는(unmount) 과정에 빗댈 수 있습니다. React 컴포넌트도 이와 비슷한 생명주기를 가지며, 각 단계에서 필요한 작업을 할 수 있도록 훅(Hook)을 제공합니다. 생명주기를 이해하면 컴포넌트가 동작하는 방식을 더 깊이 알 수 있고, 필요한 시점에 알맞은 로직을 실행할 수 있습니다.
컴포넌트의 주요 라이프사이클 단계
React 컴포넌트는 크게 세 단계의 생명주기를 거칩니다. 첫째는 마운트(mounting)로, 컴포넌트가 처음 만들어져 실제 DOM에 들어가는 단계입니다. 페이지를 불러올 때 컴포넌트가 화면에 처음 나타나는 순간이 여기에 해당합니다. 둘째는 업데이트(updating)로, 컴포넌트의 props나 state가 바뀌어 UI가 다시 그려지는 단계입니다. useState로 상태가 바뀌거나 부모에게서 새 props를 받을 때가 그 예입니다. 셋째는 언마운트(unmounting)로, 컴포넌트가 더 이상 필요하지 않아 실제 DOM에서 빠지는 단계이며, 페이지를 옮길 때 컴포넌트가 화면에서 사라지는 순간이 그 예입니다.
함수형 컴포넌트의 useEffect 훅
클래스형 컴포넌트에는 componentDidMount, componentDidUpdate, componentWillUnmount 같은 생명주기 메서드가 있었습니다. 함수 컴포넌트에서는 useEffect 훅으로 타이머나 구독, 브라우저 API처럼 컴포넌트 바깥의 시스템과 동기화를 시작하고 정리합니다. 다만 모든 생명주기 작업을 이 훅 하나로 대신한다는 뜻은 아닙니다.
useEffect에 넘긴 설정 함수(setup)는 React가 화면에 변경을 반영한 커밋 이후에 실행됩니다. 또 의존성의 값이 같으면 다시 실행하지 않고 건너뛸 수 있으므로, 컴포넌트 함수가 호출될 때마다 무조건 setup이 실행되는 것은 아닙니다. useEffect는 주로 부수 효과(side effect)를 처리할 때 씁니다. 부수 효과란 함수가 입력을 처리하는 것 말고 바깥에 영향을 주거나 바깥의 영향을 받는 작업으로, React 컴포넌트에서는 API를 호출해 데이터를 가져오기, 구독을 설정하고 해제하기, 드물지만 필요할 때 DOM을 직접 조작하기, setTimeout이나 setInterval로 타이머를 설정하고 해제하기 같은 작업이 있습니다.
useEffect의 기본 구조는 다음과 같습니다.
import React, { useEffect } from 'react';
function Example({ dependency }) {
useEffect(() => {
// 컴포넌트가 렌더링된 후에 실행될 코드 (부수 효과)
return () => {
// 컴포넌트가 언마운트되거나 다음 이펙트가 실행되기 전에 정리합니다.
};
}, [dependency]);
return null;
}useEffect의 두 번째 인자인 의존성 배열(dependency array)은 useEffect가 언제 다시 실행될지를 정하는 중요한 값입니다.
더 깊이: Effect는 화면이 그려진 뒤에 실행될까
커밋 이후라는 말이 언제나 화면이 그려진 뒤라는 뜻은 아닙니다. React 문서의 useEffect에 따르면, 클릭 같은 사용자 상호작용 없이 일어난 Effect는 대개 브라우저가 바뀐 화면을 먼저 그리게(paint) 둔 뒤에 실행됩니다. 반면 클릭처럼 상호작용으로 생긴 Effect는 브라우저가 화면을 그리기 전에 실행될 수 있는데, 그래야 이벤트 시스템이 Effect의 결과를 볼 수 있기 때문입니다. 그래서 이런 Effect에서 alert()를 띄우면 갱신된 화면보다 알림창이 먼저 보일 수 있으며, React 문서는 그리기 뒤로 미뤄야 하는 작업이라면 setTimeout으로 넘기라고 안내합니다.
반대로 툴팁의 위치를 재고 옮기는 것처럼 눈에 보이는 작업을 Effect에서 하다가 깜빡임이 보인다면 useLayoutEffect를 씁니다. useLayoutEffect는 브라우저가 화면을 다시 그리기 전에 실행되므로, 위치를 고친 화면이 처음부터 보입니다. 대신 그 안의 코드와 거기서 예약한 state 갱신이 끝날 때까지 브라우저가 화면을 다시 그리지 못하므로, 꼭 필요한 측정과 배치에만 씁니다. 두 훅 모두 클라이언트에서만 실행되고 서버 렌더링 중에는 실행되지 않습니다.
useEffect의 다양한 활용 패턴
의존성 배열을 어떻게 주느냐에 따라 useEffect는 마운트, 업데이트, 언마운트 시점에 다르게 동작합니다. 아래 예제는 모두 Vite 프로젝트의 src/components 폴더에 .jsx 파일로 저장합니다.
마운트 시에만 실행 (빈 의존성 배열 [])
빈 의존성 배열([])은 바뀌는 값에 따라 다시 동기화할 필요가 없다는 뜻이며, 클래스형 컴포넌트의 componentDidMount와 비슷합니다. 다만 개발 모드의 Strict Mode에서는 setup → cleanup → setup의 점검이 한 번 더 일어날 수 있고, 컴포넌트가 다시 마운트되면 setup도 다시 실행됩니다. 페이지 제목을 바꾸고 초기 데이터를 불러오는 예제를 봅니다.
import React, { useState, useEffect } from 'react';
function MountedComponent() {
const [data, setData] = useState(null);
useEffect(() => {
// 이 코드는 컴포넌트가 처음 화면에 나타날 때 (마운트될 때) 한 번만 실행됩니다.
console.log('MountedComponent가 마운트되었습니다!');
// 페이지 제목 변경 (브라우저 탭 제목)
const previousTitle = document.title; // 마운트 전의 제목 기억
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 = previousTitle; // 제목을 원래대로 복원
};
}, []); // (1) 빈 의존성 배열: 마운트 시 한 번만 실행
return (
<div style={{ border: '1px solid #ff7f50', padding: '20px', margin: '20px', borderRadius: '8px' }}>
<h2>마운트 시 동작하는 컴포넌트</h2>
<p>{data ? data : '데이터 로드 중...'}</p>
</div>
);
}
export default MountedComponent;return () => { ... } 안의 코드는 컴포넌트가 언마운트될 때, 예를 들어 MountedComponent가 화면에서 사라질 때 실행되는 정리 함수(cleanup)입니다. 코드 주석과 뒤의 제목에서는 같은 함수를 클린업 함수라고도 부릅니다. 타이머 해제나 구독 해제 같은 뒷정리를 여기에 씁니다. 코드 주석의 “한 번만 실행”은 일반적인 첫 마운트를 설명한 것이며, 개발 모드의 Strict Mode 점검까지 포함해 한 번으로 제한된다는 뜻은 아닙니다. Vite의 React 템플릿은 main.jsx에서 App을 StrictMode로 감싸므로, npm run dev로 실행하면 “데이터를 불러오는 중...”이 두 번 찍히는 것을 볼 수 있습니다. 또 이 컴포넌트의 cleanup은 제목만 원래대로 돌려놓을 뿐, 이미 시작한 2초 지연 Promise를 취소하지는 않습니다.
의존성이 변경될 때마다 실행
특정 state나 props의 값이 바뀔 때마다 useEffect를 다시 실행하고 싶다면, 그 값을 의존성 배열에 넣습니다. 아래 코드는 Effect가 실행되는 시점을 보여 주려고 파생 메시지를 state에 복사했습니다. 실제 UI라면 렌더하는 동안 count에서 메시지를 바로 계산해, 불필요한 추가 렌더를 줄이는 편이 알맞습니다.
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;의존성 배열에 [count]를 넣었으므로, count가 바뀔 때마다 이 useEffect의 코드가 다시 실행됩니다. console.log로 count가 바뀔 때마다 로그가 찍히는 것을 확인할 수 있습니다.
Strict Mode의 추가 점검이 없다고 보고 흐름을 따라가 봅니다. 처음 count는 0이며, 첫 커밋 뒤 setup이 “카운트가 0로 변경되었습니다.”를 기록하고 안내 메시지를 갱신합니다. 메시지가 바뀌어 다시 렌더링되지만 의존성인 count는 그대로 0이므로 setup은 다시 실행되지 않습니다. 증가 버튼을 한 번 눌러 count가 1이 되면, 이전 count 0의 cleanup이 “이전 카운트 이펙트 정리”를 찍은 뒤 count 1의 setup이 실행됩니다. 개발 모드의 Strict Mode에서는 처음에 setup → cleanup → setup의 점검이 더 보일 수 있으므로, 첫 로그의 개수를 외우기보다 어느 count로 실행된 setup과 cleanup인지 짝을 지어 확인하세요.
매 렌더링마다 실행 (의존성 배열 생략)
의존성 배열을 아예 생략하면 useEffect는 컴포넌트의 렌더 결과가 커밋될 때마다 실행됩니다. 빈 배열 []을 넘기는 것과 배열을 넘기지 않는 것은 결과가 정반대라는 점에 주의합니다. 빈 배열은 바뀔 수 있는 의존성이 없으니 처음 한 번만 실행하라는 뜻이고, 생략은 비교할 목록 자체가 없으니 커밋마다 실행하라는 뜻입니다. 아래 컴포넌트에서는 버튼을 누를 때마다 value가 바뀌어 다시 렌더링되므로, 그때마다 새 값과 함께 로그가 찍힙니다.
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를 갱신하면 무한 루프에 빠질 수 있다는 것입니다. 예를 들어 useEffect 안에서 setValue(value + 1)을 바로 호출하면, value가 바뀌고, useEffect가 다시 실행되고, 또 value가 바뀌는 일이 끝없이 되풀이됩니다.
클린업(Clean-up) 함수 활용
useEffect는 return으로 정리 함수를 돌려줄 수 있습니다. 이 정리 함수는 컴포넌트가 언마운트되어 화면에서 사라질 때, 그리고 의존성 배열의 값이 바뀌어 Effect가 다시 실행되기 직전에 실행됩니다. 정리 함수는 메모리 누수를 막거나 불필요한 네트워크 요청을 멈추는 데 쓰며, 주로 구독 해제, 타이머 해제, 이벤트 리스너 제거 같은 뒷정리를 맡습니다. 타이머를 설정하고 해제하는 예제를 봅니다.
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로 타이머를 시작하고 돌려받은 interval ID를 변수에 저장한 뒤, 정리 함수에서 clearInterval(interval)을 호출해 타이머를 해제합니다. 아래의 App.jsx에서 조건부 렌더링으로 이 컴포넌트를 트리에서 없애면 타이머가 멈춥니다. CSS로 숨기거나 컴포넌트가 null을 돌려주는 것만으로는 언마운트되지 않는다는 점에 주의하세요.
Effect setup과 cleanup 순서 — 상태 추적
interval 생성·seconds callback·부모 트리 제거·clearInterval에서 자원 존재와 표시가 달라진다. [] setup이 매 증가마다 실행되지 않음을 보여 줄 가치가 있다.
EFFECT: SETUP & CLEANUP
Effect를 교체하거나 제거할 때 이전 setup의 자원을 정리합니다
Effect는 커밋 후 외부 동기화를 시작합니다. 본문의 TimerComponent는 타이머를 만들고 정리하며, CounterWithEffect는 count 변경 시 이전 cleanup 뒤 새 setup을 실행합니다.
TimerComponent · []
setup: interval 생성
tick: seconds의 이전 값 +1
unmount: clearInterval(interval)초가 바뀌어도 [] Effect를 매초 교체하지는 않습니다. 타이머의 updater가 다음 seconds를 계산합니다.
CounterWithEffect · [count]
count 0 커밋 → setup(0)
count 1 커밋 → cleanup(0)
→ setup(1)
unmount → cleanup(1)의존성은 Object.is로 비교합니다. 값이 같으면 해당 변경에 따른 재동기화를 건너뜁니다.
배열 생략 / 개발 Strict Mode
배열을 생략하면 매 커밋 후 setup, 필요 시 이전 cleanup이 실행됩니다. 개발 Strict Mode는 최초 setup→cleanup→setup 점검을 추가할 수 있어 []가 절대 한 번이라는 뜻은 아닙니다.
Effect에 넣을 작업의 경계
외부 연결·타이머는 setup과 cleanup을 짝지어 다룹니다. count에서 메시지를 계산하는 일은 렌더에, 클릭 작업은 핸들러에 둘 수 있습니다. MountedComponent의 cleanup은 제목만 복원하고 지연 Promise를 취소하지 않습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | TimerComponent의 마운트 후 setup에서 interval 생성→콜백에 따른 seconds 표시 갱신→부모 트리 제거 시 cleanup에서 interval 해제의 노드 3개·간선 2개다. []이므로 seconds 갱신마다 setup을 재실행하지 않는다. 숨김 CSS나 null 출력만으로 제거된다고 가정하지 않는다. |
| 전체 관계·조건 설명 | TimerComponent의 마운트 후 setup에서 interval 생성→콜백에 따른 seconds 표시 갱신→부모 트리 제거 시 cleanup에서 interval 해제의 노드 3개·간선 2개다. []이므로 seconds 갱신마다 setup을 재실행하지 않는다. 숨김 CSS나 null 출력만으로 제거된다고 가정하지 않는다. 모바일은 같은 노드와 방향을 다른 배치로 보여 줍니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 마운트 setup | interval: 존재 / deps: [] | Effect가 interval을 만들고 현재 seconds를 표시한다 | setup은 seconds 갱신마다 반복되지 않는다 |
| callback tick | seconds: 이전+1 / interval: 같은 timer | updater가 seconds를 증가시키고 화면을 갱신한다 | 화면 갱신과 자원 재생성은 다르다 |
| 트리 제거 | interval: 해제 / component: 트리에서 없음 | 부모가 실제로 제거하면 cleanup이 clearInterval을 호출한다 | CSS 숨김이나 null 출력만으로 unmount를 가정하지 않는다 |
Effect setup과 cleanup 순서 — 상태 추적
interval 생성·seconds callback·부모 트리 제거·clearInterval에서 자원 존재와 표시가 달라진다. [] setup이 매 증가마다 실행되지 않음을 보여 줄 가치가 있다.
EFFECT: SETUP & CLEANUP
Effect를 교체하거나 제거할 때 이전 setup의 자원을 정리합니다
Effect는 커밋 후 외부 동기화를 시작합니다. 본문의 TimerComponent는 타이머를 만들고 정리하며, CounterWithEffect는 count 변경 시 이전 cleanup 뒤 새 setup을 실행합니다.
TimerComponent · []
setup: interval 생성
tick: seconds의 이전 값 +1
unmount: clearInterval(interval)초가 바뀌어도 [] Effect를 매초 교체하지는 않습니다. 타이머의 updater가 다음 seconds를 계산합니다.
CounterWithEffect · [count]
count 0 커밋 → setup(0)
count 1 커밋 → cleanup(0)
→ setup(1)
unmount → cleanup(1)의존성은 Object.is로 비교합니다. 값이 같으면 해당 변경에 따른 재동기화를 건너뜁니다.
배열 생략 / 개발 Strict Mode
배열을 생략하면 매 커밋 후 setup, 필요 시 이전 cleanup이 실행됩니다. 개발 Strict Mode는 최초 setup→cleanup→setup 점검을 추가할 수 있어 []가 절대 한 번이라는 뜻은 아닙니다.
Effect에 넣을 작업의 경계
외부 연결·타이머는 setup과 cleanup을 짝지어 다룹니다. count에서 메시지를 계산하는 일은 렌더에, 클릭 작업은 핸들러에 둘 수 있습니다. MountedComponent의 cleanup은 제목만 복원하고 지연 Promise를 취소하지 않습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | TimerComponent의 마운트 후 setup에서 interval 생성→콜백에 따른 seconds 표시 갱신→부모 트리 제거 시 cleanup에서 interval 해제의 노드 3개·간선 2개다. []이므로 seconds 갱신마다 setup을 재실행하지 않는다. 숨김 CSS나 null 출력만으로 제거된다고 가정하지 않는다. |
| 전체 관계·조건 설명 | TimerComponent의 마운트 후 setup에서 interval 생성→콜백에 따른 seconds 표시 갱신→부모 트리 제거 시 cleanup에서 interval 해제의 노드 3개·간선 2개다. []이므로 seconds 갱신마다 setup을 재실행하지 않는다. 숨김 CSS나 null 출력만으로 제거된다고 가정하지 않는다. 모바일은 같은 노드와 방향을 다른 배치로 보여 줍니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 마운트 setup | interval: 존재 / deps: [] | Effect가 interval을 만들고 현재 seconds를 표시한다 | setup은 seconds 갱신마다 반복되지 않는다 |
| callback tick | seconds: 이전+1 / interval: 같은 timer | updater가 seconds를 증가시키고 화면을 갱신한다 | 화면 갱신과 자원 재생성은 다르다 |
| 트리 제거 | interval: 해제 / component: 트리에서 없음 | 부모가 실제로 제거하면 cleanup이 clearInterval을 호출한다 | CSS 숨김이나 null 출력만으로 unmount를 가정하지 않는다 |
위 그림은 Effect의 setup과 cleanup이 짝을 이루는 순서를 TimerComponent로 보여 줍니다. 마운트된 뒤 setup이 setInterval로 타이머를 만들고, 타이머의 콜백이 seconds를 하나씩 늘려 시간 표시를 갱신하며, 부모가 트리에서 이 컴포넌트를 없애면 cleanup이 clearInterval로 타이머를 해제합니다. 의존성 배열이 []이므로 초가 바뀔 때마다 setup을 다시 실행하지 않고, 타이머의 업데이터가 이전 값에 1을 더해 다음 seconds를 계산합니다. 그림의 설명처럼 CSS로 숨기거나 null을 출력하는 것만으로 트리에서 없어졌다고 가정하지 않습니다.
그림의 아래 칸은 CounterWithEffect의 순서도 정리합니다. count 0이 커밋되면 setup(0)이, count 1이 커밋되면 cleanup(0)과 setup(1)이 차례로 실행되고, 언마운트될 때 cleanup(1)이 실행됩니다. React는 의존성을 Object.is로 비교해 값이 같으면 다시 동기화하지 않습니다. 의존성 배열을 생략하면 커밋마다 필요한 이전 cleanup 뒤에 setup이 실행되고, 개발 모드의 Strict Mode는 처음에 setup → cleanup → setup 점검을 더할 수 있으므로 []가 절대 한 번만 실행된다는 뜻은 아닙니다.
그림의 마지막 칸은 무엇을 Effect에 넣을지에 대한 기준입니다. 외부와의 연결이나 타이머는 setup과 cleanup을 짝지어 다루고, count에서 메시지를 계산하는 일은 렌더에, 클릭으로 시작하는 작업은 이벤트 처리 함수에 둘 수 있습니다. MountedComponent의 cleanup이 제목만 되돌리고 지연 Promise를 취소하지 않는 것처럼, 타이머 해제와 다른 종류의 작업 정리는 각각 따로 구현해야 합니다. 언제 실행되는지만 외우기보다 어떤 외부 자원을 시작하고 언제 정리해야 하는지를 함께 보세요(React 문서의 Effect로 동기화하기).
모든 컴포넌트 한곳에서 테스트
App.jsx를 고쳐 위에서 만든 컴포넌트를 모두 불러와 렌더링하고, Console을 보며 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;TimerComponent 숨기기 버튼을 누르면 Console에 “타이머 클린업”이 찍히고 타이머가 멈추며, 다시 보이기를 누르면 “타이머 시작”이 찍히고 새 타이머가 시작됩니다. MountedComponent도 숨기면 “MountedComponent가 언마운트될 예정입니다.”가 찍히고, 브라우저 탭의 제목이 마운트 전의 제목으로 돌아갑니다. 1장에서 만든 프로젝트라면 index.html의 title 요소에 적힌 my-first-react-app입니다.
정리
- 컴포넌트는 마운트, 업데이트, 언마운트의 세 단계를 거치며, 함수 컴포넌트에서는
useEffect로 바깥 시스템과의 동기화를 시작하고 정리합니다. useEffect의 setup은 커밋 이후에 실행되고, 정리 함수는 언마운트될 때와 Effect가 다시 실행되기 직전에 실행됩니다.- 의존성 배열이
[]이면 첫 마운트 때만,[count]이면count가 바뀔 때마다, 배열을 생략하면 커밋마다 실행됩니다. - 개발 모드의 Strict Mode는 처음에 setup → cleanup → setup 점검을 더할 수 있으므로, 로그의 개수보다 setup과 cleanup의 짝을 확인합니다.
- 타이머나 구독처럼 시작한 자원은 정리 함수에서 해제하며, CSS로 숨기거나
null을 돌려주는 것은 언마운트가 아닙니다. - 다른 state에서 계산할 수 있는 값은 Effect가 아니라 렌더에서 계산하고, 클릭으로 시작하는 작업은 이벤트 처리 함수에 둡니다.
연습 문제
1. 1에서 0으로 초기화하기
CounterWithEffect의 count가 1일 때 초기화 버튼을 눌러 0으로 바꾸면, 어느 count로 실행된 setup을 정리하고 새 setup은 어떤 count를 읽을까요? 이미 0일 때 다시 초기화하는 경우와도 비교해 보세요.
해설 보기
count 1로 실행된 setup에 대응하는 cleanup이 먼저 실행되고, 그 뒤에 count 0의 setup이 실행되어 “카운트가 0로 변경되었습니다.”를 기록합니다. 원래 cleanup의 로그에는 count 값이 찍히지 않으므로, 앞뒤의 setup 로그와 짝지어 읽어야 합니다.
이미 0일 때 다시 초기화하면 의존성인 count가 바뀌지 않으므로, 이 이유로 cleanup과 setup이 다시 실행되지 않습니다.
2. 다시 나타난 타이머
TimerComponent가 3초를 보여 줄 때 숨기기 버튼으로 트리에서 없앤 뒤 다시 보이게 합니다. 새 타이머는 몇 초부터 시작할까요? 표시된 시간이 늘어날 때마다 기존 interval을 다시 만드는지도 설명해 보세요.
해설 보기
새로 마운트된 컴포넌트의 useState(0)에서 시작하므로 “현재 시간: 0초”부터 다시 셉니다. 없어지기 전의 3초는 이전 인스턴스의 state였습니다.
의존성 배열이 []인 Effect는 seconds의 변화에 따라 다시 실행되지 않으므로, 시간이 늘 때마다 interval을 다시 만들지 않습니다. interval 콜백의 업데이터가 직전 seconds에 1을 더할 뿐입니다. 또 이전 인스턴스를 없앨 때 clearInterval로 정리했으므로, 이전 타이머가 새 타이머와 함께 계속 도는 일도 없습니다.
다음 글 간단한 할 일 목록 앱 만들기에서는 지금까지 배운 개념을 모아 할 일 목록 앱을 직접 만들어 봅니다.