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)
useState의 setter 함수(예: setCount)는 새로운 state 값을 직접 인자로 받을 수도 있지만, 이전 state를 인자로 받는 함수(콜백 함수)를 인자로 받을 수도 있습니다.
이 방식을 함수형 업데이트(Functional Update)라고 합니다.
setCount(prevCount => prevCount + 1);여기서 prevCount는 리액트가 업데이트 큐를 처리할 때 이 updater에 전달하는 대기 중인 state 값입니다.
각 렌더의 state는 해당 렌더 안에서 바뀌지 않는 snapshot입니다. setter를 호출해도 지금 실행 중인 코드의 state 변수가 즉시 바뀌는 것이 아니라, 다음 렌더를 위한 업데이트가 큐에 추가됩니다.
리액트는 하나의 이벤트 핸들러가 끝날 때까지 발생한 업데이트를 모아서 처리합니다. 따라서 같은 핸들러 안에서 setCount(count + 1)을 두 번 호출하면 두 표현식 모두 같은 렌더의 count를 읽고, 큐에는 같은 값으로 교체하라는 요청이 두 번 들어갑니다. 서로 다른 클릭 이벤트가 하나의 오래된 값을 공유하는 문제가 아닙니다.
App.js 파일을 수정하여 아래 BuggyCounter와 FixedCounter를 비교해 보세요.
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;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.js에 두 컴포넌트를 추가하고 비교해 보세요.
BuggyCounter는 한 번의 핸들러에서 동일한 값으로 두 번 교체하므로 1만 증가하지만, FixedCounter는 두 updater를 순서대로 적용하므로 2씩 증가합니다.
핵심: setCount(prevCount => prevCount + 1)는 순수한 updater를 큐에 추가합니다. 리액트는 다음 렌더에서 updater를 순서대로 적용하고, 앞 updater의 반환값을 다음 updater의 입력으로 전달해 최종 state를 계산합니다.
아래 다이어그램은 같은 이벤트 핸들러에서 값을 직접 전달하는 두 호출과 updater 두 호출이 큐에서 어떻게 다르게 처리되는지 비교합니다.
같은 이벤트에서 값 교체와 updater 큐 비교 — 상태 추적
두 직접 값 전달은 1, 두 updater는 0→1→2다. 세 요청인 앞 장 그림과 개념은 유사하지만 본문 수치가 다른 독립 사례다.
SAME EVENT: 1 VS 2
같은 이벤트의 updater 두 개는 앞 결과를 이어받아 2를 만듭니다
count가 0인 한 핸들러를 비교합니다. 두 setCount(count + 1)은 같은 1로 교체하고, FixedCounter의 두 updater는 대기 중인 값을 0→1→2로 계산합니다.
직접 값 전달 · BuggyCounter
count = 0
setCount(count + 1); // 1로 교체
setCount(count + 1); // 1로 교체
다음 렌더: 1Updater 큐 · FixedCounter
setCount(c => c + 1); // 0 → 1
setCount(c => c + 1); // 1 → 2
다음 렌더: 2현재 핸들러의 snapshot은 setter 호출 뒤에도 바뀌지 않습니다. 별도 클릭은 각각 처리하며 updater는 순수해야 합니다. 개발 Strict Mode의 추가 호출은 결과를 두 번 누적하라는 뜻이 아닙니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| count0 snapshot | count: 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다. 세 요청인 앞 장 그림과 개념은 유사하지만 본문 수치가 다른 독립 사례다.
SAME EVENT: 1 VS 2
같은 이벤트의 updater 두 개는 앞 결과를 이어받아 2를 만듭니다
count가 0인 한 핸들러를 비교합니다. 두 setCount(count + 1)은 같은 1로 교체하고, FixedCounter의 두 updater는 대기 중인 값을 0→1→2로 계산합니다.
직접 값 전달 · BuggyCounter
count = 0
setCount(count + 1); // 1로 교체
setCount(count + 1); // 1로 교체
다음 렌더: 1Updater 큐 · FixedCounter
setCount(c => c + 1); // 0 → 1
setCount(c => c + 1); // 1 → 2
다음 렌더: 2현재 핸들러의 snapshot은 setter 호출 뒤에도 바뀌지 않습니다. 별도 클릭은 각각 처리하며 updater는 순수해야 합니다. 개발 Strict Mode의 추가 호출은 결과를 두 번 누적하라는 뜻이 아닙니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| count0 snapshot | count: 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는 렌더 중에 실행될 수 있으므로 반드시 순수해야 합니다. 개발 환경의 <StrictMode>에서는 우발적인 비순수 로직을 찾기 위해 updater를 두 번 호출하고 한 결과를 버릴 수 있습니다. updater 안에서 기존 상태를 변이하거나 부수 효과를 실행하면 안 됩니다.
state가 대기 중인 이전 state에 의존할 때는 함수형 업데이트를 사용하세요.
자세한 큐 처리 규칙과 Strict Mode 동작은 React 공식 useState 문서와 state 업데이트 큐 안내에서 확인할 수 있습니다.
값 교체와 updater를 섞어 따라가기
앞의 두 카운터를 비교한 다음, 첫 Counter의 초기값을 3으로 바꾸고 증가 함수만 아래 변형으로 바꿔 봅니다. 원래 예제와 별도의 실험이며, 세 호출은 한 번의 클릭 핸들러 안에서 실행합니다.
const increment = () => {
setCount(c => c + 2);
setCount(count + 1);
setCount(c => c * 3);
};| 순서 | 큐에 들어간 요청 | 대기 중인 값 |
|---|---|---|
| 시작 | 이번 렌더의 count는 3 | 3 |
| 첫 호출 | 전달받은 값에 2를 더함 | 5 |
| 둘째 호출 | 이번 렌더에서 계산한 4로 교체 | 4 |
| 셋째 호출 | 앞 결과에 3을 곱함 | 12 |
화면은 다음 렌더에서 12가 됩니다. 둘째 호출은 앞 updater의 결과에 1을 더하지 않습니다. 핸들러 안의 count는 여전히 3이므로 4로 교체하는 요청입니다. 화면이 12로 바뀐 뒤 새로 클릭하면 새 핸들러는 12를 읽고, 대기값은 14→13→39가 됩니다.
직접 예측하기
초기값을 8로 바꾸고 같은 핸들러를 한 번 실행할 때, 각 요청 뒤의 대기값 세 개와 최종 화면을 적어 보세요. 실행 중인 핸들러에서 count를 읽는다면 어떤 값인지도 설명합니다.
대기값과 snapshot 대조
대기값은 10→9→27이고 최종 화면은 27입니다. 실행 중인 핸들러의 count는 8입니다. updater의 입력은 앞 요청의 결과지만, 둘째 요청의 count는 이번 렌더의 값이기 때문입니다.
객체 및 배열 state 관리 시 불변성 유지
리액트는 다음 state와 현재 state를 Object.is로 비교합니다. 기존 객체나 배열을 직접 바꾼 뒤 같은 참조를 setter에 전달하면 두 값이 같다고 판단해 렌더링을 건너뛸 수 있습니다.
따라서 기존 객체나 배열은 읽기 전용으로 다루고, 변경할 때는 새로운 객체나 배열로 교체해야 합니다.
이를 불변성(Immutability) 유지라고 합니다.
예제: 사용자 정보 업데이트 (객체 불변성)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속성만 새로운 값으로 덮어씁니다.- 이렇게 하면
userstate가 가리키는 참조가 달라져Object.is비교 결과도 달라지고, 리액트가 다음 렌더를 진행할 수 있습니다.
이전 Todo List 실습에서 이미 배열 불변성을 사용했습니다.
다시 한번 복습해 봅시다.
// ...
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 업데이트를 참고하세요.
지연 초기화 (Lazy Initialization)
리액트는 컴포넌트를 초기화할 때 만든 초기 state를 저장하고, 이후 같은 마운트의 렌더에서는 새로 전달된 초기값을 무시합니다.
하지만 useState(expensiveCalculation())처럼 함수 호출 결과를 직접 전달하면, 리액트가 그 결과를 무시하더라도 자바스크립트 표현식 자체는 컴포넌트가 렌더될 때마다 먼저 실행됩니다.
이를 방지하기 위해 useState의 초기값으로 함수를 전달할 수 있습니다.
리액트는 컴포넌트를 초기화할 때 이 함수를 호출하고 반환값을 초기 state로 저장합니다. 같은 마운트의 이후 렌더에서는 다시 호출하지 않지만, 컴포넌트가 언마운트된 뒤 다시 마운트되면 새로 초기화합니다.
이를 지연 초기화(Lazy Initialization)라고 합니다.
예제: 비용이 많이 드는 초기값 계산 시뮬레이션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.js에 LazyInitCounter를 추가하고, 버튼을 클릭하여 컴포넌트를 여러 번 리렌더링해 보세요.
버튼을 눌러 같은 컴포넌트를 리렌더해도 초기값 계산 중... 로그가 추가되지 않는 것을 확인할 수 있습니다.
initializer도 순수해야 합니다. 개발 환경의 <StrictMode>에서는 순수성을 검사하기 위해 초기화 과정에서 initializer를 두 번 호출하고 한 결과를 버릴 수 있으므로, 콘솔에 초기 로그가 두 번 보일 수 있습니다. 이는 프로덕션 동작에 영향을 주지 않으며, 컴포넌트를 다시 마운트하면 initializer는 다시 실행됩니다.
핵심: 초기값 생성 비용이 높으면 useState(expensiveCalculation) 또는 useState(() => expensiveCalculation())처럼 순수한 initializer 함수 자체를 전달하여 이후 렌더의 반복 계산을 피하세요.
정확한 호출 시점과 Strict Mode 주의사항은 React 공식 useState 문서의 초기 state 재생성 방지 안내를 참고하세요.
아래 진단표는 useState에서 자주 보이는 증상을 원인과 연결하고, 함수형 updater, 비변이 객체·배열 교체, 지연 initializer 중 어떤 패턴을 선택해 무엇을 확인할지 정리합니다.
USESTATE: DIAGNOSIS
증상과 원인을 구별한 뒤 알맞은 useState 패턴을 고릅니다
같은 이벤트의 큐, 객체·배열 참조 교체, 비싼 초기값 생성이라는 세 문제를 비교합니다. 직접 전달 값을 모두 updater로 바꾸기 전에 어떤 값과 참조가 유지되는지 재현합니다.
진단의 시작 · 재현할 조건
한 핸들러의 두 호출인가? 기존 객체를 변이했는가? 렌더에서 초기 함수를 먼저 호출했는가? 증상을 재현한 조건에서 수정 전후를 확인합니다.
각 행은 증상 → 원인 → 선택 패턴 → 확인 순서입니다.
| 증상 | 원인 | 선택 패턴 | 확인 |
|---|---|---|---|
| 한 핸들러에서 두 번 증가시켰는데 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 훅의 함수형 업데이트를 통한 안전한 state 관리, 객체 및 배열 state 관리 시 불변성 유지의 중요성, 그리고 지연 초기화를 통한 성능 최적화 기법까지 다루었습니다.
이 개념들을 잘 이해하고 적용한다면 더욱 견고하고 효율적인 리액트 컴포넌트를 만들 수 있을 것입니다.
다음 절에서는 useEffect 훅에 대해 더욱 깊이 파고들어, deps 배열의 활용, 클린업 함수, 그리고 데이터 페칭 패턴 등을 심층적으로 다룰 것입니다.