본문 바로가기

useState 심화

함수형 상태 갱신으로 이전 값을 안전하게 사용하고 객체·배열의 불변성과 비용이 큰 초기값의 지연 생성을 익힙니다.

3장의 할 일 목록 앱에서 지금까지 배운 React의 핵심 개념이 실제 애플리케이션에서 어떻게 맞물려 동작하는지 경험해 봤습니다. 4장에서는 함수 컴포넌트 개발의 바탕이 되는 훅들을 더 깊이 다룹니다. 그 첫 번째가 이미 기초를 다진 useState입니다. 이 글에서는 useState의 기본 사용법을 넘어, 상태를 더 효율적이고 안전하게 관리하는 함수형 업데이트, 불변성 유지, 지연 초기화를 살펴봅니다.


useState 복습 및 기초

useState는 함수 컴포넌트에서 상태(state)를 관리하게 해 주는 가장 기본적인 훅입니다. 아래 Counter는 2장의 state와 useState 훅 기초에서 본 MyCounter와 같은 방식으로 state를 바꾸며, increment가 이번 렌더링의 count에 1을 더한 값을 setter에 넘깁니다. 버튼을 한 번 누를 때마다 count가 1씩 늘어 화면의 숫자가 바뀌는데, 이 절의 나머지는 이 한 줄의 계산 방식이 언제 문제가 되는지를 다룹니다.

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를 계산해야 하는 경우에는 예상하지 못한 문제가 생길 수 있습니다. 이를 해결하는 방법이 함수형 업데이트입니다.


함수형 업데이트 (Functional Updates)

useState의 setter(예: setCount)는 새 state 값을 바로 인자로 받을 수도 있지만, 이전 state를 인자로 받는 함수(콜백 함수)를 받을 수도 있습니다. 이 방식을 함수형 업데이트(functional update)라고 합니다.

setCount(prevCount => prevCount + 1);

여기서 prevCount는 React가 갱신 큐를 처리할 때 이 업데이터에 넘겨주는 대기 중인 state 값입니다.

함수형 업데이트가 필요한 이유는 state가 렌더마다 고정된 스냅샷(snapshot)이기 때문입니다. setter를 호출해도 지금 실행 중인 코드의 state 변수가 바로 바뀌지 않고, 다음 렌더를 위한 갱신이 큐에 추가됩니다. React는 하나의 이벤트 처리 함수가 끝날 때까지 생긴 갱신을 모아서 처리하므로, 같은 처리 함수 안에서 setCount(count + 1)을 두 번 호출하면 두 표현식 모두 같은 렌더의 count를 읽고, 큐에는 같은 값으로 교체하라는 요청이 두 번 들어갑니다. 서로 다른 클릭 이벤트가 하나의 오래된 값을 공유하는 문제가 아니라는 점에 주의하세요.

두 카운터를 비교해 봅니다. 먼저 BuggyCounter입니다.

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

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

  const increment = () => {
    // 같은 이벤트 핸들러 안에서는 두 호출 모두 같은 렌더의 count snapshot을 읽습니다.
    setCount(count + 1);
    setCount(count + 1); // 동일한 값으로 교체하는 업데이트를 두 번 큐에 추가
  };

  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' }}>
        버튼 한 번에 두 번 호출해도 같은 snapshot을 사용하므로 '1'만 증가합니다.
      </p>
    </div>
  );
}

export default BuggyCounter;

increment 안의 두 줄은 모두 이번 렌더링의 count인 0을 읽으므로, 두 호출 모두 "1로 바꿔 달라"는 같은 요청이 됩니다. 그래서 두 번 호출해도 결과는 한 번 호출한 것과 같습니다. 다음은 함수형 업데이트로 고친 FixedCounter입니다. 바뀐 곳은 increment의 두 줄뿐이며, 값 대신 이전 상태를 받아 다음 상태를 돌려주는 함수를 넘깁니다.

