Tuesday, October 21, 2025

리액트 상태의 흐름과 동기화의 본질

리액트(React) 애플리케이션의 본질은 상태(state)가 변화함에 따라 사용자 인터페이스(UI)가 일관되게 업데이트되는 선언적 패러다임에 있습니다. 과거 클래스 컴포넌트 시절에는 `this.state`와 생명주기 메서드(lifecycle methods)를 통해 이러한 상태 변화와 그에 따른 부수 효과(side effects)를 관리했습니다. 하지만 함수형 컴포넌트가 대두되면서, 이러한 강력한 기능들을 함수라는 간결한 단위 내에서 구현하기 위한 새로운 도구가 필요해졌습니다. 바로 이 지점에서 리액트 훅(Hook)이 등장했으며, 그중에서도 `useState`와 `useEffect`는 함수형 컴포넌트의 심장과도 같은 역할을 수행합니다.

이 두 훅은 단순히 상태를 만들고 특정 시점에 코드를 실행하는 기능을 넘어, 리액트의 렌더링 메커니즘과 컴포넌트의 생명주기를 깊이 이해하는 열쇠입니다. `useState`는 컴포넌트가 기억해야 할 값을, 그리고 그 값이 어떻게 변해야 하는지에 대한 약속을 정의합니다. `useEffect`는 컴포넌트의 렌더링 결과가 실제 DOM에 반영된 이후, 외부 세계(API, 브라우저 이벤트, 서드파티 라이브러리 등)와 상호작용하는 방식을 결정합니다. 이 둘의 유기적인 결합을 통해 우리는 동적인 웹 애플리케이션을 구축할 수 있습니다. 이 글에서는 `useState`와 `useEffect`의 표면적인 사용법을 넘어, 그 내부 동작 원리, 잠재적인 문제점, 그리고 최적화 전략까지 심도 있게 탐구하여 리액트의 반응성 시스템을 근본적으로 이해하는 것을 목표로 합니다.

1. useState: 컴포넌트의 기억 장치

함수는 본질적으로 호출이 끝나면 내부의 모든 변수와 정보가 사라지는 '기억상실증'을 앓고 있습니다. 함수형 컴포넌트 역시 마찬가지입니다. 렌더링이 발생할 때마다 컴포넌트 함수는 처음부터 다시 실행되며, 이전 렌더링에서 사용했던 지역 변수들은 모두 초기화됩니다. 그렇다면 어떻게 컴포넌트는 이전의 상태를 기억하고, 그 변화에 따라 UI를 다시 그릴 수 있을까요? 이 마법과 같은 일을 가능하게 하는 것이 바로 `useState` 훅입니다.

1.1. useState의 기본 구조와 작동 원리

`useState`는 함수형 컴포넌트 내에서 '상태 변수'를 선언할 수 있게 해주는 훅입니다. 사용법은 매우 간단합니다.


import React, { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0); // [상태 값, 상태를 변경하는 함수] = useState(초기값)

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

위 코드에서 `useState(0)`은 `Counter` 컴포넌트를 위한 상태 변수를 하나 생성하라는 의미입니다. 이때 인자로 전달된 `0`은 이 상태의 '초기값'입니다. `useState`는 배열을 반환하는데, 이 배열에는 두 개의 요소가 담겨 있습니다.

  • 첫 번째 요소 (`count`): 현재 상태 값입니다. 리액트는 이 값을 컴포넌트와 연결된 내부 메모리 공간에 저장하여, 컴포넌트 함수가 다시 실행되더라도(리렌더링) 이전에 저장된 값을 기억하고 돌려줍니다.
  • 두 번째 요소 (`setCount`): 상태를 업데이트하는 '세터(setter) 함수'입니다. 이 함수를 호출해야만 리액트에게 상태가 변경되었음을 알릴 수 있으며, 리액트는 이 신호를 받고 컴포넌트를 리렌더링할지 결정합니다. 절대로 `count = count + 1`과 같이 상태 변수를 직접 수정해서는 안 됩니다. 이는 리액트의 렌더링 트리거 메커니즘을 무시하는 행위이기 때문입니다.

