React 함수형 생명주기

React 18 공식 문서를 바탕으로 함수형 컴포넌트 생명주기와 훅 타이밍을 정리합니다.

Frontend
React, TypeScript, Hooks

1. 함수형 생명주기: Render → Commit → Effect

React Docs – Render and Commit에서 설명하듯 React 18은 아래 세 단계로 동작합니다.

Render and Commit

  1. Render Phase: 컴포넌트 함수가 호출되어 화면에 표시할 내용을 계산합니다.
    순수 계산이어야 하며 DOM 변경이나 네트워크 요청은 허용되지 않습니다.
  2. Commit Phase: React가 실제 DOM 노드를 변경합니다.
    초기 렌더링은 모든 노드를 추가(appendChild)하고, 재렌더링 시에는 차이가 있는 부분만 최소한으로 수정합니다.
  3. Effect Phase: Commit 이후 useInsertionEffect → useLayoutEffect → paint → useEffect 순으로 cleanup과 실행이 이어집니다.

2. 훅으로 매핑하며 순수성 지키기

interface Profile {
  id: string;
  name: string;
}
 
function ProfileCard({ userId }: { userId: string }) {
  const [profile, setProfile] = useState<Profile | null>(null);
 
  // CSS-in-JS 라이브러리에서만 사용 (일반적으로는 필요 없음)
  useInsertionEffect(() => {
    // DOM 변형 직전에 <style> 태그 주입
  }, []);
 
  useLayoutEffect(() => {
    measureCard(); // DOM 패치 직후이므로 레이아웃 읽기 OK
  }, []);
 
  useEffect(() => {
    const controller = new AbortController();
    fetchProfile(userId, controller.signal).then(setProfile);
    return () => controller.abort();
  }, [userId]);
 
  return <Card>{profile ? profile.name : "Loading..."}</Card>;
}
  • useInsertionEffect는 DOM 변형 직전에 동기 실행되며, CSS-in-JS 라이브러리가 스타일을 주입할 때 사용합니다. 일반 애플리케이션 코드에서는 거의 사용하지 않습니다.
  • useLayoutEffect는 Commit 직후 동기 실행되므로 레이아웃 측정/DOM 스냅샷에 사용합니다.
  • useEffect는 paint 이후 비동기 실행됩니다. 네트워크, 이벤트 구독, 타이머 등 사용자에게 노출된 뒤 실행해도 되는 작업을 맡깁니다.
  • cleanup이전 렌더의 클로저를 기준으로 실행됩니다. 상태 1 → 2 변화 시 cleanup은 1을 들고 실행된 뒤 새 effect가 2를 참조합니다.
  • 의존성 배열은 Object.is 비교를 사용하므로, 객체를 deps로 넘길 때는 memoization을 통해 참조를 안정화해야 합니다.

3. React 18의 동시성과 생명주기

React 18의 동시성 렌더링은 생명주기 관점에서 중요한 변화를 가져옵니다.

  • 렌더링이 여러 번 시작되지만, 실제로 커밋되는 것은 한 번입니다.
  • useTransitionuseDeferredValue는 커밋 우선순위를 조절합니다.
  • Effect는 커밋 이후에만 실행되므로, 중단된 렌더에서는 effect가 실행되지 않습니다.
function Counter() {
  const [count, setCount] = useState(0);
 
  // ❌ 렌더링 중 로깅: startTransition 사용 시 여러 번 출력됨
  console.log('렌더됨:', count);
 
  // ✅ Effect 로깅: 실제 커밋된 시점만 출력됨
  useEffect(() => {
    console.log('커밋됨:', count);
  }, [count]);
 
  return <div>{count}</div>;
}

4. 실헝용 컴포넌트를 이용하여 타이밍 시각화

아래 실험용 컴포넌트는 useLayoutEffect, useEffect, cleanup, startTransition 로그를 모두 남깁니다.
Strict Mode 개발 서버를 켜면 공식 문서에서 설명한 패턴을 그대로 재현할 수 있습니다.

count: 0

커밋 이후 훅 동작

Child count: 0

로그

버튼을 눌러 commit/cleanup 타이밍을 관찰하세요.

관찰 포인트

  • 동기 업데이트 시 React는 트리를 깊이 우선 탐색하므로 Child가 Parent보다 먼저 실행됩니다.
Render intent (렌더링 시작)

Child useLayoutEffect cleanup

Child useLayoutEffect

Parent useLayoutEffect

(paint - 화면에 그림)

Child useEffect cleanup

Child useEffect

Parent useEffect
  • Transition 업데이트: startTransition 버튼을 누르면 count 옆에 (전환 중) 텍스트가 나타납니다. 이것이 isPending 플래그가 true인 상태이며, 커밋이 백그라운드에서 준비되고 있음을 의미합니다.
  • 자식 토글: showChild를 끄면 cleanup 로그가 바로 찍힌 뒤, 다시 켤 때 layout/effect가 새로 실행됩니다.
  • 로그 초기화: Strict Mode 이중 실행으로 헷갈릴 땐 로그를 지우고 다시 실험하면 됩니다.

5. Strict Mode와 생명주기

개발 환경에서 Strict Mode는 생명주기를 의도적으로 두 번 실행합니다.

function Timer() {
  useEffect(() => {
    console.log('Effect 실행');
    const id = setInterval(() => console.log('tick'), 1000);
 
    return () => {
      console.log('Cleanup 실행');
      clearInterval(id);
    };
  }, []);
}
 
// 개발 환경 콘솔 출력:
// Effect 실행
// Cleanup 실행    ← Strict Mode가 추가
// Effect 실행      ← Strict Mode가 다시 실행
 
// 프로덕션 환경 콘솔 출력:
// Effect 실행       ← 한 번만 실행

왜 이렇게 하는가?

  • Cleanup 로직이 누락되었는지 검증하기 위함입니다.
  • 위 예시에서 cleanup이 없으면 타이머가 2개 생성되어 문제를 발견할 수 있습니다.
  • Effect가 여러 번 실행되어도 문제없도록 코드를 작성하도록 유도합니다.

생명주기 관점:

1. 마운트 → Effect 실행
2. (Strict Mode) 언마운트 시뮬레이션 → Cleanup 실행
3. (Strict Mode) 재마운트 시뮬레이션 → Effect 재실행

이는 React가 향후 컴포넌트를 보존했다가 복원하는 기능을 위한 준비입니다.


6. 핵심 원칙

생명주기의 3단계:

  1. Render: 순수 계산, DOM 접근 금지
  2. Commit: DOM 변경, useLayoutEffect 실행
  3. Effect: 화면 표시 후 useEffect 실행

훅 선택 기준:

  • useLayoutEffect: DOM 측정, 깜빡임 방지 (동기, paint 차단)
  • useEffect: 네트워크, 구독, 타이머 등 대부분의 작업 (비동기)

Cleanup 원칙:

  • Effect에서 시작한 것은 cleanup에서 멈춰야 합니다.
  • Cleanup은 이전 렌더의 클로저를 참조합니다.
  • Strict Mode는 cleanup 누락을 검증합니다.

디버깅:

  • 컴포넌트 함수 내 로그 = 렌더 횟수 (중단된 렌더 포함)
  • Effect 내 로그 = 커밋 횟수 (실제 화면 변화)

Reference

관련 글