src/components/FixedCounter.jsx
import React, { useState } from 'react';

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

  const increment = () => {
    // ⭐️ 함수형 업데이트: 큐의 대기 중인 상태를 받아 다음 상태를 반환합니다.
    // 첫 updater의 반환값이 다음 updater의 입력이 됩니다.
    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.jsx에 함께 넣고 버튼을 눌러 비교해 보세요. BuggyCounter는 한 번의 처리 함수에서 같은 값으로 두 번 교체하므로 버튼을 누를 때마다 1만 늘지만, FixedCounter는 두 업데이터를 차례로 적용하므로 2씩 늘어납니다. setCount(prevCount => prevCount + 1)은 순수한 업데이터를 큐에 넣고, React는 다음 렌더에서 업데이터를 순서대로 적용하며 앞 업데이터의 반환값을 다음 업데이터의 입력으로 넘겨 최종 state를 계산합니다.

같은 이벤트에서 값 교체와 updater 큐 비교 — 상태 추적

두 직접 값 전달은 1, 두 updater는 0→1→2다. 세 요청인 앞 장 그림과 개념은 유사하지만 본문 수치가 다른 독립 사례다.

같은 이벤트의 updater 두 개는 앞 결과를 이어받아 2를 만듭니다

SAME EVENT: 1 VS 2

같은 이벤트의 updater 두 개는 앞 결과를 이어받아 2를 만듭니다

count가 0인 한 핸들러를 비교합니다. 두 setCount(count + 1)은 같은 1로 교체하고, FixedCounter의 두 updater는 대기 중인 값을 0→1→2로 계산합니다.

같은 이벤트에서 값 교체와 updater 큐 비교count가 0인 같은 이벤트 핸들러에서 값을 직접 두 번 전달하면 최종 상태가 1이 되고, updater 두 개를 전달하면 0에서 1, 2로 순차 계산되는 과정을 비교합니다. 값을 직접 전달렌더count = 0같은 snapshot값을 2회 전달둘 다 1을 계산값 교체1 → 1두 요청 모두 1다음 렌더count = 1updater를 전달렌더count = 0같은 snapshotupdater 2개앞 결과를 다음에순서대로 계산0 → 1 → 2다음 렌더count = 2
같은 이벤트에서 값 교체와 updater 큐 비교count가 0인 같은 이벤트 핸들러에서 값을 직접 두 번 전달하면 최종 상태가 1이 되고, updater 두 개를 전달하면 0에서 1, 2로 순차 계산되는 과정을 비교합니다. 값을 직접 전달렌더count = 0같은 snapshot값을 2회 전달둘 다 1을 계산값 교체1 → 1두 요청 모두 1다음 렌더count = 1updater를 전달렌더count = 0같은 snapshotupdater 2개앞 결과를 다음에순서대로 계산0 → 1 → 2다음 렌더count = 2

직접 값 전달 · BuggyCounter

count = 0
setCount(count + 1); // 1로 교체
setCount(count + 1); // 1로 교체
다음 렌더: 1

Updater 큐 · FixedCounter

setCount(c => c + 1); // 0 → 1
setCount(c => c + 1); // 1 → 2
다음 렌더: 2

현재 핸들러의 snapshot은 setter 호출 뒤에도 바뀌지 않습니다. 별도 클릭은 각각 처리하며 updater는 순수해야 합니다. 개발 Strict Mode의 추가 호출은 결과를 두 번 누적하라는 뜻이 아닙니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
count0 snapshotcount: 0 / handler: 같은 snapshot한 handler에 두 직접 값 또는 두 updater를 등록한다같은 handler의 값은 바뀌지 않는다
큐 적용교체: 1,1 / updater: 0→1→2직접 교체는 1→1이고 updater는 0→1→2다순수 updater가 큐의 앞 결과를 받는다
다음 render직접: 1 / updater: 2독립 비교 결과 1과 2를 표시한다앞 장의 세 요청 사례와 호출 수가 다르며 자료를 import하지 않는다

같은 이벤트에서 값 교체와 updater 큐 비교 — 상태 추적

두 직접 값 전달은 1, 두 updater는 0→1→2다. 세 요청인 앞 장 그림과 개념은 유사하지만 본문 수치가 다른 독립 사례다.

같은 이벤트의 updater 두 개는 앞 결과를 이어받아 2를 만듭니다

SAME EVENT: 1 VS 2

같은 이벤트의 updater 두 개는 앞 결과를 이어받아 2를 만듭니다

count가 0인 한 핸들러를 비교합니다. 두 setCount(count + 1)은 같은 1로 교체하고, FixedCounter의 두 updater는 대기 중인 값을 0→1→2로 계산합니다.

같은 이벤트에서 값 교체와 updater 큐 비교count가 0인 같은 이벤트 핸들러에서 값을 직접 두 번 전달하면 최종 상태가 1이 되고, updater 두 개를 전달하면 0에서 1, 2로 순차 계산되는 과정을 비교합니다. 값을 직접 전달렌더count = 0같은 snapshot값을 2회 전달둘 다 1을 계산값 교체1 → 1두 요청 모두 1다음 렌더count = 1updater를 전달렌더count = 0같은 snapshotupdater 2개앞 결과를 다음에순서대로 계산0 → 1 → 2다음 렌더count = 2
같은 이벤트에서 값 교체와 updater 큐 비교count가 0인 같은 이벤트 핸들러에서 값을 직접 두 번 전달하면 최종 상태가 1이 되고, updater 두 개를 전달하면 0에서 1, 2로 순차 계산되는 과정을 비교합니다. 값을 직접 전달렌더count = 0같은 snapshot값을 2회 전달둘 다 1을 계산값 교체1 → 1두 요청 모두 1다음 렌더count = 1updater를 전달렌더count = 0같은 snapshotupdater 2개앞 결과를 다음에순서대로 계산0 → 1 → 2다음 렌더count = 2

직접 값 전달 · BuggyCounter

count = 0
setCount(count + 1); // 1로 교체
setCount(count + 1); // 1로 교체
다음 렌더: 1

Updater 큐 · FixedCounter

setCount(c => c + 1); // 0 → 1
setCount(c => c + 1); // 1 → 2
다음 렌더: 2

현재 핸들러의 snapshot은 setter 호출 뒤에도 바뀌지 않습니다. 별도 클릭은 각각 처리하며 updater는 순수해야 합니다. 개발 Strict Mode의 추가 호출은 결과를 두 번 누적하라는 뜻이 아닙니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
count0 snapshotcount: 0 / handler: 같은 snapshot한 handler에 두 직접 값 또는 두 updater를 등록한다같은 handler의 값은 바뀌지 않는다
큐 적용교체: 1,1 / updater: 0→1→2직접 교체는 1→1이고 updater는 0→1→2다순수 updater가 큐의 앞 결과를 받는다
다음 render직접: 1 / updater: 2독립 비교 결과 1과 2를 표시한다앞 장의 세 요청 사례와 호출 수가 다르며 자료를 import하지 않는다

위 그림은 count가 0인 같은 이벤트 처리 함수에서 두 방식이 큐에서 어떻게 다르게 처리되는지 비교합니다. 위쪽의 BuggyCounter는 같은 스냅샷 0을 읽어 두 번 모두 1을 계산하므로, 큐가 1로 교체를 두 번 해 다음 렌더의 count는 1입니다. 아래쪽의 FixedCounter는 두 업데이터가 앞의 결과를 이어받아 0, 1, 2로 계산하므로 다음 렌더의 count는 2입니다. 그림의 설명처럼 현재 처리 함수의 스냅샷은 setter를 호출한 뒤에도 바뀌지 않고, 서로 다른 클릭은 각각 따로 처리됩니다.

업데이터는 렌더하는 동안 실행될 수 있으므로 반드시 순수해야 합니다. 개발 모드의 <StrictMode>는 실수로 넣은 순수하지 않은 로직을 찾으려고 업데이터를 두 번 호출하고 한 결과를 버릴 수 있는데, 그림의 마지막 설명처럼 이 추가 호출이 결과를 두 번 누적하라는 뜻은 아닙니다. 그러니 업데이터 안에서 기존 상태를 바꾸거나 부수 효과를 실행해서는 안 됩니다. 같은 렌더에서 여러 갱신을 누적하거나, 다음 state가 대기 중인 이전 state에 따라 정해질 때 함수형 업데이트를 쓰세요. 자세한 큐 처리 규칙과 Strict Mode의 동작은 React 문서의 useState와 state 갱신 큐에서 확인할 수 있습니다.

값 교체와 업데이터를 섞어 따라가기

앞의 두 카운터와는 별도의 실험으로, 첫 Counter의 초기값을 3으로 바꾸고 증가 함수만 아래처럼 바꿔 봅니다. 세 호출은 한 번의 클릭 처리 함수 안에서 실행됩니다.

const increment = () => {
  setCount(c => c + 2);
  setCount(count + 1);
  setCount(c => c * 3);
};
순서큐에 들어간 요청대기 중인 값
시작이번 렌더의 count는 33
첫 호출받은 값에 2를 더함5
둘째 호출이번 렌더에서 계산한 4로 교체4
셋째 호출앞 결과에 3을 곱함12

화면은 다음 렌더에서 “카운트: 12”가 됩니다. 둘째 호출은 앞 업데이터의 결과에 1을 더하는 것이 아니라, 처리 함수 안의 count가 여전히 3이므로 4로 교체하라는 요청입니다. 화면이 12로 바뀐 뒤 다시 클릭하면 새 처리 함수는 12를 읽으므로, 대기 값은 14, 13, 39가 됩니다.


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

React는 다음 state와 현재 state를 Object.is로 비교합니다. 그래서 기존 객체나 배열을 직접 바꾼 뒤 같은 참조를 setter에 넘기면, React가 두 값이 같다고 보고 렌더링을 건너뛸 수 있습니다. 따라서 기존 객체나 배열은 읽기 전용으로 다루고, 바꿀 때는 새 객체나 배열로 교체해야 합니다. 이를 불변성(immutability) 유지라고 합니다. 사용자 정보를 고치는 객체 예제를 봅니다.

src/components/UserForm.jsx
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가 가리키는 참조가 달라져 Object.is의 비교 결과도 달라지고, React가 다음 렌더를 진행합니다. 처음에는 “현재 사용자: 홍길동, 30세, 서울”이 보이며, 나이 입력은 Number()로 숫자로 바꿔 저장합니다.

배열의 불변성은 3장의 할 일 목록 실습에서 이미 썼습니다. 다시 한번 복습해 봅니다.

src/components/TodoList.jsx
// ...
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)처럼 모든 요소를 순회하며 조건에 맞는 요소만 새 객체로 바꿉니다.