버튼을 클릭하면 `onClick` 이벤트 핸들러가 `setCount(count + 1)`을 호출합니다. 이 순간 다음과 같은 일이 순차적으로 일어납니다.

  1. `setCount` 함수가 새로운 상태 값(예: 1)을 인자로 받아 호출됩니다.
  2. 리액트는 이 컴포넌트의 상태 업데이트를 예약(schedule)합니다. 즉시 리렌더링이 일어나는 것이 아니라, 다른 상태 업데이트들과 함께 묶어서 처리될 수 있습니다(배치 업데이트, Batching).
  3. 리액트는 `Counter` 컴포넌트 함수를 다시 호출(리렌더링)합니다.
  4. 이때 `useState(0)`는 다시 실행되지만, 초기값 `0`을 사용하는 대신 리액트가 내부적으로 기억하고 있던 가장 최신의 상태 값(1)을 `count` 변수에 할당합니다.
  5. 새로운 `count` 값(1)이 포함된 JSX가 반환되고, 리액트는 이전 가상 DOM과 비교하여 변경된 부분(`You clicked 1 times`)만을 실제 DOM에 효율적으로 업데이트합니다.

이처럼 `useState`는 단순한 변수 선언이 아니라, 리액트의 렌더링 시스템과 긴밀하게 연결된 '상태 관리 매니저'를 컴포넌트에 주입하는 행위라고 볼 수 있습니다.

1.2. 함수형 업데이트: 상태 업데이트의 안정성 확보

만약 짧은 시간 안에 여러 번의 상태 업데이트가 필요하다면 어떻게 될까요? 다음 코드를 봅시다.


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

  const handleTripleClick = () => {
    setCount(count + 1); // 1. 현재 count는 0. setCount(0 + 1)을 예약
    setCount(count + 1); // 2. 현재 count는 여전히 0. setCount(0 + 1)을 예약
    setCount(count + 1); // 3. 현재 count는 여전히 0. setCount(0 + 1)을 예약
  };

  // 버튼을 클릭하면 count는 1이 될 뿐, 3이 되지 않는다.

  return (
    // ... JSX
  );
}

버튼을 한 번 클릭했을 때 `count`가 3이 되기를 기대했지만, 실제로는 1만 증가합니다. 그 이유는 `setCount`가 비동기적으로 동작하기 때문입니다. 더 정확히 말하면, `setCount`는 상태 업데이트를 즉시 실행하는 것이 아니라 '예약'하고, 해당 이벤트 핸들러(`handleTripleClick`) 내의 모든 코드가 실행된 후에야 리액트가 상태 업데이트를 일괄적으로 처리(batching)합니다. 따라서 `handleTripleClick` 함수가 실행되는 동안 `count` 변수의 값은 계속 `0`으로 유지됩니다. 세 번의 `setCount` 호출은 모두 `setCount(0 + 1)`이라는 동일한 작업을 예약한 셈이 됩니다.

이 문제를 해결하기 위해 '함수형 업데이트(functional update)'를 사용합니다. 세터 함수에 새로운 값을 직접 전달하는 대신, 이전 상태 값을 인자로 받아 새로운 상태 값을 반환하는 함수를 전달하는 방식입니다.


const handleTripleClick = () => {
  setCount(prevCount => prevCount + 1); // 1. "현재 값에 1을 더하라"는 함수를 예약
  setCount(prevCount => prevCount + 1); // 2. "현재 값에 1을 더하라"는 함수를 예약
  setCount(prevCount => prevCount + 1); // 3. "현재 값에 1을 더하라"는 함수를 예약
};
// 버튼을 클릭하면 count는 3이 된다.

이렇게 함수를 전달하면, 리액트는 이 함수들을 큐(queue)에 쌓아두었다가 순차적으로 실행합니다. 첫 번째 함수는 초기 상태 `0`을 받아 `1`을 반환하고, 두 번째 함수는 그 결과인 `1`을 받아 `2`를 반환하며, 세 번째 함수는 다시 `2`를 받아 `3`을 반환합니다. 이처럼 함수형 업데이트는 이전 상태 값에 의존하여 다음 상태를 결정해야 할 때, 상태 업데이트의 일관성과 안정성을 보장하는 매우 중요한 기법입니다.

1.3. 복잡한 상태 관리: 객체와 배열 다루기

