useState와 useEffect: 올바른 사용법
1. 개요
useState와 useEffect는 React에서 가장 기본적인 훅이면서도 가장 많은 버그가 발생하는 곳입니다: 컴포넌트의 무한 렌더링, 예상과 다르게 업데이트되지 않는 state, 불필요하게 반복되는 effect. 이 글에서는 두 훅의 실제 작동 메커니즘과 올바른 사용 패턴, 피해야 할 일반적인 오류를 다룹니다. 읽고 나면 “이 부분에서 정말 useEffect가 필요할까?“라는 질문에 자신감 있게 답할 수 있을 겁니다 — 많은 React 개발자가 아직도 틀리게 답하는 질문이죠.
2. useState 기초
useState는 [value, setter] 쌍을 반환합니다. setter를 호출할 때마다 React는 새로운 값으로 컴포넌트를 다시 렌더링하도록 예약합니다.
import { useState } from "react";
function CustomerCounter() {
const [count, setCount] = useState(0);
return (
<div>
<p>고객 개수: {count}</p>
<button onClick={() => setCount(count + 1)}>고객 추가</button>
</div>
);
}
useState(0)은 첫 번째 렌더링에서만count = 0으로 초기화합니다.setCount(count + 1)은count가 1 증가한 새로운 렌더링을 만듭니다.- 현재 렌더링의 클로저 내
count는 **불변(immutable)**입니다 — 두 렌더링 사이에 자동으로 변하지 않습니다.
주의:
setCount를 호출해도 같은 렌더링 내에서count값이 즉시 변하지 않습니다. React은 업데이트를 배치하고 나중에 다시 렌더링합니다.
3. useState: 이전 state 기반 업데이트
같은 event handler 내에서 setter를 여러 번 호출할 때 “동결된” count 변수 기반으로 하면 결과가 잘못됩니다.
function handleTripleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// 결과: count는 1만 증가하고, 3이 아닙니다
}
최신 값을 올바르게 얻으려면 함수형 업데이트를 사용하세요:
function handleTripleClick() {
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
// 결과: count는 정확히 3 증가합니다
}
- setter에 함수를 전달하면, React은
prev가 항상 처리 시점의 최신 state 값이 되도록 보장합니다. 이전 클로저에서 캡처된 값이 아닙니다. - 규칙: 새로운 state가 이전 state에 의존하면, 항상 함수형 업데이트를 사용하세요.
4. useState: 지연 초기 state
초기값 계산이 비용이 많이 들면, 함수를 직접 호출하지 마세요 — useState에 함수를 전달해서 한 번만 실행되도록 하세요.
function parseInitialOrders(raw) {
console.log("주문 데이터 파싱 중...");
return JSON.parse(raw);
}
function OrderList({ rawOrders }) {
// 잘못된 방식: parseInitialOrders가 모든 렌더링마다 실행됩니다
// const [orders, setOrders] = useState(parseInitialOrders(rawOrders));
// 올바른 방식: 첫 번째 렌더링에서만 실행됩니다
const [orders, setOrders] = useState(() => parseInitialOrders(rawOrders));
return <p>주문 개수: {orders.length}</p>;
}
- 잘못된 방식은 React이 첫 번째 결과만 사용하더라도 모든 렌더링에서
parseInitialOrders를 호출합니다. - 올바른 방식은 함수 참조를 전달하므로, React은 첫 번째 마운트에서만 이를 호출합니다.
5. useEffect 기초
useEffect는 컴포넌트 렌더링이 완료된 후에 side effect를 실행합니다 (DOM이 업데이트된 후).
import { useEffect, useState } from "react";
function PageTitleUpdater({ orderCount }) {
useEffect(() => {
document.title = `주문이 ${orderCount}개 있습니다`;
});
return <p>현재 주문: {orderCount}</p>;
}
- dependency array가 없으면 (두 번째 인자가 없으면), effect는 모든 렌더링 후에 실행됩니다 — 마운트와 업데이트 모두.
document.title은 전형적인 side effect입니다: 이것은 React 외부의 세상과 상호작용합니다 (DOM API).
6. useEffect: 의존성 배열
의존성 배열은 effect가 언제 실행될지 결정합니다. 명확히 기억해야 할 3가지 경우가 있습니다.
6.1. 의존성 배열 없음
useEffect(() => {
console.log("모든 렌더링 후에 실행됩니다");
});
6.2. 빈 의존성 배열 []
useEffect(() => {
console.log("마운트 후 한 번만 실행됩니다");
}, []);
6.3. 값이 있는 의존성 배열
function CustomerProfile({ customerId }) {
const [customer, setCustomer] = useState(null);
useEffect(() => {
fetchCustomer(customerId).then(setCustomer);
}, [customerId]);
return <p>{customer?.name ?? "로딩 중..."}</p>;
}
- effect는 두 렌더링 사이에
customerId가 변경될 때만 다시 실행됩니다 (Object.is로 비교). - 배열에
customerId를 빠뜨리면, effect는 이전customerId값(stale closure)을 사용합니다 — prop이 변경되어도 잘못된 고객을 가져옵니다.
주의: ESLint 플러그인
eslint-plugin-react-hooks의exhaustive-deps규칙은 의존성을 빠뜨렸을 때 경고합니다. 결과를 정확히 이해하지 않는 한 이 규칙을 비활성화하지 마세요.
7. useEffect: 정리 함수
effect가 subscription, timer, 또는 event listener를 만들 때는 leak을 방지하기 위해 정리 함수를 반환해야 합니다.
function OrderStatusListener({ orderId }) {
const [status, setStatus] = useState("pending");
useEffect(() => {
const subscription = subscribeToOrderStatus(orderId, setStatus);
return () => {
subscription.unsubscribe();
};
}, [orderId]);
return <p>상태: {status}</p>;
}
- 정리 함수는 effect가 다시 실행되기 전에 (
orderId가 변경될 때) 그리고 컴포넌트가 unmount될 때 실행됩니다. - 정리하지 않으면
orderId가 변경될 때마다 새 subscription이 추가됩니다 — 결국setStatus가 이전 여러 subscription에서 호출되어 잘못된 업데이트 또는 메모리 leak이 발생합니다.
orderId가 1 -> 2로 변경될 때 실행 순서:
1. orderId = 1인 effect의 정리 함수
2. orderId = 2인 새 effect 실행
8. 일반적인 오류
8.1. 잘못된 의존성으로 인한 무한 루프
function BadExample() {
const [data, setData] = useState({ count: 0 });
useEffect(() => {
// 매 렌더링마다 새로운 객체 -> data를 deps에 넣으면 effect는 항상 "변경"을 감지합니다
setData({ count: data.count + 1 }); // 재렌더링 발생 -> effect 다시 실행 -> 무한 반복
}, [data]);
return <p>{data.count}</p>;
}
함수형 업데이트와 종료 조건을 분리하여 수정하거나, 이 로직이 실제 side effect가 필요 없다면 useEffect를 완전히 제거하세요.
8.2. setTimeout/setInterval의 Stale Closure
function Countdown() {
const [seconds, setSeconds] = useState(10);
useEffect(() => {
const id = setInterval(() => {
setSeconds((prev) => prev - 1); // 함수형 업데이트 사용, seconds를 직접 사용하지 마세요
}, 1000);
return () => clearInterval(id);
}, []); // deps가 비어있습니다 — effect 내에서 seconds를 직접 읽지 않으므로
return <p>{seconds}</p>;
}
- 함수형 업데이트 대신
setSeconds(seconds - 1)로 쓰면, 클로저는 첫 번째 effect 실행 시seconds = 10을 항상 “기억”합니다 — countdown이 잘못된 값에서 멈춥니다.
8.3. 파생 값 계산에 useEffect 사용
// 잘못된 방식: 불필요하고 추가 렌더링을 유발합니다
function OrderSummaryBad({ items }) {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price, 0));
}, [items]);
return <p>합계: {total}</p>;
}
// 올바른 방식: 렌더링 중에 직접 계산합니다
function OrderSummaryGood({ items }) {
const total = items.reduce((sum, i) => sum + i.price, 0);
return <p>합계: {total}</p>;
}
“잘못된” 버전은 추가 렌더링 쌍을 낭비합니다: 이전 total로 렌더링, effect 실행, setTotal이 올바른 값으로 두 번째 렌더링을 트리거합니다. “올바른” 버전은 렌더링 중에 바로 계산하므로 effect나 추가 state가 필요 없습니다.
9. useEffect를 사용하면 안 될 때
주의:
useEffect는 React 외부 시스템과의 동기화에만 사용해야 합니다 (DOM, API, subscription, timer). 현재 props/state에서 직접 값을 계산할 수 있다면 effect를 사용하지 마세요.
effect를 잘못 사용하는 세 가지 신호:
- effect를 사용해 다른 state에서 파생된 값을 계산합니다 (일반 변수 또는
useMemo사용). - effect를 사용해 사용자 이벤트를 처리합니다 (
onClick,onSubmithandler에서 직접 처리). - effect를 사용해 prop이 변경될 때 state를 재설정합니다 — 보통
keyprop을 사용하여 React이 effect 대신 자동으로 컴포넌트를 remount하도록 할 수 있습니다.
// customerId 변경 시 effect로 form을 재설정하는 대신:
// useEffect(() => { setForm(initialForm); }, [customerId]);
// key를 사용하여 컴포넌트를 remount하면, React이 모든 내부 state를 자동으로 재설정합니다:
<CustomerForm key={customerId} customerId={customerId} />
10. 훅의 동작을 테스트하기
Jest와 React Testing Library를 사용하여 구현 세부사항 대신 실제 동작을 테스트하세요.
npm install --save-dev @testing-library/react @testing-library/user-event jest
// CustomerCounter.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CustomerCounter } from "./CustomerCounter";
test("고객 추가 버튼 클릭 시 count 증가", async () => {
const user = userEvent.setup();
render(<CustomerCounter />);
expect(screen.getByText("고객 개수: 0")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "고객 추가" }));
expect(screen.getByText("고객 개수: 1")).toBeInTheDocument();
});
- 테스트는
render을 호출한 후userEvent로 실제 클릭을 시뮬레이션합니다 — 사용자 동작과 동일합니다. - assertion은 표시된 내용 (
getByText)에 기반하며, 내부 state에 의존하지 않습니다 — 이것이 React의 올바른 테스트 방식입니다: 동작을 테스트하고, 구현은 테스트하지 마세요.
PASS ./CustomerCounter.test.jsx
✓ 고객 추가 버튼 클릭 시 count 증가 (45 ms)
11. 결론
useState는 표면적으로는 단순하지만, 클로저 내의 값이 각 렌더링마다 “동결”된다는 사실을 잊으면 오류가 발생하기 쉽습니다 — 새로운 state가 이전 state에 의존할 때는 함수형 업데이트를 사용하세요. useEffect는 강력하지만 React 외부 시스템과의 동기화에만 사용해야 합니다. 값을 렌더링 중에 직접 계산할 수 있다면 effect로 감싸지 마세요. 의존성 배열과 정리 함수를 확실히 이해하면 이 두 훅과 관련된 대부분의 일반적인 버그를 피할 수 있습니다.