컴포넌트도 태어나고, 살아 있는 동안 몇 번씩 바뀌고, 화면에서 사라진다. 이걸 생명주기(life cycle, 컴포넌트가 겪는 단계들)라고 부른다. 옛날 클래스형 리액트에는 componentDidMount 같은 단계별 메서드가 따로 있었는데, 함수형과 훅으로 넘어오면서 이 세 순간을 전부 useEffect 하나로 다룬다. 그래서 생명주기를 이해한다는 건 사실상 useEffect가 언제 실행되고 언제 정리되는지를 아는 일이다. 단계 이름을 외우기보다 실행 시점을 눈으로 확인하는 편이 오래 남는다.


마운트(mount, 화면에 처음 등장), 업데이트(update, 다시 그려짐), 언마운트(unmount, 화면에서 제거) 세 단계를 순서대로 코드와 실행 시점으로 보여주겠다.


렌더와 커밋을 먼저 갈라 보자. 컴포넌트 함수가 실행되어 화면 모양을 계산하는 걸 렌더(render), 그 결과를 실제 DOM에 반영하는 걸 커밋(commit)이라고 한다. useEffect는 렌더 도중이 아니라 커밋이 끝나고 화면이 사용자 눈에 그려진 뒤에 실행된다. 그래서 이펙트 안에서는 이미 완성된 화면을 다룰 수 있다.


function Box() {
console.log("1. 렌더 중");
useEffect(() => {
console.log("3. 화면 그린 뒤");
});
console.log("2. 렌더 끝");
return <div>박스</div>;
}


콘솔에는 1 → 2 → 3 순서로 찍힌다. 함수 본문이 위에서 아래로 다 돌아 화면을 계산하고(1, 2), DOM에 반영돼 그려진 다음에야 이펙트(3)가 뒤따른다. 이 순서를 알아두면 왜 이펙트에서 DOM 크기를 재도 값이 제대로 나오는지가 자연스럽다. 이미 그려진 뒤니까.


마운트 시점 한 번만 실행하려면 의존성 배열을 빈 채로 둔다. useEffect의 두 번째 인자가 의존성 배열(dependency array)이다. 여기에 빈 배열 []를 주면, 컴포넌트가 처음 등장할 때 딱 한 번만 실행된다.


function Timer() {
useEffect(() => {
console.log("마운트: 화면에 처음 붙었다");
}, []);
return <p>타이머</p>;
}


이 로그는 컴포넌트가 화면에 처음 나타날 때 한 번 찍히고, 이후 부모가 몇 번을 다시 그려도 다시 찍히지 않는다. 초기 데이터 요청, 이벤트 구독, 외부 라이브러리 초기화처럼 "처음 한 번만" 하고 싶은 일이 여기 들어간다.


업데이트는 의존성이 바뀔 때마다 다시 실행된다. 배열에 값을 넣으면, 그 값이 이전 렌더와 달라졌을 때만 이펙트가 다시 돈다. 리액트가 매 렌더마다 배열 안의 값을 옛것과 새것으로 비교한다.


function Profile({ userId }) {
useEffect(() => {
console.log("userId 바뀜:", userId);
}, [userId]);
return <p>{userId}번 사용자</p>;
}


userId1에서 2로 바뀌면 이펙트가 다시 실행되고, 값이 그대로면 컴포넌트가 다시 그려져도 이펙트는 건너뛴다. 마운트 때 처음 한 번 실행되고, 그 뒤로는 userId가 달라질 때만 따라 도는 셈이다. 특정 값이 바뀔 때마다 뭔가 다시 하고 싶으면 그 값을 여기 적으면 된다.


다시 그려지는 방아쇠는 상태와 props다. 업데이트가 대체 언제 일어나는지 헷갈리기 쉬운데, 답은 둘 중 하나다. 그 컴포넌트의 상태(state)가 바뀌거나, 부모가 내려주는 props가 바뀌면 다시 그려진다. 그 외에는 가만히 있는다.


