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, onSubmit handler에서 직접 처리).
  • effect를 사용해 prop이 변경될 때 state를 재설정합니다 — 보통 key prop을 사용하여 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로 감싸지 마세요. 의존성 배열과 정리 함수를 확실히 이해하면 이 두 훅과 관련된 대부분의 일반적인 버그를 피할 수 있습니다.

reacthooksjavascriptfrontendtypescript