무엇이든 기초가 항상 중요하다.
기초를 꾸준히 연습하고 반복해서 결국 체화가 되어야한다. 그래야만 튼튼하고 견고한 결과물을 만들 수 있다.
당구를 칠 때도 맨날 길만 보고, 영상만 볼 게 아니라 기본적인 스트로크 연습이 되어야 수지가 올라간다.(요새 맨날 당구만 쳐서 당구로 비유를...)
바둑을 두는 사람들은 습관처럼 단수(單手)에 대한 본능이 있다고 한다.
왜냐하면 바둑을 처음 배울 때 대부분 단수부터 배우기 때문이다.
리액트를 처음 배울 때 보통 useState부터 배운다.
setState로 UI가 실시간으로 변하는 모습에 흥미를 느낀다.
이후 회사 업무나 사이드 프로젝트를 진행하면서 점점 큰 규모의 어플리케이션을 개발하게 된다.
이 과정에서 자연스럽게 state를 늘려가는데 익숙해진다.
유튜브의 여러 강의를 보면 state 변수를 너무나 쉽게 추가한다.
하지만 나의 개발 경험에 비추어 볼 때, state는 항상 최소화하는 방향으로 고민해야한다.
무분별하게 늘어난 state는 예상치 못한 수많은 버그의 원인이 된다.
따라서 처음 리액트를 배울 때 부터 state가 야기할 수 있는 문제점들을 충분히 인지하고, state를 최소화하려는 마인드셋을 습관화해야한다. '습관'을 강조하는 이유는 인간은 누구나 게으르고, 본디 편안함을 추구하기에 가끔은 고민없이 '무지성 state'로 문제를 해결하고 싶은 유혹에 빠지기 때문이다.(나도 마찬가지)
유혹의 순간, 체화된 Brain-Muscle Memory가 나를 멈춰 세울 수 있도록 꾸준히 반복하고 인식해야한다.
이러한 의미에서 리액트 state를 다루며 마주할 수 있는 여러 상황과 안티 패턴(Anti-Pattern)을 정리한다.
나 또한 다시 한번 이 원칙들을 뇌에 각인시키고자 이 포스팅을 작성한다.
Deriving State
다른 사람들은 단어를 번역하며 '파생 상태'라는 용어를 사용하는데, 단어 자체가 나에게는 직관적으로 와닿지가 않는다.(금융 용어로 느껴짐)
그래서 나만의 용어로 '유도 상태'라고 표현한다.
핵심 : 하나의 State 변수로부터 계산할 수 있는 값이라면, 별도의 State로 저장하지 않고 값으로 저장한다.
유도상태를 사용하지 않는 대표적인 안티패턴은 useState + useEffect 조합이다.
유도상태를 사용해야만 하는 상황은 주로 필터링, 합계, 상태(Status), 제외목록 정도가 있다.
// 필터링 + 안티 패턴
function AvailableDates() {
const [bookedDates] = useState(["2026-03-14", "2026-03-15", "2026-03-16"]);
const [availableDates, setAvailableDates] = useState<string[]>([]);
useEffect(() => {
const allDates = Array.from({ length: 30 }, (_, i) => {
const date = new Date('2026-03-01');
date.setDate(date.getDate() + i);
return date.toISOString().split('T')[0];
});
setAvailableDates(allDates.filter((date) => !bookedDates.includes(date)));
}, [bookedDates]);
return (
<ul>
{availableDates.map(date => <li key={date}>{date}</li>)}
</ul>
);
}
// 필터링 + 유도 상태
function AvailableDates() {
const [bookedDates] = useState(["2026-03-14", "2026-03-15", "2026-03-16"]);
const allDates = Array.from({ length: 30 }, (_, i) => {
const date = new Date('2024-06-01');
date.setDate(date.getDate() + i);
return date.toISOString().split('T')[0];
});
const availableDates = allDates.filter((date) => !bookedDates.includes(date));
return (
<ul>
{availableDates.map(date => <li key={date}>{date}</li>)}
</ul>
);
}// 합계 + 안티 패턴
function Cart({ items }) {
const [totalPrice, setTotalPrice] = useState(0);
useEffect(() => {
const total = items.reduce((sum, item) => sum + item.price, 0);
setTotalPrice(total);
}, [items]);
return (
<div>
<p>총 결제 금액: {totalPrice}원</p>
</div>
);
}
// 합계 + 유도 상태
function Cart({ items }) {
const totalPrice = items.reduce((sum, item) => sum + item.price, 0);
return (
<div>
<p>총 결제 금액: {totalPrice}원</p>
</div>
);
}useState + useEffect 조합을 사용할때의 잠재적인 문제점은 원천 state와의 동기화 문제이다.
아래 코드에서 개발자가 의존성을 누락하는 경우 동기화 문제가 발생한다.
function PriceCalculator() {
const [price, setPrice] = useState(10000);
const [quantity, setQuantity] = useState(1);
const [discount, setDiscount] = useState(0); // 할인 금액
const [total, setTotal] = useState(10000);
useEffect(() => {
setTotal(price * quantity - discount);
}, [price, quantity]); // discount 누락
return (
<div>
<p>단가: {price}원 / 수량: {quantity}</p>
<button onClick={() => setQuantity(q => q + 1)}>수량 추가</button>
<button onClick={() => setDiscount(2000)}>2,000원 할인 적용</button>
<h2>최종 결제 금액: {total}원</h2>
</div>
);
}단순히 이 코드만 본다면 "이런 실수를 한다고?" "금방 디버깅 하겠네?"라고 생각할 수 있지만 프로젝트의 규모가 커질수록 생각보다 의존성 배열로 인한 문제가 많이 발생한다.
하지만 내가 생각하기에 의존성 누락으로 인한 동기화 문제보다 더 까다로운 문제는 외부 소스와의 동기화 문제다.
API, LocalStorage, Window Event 들은 대표적인 외부 소스다.
아래 코드를 보면 API 소스와의 동기화 문제를 볼 수 있다.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setIsLoading(true);
fetch(`https://api.example.com/user/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setIsLoading(false);
});
}, [userId]);
if (isLoading) return <p>로딩 중...</p>;
return <div>{user?.name}님의 프로필</div>;
}위 코드에서 언제 동기화 문제가 발생할까?
사용자가 userId를 1에서 2로 바꾸었다고 하자.
userId가 1일때 요청을 보냈는데 응답이 늦게오고, 그 사이에 userId가 2로 바뀌어 새로운 요청을 보냈는데 2번의 응답이 먼저 올 수 있다. 이때 마지막에 도착한 userId1의 데이터가 화면에 반영된다.
즉 실제 사용자는 userId = 2인 유저인데, 화면의 내용은 userId = 1인 데이터로 채워져 있다.
이처럼 API 응답은 보낸 순서대로 오지 않을 수 있다는 점을 명확하게 고려해야한다.
(물론 외부 소스와의 동기화 문제를 해결하는 훅이나 라이브러리가 있다)
Refs
리액트 주니어 개발자들이 기초를 다진 후 가장 많이 접하고 활용하게 되는 시나리오 중 하나가 바로 useRef 활용이다.
핵심 : 렌더링에 영향을 미치지 않는 값은 useRef로 처리한다
Ref의 대표적인 안티패턴은 리렌더링이 필요없는 값을 state로 저장하는 상황이다.
다만, 값(변수)이 아닌 Ref를 꼭 사용해야 하는 상황은 크게 다음과 같다.
(1) DOM Reference 저장 : 특정 DOM 요소에 접근할 때
(2) Timer 관리 : setTimeout 이나 setInterval의 ID를 저장할 때
(3) Previous Value : 리렌더링 사이에 이전 값을 유지하고 싶을 때
practical한 관점으로는 expensive calculation 결과를 캐싱하기 위해 사용하거나 scroll position을 트래킹할 때 사용한다
보통은 리렌더링이 되었지만 값을 초기화하지 않고 유지해야하는 상황에 ref를 많이 사용한다.
// 안티 패턴
function Timer() {
const [timeLeft, setTimeLeft] = useState(60);
const [timerId, setTimerId] = useState<NodeJS.Timeout | null>(null);
const startTimer = () => {
const id = setInterval(() => {
setTimeLeft((prev) => prev - 1);
}, 1000);
setTimerId(id); // 여기서 불필요한 리렌더링 발생
};
useEffect(() => {
return () => timerId && clearInterval(timerId);
}, [timerId]); // timerId가 바뀔때마다 실행
return <div>{timeLeft}s remaining</div>;
}
function Timer() {
const [timeLeft, setTimeLeft] = useState(60);
const timerIdRef = useRef<NodeJS.Timeout | null>(null);
const startTimer = () => {
const id = setInterval(() => {
setTimeLeft((prev) => prev - 1);
}, 1000);
timerIdRef.current = id;
};
useEffect(() => {
return () => timerIdRef.current && clearInterval(timerIdRef.current);
}, []); //
return <div>{timeLeft}s remaining</div>;
}Redundant State
일반적인 소프트웨어 개발론에서도 강조하듯, "Single Source of Truth"를 지키는 것은 중요하다.
리액트의 state에서도 중복 상태(Redundant State)를 가지는 것은 반드시 피해야한다.
핵심 : 중복된 데이터를 가지는 state를 갖지 않는다.
맨 처음에 언급한 유도 상태(Derived State)와는 조금 다른데, 중복 상태의 안티 패턴은 말 그대로 중복된 데이터를 state로 가지는 상황이다.
즉 안티 패턴은 다양한 위치에서 중복된 데이터를 state로 가지는 상황이다.
// 중복상태 + 안티 패턴
function HotelSelection() {
const [hotels] = useState([
{ id: '1', name: 'Seoul Hyatt', price: 200 },
{ id: '2', name: 'Marriott', price: 100 },
]);
const [selectedHotel, setSelectedHotel] = useState<Hotel | null>(null); // 중복된 데이터
const handleSelect = (hotel: Hotel) => {
setSelectedHotel(hotel); //
};
return (
<div>
{selectedHotel && (
<div>
{selectedHotel.name} - ${selectedHotel.price}
</div>
)}
</div>
);
}
function HotelSelection() {
const [hotels] = useState([
{ id: '1', name: 'Seoul Hyatt', price: 200 },
{ id: '2', name: 'Marriott', price: 100 },
]);
const [selectedHotelId, setSelectedHotelId] = useState<string | null>(null); // 전체 데이터 대신 ID만
const handleSelect = (hotelId: string) => {
setSelectedHotelId(hotelId);
};
// ID를 이용해서 Derived State로 처리
const selectedHotel = hotels.find((h) => h.id === selectedHotelId);
return (
<div>
{selectedHotel && (
<div>
{selectedHotel.name} - ${selectedHotel.price}
</div>
)}
</div>
);
}중복상태는 잠재적으로 항상 문제를 가지고 있다.
데이터를 중복해서 가지고 있기 때문에 언제든지 두 데이터가 동기화되지 않을 위험을 내포한다.
1. 만약 원본인 hotels 리스트의 가격 정보가 API 업데이트로 변경이 되었따면, selectedHotel에 담긴 객체는 예전 가격을 그대로 들고있게 된다.
2. 데이터가 꼬였을 때 디버깅을 하기가 매우 어렵다.
결국 다시, 기초
지금까지 리액트 개발에서 흔히 마주치는 3가지 안티패턴을 정리했다.
항상 느끼지만 가장 기본이 되는 것이 가장 어렵다.
"무지성의 유혹"을 뿌리치고, 원칙을 "뇌와 손"에 각인시키도록 하자.