function Counter() {
const [n, setN] = useState(0);
console.log("렌더:", n);
useEffect(() => {
console.log("이펙트:", n);
}, [n]);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}


버튼을 누르면 setN이 상태를 바꾸고, 그 때문에 컴포넌트가 다시 그려진다. 콘솔에는 먼저 "렌더"가 찍히고 화면이 갱신된 뒤 "이펙트"가 뒤따른다. n0 → 1 → 2로 오를 때마다 이 짝이 반복된다. 상태를 안 건드리면 아무리 클릭 이벤트가 붙어 있어도 컴포넌트는 다시 그려지지 않는다는 것도 같이 기억해두면, 업데이트가 언제 도는지 헷갈릴 일이 준다.


언마운트 정리는 이펙트가 돌려주는 함수에서 한다. 이게 생명주기에서 제일 자주 빠뜨리는 부분이다. 이펙트 안에서 return으로 함수를 하나 돌려주면, 그 함수는 컴포넌트가 화면에서 사라질 때(그리고 다음 이펙트가 실행되기 직전에) 호출된다. 이걸 정리 함수(cleanup)라고 부른다.


function Clock() {
useEffect(() => {
const id = setInterval(() => {
console.log("1초마다");
}, 1000);
return () => {
clearInterval(id);
console.log("언마운트: 타이머 정리");
};
}, []);
return <p>시계</p>;
}


컴포넌트가 붙을 때 setInterval이 걸리고, 사라질 때 clearInterval로 그 타이머를 끈다. 정리 함수를 안 달면 어떻게 되냐면, 컴포넌트는 화면에서 사라졌는데 타이머는 계속 살아서 1초마다 콘솔을 두드린다. 나는 이걸 모르고 페이지를 왔다 갔다 하다가, 어느 순간 같은 로그가 네 번씩 찍히는 걸 봤다. 화면을 옮길 때마다 정리 안 된 타이머가 하나씩 쌓였던 것이다. 이게 그 유명한 메모리 누수(memory leak)다.


개발 모드에서 이펙트가 두 번 실행돼도 놀라지 마라. 리액트 18의 StrictMode(엄격 모드)를 켜면, 개발 중에는 마운트 이펙트를 일부러 한 번 실행하고 정리한 뒤 다시 실행한다.


// 개발 모드 콘솔
// 마운트: 화면에 처음 붙었다
// 언마운트: 타이머 정리
// 마운트: 화면에 처음 붙었다


버그가 아니라 일부러 그러는 것이다. 정리 함수를 제대로 안 짜면 이 두 번 실행에서 티가 나도록, 리액트가 미리 리허설을 시키는 셈이다. 정리가 제대로 돼 있으면 붙였다 떼도 아무 부작용이 없어야 한다. 이 검사는 개발 모드에서만 돌고 실제 배포된 화면에서는 한 번만 실행되니, 두 번 찍힌다고 코드를 억지로 뜯어고칠 필요는 없다. 오히려 이 두 번을 견디게 짜는 게 정리 함수를 제대로 짰다는 증거다.


생명주기는 결국 붙이고, 따라가고, 치우는 일이다. 클래스 시절의 여러 메서드를 외울 필요 없이, useEffect 하나로 마운트(빈 배열), 업데이트(의존성 배열), 언마운트(정리 함수) 세 순간을 다 다룬다. 렌더가 화면을 계산하고 커밋이 DOM에 반영한 뒤 이펙트가 뒤따르며, 상태나 props가 바뀔 때 이 과정이 다시 돈다는 큰 그림만 잡고 있으면 나머지는 상황마다 맞춰 쓰면 된다. 화면에 뭔가 붙였으면 사라질 때 반드시 치운다. 이 한 문장만 지켜도 타이머가 유령처럼 남아 도는 사고는 거의 안 만난다.