React useState를 언제 작별해야 할까?
David Khourshid의 “Goodbye, useState()” 발표 영상을 보고 정리한 글이고, 팀 내 발표 자료로도 썼습니다.
들어가며
최근 David Khourshid의 “Goodbye, useState()” 영상을 보고 큰 감명을 받았습니다. 5년간 React로 개발하면서 막연히 느꼈던 불편함들이 명확하게 정리되는 느낌이었거든요.
“요즘은 AI가 코드를 다 짜주는데, 이런 세부적인 패턴까지 알아야 할까?”라고 생각할 수도 있습니다. 하지만 실제로 ChatGPT나 Copilot과 함께 개발해보면, AI가 완벽한 코드를 만들어주는 것은 아니라는 걸 금방 깨닫게 됩니다.
AI는 문법적으로 올바른 코드는 잘 생성하지만, 어떤 패턴이 장기적으로 유지보수하기 좋은지, 어떤 상황에서 어떤 도구를 써야 하는지에 대한 판단은 여전히 개발자의 몫입니다. 오히려 AI 시대에는 이런 아키텍처 레벨의 의사결정 능력이 더욱 중요해졌다고 생각합니다.
사실 useState는 React를 배울 때 가장 먼저 접하는 Hook이고, 가장 직관적이라서 거의 모든 상황에서 사용해왔습니다. 하지만 프로젝트 규모가 커지고 복잡해질수록, “이게 정말 최선의 방법일까?”라는 의문이 자주 들었습니다.
David의 발표는 useState가 나쁜 도구라는 것이 아니라, 매우 간단한 만큼 복잡한 상황에서 부적절하게 사용되어 유지보수가 어려운 코드를 만들게 된다는 점을 명확히 짚어주었습니다.
이 글에서는 발표 내용과 제가 실무에서 겪었던 경험들을 바탕으로, useState의 5가지 주요 한계점과 더 나은 해결책들을 정리해보겠습니다. AI와 함께 개발할 때도 이런 원칙들을 알고 있다면, 더 나은 코드 방향을 제시하고 리뷰할 수 있을 것입니다.
useState가 만들어내는 5가지 문제 상황
1. 뒤엉킨 상태 로직: 스파게티 코드의 원흉
🚨 문제의 핵심: 상태 변경의 연쇄 반응
가장 심각한 문제는 useState와 useEffect가 결합될 때 발생하는 상태 흐름의 복잡성입니다. 마치 도미노처럼 하나의 상태 변화가 연쇄적으로 다른 상태들을 변경시키는 패턴입니다.
// ❌ 전형적인 스파게티 코드 패턴
function ShoppingCart() {
const [items, setItems] = useState([]);
const [subtotal, setSubtotal] = useState(0);
const [tax, setTax] = useState(0);
const [total, setTotal] = useState(0);
const [discount, setDiscount] = useState(0);
// 1단계: items 변경 → subtotal 계산
useEffect(() => {
const newSubtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
setSubtotal(newSubtotal);
}, [items]);
// 2단계: subtotal 변경 → tax 계산
useEffect(() => {
setTax(subtotal * 0.08);
}, [subtotal]);
// 3단계: subtotal, tax, discount 변경 → total 계산
useEffect(() => {
setTotal(subtotal + tax - discount);
}, [subtotal, tax, discount]);
// 🔥 이 시점에서 상태 변화의 흐름을 추적하는 것은 거의 불가능
}왜 문제일까요?
- 하나의 아이템 추가가 4번의 리렌더링을 유발
- 디버깅 시 어떤 useEffect가 언제 실행되는지 예측 불가능
- 상태 간의 의존성이 컴포넌트 전체에 흩어져 있음
✅ 해결책: useReducer로 로직 중앙화
// ✅ 모든 상태 로직을 한 곳에 모음
const cartReducer = (state, action) => {
switch (action.type) {
case 'ADD_ITEM':
const newItems = [...state.items, action.payload];
const subtotal = newItems.reduce((sum, item) => sum + item.price * item.quantity, 0);
const tax = subtotal * 0.08;
return {
...state,
items: newItems,
subtotal,
tax,
total: subtotal + tax - state.discount
};
case 'APPLY_DISCOUNT':
return {
...state,
discount: action.payload,
total: state.subtotal + state.tax - action.payload
};
default:
return state;
}
};
function ShoppingCart() {
const [state, dispatch] = useReducer(cartReducer, {
items: [],
subtotal: 0,
tax: 0,
total: 0,
discount: 0
});
// 단 하나의 dispatch로 모든 관련 상태가 일관성 있게 업데이트
const addItem = (item) => dispatch({ type: 'ADD_ITEM', payload: item });
}2. 불필요한 리렌더링: 모든 상태가 UI용은 아니다
🚨 문제점: UI와 무관한 상태까지 리렌더링 유발
useState는 상태가 변경될 때마다 컴포넌트를 리렌더링합니다. 하지만 모든 상태가 화면에 표시되어야 하는 것은 아닙니다.
// ❌ 분석용 데이터 때문에 불필요한 리렌더링 발생
function AnalyticsButton() {
const [clickCount, setClickCount] = useState(0); // 화면에 안 보이는 데이터
const [buttonText, setButtonText] = useState('클릭하세요');
const handleClick = () => {
// 🔥 분석용 카운터 때문에 불필요한 리렌더링 발생
setClickCount(prev => prev + 1);
// 실제 필요한 UI 업데이트
setButtonText('클릭됨!');
// 분석 서버로 데이터 전송
sendAnalytics('button_click', { count: clickCount + 1 });
};
return <button onClick={handleClick}>{buttonText}</button>;
}✅ 해결책: useRef로 UI와 무관한 상태 관리
// ✅ UI와 무관한 데이터는 useRef 사용
function AnalyticsButton() {
const clickCountRef = useRef(0); // 리렌더링을 유발하지 않음
const [buttonText, setButtonText] = useState('클릭하세요');
const handleClick = () => {
// UI와 무관한 데이터 업데이트 (리렌더링 없음)
clickCountRef.current += 1;
// 실제 필요한 UI 업데이트만 발생
setButtonText('클릭됨!');
sendAnalytics('button_click', { count: clickCountRef.current });
};
return <button onClick={handleClick}>{buttonText}</button>;
}3. URL에 저장되어야 할 상태: 사용자 경험의 핵심
🚨 문제점: 상태의 영속성과 공유 가능성 부족
정렬, 필터링, 페이지네이션, 활성 탭 등을 useState로만 관리하면 심각한 사용자 경험 저하가 발생합니다.
// ❌ 새로고침하면 모든 설정이 사라지는 코드
function ProductList() {
const [sortOrder, setSortOrder] = useState('name_asc');
const [currentPage, setCurrentPage] = useState(1);
const [priceFilter, setPriceFilter] = useState({ min: 0, max: 1000 });
const [activeTab, setActiveTab] = useState('products');
// 🔥 사용자가 페이지를 새로고침하면?
// 🔥 현재 보고 있는 화면을 링크로 공유하려면?
// 🔥 뒤로 가기 버튼을 누르면?
// → 모든 상태가 초기값으로 돌아감!
return (
<div>
<TabMenu activeTab={activeTab} onTabChange={setActiveTab} />
<SortControls sortOrder={sortOrder} onSortChange={setSortOrder} />
<PriceFilter filter={priceFilter} onFilterChange={setPriceFilter} />
<Pagination currentPage={currentPage} onPageChange={setCurrentPage} />
</div>
);
}✅ 해결책: URL을 상태 저장소로 활용
// ✅ URL이 모든 상태를 담고 있는 구조
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
function ProductList() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
// URL에서 상태를 읽어옴 (새로고침에도 유지됨)
const sortOrder = searchParams.get('sort') || 'name_asc';
const currentPage = parseInt(searchParams.get('page') || '1');
const activeTab = searchParams.get('tab') || 'products';
const priceMin = parseInt(searchParams.get('price_min') || '0');
const priceMax = parseInt(searchParams.get('price_max') || '1000');
const updateUrlState = (updates) => {
const params = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
params.set(key, value.toString());
} else {
params.delete(key);
}
});
router.push(`${pathname}?${params.toString()}`);
};
// 이제 모든 상태 변경이 URL에 반영됨
return (
<div>
<TabMenu
activeTab={activeTab}
onTabChange={(tab) => updateUrlState({ tab })}
/>
<SortControls
sortOrder={sortOrder}
onSortChange={(sort) => updateUrlState({ sort, page: 1 })}
/>
<PriceFilter
min={priceMin}
max={priceMax}
onFilterChange={(min, max) => updateUrlState({ price_min: min, price_max: max, page: 1 })}
/>
<Pagination
currentPage={currentPage}
onPageChange={(page) => updateUrlState({ page })}
/>
</div>
);
}결과: URL 자체가 애플리케이션 상태의 “진실의 원천”이 되어, 새로고침, 북마크, 링크 공유, 브라우저 뒤로/앞으로 가기가 모두 완벽하게 작동합니다.
4. 데이터 페칭 상태의 함정: 논리적으로 불가능한 상태 조합
🚨 문제점: 상태 간의 일관성 보장 실패
데이터를 가져올 때 흔히 사용하는 패턴이 심각한 논리적 결함을 가지고 있습니다.
// ❌ 논리적으로 불가능한 상태 조합을 허용하는 코드
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUser(userId)
.then(userData => {
setUser(userData);
setIsLoading(false);
// 🔥 실수로 error를 null로 설정하는 것을 깜빡함
})
.catch(err => {
setError(err.message);
setIsLoading(false);
// 🔥 실수로 user를 null로 설정하는 것을 깜빡함
});
}, [userId]);
// 🔥 논리적으로 불가능한 상태들:
// - isLoading=true && error="Some error" (로딩 중인데 에러?)
// - isLoading=false && user=null && error=null (성공도 실패도 아닌 상태?)
// - user={...} && error="Some error" (성공했는데 에러?)
}✅ 해결책 1: 구별된 유니온 타입 사용
// ✅ 논리적으로 가능한 상태만 허용하는 구조
function UserProfile({ userId }) {
const [state, setState] = useState({ status: 'loading' });
useEffect(() => {
setState({ status: 'loading' });
fetchUser(userId)
.then(userData => {
setState({ status: 'success', data: userData });
})
.catch(err => {
setState({ status: 'error', error: err.message });
});
}, [userId]);
// 상태에 따른 명확한 렌더링 로직
if (state.status === 'loading') {
return <div>로딩 중...</div>;
}
if (state.status === 'error') {
return <div>에러: {state.error}</div>;
}
// 이 지점에서는 TypeScript가 state.data의 존재를 보장
return <div>안녕하세요, {state.data.name}님!</div>;
}✅ 해결책 2: 전문 라이브러리 사용
// ✅ TanStack Query로 완벽한 데이터 페칭
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // 5분간 캐시 유지
retry: 3, // 실패 시 3번 재시도
});
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error.message}</div>;
return <div>안녕하세요, {user.name}님!</div>;
}David Khourshid의 말: “이 라이브러리가 당신보다 데이터 페칭을 더 잘합니다.”
실제로 TanStack Query를 도입한 후, 데이터 페칭 관련 버그가 현저히 줄어들었고 코드도 훨씬 간결해졌습니다.
5. 과도한 상태 사용: 파생 상태와 복잡한 비즈니스 로직
🚨 문제점 1: 파생 상태의 중복 저장
원본 데이터에서 계산할 수 있는 값들을 별도의 useState로 저장하는 것은 전형적인 안티패턴입니다.
// ❌ 파생 상태를 useState로 중복 저장
function DonutShop() {
const [selectedDonuts, setSelectedDonuts] = useState([]);
const [subtotal, setSubtotal] = useState(0);
const [tax, setTax] = useState(0);
const [total, setTotal] = useState(0);
// 🔥 원본 데이터가 바뀔 때마다 파생 값들을 수동으로 동기화
useEffect(() => {
const newSubtotal = selectedDonuts.reduce((sum, donut) => sum + donut.price, 0);
setSubtotal(newSubtotal);
}, [selectedDonuts]);
useEffect(() => {
setTax(subtotal * 0.08);
}, [subtotal]);
useEffect(() => {
setTotal(subtotal + tax);
}, [subtotal, tax]);
// 다시 1번 문제로 돌아감: 뒤엉킨 상태 로직!
}✅ 해결책: 렌더링 중 직접 계산
// ✅ 파생 상태는 렌더링할 때 계산
function DonutShop() {
const [selectedDonuts, setSelectedDonuts] = useState([]);
// useState 없이 렌더링 중에 직접 계산
const subtotal = selectedDonuts.reduce((sum, donut) => sum + donut.price, 0);
const tax = subtotal * 0.08;
const total = subtotal + tax;
// React는 충분히 빠르므로 대부분의 경우 성능에 문제없음
return (
<div>
<DonutSelector donuts={selectedDonuts} onSelect={setSelectedDonuts} />
<Receipt subtotal={subtotal} tax={tax} total={total} />
</div>
);
}🚨 문제점 2: 복잡한 비즈니스 로직의 분산
// ❌ 비즈니스 로직이 컴포넌트 곳곳에 흩어진 코드
function DonutShop() {
const [selectedDonuts, setSelectedDonuts] = useState([]);
const [inventory, setInventory] = useState({});
const addDonut = (donut) => {
// 🔥 비즈니스 로직이 여기저기 흩어져 있음
if (inventory[donut.id] > 0) {
setSelectedDonuts(prev => [...prev, donut]);
setInventory(prev => ({
...prev,
[donut.id]: prev[donut.id] - 1
}));
} else {
alert('재고가 부족합니다!');
}
};
const removeDonut = (donutId) => {
// 🔥 또 다른 곳에 있는 비즈니스 로직
setSelectedDonuts(prev => prev.filter(d => d.id !== donutId));
setInventory(prev => ({
...prev,
[donutId]: prev[donutId] + 1
}));
};
}✅ 해결책: useReducer로 비즈니스 로직 중앙화
// ✅ 모든 비즈니스 로직을 reducer에 중앙화
const donutShopReducer = (state, action) => {
switch (action.type) {
case 'ADD_DONUT':
const { donut } = action.payload;
// 비즈니스 규칙: 재고가 있을 때만 추가 가능
if (state.inventory[donut.id] > 0) {
return {
...state,
selectedDonuts: [...state.selectedDonuts, donut],
inventory: {
...state.inventory,
[donut.id]: state.inventory[donut.id] - 1
}
};
}
return state; // 재고 부족 시 상태 변경 없음
case 'REMOVE_DONUT':
const { donutId } = action.payload;
return {
...state,
selectedDonuts: state.selectedDonuts.filter(d => d.id !== donutId),
inventory: {
...state.inventory,
[donutId]: state.inventory[donutId] + 1
}
};
default:
return state;
}
};
function DonutShop() {
const [state, dispatch] = useReducer(donutShopReducer, {
selectedDonuts: [],
inventory: { 1: 10, 2: 5, 3: 8 } // 초기 재고
});
// 간단하고 명확한 이벤트 디스패치
const addDonut = (donut) => dispatch({ type: 'ADD_DONUT', payload: { donut } });
const removeDonut = (donutId) => dispatch({ type: 'REMOVE_DONUT', payload: { donutId } });
return <DonutInterface state={state} onAdd={addDonut} onRemove={removeDonut} />;
}핵심 장점:
- 모든 비즈니스 로직이 한 곳에 집중되어 테스트하기 쉬움
dispatch는 “무엇이 일어났는지”를 명확히 표현 (vssetState의 “어떻게 바꿀지”)- 상태 변경의 일관성 보장
실무에서 배운 교훈: 적재적소의 도구 선택
useState를 사용해야 하는 경우
React 19와 함께 useState는 여전히 중요한 역할을 합니다:
// ✅ useState가 완벽한 경우들
function Modal() {
const [isOpen, setIsOpen] = useState(false); // 단순한 UI 상태
return <ModalComponent isOpen={isOpen} onClose={() => setIsOpen(false)} />;
}
function TextField() {
const [value, setValue] = useState(''); // 간단한 폼 입력
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}리팩터링의 신호들
실무에서 코드를 작성하다 보면, 이런 패턴들이 보일 때 리팩터링을 고려해야 한다는 것을 배웠습니다:
- 여러 개의 useEffect가 서로 다른 상태를 변경하고 있다면 →
useReducer - 화면에 표시되지 않는 값을 useState로 관리한다면 →
useRef - URL에 반영되어야 할 상태라면 → Search Params
- 원본 데이터에서 계산 가능한 값을 별도로 저장한다면 → 파생 상태로 계산
- 데이터 페칭을 위해 여러 useState를 조합한다면 → TanStack Query
- 복잡한 비즈니스 로직이 컴포넌트에 분산되어 있다면 →
useReducer
처음에는 이런 신호들을 놓치고 useState로만 해결하려고 했었는데, 경험이 쌓이면서 언제 다른 패턴을 사용해야 하는지 감이 생기기 시작했습니다.
결론: “Easy”보다 “Simple”을 추구하라
David Khourshid의 발표에서 가장 인상 깊었던 메시지는 이것입니다:
단순히 작성하기 ‘쉬운(easy)’ 코드보다, 구조적으로 이해하기 ‘간결한(simple)’ 코드가 장기적으로 훨씬 가치 있습니다.
useState로 시작하는 것은 전혀 문제없습니다. 하지만 코드가 복잡해지기 시작하면, 주저하지 말고 더 나은 패턴으로 리팩터링하세요. React는 이미 우리에게 훌륭한 도구들을 제공하고 있습니다:
- useReducer: 복잡한 상태 로직과 비즈니스 규칙
- useRef: UI와 무관한 값들
- URL Search Params: 공유 가능하고 영속적인 상태
- 파생 상태: 렌더링 중 직접 계산
- 전문 라이브러리: 데이터 페칭, 폼 관리 등
5년간 React를 사용하면서 깨달은 것은, 적절한 도구를 적절한 때에 사용하는 것이 개발자로서 성장하는 길이라는 점입니다. David의 영상은 그런 관점에서 정말 값진 통찰을 제공해주었습니다.
React 19의 새로운 기능들과 최신 가이드라인은 React 공식 문서에서 확인하실 수 있습니다.