`useState`는 원시 타입(숫자, 문자열, 불리언)뿐만 아니라 객체나 배열과 같은 복잡한 데이터 구조도 상태로 관리할 수 있습니다. 하지만 여기서 반드시 지켜야 할 원칙이 있습니다. 바로 '불변성(immutability)'입니다.

리액트는 상태가 '변경'되었는지 판단하기 위해 객체나 배열의 경우, 내부 속성 하나하나를 비교하는 것이 아니라 이전 상태와 다음 상태의 참조(메모리 주소)를 비교합니다(얕은 비교, shallow comparison). 만약 원본 객체를 직접 수정하면, 참조 주소가 바뀌지 않기 때문에 리액트는 상태가 변경되었다고 인지하지 못하고 리렌더링을 일으키지 않습니다.

잘못된 예시:


function UserProfile() {
  const [user, setUser] = useState({ name: 'Alice', age: 30 });

  const handleAgeIncrement = () => {
    // 💥 잘못된 방식: 원본 객체를 직접 수정 (mutation)
    user.age += 1; 
    setUser(user); // user 객체의 참조가 그대로이므로 리액트는 변화를 감지하지 못함
  };
  // ...
}

올바른 예시:


function UserProfile() {
  const [user, setUser] = useState({ name: 'Alice', age: 30 });

  const handleAgeIncrement = () => {
    // ✨ 올바른 방식: 새로운 객체를 생성하여 상태를 업데이트
    setUser({
      ...user, // 스프레드 연산자로 기존 속성을 복사
      age: user.age + 1 // 변경하려는 속성만 새로운 값으로 덮어씀
    });
  };
  
  // 혹은 함수형 업데이트를 사용하여 더 안전하게 처리
  const handleAgeIncrementSafely = () => {
    setUser(prevUser => ({
        ...prevUser,
        age: prevUser.age + 1
    }));
  };
  // ...
}

배열의 경우도 마찬가지입니다. `push`, `pop`, `splice`와 같이 원본 배열을 직접 수정하는 메서드 대신, `map`, `filter`, `concat`이나 스프레드 연산자(`...`)처럼 새로운 배열을 반환하는 메서드를 사용하여 불변성을 지켜야 합니다.


const [items, setItems] = useState(['apple', 'banana']);

// 아이템 추가 (잘못된 방식)
// items.push('cherry');
// setItems(items);

// 아이템 추가 (올바른 방식)
setItems([...items, 'cherry']); 
// 혹은
setItems(prevItems => [...prevItems, 'cherry']);

// 아이템 제거 (올바른 방식)
setItems(items.filter(item => item !== 'banana'));

불변성을 지키는 것은 리액트 상태 관리의 핵심 원칙 중 하나입니다. 이는 리액트가 변화를 효율적으로 감지하게 할 뿐만 아니라, 상태 변화의 추적을 용이하게 하고 예기치 않은 버그를 방지하는 데 큰 도움이 됩니다.

2. useEffect: 컴포넌트와 외부 세계의 연결고리

리액트 컴포넌트의 주된 임무는 상태를 받아 UI를 렌더링하는 것입니다. 이는 순수 함수처럼 입력(props, state)이 같으면 항상 같은 출력(JSX)을 내놓는 것이 이상적입니다. 하지만 실제 애플리케이션에서는 렌더링과 직접적인 관련이 없는 작업들, 즉 '부수 효과(side effects)'를 처리해야 할 때가 많습니다. 예를 들어, 서버로부터 데이터를 가져오거나(Data Fetching), 브라우저의 타이머(`setTimeout`, `setInterval`)를 설정하거나, DOM을 직접 조작하는 등의 작업이 이에 해당합니다.

`useEffect`는 이러한 부수 효과를 함수형 컴포넌트 내에서 수행할 수 있게 해주는 훅입니다. 이름에서 알 수 있듯이, 'effect'를 발생시키는 역할을 하며, 이 effect는 리액트가 렌더링을 완료한 '이후'에 실행됩니다. 이를 통해 렌더링 과정 자체는 순수하게 유지하면서, 필요한 외부 상호작용을 처리할 수 있습니다.

2.1. useEffect의 구조와 실행 시점

`useEffect`는 두 개의 인자를 받습니다: effect를 수행하는 '콜백 함수'와 effect의 실행 조건을 결정하는 '의존성 배열(dependency array)'입니다.


import React, { useState, useEffect } from 'react';

