Dev Notes
Web Vitals
Web Vitals
Dev Notes
DocsuseEffect를 남용하고 있다는 신호들
Web Vitals
Web Vitals
GitHub
React63분

useEffect를 남용하고 있다는 신호들

useEffect 과사용의 근본 원인과 올바른 멘탈 모델

2025년 11월 4일
reacthooksuseEffectperformanceanti-patternbest-practices

적용 환경: React 18+, TypeScript 5+

들어가며

React 개발을 하다 보면 무의식적으로 useEffect에 손이 가는 순간이 있습니다. "이 값이 바뀌면 저 값도 업데이트해야 하니까 useEffect", "props가 바뀌면 state를 리셋해야 하니까 useEffect", "이벤트가 발생한 뒤에 뭔가 해야 하니까 useEffect". 값이 바뀔 때마다 반사적으로 useEffect를 꺼내 드는 습관은 생각보다 많은 코드베이스에 뿌리내리고 있습니다.

이 습관은 우연히 형성된 것이 아닙니다. class 컴포넌트 시절에는 componentDidUpdate가 "무언가 바뀐 뒤에 반응하는" 대표적인 수단이었고, useEffect가 그 역할을 이어받았다고 인식하는 개발자가 많습니다. 하지만 useEffect는 lifecycle 메서드의 후계자가 아니라, 완전히 다른 목적의 도구입니다.

이 글에서는 useEffect를 실제로 써야 하는 경우와 그렇지 않은 경우를 구분하는 멘탈 모델을 제시합니다. React 공식 문서의 You Might Not Need an Effect가 패턴 카탈로그를 제공한다면, 이 글은 그 패턴들이 왜 문제가 되는지에 대한 근본적인 사고방식의 전환에 초점을 맞춥니다.

렌더링이 곧 반응이다

useEffect 남용의 뿌리에는 하나의 오해가 있습니다. "state가 바뀌었을 때 뭔가 하려면 별도의 메커니즘이 필요하다"는 생각입니다.

React의 렌더링 모델을 다시 살펴보면 이 오해가 왜 틀린지 명확해집니다. state나 props가 바뀌면 컴포넌트 함수가 다시 실행됩니다. 이 "다시 실행"이 바로 React가 변화에 반응하는 방식입니다. 함수 본문에 있는 모든 코드가 새로운 값으로 재평가되므로, 파생 값을 계산하거나 조건부 로직을 실행하기 위해 별도의 구독 메커니즘이 필요하지 않습니다.

TSX
function UserGreeting({ user }: { user: User }) {
  // 컴포넌트가 리렌더링되면 이 줄이 다시 실행된다 — 이것이 반응이다
  const displayName = user.firstName + ' ' + user.lastName;
  const isVIP = user.purchaseCount > 100;
 
  return (
    <div>
      <h1>{displayName}</h1>
      {isVIP && <VIPBadge />}
    </div>
  );
}

displayName을 state로 관리하면서 useEffect로 동기화할 필요가 없습니다. 컴포넌트가 다시 실행될 때 자연스럽게 새 값이 계산됩니다.

그렇다면 useEffect는 언제 필요할까요? React의 렌더링 사이클 바깥에 있는 시스템과 동기화해야 할 때입니다. DOM API를 직접 조작하거나, 브라우저 이벤트를 구독하거나, 네트워크 요청을 보내는 것처럼 React가 관리하지 않는 세계와 소통할 때 useEffect가 필요합니다.

핵심 판단 기준

"이 로직은 외부 시스템과의 동기화인가, 아니면 React 내부의 데이터 변환인가?"

외부 시스템이 관여하지 않는다면, useEffect가 필요하지 않을 가능성이 높습니다.

파생 값이라면 그냥 계산하면 된다

가장 흔한 useEffect 남용 패턴은 파생 상태(derived state)를 별도의 state로 관리하는 것입니다. 기존 state나 props에서 계산할 수 있는 값을 굳이 useState + useEffect 조합으로 동기화하는 패턴은 불필요한 렌더링을 유발할 뿐 아니라, state 간 불일치가 발생할 수 있는 취약한 구조를 만듭니다.

TSX
// ❌ 파생 값을 state + Effect로 관리
function SearchResults({ items, query }: SearchResultsProps) {
  const [filteredItems, setFilteredItems] = useState<Item[]>([]);
 
  useEffect(() => {
    setFilteredItems(items.filter(item =>
      item.name.toLowerCase().includes(query.toLowerCase())
    ));
  }, [items, query]);
 
  return <ItemList items={filteredItems} />;
}

