React의 useState, useRef, 일반 변수의 생명주기 이해하기

React의 상태 관리와 생명주기 이해하기

안녕하세요! 오늘은 React에서 상태를 관리하는 세 가지 방법 - useState, useRef, 일반 변수의 차이점과 생명주기 내에서의 동작을 이해해보려고 합니다. 특히 useEffect와 클린업 함수가 이와 어떻게 상호작용하는지에 초점을 맞춰 보겠습니다.

문제: 이 코드의 출력 결과는?

아래 코드를 보고 어떤 결과가 출력될지 예상해 보세요:

"use client";
 
import { useEffect, useRef, useState } from "react";
 
export default function Test1() {
  const [data, setData] = useState(0);
  const aRef = useRef(data);
  let a = 1;
  useEffect(() => {
    console.log("let", a);
    console.log("useState", data);
    console.log("useRef", aRef.current);
    setData((prev) => prev + 1);
    aRef.current = data;
    a = data;
 
    return () => {
      console.log(
        "cleanup a",
        a,
        "clean up data",
        data,
        "clean up aRef ",
        aRef.current
      );
    };
  }, [data]);
 
  return <div>Test</div>;
}

왜 이 문제를 만들었나요?

이 문제는 React의 다음 중요 개념들을 이해하는 데 도움이 됩니다:

  1. 리렌더링 시 값 보존 방식의 차이

    • useState로 관리되는 값
    • useRef로 관리되는 값
    • 함수 컴포넌트 내의 일반 변수
  2. 클로저(Closure)가 React 생명주기에 미치는 영향

    • 이펙트와 클린업 함수가 캡처하는 값들
  3. useEffect의 실행 순서와 클린업 함수의 호출 시점

코드의 실행 결과와 설명

이 코드를 실행하면 다음과 같은 출력 결과가 나옵니다:

// 첫 번째 렌더링
let 1
useState 0
useRef 0

// 두 번째 렌더링 (data가 1이 됨)
cleanup a 0 clean up data 0 clean up aRef 0
let 1
useState 1
useRef 0

// 세 번째 렌더링 (data가 2가 됨)
cleanup a 1 clean up data 1 clean up aRef 1
let 1
useState 2
useRef 1

// ... 계속 ...

왜 이런 결과가 나올까요?

첫 번째 렌더링

  1. 컴포넌트가 마운트되고 초기값들이 설정됩니다:

    • data = 0 (useState 초기값)
    • aRef.current = 0 (useRef 초기값으로 data 사용)
    • a = 1 (일반 변수 초기값)
  2. useEffect가 실행됩니다:

    • console.log('let', a) → let 1 출력
    • console.log('useState', data) → useState 0 출력
    • console.log('useRef', aRef.current) → useRef 0 출력
    • setData((prev) => prev + 1) → data를 1로 업데이트 (비동기적으로 실행)
    • aRef.current = data → aRef.current를 0으로 설정 (이 시점에서 data는 아직 0)
    • a = data → a를 0으로 설정

두 번째 렌더링

  1. data가 1로 변경되어 컴포넌트가 리렌더링됩니다.

  2. 함수 컴포넌트가 다시 실행되면서:

    • data = 1 (이전 렌더링에서 업데이트된 값)
    • aRef.current = 0 (이전 렌더링에서 설정한 값 유지)
    • a = 1 (함수가 재실행되므로 초기값으로 다시 설정)
  3. 이전 useEffect의 클린업 함수가 실행됩니다:

    • 'cleanup a', a → 첫 번째 렌더링의 useEffect 내에서 수정된 a 값인 0 출력
    • 'clean up data', data → 첫 번째 렌더링 시점의 data 값인 0 출력
    • 'clean up aRef ', aRef.current → 현재 시점의 aRef.current 값인 0 출력 (아직 첫 번째 렌더링에서 설정한 값)
  4. 새 useEffect가 실행됩니다:

    • console.log('let', a) → let 1 출력 (함수가 재실행되어 초기화됨)
    • console.log('useState', data) → useState 1 출력
    • console.log('useRef', aRef.current) → useRef 0 출력
    • setData((prev) => prev + 1) → data를 2로 업데이트
    • aRef.current = data → aRef.current를 1로 설정
    • a = data → a를 1로 설정