useEffect(() => {
  // 부수 효과를 수행하는 코드 (Effect)
  // 이 함수는 렌더링이 DOM에 반영된 후에 실행됩니다.

  return () => {
    // 정리(cleanup) 함수.
    // 다음 effect가 실행되기 전, 혹은 컴포넌트가 언마운트될 때 실행됩니다.
  };
}, [dependency1, dependency2]); // 의존성 배열

`useEffect`의 가장 중요한 특징은 의존성 배열에 따라 실행 시점이 결정된다는 점입니다. 이 배열을 어떻게 설정하느냐에 따라 클래스 컴포넌트의 `componentDidMount`, `componentDidUpdate`, `componentWillUnmount`와 유사한 동작을 구현할 수 있습니다.

케이스 1: 의존성 배열을 생략한 경우


useEffect(() => {
  console.log('컴포넌트가 렌더링될 때마다 실행됩니다.');
}); // 의존성 배열 없음

의존성 배열을 아예 전달하지 않으면, 이 effect는 컴포넌트가 최초 렌더링될 때와 리렌더링될 때마다 항상 실행됩니다. 이는 `componentDidMount`와 `componentDidUpdate`가 합쳐진 것과 유사합니다. 하지만 상태가 변경될 때마다 불필요하게 effect가 반복 실행될 수 있어 성능 문제를 야기하거나 무한 루프에 빠질 위험이 있습니다. 예를 들어, effect 내에서 상태를 업데이트하는 코드가 있다면, `상태 업데이트 → 리렌더링 → effect 실행 → 상태 업데이트 ...`의 무한 반복이 발생할 수 있습니다. 따라서 이 방식은 매우 신중하게 사용해야 합니다.

케이스 2: 빈 배열(`[]`)을 전달한 경우


useEffect(() => {
  console.log('컴포넌트가 처음 마운트될 때 한 번만 실행됩니다.');
  // 예: API 호출, 이벤트 리스너 등록 등
}, []); // 빈 의존성 배열

의존성 배열로 빈 배열(`[]`)을 전달하면, 이 effect는 컴포넌트가 최초 렌더링(마운트)된 직후에 단 한 번만 실행됩니다. 리렌더링이 발생하더라도 의존하는 값이 없기 때문에 다시 실행되지 않습니다. 이는 클래스 컴포넌트의 `componentDidMount`와 정확히 동일한 역할을 합니다. 초기 데이터 로딩, 외부 라이브러리 연동 등 컴포넌트 생애 동안 한 번만 수행하면 되는 작업을 처리하기에 매우 적합합니다.

케이스 3: 배열에 특정 값들을 전달한 경우


useEffect(() => {
  console.log(`${someProp} 또는 ${someState}가 변경되었습니다.`);
}, [someProp, someState]); // 특정 의존성

의존성 배열에 특정 변수(props나 state)를 넣으면, effect는 최초 마운트 시 한 번 실행되고, 이후에는 배열에 포함된 변수 중 하나라도 값이 변경될 때마다 다시 실행됩니다. 리액트는 리렌더링이 발생하면 의존성 배열의 각 항목을 이전 렌더링 시점의 값과 비교(Object.is 비교)하여 변화 여부를 감지합니다. 이는 클래스 컴포넌트의 `componentDidUpdate`에서 특정 조건(`if (prevProps.someProp !== this.props.someProp)`)을 걸어주는 것과 유사한 효과를 냅니다. 특정 값의 변화에 반응하여 부수 효과를 일으켜야 할 때 사용되는 가장 일반적인 패턴입니다.

2.2. 정리(Cleanup) 함수의 중요성

부수 효과 중에는 '정리'가 필요한 작업들이 있습니다. 예를 들어, `setInterval`로 타이머를 설정했다면 컴포넌트가 사라질 때 타이머를 해제(`clearInterval`)해야 메모리 누수를 막을 수 있습니다. `window`에 이벤트 리스너를 추가했다면, 컴포넌트가 사라질 때 리스너를 제거해야 합니다. 그렇지 않으면 보이지 않는 컴포넌트가 계속해서 이벤트를 처리하는 좀비 같은 상황이 발생할 수 있습니다.