이 코드의 실행 흐름을 추적해 보면 문제가 드러납니다. items나 query가 바뀌면 컴포넌트가 렌더링되는데, 이때 filteredItems는 아직 이전 값입니다. 렌더링이 끝나고 나서야 Effect가 실행되어 setFilteredItems를 호출하고, 이로 인해 다시 렌더링이 발생합니다. 결과적으로 한 번의 변경에 두 번의 렌더링이 일어나며, 첫 번째 렌더링에서는 오래된 필터 결과가 잠깐 화면에 노출됩니다.

TSX
// ✅ 렌더링 중에 계산
function SearchResults({ items, query }: SearchResultsProps) {
  const filteredItems = items.filter(item =>
    item.name.toLowerCase().includes(query.toLowerCase())
  );
 
  return <ItemList items={filteredItems} />;
}

이렇게 하면 items나 query가 바뀔 때 컴포넌트가 한 번만 렌더링되고, filteredItems는 항상 최신 값을 반영합니다.

비용이 걱정된다면 useMemo

"매 렌더링마다 필터링하면 느리지 않을까?"라는 의문이 들 수 있습니다. 대부분의 경우 이 걱정은 기우입니다. 배열 수천 개를 필터링하는 연산은 1ms 미만으로 끝납니다. 하지만 데이터가 수만 건이거나 복잡한 정렬/그루핑을 수행하는 경우, useMemo로 연산 결과를 캐싱할 수 있습니다.

TSX
// ✅ 비싼 계산에만 useMemo 적용
function SearchResults({ items, query }: SearchResultsProps) {
  const filteredItems = useMemo(
    () => items.filter(item =>
      item.name.toLowerCase().includes(query.toLowerCase())
    ),
    [items, query]
  );
 
  return <ItemList items={filteredItems} />;
}

여기서 주의할 점이 있습니다. useMemo는 useEffect + useState 조합의 대체재가 아닙니다. useMemo는 같은 렌더링 안에서 결과를 반환하지만, Effect는 렌더링이 끝난 뒤에 실행됩니다. 동기적인 계산 캐싱에는 useMemo, 렌더링 이후에 수행하는 외부 시스템과의 동기화에는 useEffect입니다.

이벤트에 속하는 로직은 이벤트 핸들러에

두 번째로 흔한 남용 패턴은 사용자 행동에 의해 촉발되는 로직을 Effect에 넣는 것입니다. 장바구니에 상품을 추가한 뒤 알림을 보여주는 코드를 예로 들겠습니다.

TSX
// ❌ 이벤트 로직을 Effect에 위임
function ProductPage({ product }: ProductPageProps) {
  useEffect(() => {
    if (product.isInCart) {
      showNotification(`${product.name}이 장바구니에 추가되었습니다`);
    }
  }, [product]);
 
  function handleBuyClick() {
    addToCart(product);
  }
 
  return <button onClick={handleBuyClick}>구매</button>;
}

이 코드는 "상품이 장바구니에 있으면 알림을 보여줘"라고 선언하고 있습니다. 하지만 실제 의도는 "사용자가 구매 버튼을 클릭했을 때 알림을 보여줘"입니다. 이 차이가 실제 버그로 이어집니다. 사용자가 장바구니에 상품이 있는 상태에서 페이지를 새로고침하면, 아무 행동도 하지 않았는데 알림이 다시 나타납니다. Effect는 "왜" 이 코드가 실행되어야 하는지를 알지 못하기 때문입니다.

TSX
// ✅ 이벤트 로직은 이벤트 핸들러에
function ProductPage({ product }: ProductPageProps) {
  function handleBuyClick() {
    addToCart(product);
    showNotification(`${product.name}이 장바구니에 추가되었습니다`);
  }
 
  return <button onClick={handleBuyClick}>구매</button>;
}

Effect에 적합한 것과 아닌 것

같은 컴포넌트 안에서도 Effect에 넣어야 할 로직과 이벤트 핸들러에 넣어야 할 로직이 공존하는 경우가 있습니다. 폼 제출과 페이지 분석 로그가 대표적인 사례입니다.

TSX
function RegistrationForm() {
  const [formData, setFormData] = useState({ name: '', email: '' });
 
  // ✅ 분석 로그: 컴포넌트가 화면에 "보이기 때문에" 실행
  useEffect(() => {
    analytics.track('registration_form_viewed');
  }, []);
 
  // ✅ 폼 제출: 사용자가 "버튼을 클릭했기 때문에" 실행
  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    api.register(formData);
  }
 
  return <form onSubmit={handleSubmit}>{/* ... */}</form>;
}