정리하면 객체에는 스프레드 문법({ ...object, changed })을, 배열에는 새 배열을 돌려주는 map(), filter(), [...items] 같은 비변이 패턴을 씁니다. 스프레드는 얕은 복사이므로, 중첩된 값을 바꿀 때는 바뀌는 지점부터 최상위까지 각 객체를 새로 만들어야 합니다. Object.assign()이나 reduce()는 쓰는 방식에 따라 기존 값을 바꾸거나 같은 참조를 돌려줄 수 있으므로, 이름만 보고 불변성이 지켜진다고 생각해서는 안 됩니다. 자세한 예는 React 문서의 객체 state 갱신과 배열 state 갱신을 참고하세요.

더 깊이: Object.is 비교가 건너뛰는 범위

React가 쓰는 Object.is는 ===와 거의 같지만 두 경우에 결과가 다릅니다. NaN === NaN은 false이지만 Object.is(NaN, NaN)은 true이고, 0 === -0은 true이지만 Object.is(0, -0)은 false입니다. 그래서 계산 결과가 NaN이 된 state에 다시 NaN을 넘기면 React는 같은 값으로 보고 다시 렌더링하지 않습니다. 객체와 배열은 내용이 아니라 참조만 비교하므로, 내용이 같아도 새로 만든 객체를 넘기면 다시 렌더링합니다.