`useEffect`의 콜백 함수에서 또 다른 함수를 반환하면, 이 함수가 바로 '정리(cleanup) 함수'가 됩니다. 이 정리 함수는 다음과 같은 두 가지 시점에 실행됩니다.

  1. 컴포넌트가 언마운트될 때 (사라질 때): `componentWillUnmount`의 역할을 합니다.
  2. 다음 effect가 실행되기 직전: 의존성 배열의 값이 변경되어 effect가 다시 실행되어야 할 때, 새로운 effect를 실행하기 전에 이전 effect를 정리하기 위해 먼저 호출됩니다.

다음은 타이머를 사용하는 예시입니다.


function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    // Effect: 1초마다 seconds를 1씩 증가시키는 타이머 설정
    const intervalId = setInterval(() => {
      setSeconds(prevSeconds => prevSeconds + 1);
    }, 1000);
    
    console.log('타이머가 설정되었습니다. ID:', intervalId);

    // Cleanup: 컴포넌트가 언마운트되거나, effect가 다시 실행되기 전에 타이머를 해제
    return () => {
      console.log('타이머를 정리합니다. ID:', intervalId);
      clearInterval(intervalId);
    };
  }, []); // 빈 배열이므로, 컴포넌트 마운트 시 1번 실행되고 언마운트 시 1번 정리됨

  return <h1>{seconds}초</h1>;
}

만약 위 코드에서 정리 함수(`return () => { ... }`)가 없다면, `Timer` 컴포넌트가 화면에서 사라져도 `setInterval`은 백그라운드에서 계속 실행되며 불필요한 자원을 소모하고 잠재적인 버그를 유발할 것입니다. 정리 함수는 이처럼 부수 효과의 생명주기를 컴포넌트의 생명주기와 동기화하여 애플리케이션의 안정성을 높이는 필수적인 장치입니다.

특히 의존성 배열에 값이 있는 경우, 정리 함수의 동작 방식은 더욱 중요해집니다. 예를 들어, 특정 `userId`가 바뀔 때마다 해당 유저의 채팅방에 접속(구독)하는 effect가 있다면, `userId`가 변경될 때 새로운 유저의 채팅방에 접속하기 전에 '이전' 유저의 채팅방에서 접속을 해제(구독 취소)하는 로직을 정리 함수에 포함해야 합니다.

3. useState와 useEffect의 협력: 데이터 가져오기 예제

`useState`와 `useEffect`의 진정한 힘은 이 둘이 함께 사용될 때 발휘됩니다. 가장 대표적인 예시가 바로 서버로부터 데이터를 비동기적으로 가져와 화면에 표시하는 작업입니다. 이 과정에는 여러 상태(로딩 중, 데이터 로딩 성공, 에러 발생)가 존재하며, 데이터 요청이라는 부수 효과가 필요하기 때문입니다.

사용자 ID를 기반으로 사용자 정보를 가져오는 컴포넌트를 만들어보겠습니다.


import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // 데이터 fetching 로직을 effect 내부에 정의
    const fetchUser = async () => {
      // 1. 이전 요청에 대한 상태 초기화 및 로딩 시작
      setLoading(true);
      setError(null);
      
      try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) {
          throw new Error('데이터를 불러오는 데 실패했습니다.');
        }
        const data = await response.json();
        // 2. 데이터 로딩 성공 시 상태 업데이트
        setUser(data);
      } catch (e) {
        // 3. 에러 발생 시 에러 상태 업데이트
        setError(e);
      } finally {
        // 4. 성공/실패 여부와 관계없이 로딩 상태 종료
        setLoading(false);
      }
    };

    fetchUser();
    
    // 이 effect는 userId가 변경될 때마다 다시 실행되어야 함
  }, [userId]); 

  if (loading) {
    return <div>로딩 중...</div>;
  }

  if (error) {
    return <div>에러가 발생했습니다: {error.message}</div>;
  }

  return (
    <div>
      <h1>{user?.name}</h1>
      <p>이메일: {user?.email}</p>
    </div>
  );
}