분석 로그는 컴포넌트가 화면에 마운트되었다는 사실 자체가 트리거이므로 Effect가 적절합니다. 반면 폼 제출은 사용자의 명시적인 행동이 트리거이므로 이벤트 핸들러에 속합니다.

이 구분을 위한 질문은 단순합니다. "이 코드는 컴포넌트가 화면에 보이기 때문에 실행되어야 하는가, 아니면 사용자가 특정 행동을 했기 때문에 실행되어야 하는가?" 전자라면 Effect, 후자라면 이벤트 핸들러입니다.

Effect 체인이 만드는 렌더링 폭포

여러 개의 useEffect가 서로의 state 변경을 트리거하는 체인 구조는 가장 파악하기 어렵고 성능에 치명적인 패턴입니다. 카드 게임을 예로 들어 이 문제를 살펴보겠습니다.

TSX
// ❌ Effect 체인: 각 Effect가 다음 Effect를 트리거
function Game() {
  const [card, setCard] = useState<Card | null>(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);
  const [isGameOver, setIsGameOver] = useState(false);
 
  useEffect(() => {
    if (card !== null && card.gold) {
      setGoldCardCount(c => c + 1);
    }
  }, [card]);
 
  useEffect(() => {
    if (goldCardCount > 3) {
      setRound(r => r + 1);
      setGoldCardCount(0);
    }
  }, [goldCardCount]);
 
  useEffect(() => {
    if (round > 5) {
      setIsGameOver(true);
    }
  }, [round]);
 
  // ...
}

카드 한 장을 놓았을 뿐인데 일어나는 일의 순서를 추적해 보면 문제의 심각성이 보입니다. setCard → 렌더링 → Effect 실행 → setGoldCardCount → 렌더링 → Effect 실행 → setRound → 렌더링 → Effect 실행 → setIsGameOver → 렌더링. 카드 한 장에 최대 4번의 렌더링이 발생합니다.

더 큰 문제는 이 코드가 "게임의 규칙"을 여러 Effect에 분산시켜 놓았다는 것입니다. 게임 히스토리를 되감기하거나 특정 시점의 상태를 복원하려면 모든 Effect가 순서대로 다시 실행되어야 하는데, 이는 의도한 동작이 아닐 수 있습니다.

TSX
// ✅ 이벤트 핸들러에서 모든 게임 로직을 처리
function Game() {
  const [card, setCard] = useState<Card | null>(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);
 
  // 파생 값: state가 아니라 계산
  const isGameOver = round > 5;
 
  function handlePlaceCard(nextCard: Card) {
    if (isGameOver) {
      throw new Error('Game already ended.');
    }
 
    setCard(nextCard);
 
    if (nextCard.gold) {
      if (goldCardCount < 3) {
        setGoldCardCount(goldCardCount + 1);
      } else {
        setGoldCardCount(0);
        setRound(round + 1);
        if (round === 5) {
          alert('Good game!');
        }
      }
    }
  }
 
  // ...
}

모든 게임 로직이 한 곳에 모여 있으므로 흐름을 따라가기 쉽고, React가 여러 setState 호출을 자동으로 배치 처리하여 렌더링이 한 번만 발생합니다. isGameOver는 round에서 파생되는 값이므로 별도의 state로 관리할 이유가 없습니다.

Effect 체인의 징후

코드에서 useEffect 안에 setState를 호출하고, 그 state를 의존성 배열로 가진 또 다른 useEffect가 존재한다면 Effect 체인이 형성되고 있을 가능성이 높습니다. 이벤트 핸들러에서 모든 상태 변경을 한꺼번에 처리하는 방식으로 리팩토링하는 것이 좋습니다.

컴포넌트 초기화와 key의 힘

props가 바뀔 때 컴포넌트의 state를 리셋해야 하는 상황은 실무에서 자주 마주칩니다. 프로필 페이지에서 다른 사용자로 전환할 때 댓글 입력창의 내용이 초기화되어야 하는 경우가 대표적입니다. 이때 Effect로 state를 리셋하는 패턴은 항상 한 프레임 늦습니다.

TSX
// ❌ Effect로 state 리셋 — 이전 사용자의 댓글이 잠깐 보인다
function ProfilePage({ userId }: { userId: string }) {
  const [comment, setComment] = useState('');
 
  useEffect(() => {
    setComment('');
  }, [userId]);
 
  return <CommentInput value={comment} onChange={setComment} />;
}

