[React 공식문서 정독] useState의 안티패턴

김진서·2025년 5월 7일

우아한테크코스 7기

목록 보기
38/56
post-thumbnail

React 애플리케이션을 개발할 때, 컴포넌트의 State 관리는 사용자 경험과 유지보수성, 성능에 직결되는 매우 중요한 부분이다. 그러나 ReactState 업데이트 방식과 훅(Hooks)의 특성을 충분히 이해하지 못한 채 코드를 작성하다 보면, 다음과 같은 문제들이 쉽게 발생한다.

1. 불변성(Immutable)을 깨트리는 직접 변이
2. set 함수 호출 직후 값이 갱신되었다고 오해
3. 렌더링 중 무분별한 State 설정으로 인한 무한 루프
4. 초기 State 계산 로직의 불필요한 재실행
5. State에 함수 참조를 저장할 때의 오동작

이 글에서는 위 다섯 가지 안티패턴을 짚어보고, 각각 왜 문제가 되는지, 그리고 어떻게 올바르게 작성해야 하는지를 짧은 예시 코드와 함께 살펴본다. React의 동작 원리를 이해하고, 깔끔하면서도 안전한 상태 관리 패턴을 익혀 보자.

1. State를 직접 변이하는 것 (Mutating State)

React에서 State불변(immutable)하다고 간주되므로, 객체나 배열 같은 참조 타입의 State를 업데이트할 때 기존 객체나 배열을 직접 수정(push, splice, 객체의 속성 직접 변경 등)해서는 안 된다.

  • 왜 안티패턴인가?: ReactState 값이 변경되었는지 확인하기 위해 이전 State새 StateObject.is 비교 등을 통해 체크한다. 객체나 배열을 직접 변이한 후 set 함수에 전달하면, 참조 값이 동일하기 때문에 ReactState가 변경되지 않았다고 판단하여 리렌더링을 건너뛰게 된다.
  • 올바른 방법: 대신 기존 State를 기반으로 새로운 객체나 배열을 생성하여 set 함수에 전달해야 한다. 스프레드 문법 (...)이나 배열 메서드(map, filter 등)를 활용하여 새 객체/배열을 만드는 것이 일반적이다.
// 객체 State 업데이트 예시
import React, { useState } from 'react';

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

  // ❌ 안티패턴: 직접 변이
  const badUpdateAge = newAge => {
    user.age = newAge;
    setUser(user); // 참조 동일 → 리렌더링 안 됨
  };

  // ✅ 올바른 패턴: 새 객체 생성
  const goodUpdateAge = newAge => {
    setUser(prev => ({ ...prev, age: newAge }));
  };

  return (
    <div>
      <p>{user.name}, {user.age} years old</p>
      <button onClick={() => badUpdateAge(31)}>Bad Update</button>
      <button onClick={() => goodUpdateAge(31)}>Good Update</button>
    </div>
  );
}

2. set 함수 호출 후 동일한 코드 실행 내에서 State 값이 즉시 변경되었다고 가정하는 것

set 함수를 호출하는 것은 현재 실행 중인 코드에서 State 변수의 값을 즉시 변경시키지 않는다. set 함수React에게 다음 렌더링 시 해당 State 값을 새 값으로 사용해 달라고 요청하는 역할을 한다. State는 마치 렌더링 시점의 스냅샷과 같다.

  • 왜 안티패턴인가?: setCount(count + 1); console.log(count);와 같이 코드를 작성하면, console.log 시점에서는 아직 count 변수의 값이 업데이트되기 전의 값(setCount 호출 전 값)을 가지게 된다. 이는 특히 동일한 이벤트 핸들러 내에서 이전 State 값에 기반하여 여러 번 State를 업데이트하려 할 때 예상치 못한 결과를 초래한다.
  • 올바른 방법: 다음 State 값이 필요한 경우, set 함수에 전달할 때 사용할 값을 별도의 변수에 저장하거나, 또는 업데이터 함수 형태(setCount(prevCount => prevCount + 1))를 사용하여 React최신 State 값을 기반으로 다음 State를 계산하도록 해야 한다.
import React, { useState } from 'react';

function Counter() {
  // ❌ 안티패턴: setCount 후 즉시 count를 읽음
  const [count, setCount] = useState(0);

  const badHandler = () => {
    setCount(count + 1);
    console.log('Bad:', count);  
    // 출력: Bad: 0  (여전히 이전 값)
  };

  // ✅ 올바른 패턴 A: updater 함수 사용
  const goodHandlerA = () => {
    setCount(prev => prev + 1);
    console.log('Good A: scheduled increment using prev callback');
  };

  // ✅ 올바른 패턴 B: 값 미리 계산 후 사용
  const goodHandlerB = () => {
    const next = count + 1;
    setCount(next);
    console.log('Good B:', next);  
    // 출력: Good B: 1  (미리 계산한 값)
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={badHandler}>Increment (Bad)</button>
      <button onClick={goodHandlerA}>Increment (Good A)</button>
      <button onClick={goodHandlerB}>Increment (Good B)</button>
    </div>
  );
}

export default Counter;

3. 컴포넌트 렌더링 중에 조건 없이 State를 설정하는 것