이 코드의 흐름을 단계별로 분석해 봅시다.

  1. 상태 정의 (`useState`):
    • `user`: 가져온 사용자 데이터를 저장할 상태. 초기값은 `null`.
    • `loading`: 데이터 로딩 중인지 여부를 나타내는 상태. 초기값은 `true` (컴포넌트가 처음 렌더링될 때 바로 데이터 로딩을 시작하므로).
    • `error`: 에러 발생 시 에러 객체를 저장할 상태. 초기값은 `null`.
    이렇게 세분화된 상태를 통해 UI는 현재 데이터 요청의 각 단계(로딩, 성공, 실패)에 맞게 적절한 화면을 보여줄 수 있습니다.
  2. 부수 효과 정의 (`useEffect`):
    • 의존성 배열 `[userId]`:** 이 effect는 `userId` prop이 변경될 때마다 다시 실행됩니다. 만약 부모 컴포넌트에서 다른 사용자를 선택하여 `userId`가 `1`에서 `2`로 바뀐다면, `useEffect`는 새로운 `userId`로 데이터를 다시 가져오기 위해 재실행됩니다. 만약 의존성 배열이 `[]`였다면, `userId`가 바뀌어도 새로운 데이터를 가져오지 않는 버그가 발생했을 것입니다.
    • 비동기 함수 `fetchUser`:** `useEffect`의 콜백 함수 자체는 비동기가 될 수 없으므로(`async` 키워드를 직접 붙일 수 없음), 내부에 별도의 `async` 함수를 선언하고 호출하는 패턴을 사용합니다. 이는 정리 함수를 반환하는 `useEffect`의 구조와 비동기 함수의 반환 값(Promise)이 충돌하는 것을 막기 위함입니다.
    • 상태 업데이트:** `try-catch-finally` 블록을 사용하여 비동기 작업의 흐름에 따라 `setLoading`, `setUser`, `setError`를 적절히 호출하여 상태를 변경합니다. `userId`가 변경되어 effect가 재실행될 때, `setLoading(true)`를 다시 호출하여 새로운 데이터 요청이 시작되었음을 UI에 알리는 것이 중요합니다.
  3. 조건부 렌더링:** `loading`과 `error` 상태 값을 사용하여 현재 상태에 맞는 UI를 반환합니다. 데이터가 아직 로딩 중이면 "로딩 중..." 메시지를, 에러가 발생했다면 에러 메시지를, 모든 것이 성공적이라면 사용자 정보를 표시합니다.

이 예시는 `useState`로 UI의 상태를 정의하고, `useEffect`로 외부 세계(서버)와의 동기화를 맞추며, 그 결과에 따라 다시 `useState`로 상태를 업데이트하는 리액트 애플리케이션의 핵심적인 데이터 흐름을 명확하게 보여줍니다.

4. 심화 탐구: 흔히 발생하는 문제와 최적화 전략

`useState`와 `useEffect`는 강력하지만, 그 내부 동작 원리를 정확히 이해하지 못하면 몇 가지 함정에 빠지기 쉽습니다. 특히 `useEffect`의 의존성 배열과 관련된 문제들은 많은 개발자들이 초기에 겪는 어려움입니다.

4.1. Stale Closure 문제와 해결 방안

'Stale closure'는 클로저(closure)가 오래된(stale) 상태 값을 참조하는 현상을 말합니다. `useEffect`에서 빈 의존성 배열(`[]`)을 사용하고, effect 내부에서 외부의 상태 값을 참조할 때 자주 발생합니다.


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

  useEffect(() => {
    // 이 effect는 최초 렌더링 시에만 실행된다.
    const id = setInterval(() => {
      // 💥 문제 발생: 이 콜백 함수는 최초 렌더링 시점의 'count' 값 (0)을 영원히 기억한다.
      // setCount(count + 1)은 매번 setCount(0 + 1)을 호출하는 것과 같다.
      console.log(`Interval fired. count is ${count}`);
      setCount(count + 1); 
    }, 2000);
    
    return () => clearInterval(id);
  }, []); // 의존성 배열이 비어있음

  return <h1>{count}</h1>; // count는 0에서 1로 한 번만 바뀌고 더 이상 증가하지 않는다.
}

위 코드에서 `setInterval`의 콜백 함수는 `DelayedCounter` 컴포넌트가 처음 렌더링될 때 생성된 클로저입니다. 이 클로저는 당시의 `count` 값, 즉 `0`을 '포획'합니다. `setCount`가 호출되어 컴포넌트가 리렌더링되고 새로운 `count` 값(1)이 생겨나도, `setInterval`의 콜백 함수는 여전히 자신이 기억하는 옛날 `count`(0)를 사용합니다. 따라서 2초마다 `setCount(0 + 1)`만 반복하게 됩니다.

