icon
4장 : React 훅 기초

useState 심화

지금까지 배운 리액트의 핵심 개념들이 어떻게 실제 애플리케이션에서 유기적으로 동작하는지 경험해 보셨을 것입니다.

이제 리액트 함수형 컴포넌트 개발의 기반이 되는 가장 중요한 훅들에 대해 더 깊이 있게 파고들 것입니다. 그 첫 번째는 바로 우리가 이미 기초를 다진 useState입니다. 이번 장에서는 useState의 기본적인 사용법을 넘어, 더욱 효율적이고 안전하게 상태를 관리하는 심화 기법들을 알아보겠습니다.


useState 복습 및 기초

useState 훅은 함수형 컴포넌트에서 상태(state)를 관리할 수 있게 해주는 가장 기본적인 훅입니다.

import React, { useState } from 'react';

function Counter() {
  // [현재 상태 값, 상태를 업데이트하는 함수] = useState(초기값);
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount(count + 1); // 현재 값에 1을 더하여 업데이트
  };

  return (
    <div>
      <p>카운트: {count}</p>
      <button onClick={increment}>증가</button>
    </div>
  );
}

여기서 setCount(count + 1)처럼 현재 state 값을 직접 사용하여 다음 state 값을 계산하는 방식은 대부분의 경우 문제가 없습니다. 하지만 state 업데이트가 빈번하게 일어나거나, 이전 state에 의존하여 새로운 state를 계산해야 하는 경우에는 예상치 못한 문제가 발생할 수 있습니다. 이를 해결하기 위해 함수형 업데이트 개념이 등장합니다.


함수형 업데이트 (Functional Updates)

useStatesetter 함수(예: setCount)는 새로운 state 값을 직접 인자로 받을 수도 있지만, 이전 state를 인자로 받는 함수(콜백 함수) 를 인자로 전달할 수도 있습니다. 이 방식을 함수형 업데이트(Functional Update) 라고 합니다.

setCount(prevCount => prevCount + 1);

여기서 prevCountsetCount가 호출될 시점의 최신 state을 의미합니다.

왜 함수형 업데이트가 필요한가?

리액트의 state 업데이트는 비동기적으로 처리될 수 있습니다. 여러 번의 setCount 호출이 짧은 시간 내에 발생하면, 각 setCountstate를 읽어오는 시점이 다를 수 있어 동기화 문제가 발생할 수 있습니다.

예제: 동시 다발적인 카운트 증가 문제 해결

App.js 파일을 수정하여 아래 BuggyCounterFixedCounter를 비교해 보세요.

src/components/BuggyCounter.js
// src/components/BuggyCounter.js
import React, { useState } from 'react';

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

  const increment = () => {
    // 이 방식은 Closure 이슈로 인해 예상치 못한 결과를 낳을 수 있습니다.
    // 클릭이 빠르게 여러 번 발생하면, 각 setCount가 동일한 'count' 값(클릭 당시의 count)을 참조하게 됩니다.
    setCount(count + 1);
    setCount(count + 1); // 같은 count 값을 기반으로 두 번 업데이트 시도
  };

  return (
    <div style={{ border: '1px solid #dc3545', padding: '15px', margin: '20px', borderRadius: '8px' }}>
      <h3>버그 있는 카운터</h3>
      <p>카운트: {count}</p>
      <button onClick={increment}>두 번 증가 시도</button>
      <p style={{ fontSize: '14px', color: '#666' }}>
        버튼 한 번 클릭 시 '2'가 증가해야 하지만, '1'만 증가하는 경우가 발생할 수 있습니다.
      </p>
    </div>
  );
}

export default BuggyCounter;
src/components/FixedCounter.js
// src/components/FixedCounter.js
import React, { useState } from 'react';

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

  const increment = () => {
    // ⭐️ 함수형 업데이트: 이전 상태를 인자로 받아 새로운 상태를 반환합니다.
    // 이렇게 하면 각 setCount 호출이 항상 최신 상태를 기반으로 업데이트됩니다.
    setCount(prevCount => prevCount + 1);
    setCount(prevCount => prevCount + 1);
  };

  return (
    <div style={{ border: '1px solid #28a745', padding: '15px', margin: '20px', borderRadius: '8px' }}>
      <h3>수정된 카운터</h3>
      <p>카운트: {count}</p>
      <button onClick={increment}>두 번 증가 시도</button>
      <p style={{ fontSize: '14px', color: '#666' }}>
        버튼 한 번 클릭 시 항상 '2'가 증가합니다.
      </p>
    </div>
  );
}