핵심 개념 정리

1. 함수 컴포넌트의 실행과 값 초기화

함수 컴포넌트는 렌더링이 필요할 때마다 전체가 다시 실행됩니다. 이 때:

  • 일반 변수(let a = 1)는 항상 초기값으로 재설정됩니다.
  • useState의 값은 React에 의해 보존되어 새 렌더링에 전달됩니다.
  • useRef의 .current 값은 렌더링 간에 유지됩니다.

2. 클로저와 useEffect

useEffect와 그 안의 클린업 함수는 클로저의 원리로 동작합니다:

  • 각 렌더링마다 useEffect 내부에서 사용하는 변수들(a, data, aRef.current)은 해당 렌더링 시점의 값으로 캡처됩니다.
  • 클린업 함수도 자신이 정의된 렌더링 사이클의 값들을 캡처하여 기억합니다.
  • 클린업 함수는 비록 다음 렌더링 시점에 실행되지만, 그 함수 내부에서 참조하는 모든 변수들은 함수가 정의된 렌더링의 useEffect 내에서 수정된 최종 값을 사용합니다.
  • 중요한 것은 모든 값(a, data, aRef.current)이 동일한 값으로 출력된다는 점입니다. 이는 모두 같은 시점(useEffect 내에서 a = data, aRef.current = data로 수정된 후)의 값을 캡처하기 때문입니다.

이는 React의 "클로저 캡처" 특성의 전형적인 예시입니다. 클린업 함수는 정의될 때의 환경(렉시컬 환경)을 기억하고, 그 환경에서의 최종 값을 사용합니다.

위 예제에서 확인할 수 있듯이:

  • 첫 번째 렌더링에서 정의된 클린업 함수는 useEffect 실행 후 수정된 값(a=0, data=0, aRef.current=0)을 사용합니다.
  • 두 번째 렌더링에서 정의된 클린업 함수는 그 시점의 수정된 값(a=1, data=1, aRef.current=1)을 사용합니다.
  • 세 번째 렌더링에서는 모두 2의 값을 사용합니다.

이처럼 클린업 함수는 항상 자신이 정의된 렌더링의 useEffect에서 설정된 최종 값들을 사용합니다. 따라서 항상 세 값이 동일하게 출력되는 것입니다.

3. useRef vs useState

  • useRef는 값을 저장하지만 변경해도 리렌더링을 트리거하지 않습니다.
  • useState는 값을 저장하고 변경하면 컴포넌트의 리렌더링을 트리거합니다.
  • 둘 다 렌더링 간에 값을 유지하지만, 목적과 사용 방식이 다릅니다.

4. 클린업 함수의 실행 시점과 값 캡처

클린업 함수는 다음 경우에 실행됩니다:

  • 컴포넌트가 언마운트될 때
  • 의존성 배열의 값이 변경되어 이펙트가 다시 실행되기 전에

중요한 점: 클린업 함수는 다음 렌더링 시점에 실행되지만, 그 함수 내부에서 참조하는 변수들은 함수가 정의된 렌더링 시점의 값을 사용합니다. 이것이 React의 클로저 기반 동작 방식입니다.

위 예제에서 확인할 수 있듯이:

  • 첫 번째 렌더링에서 정의된 클린업 함수는 useEffect 실행 후 수정된 값(a=0, data=0, aRef.current=0)을 사용합니다.
  • 두 번째 렌더링에서 정의된 클린업 함수는 그 시점의 수정된 값(a=1, data=1, aRef.current=1)을 사용합니다.

이런 동작을 이해하는 것은 사이드 이펙트를 관리할 때 매우 중요합니다.

결론

이 예제를 통해 React의 상태 관리 방식과 생명주기에 대해 더 깊이 이해할 수 있습니다. 특히 클로저가 React의 동작에 미치는 영향을 이해하는 것은 예측 가능한 컴포넌트를 작성하는 데 큰 도움이 됩니다.

클로저, 리렌더링, 그리고 각 상태 관리 방식의 차이점을 제대로 이해하면 더 효율적이고 버그가 적은 React 코드를 작성할 수 있을 것입니다.