[번역] 왜 우리는 React의 useEffect를 금지했는가

AI, React

FactoryAI의 Alvin Sng의 글, Why we banned React's useEffect의 (https://x.com/alvinsng/status/2033969062834045089?s=46)의 번역글입니다. 저자에게 허가를 받고 번역을 진행했습니다. 흔쾌히 수락해준 저자에게 감사를 전합니다.


왜 우리는 React의 useEffect를 금지했는가

Factory에서는 단순하지만 중요한 프런트엔드 규칙이 하나 있다. 바로 useEffect 금지다. 그렇다, 꽤 엄격하게 들린다. 하지만 실제로는 이 규칙 덕분에 코드베이스를 더 쉽게 추론할 수 있게 되었고, 실수로 무언가를 깨뜨리기도 훨씬 어려워졌다.

"금지"라는 말의 뜻은?

우리는 useEffect를 직접 호출하지 않는다. 아주 드물게 마운트 시점에 외부 시스템과 동기화해야 하는 경우를 위해 useMountEffect()를 따로 두고 있다.

export function useMountEffect(effect: () => void | (() => void)) {
  /* eslint-disable no-restricted-syntax */
  useEffect(effect, []);
}

대부분의 useEffect 사용은 사실 React가 이미 더 나은 기본 도구를 제공하는 문제를 억지로 메우는 경우다. 예를 들면 파생 상태, 이벤트 핸들러, 데이터 패칭 추상화 같은 것들이다.

이건 이제 에이전트가 코드를 작성하는 시대라서 더 중요해졌다. useEffect는 종종 '혹시 모르니까'라는 이유로 추가되는데, 바로 그 선택이 다음 레이스 컨디션이나 무한 루프의 씨앗이 된다. 이 훅을 금지하면 로직이 선언적이고 예측 가능하도록 강제할 수 있다.

뼈아픈 교훈

우리가 이 규칙에 쉽게 도달한 것은 아니다. 실제 프로덕션 버그를 겪으며 여기까지 오게 됐다.

useEffect 관련 사고로 촉발된 Slack 스레드들의 일부.

문제는 눈덩이처럼 불어난다

깨지기 쉬움: dependency 배열은 결합을 숨긴다. 겉보기에 관련 없어 보이는 리팩터링도 effect의 동작을 조용히 바꿔버릴 수 있다.

무한 루프: 상태 업데이트 -> 렌더 -> effect -> 상태 업데이트 루프를 만들기 너무 쉽다. 특히 dependency 목록을 조금씩 "수정"해 나갈수록 더 그렇다.

의존성 지옥: effect 체인(A가 상태를 바꾸고, 그게 B를 트리거하는 식)은 시간 기반 제어 흐름이다. 추적하기 어렵고, 회귀 버그가 생기기 쉽다.

디버깅 고통: 핸들러처럼 명확한 진입점이 없어서, 결국 "왜 이게 실행됐지?" 혹은 "왜 이건 실행되지 않았지?"를 계속 묻게 된다.

하나의 문화적 밈

이쯤 되면 useEffect는 React 커뮤니티 전체에서 하나의 러닝 개그가 됐다.

2025년 10월 19일
올해 나는 useEffect 루프로 분장할 거야

2025년 11월 5일
(gif)
React 코어 팀이 개발자들이 useEffect 쓰는 걸 볼 때

2024년 5월 9일
React가 이름을 바꿔야 할 것들
"use server" -> "use action" (예전엔 진짜 이 이름이었음 lol)
"use client" -> "use interactive"
React.cache -> React.dedupe
useEffect -> DO_NOT_USE_EFFECT_OR_YOU_WILL_BE_FIRED

2025년 10월 25일
이걸 에이전트에 먹여준 뒤 오늘 useEffect 호출 141개를 리팩터링했다

공식 React 팀도 같은 얘기를 한다

이건 단지 우리 내부 취향의 문제가 아니다. React에는 아예 You Might Not Need an Effect라는 전체 가이드가 있다.

문제는 무엇이었나? useEffect는 많은 팀을 명시적인 이벤트 중심 로직에서 암묵적인 동기화 로직으로 옮겨가게 만들었다. 분명한 이벤트에 반응하는 대신, dependency 배열을 통해 값들과 부수 효과 사이의 관계를 관리하게 된 것이다.

