컴포넌트가 화면에 나타나고, 변경되고, 사라지는 동안 React 내부에서는 정확히 어떤 일이 벌어질까요?

개념 정의

React 컴포넌트의 생명주기(Lifecycle)란 컴포넌트가 생성(Mount)갱신(Update)제거(Unmount) 되는 일련의 과정을 말합니다. 클래스형 컴포넌트에서는 componentDidMount 같은 메서드로 표현했지만, 함수형 컴포넌트에서는 훅(Hooks) 으로 이 흐름을 제어합니다.

왜 필요한가

컴포넌트가 화면에 나타날 때 데이터를 가져오고, 값이 바뀔 때 외부 시스템과 동기화하고, 사라질 때 리소스를 정리해야 합니다. 이런 부수효과(Side Effect) 관리가 생명주기 이해의 핵심입니다.

  • API 호출 시점을 정확히 제어해야 합니다
  • WebSocket, EventListener 등의 구독을 해제해야 메모리 누수를 방지합니다
  • 애니메이션이나 타이머를 적절한 시점에 시작하고 정리해야 합니다

내부 동작 — 세 단계로 나누기

1단계: 마운트 (Mount)

컴포넌트가 처음으로 DOM에 추가되는 단계입니다.

JSX
function UserProfile({ userId }) {
  // 1. useState 초기화 — 최초 렌더 시에만 초기값 사용
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  // 2. 렌더링 — JSX 반환, DOM에 반영

  // 3. useEffect 실행 — DOM이 그려진 후
  useEffect(() => {
    console.log('마운트: 데이터를 가져옵니다');
    fetchUser(userId).then(data => {
      setUser(data);
      setLoading(false);
    });
  }, [userId]);

  return loading ? <Spinner /> : <div>{user.name}</div>;
}

마운트 시 실행 순서를 정리하면 다음과 같습니다.

  1. 함수 컴포넌트 본문 실행 (useState 초기화 포함)
  2. JSX를 React Element로 변환
  3. React가 DOM을 업데이트
  4. 브라우저가 화면을 그림 (paint)
  5. useEffect 콜백 실행

2단계: 업데이트 (Update)

props나 state가 변경되면 컴포넌트가 다시 렌더링됩니다.

JSX
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log(`count가 ${count}로 변경되었습니다`);
    document.title = `클릭 ${count}회`;

    return () => {
      console.log(`cleanup: 이전 count ${count}에 대한 정리`);
    };
  }, [count]);

  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

업데이트 시 실행 순서입니다.

  1. state/props 변경 감지
  2. 함수 컴포넌트 본문 재실행
  3. 새로운 JSX와 이전 JSX 비교 (Reconciliation)
  4. 변경된 부분만 DOM에 반영
  5. 이전 effect의 cleanup 함수 실행
  6. 새로운 useEffect 콜백 실행

cleanup이 다음 effect 실행 전 에 호출된다는 점이 중요합니다.

3단계: 언마운트 (Unmount)

컴포넌트가 DOM에서 제거되는 단계입니다.

JSX
function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    console.log(`${roomId} 채팅방에 연결했습니다`);

    // cleanup: 언마운트 시 또는 roomId 변경 시 실행
    return () => {
      connection.disconnect();
      console.log(`${roomId} 채팅방에서 연결 해제했습니다`);
    };
  }, [roomId]);

  return <div>채팅방: {roomId}</div>;
}

언마운트 시에는 마지막 effect의 cleanup 함수 가 실행됩니다. 이때 구독 해제, 타이머 정리, 연결 종료 등의 정리 작업을 수행합니다.

클래스형과 함수형의 매핑

클래스형 메서드함수형 훅
constructoruseState 초기값, useRef 초기값
componentDidMountuseEffect(() => {}, [])
componentDidUpdateuseEffect(() => {}, [deps])
componentWillUnmountuseEffect의 cleanup 함수
shouldComponentUpdateReact.memo, useMemo
getDerivedStateFromProps렌더 중 state 업데이트

하지만 React 팀은 이 매핑으로 생각하지 말라고 권합니다. 함수형 컴포넌트에서는 "생명주기"가 아니라 "동기화" 로 사고를 전환해야 합니다.

Effect는 "생명주기"가 아니라 "동기화"

React 공식 문서에서 강조하는 중요한 관점 전환입니다.

JSX
// ❌ 이렇게 생각하지 마세요
// "마운트 시 데이터를 가져오고, userId가 바뀌면 다시 가져온다"

// ✅ 이렇게 생각하세요
// "이 effect는 userId와 동기화된다"
useEffect(() => {
  const controller = new AbortController();

  fetchUser(userId, { signal: controller.signal })
    .then(setUser);

  return () => controller.abort();
}, [userId]); // userId가 변하면 다시 동기화

