Allra Fintech

훅의 생명주기와 부수 효과

훅의 실행 타이밍을 마운트, 리렌더, 언마운트 기준으로 정리합니다.

학습 목표

  • 마운트, 언마운트, 리렌더가 무엇인지 정확히 구분할 수 있습니다.
  • useEffectuseLayoutEffect의 동작 타이밍을 설명할 수 있습니다.
  • 글로벌 이벤트와 타이머를 안전하게 등록/해제할 수 있습니다.
  • React 18에서 상태 업데이트가 배칭되는 이유를 설명할 수 있습니다.

1) 문맥: 렌더 파이프라인을 알아야 훅을 이해할 수 있다

React는 상태 변경이 발생하면 먼저 렌더에서 다음 상태 기반 UI 계산을 하고, 그 다음 커밋에서 DOM을 반영합니다.

  • 렌더 단계: 훅 실행 + 상태 계산(순수)
  • 커밋 단계: DOM 변경, ref/이벤트 바인딩
  • 브라우저 페인트: 픽셀 그리기

부수 효과는 렌더 계산 바깥에서 다룹니다. 즉, 렌더는 “계산”, effect는 “동기화”입니다.

2) 마운트 / 언마운트란 무엇인가

마운트

컴포넌트가 트리에 처음 들어가고 React가 그 인스턴스를 관리하기 시작하는 시점입니다.

  • useEffect, useLayoutEffect의 setup이 실행될 준비가 생깁니다.

언마운트

컴포넌트가 트리에서 제거되어 React가 더 이상 관리하지 않는 시점입니다.

  • 리스너/타이머/구독 같은 외부 리소스 정리가 반드시 필요합니다.

리렌더

상태나 props 변화로 같은 컴포넌트가 다시 렌더링되는 업데이트 경로입니다.

  • 마운트/언마운트와 달리 인스턴스는 유지됩니다.

3) useEffect의 타이밍

  1. 렌더 계산이 끝나고 커밋 후에 실행 스케줄됨
  2. 마운트 시 초기 실행
  3. 의존성 변경 시
    • 이전 effect cleanup
    • 새 effect 실행
  4. 언마운트 시 마지막 cleanup 실행

cleanup은 부수효과의 “마무리 장치”입니다. 시작한 동작은 반드시 정리해야 합니다.

useLayoutEffect는 DOM 변경 직후, paint 이전에 실행되어 grid/position 측정처럼 DOM 의존 동작에 맞습니다. 일반적인 동기화는 useEffect를 기본으로 둡니다.

4) 실무 패턴: 이벤트와 타이머 정리

window 이벤트/타이머는 컴포넌트가 화면에서 사라져도 계속 살아남을 수 있어, 정리가 없으면 메모리 누수와 예기치 않은 setState 호출이 생깁니다.

function LayoutWatch() {
  const [width, setWidth] = useState(0)
  const [resizeCount, setResizeCount] = useState(0)

  useEffect(() => {
    const onResize = () => {
      setWidth(window.innerWidth)
      setResizeCount((count) => count + 1)
    }

    onResize()

    const timerId = setInterval(() => {
      setResizeCount((count) => count + 1)
    }, 1000)

    window.addEventListener('resize', onResize)

    return () => {
      window.removeEventListener('resize', onResize)
      clearInterval(timerId)
    }
  }, [])

  return (
    <div>
      width: {width}, resize events: {resizeCount}
    </div>
  )
}

핵심은 addEventListener를 추가했다면 removeEventListener를, setInterval을 시작했다면 clearInterval을 꼭 맞춰주는 것입니다.

5) React 18 배칭: 여러 상태 업데이트를 한 번에 반영하기

batching은 여러 상태 업데이트를 합쳐서 렌더링 횟수를 줄여주는 동작입니다. React 18부터는 이벤트 핸들러뿐 아니라 비동기 경로에서도 더 넓게 적용됩니다.

  • 같은 함수/핸들러 안에서의 여러 setState는 한 번 렌더에 묶일 수 있습니다.
  • setTimeout, Promise then, effect 내부의 연속 업데이트도 배칭 대상이 됩니다.
function Counter() {
  const [a, setA] = useState(0)
  const [b, setB] = useState(0)

  const onClick = () => {
    setA((prev) => prev + 1)
    setB((prev) => prev + 1)
    // 보통 한 번 렌더로 반영
  }

  useEffect(() => {
    const timer = setTimeout(() => {
      setA((prev) => prev + 1)
      setB((prev) => prev + 1)
      // 여기도 배칭되어 커밋 횟수를 줄임
    }, 0)

    return () => clearTimeout(timer)
  }, [])

  return <button onClick={onClick}>{a + b}</button>
}

flushSync를 쓰면 배칭을 의도적으로 끊고 즉시 렌더링을 강제로 만들 수 있습니다. 필요할 때만 제한적으로 사용하세요.

6) 운영 체크리스트

  • useEffect의 setup/cleanup이 반드시 한 쌍인가?
  • 의존성 배열이 의도하지 않은 무한 루프를 만들지 않나?
  • 마운트-언마운트 경계에서 이벤트/타이머/구독이 정리되는가?
  • 언마운트 뒤에도 state 업데이트가 시도되지 않도록 되어 있는가?
  • 같은 함수 안 여러 상태 업데이트가 언제 배칭되는지 설명 가능한가?

핵심 요약

  • 훅의 정확한 사용은 렌더 파이프라인(렌더-커밋)을 이해할 때 시작됩니다.
  • 마운트는 시작, 언마운트는 정리로 기억하면 정돈됩니다.
  • 전역 이벤트/타이머는 반드시 cleanup을 작성해야 메모리 누수를 줄일 수 있습니다.
  • React 18 배칭은 여러 상태 갱신을 묶어 렌더 성능을 개선합니다.

참고