해결책

아래 다섯 가지 패턴이 우리 코드베이스에서 대부분의 useEffect 사용을 대체했다.

규칙 1: 상태를 동기화하지 말고, 파생시켜라

다른 상태로부터 상태를 세팅하는 대부분의 effect는 불필요하고, 렌더를 한 번 더 발생시킨다.

// ❌ 나쁨: 렌더 사이클이 두 번 돈다 - 처음엔 stale 상태, 그다음 필터링됨
function ProductList() {
  const [products, setProducts] = useState([]);
  const [filteredProducts, setFilteredProducts] = useState([]);

  useEffect(() => {
    setFilteredProducts(products.filter((p) => p.inStock));
  }, [products]);
}

// ✅ 좋음: 한 번의 렌더 안에서 바로 계산
function ProductList() {
  const [products, setProducts] = useState([]);
  const filteredProducts = products.filter((p) => p.inStock);
}

이 패턴은 루프 위험도 만든다.

// ❌ 나쁨: total이 deps에 있어서 루프가 날 수 있다
function Cart({ subtotal }) {
  const [tax, setTax] = useState(0);
  const [total, setTotal] = useState(0);

  useEffect(() => {
    setTax(subtotal * 0.1);
  }, [subtotal]);

  useEffect(() => {
    setTotal(subtotal + tax);
  }, [subtotal, tax, total]);
}

// ✅ 좋음: effect가 전혀 필요 없다
function Cart({ subtotal }) {
  const tax = subtotal * 0.1;
  const total = subtotal + tax;
}

이상 신호 체크:

  • useEffect(() => setX(deriveFromY(y)), [y])를 쓰려 하고 있다
  • 다른 상태나 props를 그대로 반영만 하는 state가 있다

규칙 2: 데이터 패칭 라이브러리를 써라

effect 기반 패칭은 레이스 컨디션과 중복 캐싱 로직을 자주 만든다.

// ❌ 나쁨: 레이스 컨디션 위험
function ProductPage({ productId }) {
  const [product, setProduct] = useState(null);

  useEffect(() => {
    fetchProduct(productId).then(setProduct);
  }, [productId]);
}

// ✅ 좋음: 쿼리 라이브러리가 취소/캐싱/stale 처리까지 맡아준다
function ProductPage({ productId }) {
  const { data: product } = useQuery(['product', productId], () =>
    fetchProduct(productId)
  );
}

이상 신호 체크:

  • effect 안에서 fetch(...)를 하고, 그다음 setState(...)를 한다
  • 캐싱, 재시도, 취소, stale 처리를 직접 다시 구현하고 있다

규칙 3: effect가 아니라 이벤트 핸들러를 써라

사용자가 버튼을 클릭했다면, 그 작업은 핸들러 안에서 수행하면 된다.

// ❌ 나쁨: effect를 액션 전달 수단으로 사용
function LikeButton() {
  const [liked, setLiked] = useState(false);

  useEffect(() => {
    if (liked) {
      postLike();
      setLiked(false);
    }
  }, [liked]);

  return <button onClick={() => setLiked(true)}>Like</button>;
}

// ✅ 좋음: 직접적인 이벤트 중심 액션
function LikeButton() {
  return <button onClick={() => postLike()}>Like</button>;
}

이상 신호 체크:

  • effect가 진짜 액션을 수행할 수 있도록 state를 플래그처럼 쓰고 있다
  • "플래그 설정 -> effect 실행 -> 플래그 초기화" 메커니즘을 만들고 있다

규칙 4: 일회성 외부 동기화에는 useMountEffect를 써라

useMountEffect는 그저 useEffect(..., [])를 이름 있는 훅으로 감싼 것에 불과하지만, 의도를 더 명확하게 드러내고 컴포넌트 안에서 즉흥적으로 effect를 남용하는 일을 막아준다.

function useMountEffect(callback: () => void | (() => void)) {
  useEffect(callback, []);
}

좋은 사용처:

  • DOM 연동(focus, scroll)
  • 서드파티 위젯 라이프사이클
  • 브라우저 API 구독