effect의 시작과 정리를 마운트/언마운트 시점이 아니라, 의존성과의 동기화 로 이해하면 훨씬 자연스럽게 코드를 작성할 수 있습니다.

useLayoutEffect — paint 이전에 실행

JSX
function Tooltip({ anchorRef, children }) {
  const [position, setPosition] = useState({ top: 0, left: 0 });
  const tooltipRef = useRef(null);

  // DOM 측정은 paint 전에 해야 깜빡임이 없습니다
  useLayoutEffect(() => {
    const anchorRect = anchorRef.current.getBoundingClientRect();
    const tooltipRect = tooltipRef.current.getBoundingClientRect();

    setPosition({
      top: anchorRect.top - tooltipRect.height - 8,
      left: anchorRect.left + (anchorRect.width - tooltipRect.width) / 2,
    });
  }, [anchorRef]);

  return (
    <div ref={tooltipRef} style={{ position: 'fixed', ...position }}>
      {children}
    </div>
  );
}

useLayoutEffect는 DOM이 변경된 직후, 브라우저가 화면을 그리기  에 동기적으로 실행됩니다. 레이아웃을 측정하거나 DOM을 수정해야 깜빡임이 발생하지 않는 경우에 사용합니다.

StrictMode와 이중 실행

React의 StrictMode는 개발 환경에서 effect를 두 번 실행합니다.

JSX
// 개발 환경에서의 실행 순서
// 1. 마운트 → effect 실행
// 2. 언마운트 시뮬레이션 → cleanup 실행
// 3. 재마운트 → effect 다시 실행

이것은 cleanup이 제대로 구현되었는지 검증하기 위한 장치입니다. cleanup 없이 구독만 하면 두 번 구독되는 문제가 드러납니다.

JSX
// ❌ cleanup이 없으면 StrictMode에서 이중 구독
useEffect(() => {
  const handler = () => console.log('resize');
  window.addEventListener('resize', handler);
  // cleanup이 없다!
}, []);

// ✅ cleanup을 항상 작성
useEffect(() => {
  const handler = () => console.log('resize');
  window.addEventListener('resize', handler);
  return () => window.removeEventListener('resize', handler);
}, []);

주의할 점

cleanup 누락으로 인한 메모리 누수

이벤트 리스너, WebSocket, 타이머를 등록하고 cleanup을 작성하지 않으면, 컴포넌트가 언마운트된 후에도 핸들러가 계속 실행됩니다. 이는 메모리 누수뿐 아니라 setState on unmounted component 경고의 원인이 됩니다.

JSX
// ❌ cleanup 누락 — 메모리 누수 발생
useEffect(() => {
  const ws = new WebSocket('ws://api.example.com');
  ws.onmessage = (msg) => setData(JSON.parse(msg.data));
}, []);

// ✅ cleanup으로 연결 해제
useEffect(() => {
  const ws = new WebSocket('ws://api.example.com');
  ws.onmessage = (msg) => setData(JSON.parse(msg.data));
  return () => ws.close();
}, []);

useEffect 안에서 state 업데이트 무한 루프

의존성 배열에 effect 내부에서 변경하는 state를 넣으면 무한 루프가 발생합니다. effect가 state를 변경하고, state 변경이 effect를 다시 트리거하기 때문입니다.

JSX
// ❌ 무한 루프
const [count, setCount] = useState(0);
useEffect(() => {
  setCount(count + 1); // count가 바뀌면 effect 재실행 → 무한
}, [count]);

useLayoutEffect의 SSR 경고

useLayoutEffect는 서버에서 실행할 수 없으므로 SSR 환경에서 경고가 발생합니다. SSR이 필요한 경우 useEffect로 대체하거나, 조건부로 실행해야 합니다.

정리

항목설명
마운트함수 실행 → JSX 변환 → DOM 반영 → paint → useEffect 실행
업데이트함수 재실행 → Reconciliation → DOM 반영 → 이전 cleanup → 새 effect
언마운트마지막 effect의 cleanup 함수 실행
useEffect의 본질생명주기 대체가 아닌 외부 시스템과의 동기화
useLayoutEffectpaint 전 동기 실행 — DOM 측정, 깜빡임 방지 용도
StrictMode개발 환경에서 effect를 두 번 실행하여 cleanup 누락 검증

생명주기를 "시점"이 아니라 "동기화"로 바라보면, effect의 의존성 배열과 cleanup 패턴이 자연스럽게 이해됩니다.

댓글 로딩 중...