이 문제를 해결하는 방법은 두 가지입니다.

  1. 함수형 업데이트 사용: 이것이 가장 권장되는 해결책입니다. 함수형 업데이트는 이전 상태 값을 인자로 받기 때문에 클로저가 오래된 상태 값을 기억하고 있어도 문제가 되지 않습니다.
    
      useEffect(() => {
        const id = setInterval(() => {
          // ✨ 해결: 함수형 업데이트를 사용하여 최신 상태를 기반으로 값을 변경
          setCount(prevCount => prevCount + 1);
        }, 2000);
        
        return () => clearInterval(id);
      }, []);
      
  2. 의존성 배열에 상태 추가: `count`가 변경될 때마다 effect를 재실행하도록 의존성 배열에 `count`를 추가할 수도 있습니다.
    
      useEffect(() => {
        const id = setInterval(() => {
          setCount(count + 1);
        }, 2000);
        
        return () => clearInterval(id); // count가 바뀔 때마다 이전 interval을 정리
      }, [count]); // count가 바뀔 때마다 effect가 다시 실행됨
      
    이 방법도 작동은 하지만, `count`가 바뀔 때마다 `setInterval`을 해제하고 다시 설정하는 과정이 반복되므로, 첫 번째 방법인 함수형 업데이트가 더 효율적이고 의도도 명확합니다.

4.2. 의존성 배열과 참조 안정성 (useCallback, useMemo)

의존성 배열은 값의 변화를 감지할 때 얕은 비교를 사용합니다. 원시 타입(숫자, 문자열 등)은 값이 같으면 같다고 판단하지만, 객체나 배열, 함수는 렌더링마다 새로 생성되기 때문에 내용이 같더라도 참조(메모리 주소)가 달라져 다른 값으로 인식됩니다.


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

  // 이 함수는 ParentComponent가 리렌더링될 때마다 새로 생성됨
  const fetchData = () => {
    console.log('Fetching data...');
  };
  
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Increment: {count}</button>
      <ChildComponent fetchData={fetchData} />
    </div>
  );
}

function ChildComponent({ fetchData }) {
  useEffect(() => {
    // 💥 문제: ParentComponent의 count가 바뀌어 리렌더링되면,
    // 새로운 fetchData 함수가 props로 전달되고, 이 effect는 불필요하게 재실행된다.
    fetchData();
  }, [fetchData]);

  return <div>Child</div>;
}

위 예시에서 부모의 `count` 상태가 바뀌면 `ParentComponent`가 리렌더링되면서 `fetchData` 함수가 새로 만들어집니다. `ChildComponent`는 새로운 `fetchData` 함수를 props로 받고, `useEffect`는 이전 렌더링의 `fetchData`와 참조가 달라졌다고 판단하여 effect를 불필요하게 다시 실행합니다.

이러한 문제를 해결하기 위해 `useCallback`과 `useMemo` 훅을 사용합니다.

  • `useCallback`: 함수를 메모이제이션(memoization)합니다. 즉, 의존성이 변경되지 않는 한 함수를 새로 생성하지 않고 이전에 생성한 함수를 재사용합니다.
    
      // ParentComponent 내부
      import { useCallback } from 'react';
    
      // count가 바뀌어도 fetchData 함수는 재 생성되지 않음
      const fetchData = useCallback(() => {
        console.log('Fetching data...');
      }, []); // 의존성 배열이 비어있으므로, 컴포넌트 생애 동안 단 한 번만 생성됨
      
  • `useMemo`: 복잡한 연산의 '결과 값'을 메모이제이션합니다. 의존성이 변경되지 않는 한 연산을 다시 수행하지 않고 이전에 계산된 값을 재사용합니다. 의존성 배열에 객체나 배열을 넣어야 할 때 유용합니다.
    
      // 복잡한 계산을 통해 생성된 객체
      const options = useMemo(() => ({
        settingA: someValue,
        settingB: anotherValue
      }), [someValue, anotherValue]);
    
      useEffect(() => {
        // options 객체는 someValue나 anotherValue가 바뀔 때만 새로 생성되므로,
        // 이 effect는 불필요하게 실행되지 않는다.
        configureLibrary(options);
      }, [options]);
      

