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
는 setCount
가 호출될 시점의 최신 state
값을 의미합니다.
왜 함수형 업데이트가 필요한가?
리액트의 state
업데이트는 비동기적으로 처리될 수 있습니다. 여러 번의 setCount
호출이 짧은 시간 내에 발생하면, 각 setCount
가 state
를 읽어오는 시점이 다를 수 있어 동기화 문제가 발생할 수 있습니다.
예제: 동시 다발적인 카운트 증가 문제 해결
App.js
파일을 수정하여 아래 BuggyCounter
와 FixedCounter
를 비교해 보세요.
// 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
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
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 (일부 발췌)
// ...
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
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.js
에 LazyInitCounter
를 추가하고, 버튼을 클릭하여 컴포넌트를 여러 번 리렌더링해 보세요. 콘솔에 초기값 계산 중...
메시지가 맨 처음 한 번만 뜨는 것을 확인할 수 있을 것입니다.
핵심: useState
의 초기값이 복잡하거나 계산 비용이 높을 경우, useState(() => expensiveCalculation())
와 같이 함수를 전달하여 지연 초기화를 구현하면 불필요한 성능 저하를 방지할 수 있습니다.
이 장에서는 useState
훅의 함수형 업데이트를 통한 안전한 state
관리, 객체 및 배열 state
관리 시 불변성 유지의 중요성, 그리고 지연 초기화를 통한 성능 최적화 기법까지 다루었습니다. 이 개념들을 잘 이해하고 적용한다면 더욱 견고하고 효율적인 리액트 컴포넌트를 만들 수 있을 것입니다.
다음 장에서는 useEffect
훅에 대해 더욱 깊이 파고들어, deps
배열의 활용, 클린업 함수, 그리고 데이터 페칭 패턴 등을 심층적으로 다룰 것입니다.