같은 값이라 갱신을 건너뛴다는 말에도 단서가 있습니다. React 문서의 useState는 같은 값이면 그 컴포넌트와 자식의 다시 렌더링을 건너뛰지만, 경우에 따라 자식을 건너뛰기 전에 그 컴포넌트를 한 번 더 호출할 수 있다고 설명합니다. 그러니 같은 값을 넘긴 뒤 컴포넌트 함수 안의 로그가 한 번 더 찍혔다고 해서 비교가 실패했다고 판단하지 않습니다. React 문서는 이 호출이 코드에 영향을 주지 않아야 한다고 덧붙이며, 렌더가 순수하다면 실제로 결과가 달라지지 않습니다.


지연 초기화 (Lazy Initialization)

React는 컴포넌트를 초기화할 때 만든 초기 state를 저장하고, 같은 마운트의 이후 렌더에서는 새로 넘긴 초기값을 무시합니다. 하지만 useState(expensiveCalculation())처럼 함수 호출의 결과를 바로 넘기면, React가 그 결과를 무시하더라도 자바스크립트 표현식 자체는 렌더할 때마다 먼저 실행됩니다.

이를 막으려면 useState의 초기값으로 함수를 넘깁니다. React는 컴포넌트를 초기화할 때 이 함수를 호출해 그 반환값을 초기 state로 저장하고, 같은 마운트의 이후 렌더에서는 다시 호출하지 않습니다. 다만 컴포넌트가 언마운트된 뒤 다시 마운트되면 새로 초기화합니다. 이를 지연 초기화(lazy initialization)라고 합니다. 초기값을 계산하는 데 비용이 많이 드는 경우를 흉내 낸 예제를 봅니다.