렌더링 로직의 최상위나 조건문 없이 set 함수를 호출하면, State 변경이 다시 렌더링을 유발하고, 그 렌더링 과정에서 다시 set 함수가 호출되는 무한 루프에 빠지게 된다. 이는 "Too many re-renders" 오류의 가장 흔한 원인이다.

  • 왜 안티패턴인가?: 렌더링 -> State 변경 요청 -> 리렌더링 -> State 변경 요청 -> ... 과정이 반복된다. 종종 이벤트 핸들러를 전달해야 할 곳에 함수 호출 결과를 전달하는 실수(onClick={handleClick()} 대신 onClick={handleClick} 또는 onClick={() => handleClick()})로 인해 발생하기도 한다.
  • 올바른 방법: State는 주로 이벤트 핸들러에서 사용자의 상호작용에 응답하여 업데이트해야 한다. 드물게 렌더링 중 State 업데이트가 필요한 경우도 있지만, 이는 반드시 조건문 안에서 이루어져야 하며, 현재 렌더링 중인 컴포넌트의 State만 가능하다.
import React, { useState, useEffect } from 'react';

// ❌ 안티패턴: 렌더링 중에 조건 없이 setState 호출
function InfiniteLoop() {
  const [count, setCount] = useState(0);

  // 컴포넌트가 렌더될 때마다 실행 → 무한 루프
  setCount(count + 1);

  return <div>Count: {count}</div>;
}


// ✅ 올바른 패턴 1: 이벤트 핸들러에서만 State 업데이트
function CounterButton() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(prev => prev + 1);
  };

  return (
    <button onClick={handleClick}>
      Count: {count} (Click me)
    </button>
  );
}


// ✅ 올바른 패턴 2: 렌더 중 State 업데이트가 꼭 필요할 땐 useEffect + 조건문 사용
function AutoIncrementOnce() {
  const [initialized, setInitialized] = useState(false);
  const [value, setValue] = useState(0);

  useEffect(() => {
    if (!initialized) {
      setValue(100);        // 최초 렌더 후 한 번만 실행
      setInitialized(true);
    }
  }, [initialized]);

  return <div>Value: {value}</div>;
}

4. 초기 State 계산에 비용이 많이 드는 함수를 매 렌더링마다 호출하도록 사용하는 것

useState의 초기값으로 비용이 많이 드는 함수(createInitialTodos())의 호출 결과를 직접 전달하면, 이 함수는 컴포넌트가 리렌더링될 때마다 불필요하게 다시 호출된다. 초기 State는 첫 렌더링 시에만 사용됨에도 불구하고 말이다.

  • 왜 안티패턴인가?: 불필요한 함수 호출은 특히 큰 데이터를 생성하거나 복잡한 계산을 할 때 성능 저하를 유발할 수 있다.
  • 올바른 방법: 비용이 많이 드는 초기 State 계산은 초기화 함수 형태로 useState에 전달해야 한다(useState(createInitialTodos)). useState는 초기화 함수를 전달받으면 첫 렌더링 시에만 이 함수를 호출하고 그 반환 값을 초기 State로 사용한다.
import React, { useState } from 'react';

// 비용이 많이 드는 초기 State 계산 함수 예시
function createInitialTodos() {
  console.log('Initializing todos…');
  // 예: 큰 배열 생성, 복잡한 계산 등
  return Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    text: `Task #${i}`,
    done: false,
  }));
}

// ❌ 안티패턴: 매 렌더링마다 createInitialTodos() 호출
function TodoListBad() {
  // 렌더될 때마다 함수가 실행!
  const [todos, setTodos] = useState(createInitialTodos());
  return <div>Todos: {todos.length}</div>;
}

// ✅ 올바른 패턴: 초기화 함수 형태로 전달
function TodoListGood() {
  // 첫 렌더링 시에만 createInitialTodos()가 호출.
  const [todos, setTodos] = useState(() => createInitialTodos());
  return <div>Todos: {todos.length}</div>;
}

export { TodoListBad, TodoListGood };

5. State 값으로 함수 자체를 저장하려 할 때 () => ... 형태로 래핑하지 않는 것

useState(someFunction) 또는 setFn(someOtherFunction)과 같이 함수 참조 자체를 State 값으로 저장하려고 시도하면, React는 전달된 함수를 초기화 함수 또는 업데이터 함수로 간주하여 실제로 호출해 버린다.

  • 왜 안티패턴인가?: 개발자의 의도는 함수 객체 자체를 State에 저장하는 것인데, React의 특정 동작 방식 때문에 함수가 실행되어 버리고 그 실행 결과가 State에 저장되거나 오류가 발생할 수 있다.
  • 올바른 방법: State 값으로 함수 자체를 저장하려면, 해당 함수를 () => myFn 형태의 화살표 함수로 래핑하여 전달해야 한다. 이렇게 하면 React는 래핑된 화살표 함수를 호출하는 대신, 그 안에 있는 myFn 함수 자체를 State 값으로 저장한다.
import React, { useState } from 'react';

// 예시 함수
function greet() {
  alert('Hello!');
}

// ❌ 안티패턴: useState에 함수 참조를 직접 전달하면 React가 초기화 함수로 실행.
function BadComponent() {
  // React는 greet를 호출해서 반환값(undefined)를 state로 저장
  const [fn, setFn] = useState(greet);

  return (
    <button onClick={() => fn?.()}>
      Call fn (❌ actually no-op, fn is undefined)
    </button>
  );
}

// ✅ 올바른 패턴: 함수 자체를 저장하려면 래핑된 함수 형태로 전달.
function GoodComponent() {
  // React는 초기화 함수(() => greet)를 호출해 greet 함수 자체를 반환값으로 저장
  const [fn, setFn] = useState(() => greet);

  return (
    <button onClick={() => fn()}>
      Call fn (✅ alert 'Hello!')
    </button>
  );
}

// setFn 사용 시에도 동일한 패턴 적용:
// ❌ setFn(someOtherFunction)
// ✅ setFn(() => someOtherFunction)
profile
PAy IT forwaRD를 실천하는 프론트엔드 개발자.

0개의 댓글