export default FixedCounter;

App.js에 두 컴포넌트를 추가하고 비교해 보세요. BuggyCounter는 여러 번 클릭 시 한 번 클릭에 1만 증가하는 현상을 보일 수 있지만, FixedCounter는 항상 2씩 증가할 것입니다.

핵심: setCount(prevCount => prevCount + 1)state 업데이트를 대기열에 추가하여, 리액트가 다음 렌더링 시점에 대기열에 있는 모든 업데이트를 순서대로 처리하고 최종 state를 계산하도록 합니다. 따라서 prevCount는 항상 올바른 이전 state 값을 보장합니다. 이전 state에 의존하여 새로운 state를 계산해야 할 때는 반드시 함수형 업데이트를 사용하세요.


객체 및 배열 state 관리 시 불변성 유지

useState로 객체나 배열을 state로 관리할 때, setter 함수는 state새로운 객체/배열 인스턴스일 때만 변경을 감지하고 재렌더링합니다. 따라서 기존 객체나 배열을 직접 수정하는 것이 아니라, 항상 새로운 객체나 배열을 생성하여 반환해야 합니다. 이를 불변성(Immutability) 유지라고 합니다.

예제: 사용자 정보 업데이트 (객체 불변성)

src/components/UserForm.js
// src/components/UserForm.js
import React, { useState } from 'react';

function UserForm() {
  const [user, setUser] = useState({ name: '홍길동', age: 30, city: '서울' });

  const handleNameChange = (e) => {
    // ❌ 잘못된 방식: 객체를 직접 변경 (재렌더링 안 될 수 있음)
    // user.name = e.target.value;
    // setUser(user);

    // ⭕ 올바른 방식: 스프레드 문법(...)을 사용하여 새로운 객체 생성
    setUser({
      ...user, // 기존 user 객체의 모든 속성 복사
      name: e.target.value // name 속성만 새로운 값으로 덮어쓰기
    });
  };

  const handleAgeChange = (e) => {
    setUser(prevUser => ({
      ...prevUser,
      age: Number(e.target.value) // 숫자 타입으로 변환
    }));
  };

  return (
    <div style={{ border: '1px solid #00BCD4', padding: '15px', margin: '20px', borderRadius: '8px' }}>
      <h3>사용자 정보 편집</h3>
      <div>
        <label>
          이름:
          <input type="text" value={user.name} onChange={handleNameChange} style={{ marginLeft: '5px' }} />
        </label>
      </div>
      <div style={{ marginTop: '10px' }}>
        <label>
          나이:
          <input type="number" value={user.age} onChange={handleAgeChange} style={{ marginLeft: '5px' }} />
        </label>
      </div>
      <p style={{ marginTop: '15px' }}>
        현재 사용자: {user.name}, {user.age}세, {user.city}
      </p>
    </div>
  );
}

export default UserForm;
  • setUser({ ...user, name: e.target.value })
    • ...user: user 객체의 모든 기존 속성(age, city 등)을 복사합니다.
    • name: e.target.value: 복사된 객체에서 name 속성만 새로운 값으로 덮어씁니다.
    • 이렇게 하면 user state가 가리키는 객체의 참조 자체가 변경되므로, 리액트가 state 변경을 감지하고 컴포넌트를 재렌더링합니다.

예제: 할 일 목록 항목 업데이트 (배열 불변성)

이전 Todo List 실습에서 이미 배열 불변성을 사용했습니다. 다시 한번 복습해 봅시다.

src/components/TodoList.js
// src/components/TodoList.js (일부 발췌)
// ...
const [todoItems, setTodoItems] = useState([]);

// 할 일 완료 상태 토글 핸들러
const handleToggleComplete = (id) => {
  setTodoItems(
    // ❌ 잘못된 방식: item.completed = !item.completed;
    // ⭕ 올바른 방식: map()을 사용하여 새로운 배열 생성, 특정 아이템만 새 객체로 교체
    todoItems.map((item) =>
      item.id === id ? { ...item, completed: !item.completed } : item // 해당 아이템만 새 객체 생성
    )
  );
};

// 할 일 삭제 핸들러
const handleDeleteTodo = (id) => {
  setTodoItems(todoItems.filter((item) => item.id !== id)); // filter()를 사용하여 새 배열 생성
};

