
React 애플리케이션을 개발할 때, 컴포넌트의 State 관리는 사용자 경험과 유지보수성, 성능에 직결되는 매우 중요한 부분이다. 그러나 React의 State 업데이트 방식과 훅(Hooks)의 특성을 충분히 이해하지 못한 채 코드를 작성하다 보면, 다음과 같은 문제들이 쉽게 발생한다.
1. 불변성(Immutable)을 깨트리는 직접 변이
2. set 함수 호출 직후 값이 갱신되었다고 오해
3. 렌더링 중 무분별한 State 설정으로 인한 무한 루프
4. 초기 State 계산 로직의 불필요한 재실행
5. State에 함수 참조를 저장할 때의 오동작
이 글에서는 위 다섯 가지 안티패턴을 짚어보고, 각각 왜 문제가 되는지, 그리고 어떻게 올바르게 작성해야 하는지를 짧은 예시 코드와 함께 살펴본다. React의 동작 원리를 이해하고, 깔끔하면서도 안전한 상태 관리 패턴을 익혀 보자.
State를 직접 변이하는 것 (Mutating State)React에서 State는 불변(immutable)하다고 간주되므로, 객체나 배열 같은 참조 타입의 State를 업데이트할 때 기존 객체나 배열을 직접 수정(push, splice, 객체의 속성 직접 변경 등)해서는 안 된다.
React는 State 값이 변경되었는지 확인하기 위해 이전 State와 새 State를 Object.is 비교 등을 통해 체크한다. 객체나 배열을 직접 변이한 후 set 함수에 전달하면, 참조 값이 동일하기 때문에 React는 State가 변경되지 않았다고 판단하여 리렌더링을 건너뛰게 된다.기존 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>
);
}
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;
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>;
}
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 };
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)