`useCallback`과 `useMemo`는 성능 최적화를 위한 강력한 도구이지만, 남용해서는 안 됩니다. 모든 함수와 값을 메모이제이션하는 것은 오히려 메모리 사용량을 늘리고 코드를 복잡하게 만들 수 있습니다. 불필요한 effect 재실행이나 복잡한 계산으로 인해 실제 성능 저하가 발생했을 때 사용하는 것이 바람직합니다.

4.3. 사용자 정의 훅(Custom Hook)으로 로직 분리하기

앞서 살펴본 데이터 가져오기 로직은 여러 컴포넌트에서 반복적으로 사용될 수 있습니다. `useState`와 `useEffect`를 조합하여 재사용 가능한 로직을 추출하는 것을 '사용자 정의 훅(Custom Hook)'이라고 합니다. 사용자 정의 훅은 이름이 `use`로 시작하는 자바스크립트 함수이며, 내부에서 다른 훅(`useState`, `useEffect` 등)을 호출할 수 있습니다.

데이터 가져오기 로직을 `useFetch`라는 커스텀 훅으로 만들어 보겠습니다.


import { useState, useEffect } from 'react';

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // AbortController를 사용하여 컴포넌트 언마운트 시 fetch 요청을 취소
    const controller = new AbortController();
    const signal = controller.signal;

    const fetchData = async () => {
      setLoading(true);
      setError(null);
      try {
        const response = await fetch(url, { signal });
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        const result = await response.json();
        setData(result);
      } catch (e) {
        if (e.name !== 'AbortError') {
          setError(e);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchData();

    // 정리 함수: url이 바뀌거나 컴포넌트가 언마운트되면 이전 요청을 취소
    return () => {
      controller.abort();
    };
  }, [url]); // url이 변경되면 데이터를 다시 가져옴

  return { data, loading, error };
}

이제 이 `useFetch` 훅을 사용하여 `UserProfile` 컴포넌트를 훨씬 간결하게 만들 수 있습니다.


function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`https://api.example.com/users/${userId}`);

  if (loading) return <div>로딩 중...</div>;
  if (error) return <div>에러: {error.message}</div>;

  return (
    <div>
      <h1>{user?.name}</h1>
      <p>이메일: {user?.email}</p>
    </div>
  );
}

사용자 정의 훅을 사용함으로써 복잡한 상태 관리와 부수 효과 로직을 컴포넌트의 UI 렌더링 로직으로부터 완벽하게 분리했습니다. 코드는 훨씬 더 선언적이고 읽기 쉬워졌으며, `useFetch` 훅은 다른 어떤 컴포넌트에서도 재사용할 수 있게 되었습니다. 이것이 바로 리액트 훅이 지향하는 강력한 조합성과 재사용성의 철학입니다.

결론: 상태와 효과의 조화

`useState`와 `useEffect`는 현대 리액트 개발의 근간을 이루는 두 기둥입니다. `useState`는 컴포넌트에 '기억'을 부여하여 동적인 UI를 가능하게 하고, `useEffect`는 리액트 세상과 외부 세계를 '동기화'하는 창구 역할을 합니다. 이 두 훅의 동작 원리, 특히 상태 업데이트의 비동기적 특성, 불변성의 원칙, 그리고 `useEffect` 의존성 배열의 정확한 사용법을 깊이 이해하는 것은 예측 가능하고 안정적인 리액트 애플리케이션을 구축하는 데 필수적입니다.

단순히 사용하는 것을 넘어, Stale Closure와 같은 잠재적 문제를 인지하고 함수형 업데이트나 `useCallback`과 같은 해결책을 적재적소에 적용할 수 있을 때, 그리고 반복되는 로직을 사용자 정의 훅으로 우아하게 분리해낼 수 있을 때, 비로소 리액트의 상태 관리 시스템을 효과적으로 다룬다고 말할 수 있을 것입니다. 이 두 가지 기본 훅에 대한 탄탄한 이해는 앞으로 마주하게 될 더 복잡한 상태 관리 라이브러리(Zustand, Recoil 등)나 리액트의 다른 고급 기능들을 배우는 데 훌륭한 밑거름이 될 것입니다.


0 개의 댓글:

Post a Comment