여기서 유용한 패턴 하나는 조건부 마운팅이다.

// ❌ 나쁨: effect 내부에서 조건 검사
function VideoPlayer({ isLoading }) {
  useEffect(() => {
    if (!isLoading) playVideo();
  }, [isLoading]);
}

// ✅ 좋음: 선행 조건이 충족될 때만 마운트
function VideoPlayerWrapper({ isLoading }) {
  if (isLoading) return <LoadingScreen />;
  return <VideoPlayer />;
}

function VideoPlayer() {
  useMountEffect(() => playVideo());
}

// ✅ 이것도 좋음: 껍데기는 유지하고, 인스턴스만 조건부로 생성
function VideoPlayerInstance() {
  useMountEffect(() => playVideo());
}

function VideoPlayerContainer({ isLoading }) {
  return (
    <>
      <VideoPlayerShell isLoading={isLoading} />
      {!isLoading && <VideoPlayerInstance />}
    </>
  );
}

이상 신호 체크:

  • 외부 시스템과 동기화하고 있다
  • 동작 자체가 본질적으로 "마운트 시 설정, 언마운트 시 정리"에 가깝다

규칙 5: dependency 안무 대신 key로 리셋하라

// ❌ 나쁨: 리마운트를 흉내 내려고 effect를 사용
function VideoPlayer({ videoId }) {
  useEffect(() => {
    loadVideo(videoId);
  }, [videoId]);
}

// ✅ 좋음: key가 깨끗한 리마운트를 강제
function VideoPlayer({ videoId }) {
  useMountEffect(() => {
    loadVideo(videoId);
  });
}

function VideoPlayerWrapper({ videoId }) {
  return <VideoPlayer key={videoId} videoId={videoId} />;
}

요구사항이 "ID가 바뀌면 새로 시작해야 한다"라면, React의 리마운트 의미론을 직접 활용하면 된다.

이상 신호 체크:

  • ID/prop이 바뀔 때 로컬 상태를 리셋하는 일만 하는 effect를 쓰고 있다
  • 각 엔터티마다 완전히 새로운 인스턴스처럼 동작하길 원한다

중첩 구조를 더 깔끔하게 만들도록 강제한다

useEffect 직접 사용을 금지하면 더 깔끔한 트리 설계를 하도록 강제하는 효과가 있다. 부모는 오케스트레이션과 라이프사이클 경계를 담당하고, 자식은 필요한 선행 조건이 이미 충족됐다고 가정할 수 있다. 그 결과 컴포넌트는 더 단순해지고, 숨겨진 부수 효과도 줄어든다.

이건 사실 React 컴포넌트에 적용한 유닉스 철학과도 비슷하다. 각 단위는 하나의 일만 하고, 조율은 명확한 경계에서 일어난다.

어떤 버그를 고를 것인가

버그를 전혀 내지 않는 팀은 없다. 문제는 어떤 실패 모드를 선택할 것이냐다.

useMountEffect의 실패는 대체로 이진적이고 시끄럽다. 한 번 실행됐거나, 아예 실행되지 않았거나 둘 중 하나다. 반면 직접적인 useEffect 실패는 점진적으로 망가지는 경우가 많고, 치명적인 실패에 이르기 전에 flaky한 동작, 성능 문제, 루프 같은 형태로 먼저 드러난다.

당신도 이렇게 해야 한다

대규모 앱에서 useEffect 직접 사용 없는 코드베이스를 운영해 본 결과, 무한 루프가 줄었고, 레이스 컨디션 회귀도 줄었으며, 제어 흐름을 따라가기 쉬워져서 온보딩도 더 빨라졌다.

처음에는 이 규칙이 극단적으로 느껴졌다. 지금은 기본적인 엔지니어링 가드레일처럼 느껴진다.

이 규칙을 도입하는 방법

lint 규칙과 AGENTS.md 안의 명확한 에이전트 가이드로 이 규칙을 강제하라. 기존 사례를 대량으로 고치려면, 각 위반 사례를 일괄 수정하는 우리 새 Missions 제품을 써보라. 이 글 자체를 당신의 드로이드들에게 참고 가이드로 넘겨줘도 된다.