userId가 바뀌면 먼저 이전 comment 값으로 렌더링이 일어나고, 그 뒤에 Effect가 실행되어 빈 문자열로 다시 렌더링됩니다. 한 프레임 동안이라 눈에 띄지 않을 수 있지만, 이전 사용자의 댓글이 새 사용자의 프로필에 잠깐 노출되는 것은 명백한 버그입니다.

React의 key prop은 이 문제를 우아하게 해결합니다.

TSX
// ✅ key로 컴포넌트 인스턴스를 분리
function ProfilePage({ userId }: { userId: string }) {
  return <Profile userId={userId} key={userId} />;
}
 
function Profile({ userId }: { userId: string }) {
  const [comment, setComment] = useState('');
  // userId가 바뀌면 React는 이 컴포넌트를 아예 새로 마운트한다
  return <CommentInput value={comment} onChange={setComment} />;
}

key가 바뀌면 React는 해당 컴포넌트를 언마운트하고 새 인스턴스를 마운트합니다. 모든 내부 state가 자동으로 초기화되므로 Effect가 필요 없고, 이전 값이 화면에 노출되는 문제도 발생하지 않습니다.

이 패턴은 리스트 렌더링에서 key를 사용하는 것과 동일한 원리입니다. React에게 "이 key의 컴포넌트는 저 key의 컴포넌트와 다른 존재"라고 알려주는 것이며, 탭 전환, 다중 폼, 위저드(wizard) UI 등 "같은 컴포넌트인데 맥락이 달라지는" 모든 상황에서 활용할 수 있습니다.

부모-자식 간 데이터 흐름

자식 컴포넌트가 데이터를 가져온 뒤 Effect로 부모에게 올려보내는 패턴은 React의 단방향 데이터 흐름을 정면으로 위반합니다.

TSX
// ❌ 자식이 Effect로 부모에게 데이터를 올림
function Parent() {
  const [data, setData] = useState<Data | null>(null);
  return <Child onFetched={setData} />;
}
 
function Child({ onFetched }: { onFetched: (data: Data) => void }) {
  const data = useSomeAPI();
 
  useEffect(() => {
    if (data) {
      onFetched(data);
    }
  }, [onFetched, data]);
 
  return <div>{/* ... */}</div>;
}

이 구조에서는 데이터의 출처를 추적하기가 매우 어렵습니다. 화면에 문제가 있을 때 "이 data가 어디서 온 거지?"라고 추적하면, Parent → Child → useSomeAPI → Effect → onFetched → 다시 Parent의 state라는 복잡한 경로를 따라가야 합니다.

TSX
// ✅ 데이터를 사용하는 곳에서 직접 가져오기
function Parent() {
  const data = useSomeAPI();
  return <Child data={data} />;
}
 
function Child({ data }: { data: Data | null }) {
  return <div>{/* ... */}</div>;
}

데이터 페칭의 주체를 부모로 끌어올리면 데이터 흐름이 위에서 아래로 단순해집니다. 어떤 값이든 "이 값은 어디서 왔지?"라는 질문에 컴포넌트 트리를 위로 올라가면 답을 찾을 수 있어야 합니다.

외부 저장소 구독: useSyncExternalStore

브라우저 API나 서드파티 라이브러리의 상태를 구독해야 할 때, Effect로 이벤트 리스너를 등록하는 방식도 동작하지만 useSyncExternalStore를 사용하는 것이 더 안전합니다.

TSX
// ⚠️ 동작하지만 더 나은 대안이 있음
function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(true);
 
  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);
 
  return isOnline;
}
TSX
// ✅ useSyncExternalStore로 외부 상태 구독
function subscribe(callback: () => void) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}
 
function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine,   // 클라이언트 값
    () => true                  // 서버 렌더링 시 기본값
  );
}

useSyncExternalStore는 외부 저장소와의 동기화라는 목적에 특화된 Hook입니다. 서버 렌더링 시의 기본값 처리, concurrent 렌더링에서의 tearing 방지 등 Effect로 직접 구현하면 놓치기 쉬운 엣지 케이스를 내부적으로 처리합니다.

그래서 언제 useEffect를 쓰는가

지금까지 useEffect가 불필요한 경우를 살펴보았습니다. 그렇다면 useEffect가 정말 적절한 경우는 무엇일까요? 핵심은 "React 바깥 세계와의 동기화"입니다.

외부 시스템과의 동기화

React가 관리하지 않는 API와 상호작용해야 할 때 Effect가 필요합니다. 서드파티 위젯의 초기화와 정리, IntersectionObserver 같은 브라우저 API 연결, 타이머 등이 여기에 해당합니다.