// 새 할 일 추가 핸들러
const handleAddTodo = (e) => {
  // ...
  const newTodoItem = { id: newId, text: newTodo, completed: false };
  setTodoItems([...todoItems, newTodoItem]); // 스프레드 문법으로 새 배열 생성
  // ...
};
  • 배열에 새 요소 추가: [...todoItems, newTodoItem] (기존 배열 복사 + 새 요소)
  • 배열에서 요소 제거: todoItems.filter(condition) (조건에 맞는 요소만 포함된 새 배열)
  • 배열 내 특정 요소 업데이트: todoItems.map(item => item.id === id ? { ...item, updatedProp: newValue } : item) (모든 요소를 순회하며, 조건에 맞는 요소만 새로운 객체로 교체)

핵심: 객체나 배열을 state로 사용할 때는 항상 Object.assign(), 스프레드 문법(...), map(), filter(), reduce() 등의 메서드를 사용하여 새로운 객체/배열 인스턴스를 반환해야 합니다.


지연 초기화 (Lazy Initialization)

useState의 초기값은 컴포넌트가 처음 렌더링될 때 한 번만 계산됩니다. 하지만 초기값 계산에 비용이 많이 드는 작업(예: 복잡한 연산, 로컬 스토리지에서 대량의 데이터 불러오기)이 포함된다면, 컴포넌트가 리렌더링될 때마다 이 초기값 계산 로직이 불필요하게 다시 실행될 수 있습니다.

이를 방지하기 위해 useState의 초기값으로 함수를 전달할 수 있습니다. 이 함수는 컴포넌트가 처음 마운트될 때 단 한 번만 실행되고, 그 반환값이 state의 초기값이 됩니다. 이를 지연 초기화(Lazy Initialization) 라고 합니다.

예제: 비용이 많이 드는 초기값 계산 시뮬레이션

src/components/LazyInitCounter.js
// src/components/LazyInitCounter.js
import React, { useState } from 'react';

// 비용이 많이 드는 초기값 계산을 시뮬레이션하는 함수
function expensiveCalculation() {
  console.log('초기값 계산 중...');
  let sum = 0;
  for (let i = 0; i < 100000000; i++) {
    sum += i;
  }
  return sum;
}

function LazyInitCounter() {
  // ❌ 일반적인 초기값 설정 (매 렌더링 시 expensiveCalculation 호출)
  // const [count, setCount] = useState(expensiveCalculation());

  // ⭕ 지연 초기화: 함수를 전달하여 컴포넌트 마운트 시 한 번만 호출
  const [count, setCount] = useState(() => expensiveCalculation());

  const increment = () => {
    setCount(prevCount => prevCount + 1);
  };

  return (
    <div style={{ border: '1px solid #ff007f', padding: '15px', margin: '20px', borderRadius: '8px' }}>
      <h3>지연 초기화 카운터</h3>
      <p>카운트: {count}</p>
      <button onClick={increment}>증가</button>
      <p style={{ fontSize: '14px', color: '#666' }}>
        콘솔을 확인하여 '초기값 계산 중...' 메시지가 한 번만 뜨는지 확인하세요.
      </p>
    </div>
  );
}

export default LazyInitCounter;

App.jsLazyInitCounter를 추가하고, 버튼을 클릭하여 컴포넌트를 여러 번 리렌더링해 보세요. 콘솔에 초기값 계산 중... 메시지가 맨 처음 한 번만 뜨는 것을 확인할 수 있을 것입니다.

핵심: useState의 초기값이 복잡하거나 계산 비용이 높을 경우, useState(() => expensiveCalculation())와 같이 함수를 전달하여 지연 초기화를 구현하면 불필요한 성능 저하를 방지할 수 있습니다.


이 장에서는 useState 훅의 함수형 업데이트를 통한 안전한 state 관리, 객체 및 배열 state 관리 시 불변성 유지의 중요성, 그리고 지연 초기화를 통한 성능 최적화 기법까지 다루었습니다. 이 개념들을 잘 이해하고 적용한다면 더욱 견고하고 효율적인 리액트 컴포넌트를 만들 수 있을 것입니다.

다음 장에서는 useEffect 훅에 대해 더욱 깊이 파고들어, deps 배열의 활용, 클린업 함수, 그리고 데이터 페칭 패턴 등을 심층적으로 다룰 것입니다.