src/components/LazyInitCounter.jsx
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());

  // ⭕ 지연 초기화: 순수한 initializer를 전달하여 초기화할 때만 호출
  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.jsx에 LazyInitCounter를 넣고 버튼을 눌러 여러 번 다시 렌더링해 보세요. 화면의 카운트는 0부터 99,999,999까지의 합인 4999999950000000에서 시작하고, 버튼을 눌러 다시 렌더링해도 “초기값 계산 중...” 로그가 더 찍히지 않습니다. 초기화 함수(initializer)도 순수해야 합니다. 개발 모드의 <StrictMode>는 순수성을 검사하려고 초기화 과정에서 초기화 함수를 두 번 호출하고 한 결과를 버릴 수 있으므로, Console에 처음의 로그가 두 번 보일 수 있습니다. 이는 프로덕션 동작에는 영향을 주지 않으며, 컴포넌트가 다시 마운트되면 초기화 함수는 다시 실행됩니다.

초기값을 만드는 비용이 크다면 useState(expensiveCalculation)이나 useState(() => expensiveCalculation())처럼 순수한 초기화 함수 자체를 넘겨, 이후 렌더의 반복 계산을 피하세요. 정확한 호출 시점과 Strict Mode의 주의사항은 React 문서의 초기 state 재생성 방지에서 확인할 수 있습니다.


증상으로 패턴 고르기

증상과 원인을 구별한 뒤 알맞은 useState 패턴을 고릅니다

USESTATE: DIAGNOSIS

증상과 원인을 구별한 뒤 알맞은 useState 패턴을 고릅니다

같은 이벤트의 큐, 객체·배열 참조 교체, 비싼 초기값 생성이라는 세 문제를 비교합니다. 직접 전달 값을 모두 updater로 바꾸기 전에 어떤 값과 참조가 유지되는지 재현합니다.

진단의 시작 · 재현할 조건

한 핸들러의 두 호출인가? 기존 객체를 변이했는가? 렌더에서 초기 함수를 먼저 호출했는가? 증상을 재현한 조건에서 수정 전후를 확인합니다.

각 행은 증상 → 원인 → 선택 패턴 → 확인 순서입니다.

증상 → 원인 → 선택 패턴 → 확인 순서로 읽는 useState 진단표
증상 원인 선택 패턴 확인
한 핸들러에서 두 번 증가시켰는데 1만 증가한다 두 setCount(count + 1) 호출이 같은 렌더 snapshot으로 동일한 교체 값을 계산한다. setCount(c => c + 1)처럼 대기 중인 state를 받는 순수 updater를 큐에 넣는다. 큐가 0 → 1 → 2로 순서대로 계산되는지 확인한다.
객체나 배열을 바꿨는데 화면이 갱신되지 않는다 기존 state를 직접 변이하고 같은 참조를 전달해 Object.is 비교가 같게 나온다. 스프레드, map(), filter()로 변경 지점부터 새 객체·배열을 만들어 교체한다. 이전 state는 그대로이고 변경된 경로의 참조만 새로 생겼는지 확인한다.
버튼으로 리렌더할 때마다 비싼 초기 계산이 실행된다 useState(compute())의 인자 표현식이 렌더마다 먼저 평가된다. useState(compute)처럼 순수한 lazy initializer 함수를 전달한다. 같은 마운트의 리렌더에서는 반복하지 않는지 확인한다. remount 때는 다시 실행되고 개발 Strict Mode에서는 초기화 중 두 번 호출될 수 있다.
한 핸들러에서 두 번 증가시켰는데 1만 증가한다
원인 두 호출이 같은 렌더 snapshot에서 동일한 교체 값을 계산합니다.
선택 setCount(c => c + 1)처럼 순수한 updater를 사용합니다.
확인 큐가 0 → 1 → 2로 계산되는지 봅니다.
객체나 배열을 바꿨는데 화면이 갱신되지 않는다
원인 기존 state를 변이해 Object.is 비교에서 같은 참조로 남았습니다.
선택 스프레드, map(), filter()로 새 참조를 만듭니다.
확인 이전 state는 그대로이고 변경 경로만 새 참조인지 봅니다.
리렌더할 때마다 비싼 초기 계산이 실행된다
원인 initializer의 결과를 직접 전달해 호출 표현식이 렌더마다 평가됩니다.
선택 순수한 lazy initializer 함수 자체를 전달합니다.
확인 같은 마운트의 리렌더에서는 반복하지 않는지 봅니다. remount와 개발 Strict Mode의 추가 호출은 구분합니다.