TSX
// ✅ Effect의 적절한 사용: 외부 라이브러리 동기화
function ChatRoom({ roomId }: { roomId: string }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
 
    return () => connection.disconnect();
  }, [roomId]);
 
  return <h1>{roomId}에 연결됨</h1>;
}

데이터 페칭

네트워크 요청은 외부 시스템과의 상호작용이므로 Effect에 넣는 것이 맞습니다. 다만 race condition을 반드시 처리해야 합니다. 사용자가 검색어를 빠르게 바꾸면 이전 요청의 응답이 나중에 도착하여 오래된 결과가 화면에 표시될 수 있기 때문입니다.

TSX
// ✅ 데이터 페칭 시 race condition 방어
function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState<Result[]>([]);
 
  useEffect(() => {
    let cancelled = false;
 
    fetchResults(query).then(data => {
      if (!cancelled) {
        setResults(data);
      }
    });
 
    return () => {
      cancelled = true;
    };
  }, [query]);
 
  return <ResultList items={results} />;
}

cleanup 함수에서 cancelled 플래그를 설정하면, 새 요청이 시작되기 전에 이전 요청의 결과가 무시됩니다. 물론 프로덕션 환경에서는 React Query, SWR 같은 데이터 페칭 라이브러리가 이러한 처리를 내장하고 있으므로, 직접 Effect를 작성하는 것보다 이들을 활용하는 편이 낫습니다.

판단 플로차트

코드를 작성하다가 useEffect에 손이 갈 때 아래 질문을 순서대로 던져보면 됩니다.

질문답이 Yes라면
기존 state/props에서 계산할 수 있는 값인가?렌더링 중에 계산 (필요시 useMemo)
사용자의 특정 행동이 트리거인가?이벤트 핸들러에 작성
props가 바뀔 때 state를 리셋해야 하는가?key prop 활용
외부 시스템(DOM, 네트워크, 서드파티)과 동기화하는가?useEffect 사용
컴포넌트가 화면에 보이는 것 자체가 트리거인가?useEffect 사용

정리

핵심 정리

  1. 렌더링이 곧 반응입니다. state가 바뀌면 컴포넌트가 다시 실행되고, 그 안의 모든 코드가 새 값으로 재평가됩니다. 이것이 React의 반응 메커니즘입니다.
  2. 파생 값은 계산하면 됩니다. 기존 state/props에서 도출할 수 있는 값을 별도의 state로 관리하면 불필요한 렌더링과 동기화 버그가 발생합니다.
  3. 이벤트 로직은 이벤트 핸들러에 속합니다. "사용자가 무엇을 했기 때문에" 실행되는 코드는 Effect가 아니라 핸들러에 있어야 합니다.
  4. useEffect는 외부 동기화 도구입니다. React가 관리하지 않는 시스템과 소통할 때만 사용합니다.

참고 자료

  • You Might Not Need an Effect - React 공식 문서
  • Synchronizing with Effects - React 공식 문서
  • useSyncExternalStore API Reference
  • A Complete Guide to useEffect - Dan Abramov
Written by

Mirunamu (Park Geonwoo)

Software Developer

관련 글 더보기

React

SSR Hydration Mismatch의 원리와 해결 패턴

React SSR에서 서버/클라이언트 렌더링 불일치가 발생하는 근본 원인과 체계적 해결 전략

읽기
React

Next.js App Router 렌더링 전략 완전 정리

Server Component와 Client Component, SSG·SSR·ISR의 두 축으로 이해하는 Next.js 렌더링 모델

읽기
React

Custom Hook 설계 원칙

재사용 가능하고 테스트하기 쉬운 Custom Hook을 설계하는 실전 원칙과 패턴

읽기
React

useEffectEvent: Effect 의존성 딜레마의 종착점

React 19.2 useEffectEvent 훅의 동작 원리, ref 트릭 대체, 실무 적용 전략

읽기
이전 글useReducer 완벽 가이드
다음 글useEffectEvent: Effect 의존성 딜레마의 종착점
목차
  • 들어가며
  • 렌더링이 곧 반응이다
  • 파생 값이라면 그냥 계산하면 된다
    • 비용이 걱정된다면 useMemo
  • 이벤트에 속하는 로직은 이벤트 핸들러에
    • Effect에 적합한 것과 아닌 것
  • Effect 체인이 만드는 렌더링 폭포
  • 컴포넌트 초기화와 key의 힘
  • 부모-자식 간 데이터 흐름
    • 외부 저장소 구독: useSyncExternalStore
  • 그래서 언제 useEffect를 쓰는가
    • 외부 시스템과의 동기화
    • 데이터 페칭
    • 판단 플로차트
  • 정리
  • 참고 자료