위 진단표는 useState에서 자주 보이는 세 가지 증상을 원인과 이어 주고, 어떤 패턴을 골라 무엇을 확인할지 정리합니다. 각 행은 증상, 원인, 선택할 패턴, 확인할 내용의 순서로 읽습니다. 한 처리 함수에서 두 번 늘렸는데 1만 늘어난다면, 두 setCount(count + 1)이 같은 렌더의 스냅샷으로 같은 교체 값을 계산한 것이므로, setCount(c => c + 1)처럼 순수한 업데이터를 넣고 큐가 0, 1, 2로 차례로 계산되는지 확인합니다.

객체나 배열을 바꿨는데 화면이 갱신되지 않는다면, 기존 state를 직접 바꾸고 같은 참조를 넘겨 Object.is 비교가 같게 나온 것입니다. 이때는 스프레드나 map(), filter()로 바뀌는 지점부터 새 객체와 배열을 만들고, 이전 state는 그대로이면서 바뀐 경로의 참조만 새로 생겼는지 확인합니다. 다시 렌더할 때마다 비싼 초기 계산이 실행된다면 useState(compute())의 인자가 렌더마다 먼저 평가된 것이므로, useState(compute)처럼 초기화 함수를 넘기고 같은 마운트의 다시 렌더링에서 반복되지 않는지 봅니다. 다시 마운트될 때 실행되는 것과 개발 모드의 Strict Mode가 초기화 중에 두 번 호출하는 것은 구별합니다.

그림의 첫 칸이 말하듯, 직접 넘기는 값을 모두 업데이터로 바꾸기 전에 먼저 어떤 조건에서 증상이 재현되는지 확인하세요. 한 처리 함수 안의 두 호출인지, 기존 객체를 바꿨는지, 렌더에서 초기 함수를 먼저 호출했는지를 확인하고, 그 조건에서 고치기 전과 후를 비교합니다.


정리

  • state는 렌더마다 고정된 스냅샷이며, setter는 현재 처리 함수의 변수를 바꾸지 않고 다음 렌더를 위한 갱신을 큐에 넣습니다.
  • 같은 렌더에서 여러 갱신을 누적하거나 이전 state에 따라 계산할 때는 setCount(c => c + 1)처럼 함수형 업데이트를 씁니다.
  • 업데이터와 초기화 함수는 순수해야 하며, 개발 모드의 Strict Mode는 순수성을 확인하려고 두 번 호출할 수 있습니다.
  • 객체와 배열 state는 직접 고치지 않고 스프레드, map(), filter()로 새 참조를 만들어 교체하며, 중첩된 값은 바뀌는 경로를 따라 모두 새로 만듭니다.
  • 초기값을 만드는 비용이 크면 useState(compute)처럼 초기화 함수를 넘겨, 같은 마운트의 이후 렌더에서 다시 계산하지 않게 합니다.

연습 문제

1. 초기값이 8일 때

값 교체와 업데이터를 섞은 위의 처리 함수에서 초기값만 8로 바꾸고 한 번 실행합니다. 각 요청 뒤의 대기 값 세 개와 최종 화면은 무엇일까요? 실행 중인 처리 함수에서 count를 읽으면 어떤 값일까요?

해설 보기

대기 값은 10, 9, 27이고 최종 화면은 “카운트: 27”입니다. 실행 중인 처리 함수의 count는 8입니다.

업데이터의 입력은 앞 요청의 결과이지만, 둘째 요청의 count는 이번 렌더의 값 8이므로 9로 교체하라는 요청이 됩니다.

2. 직접 바꾼 객체

UserForm의 handleNameChange를 주석 처리된 잘못된 방식, 곧 user.name = e.target.value; setUser(user);로 바꾸면 이름 입력칸에 글자를 입력할 때 화면은 어떻게 될까요?

해설 보기

user 객체를 직접 바꾼 뒤 같은 참조를 넘기므로, React가 Object.is로 비교해 이전 state와 같다고 보고 다시 렌더링을 건너뛸 수 있습니다. 그러면 입력칸과 “현재 사용자:” 문장이 새 글자를 보여 주지 않습니다.

나중에 나이처럼 다른 값이 바뀌어 다시 렌더링되면, 그때 이미 바뀌어 있던 name이 갑자기 나타날 수도 있습니다. 이렇게 언제 화면에 반영될지 예측할 수 없게 되므로, 언제나 새 객체를 만들어 넘겨야 합니다.

다음 글 useEffect 심화에서는 useEffect를 더 깊이 다뤄, 의존성 배열의 활용과 정리 함수, 데이터를 가져오는 패턴을 살펴봅니다.