왜 우리 팀은 React useEffect를 쓰지 않기로 했나
TMTFactory(Factory.AI)에서는 아주 간단하지만 중요한 프론트엔드 규칙이 하나 있습니다: useEffect 사용 금지. 네, 꽤 엄격하게 들립니다. 하지만 실제로는 우리 코드베이스를 더 이해하기 쉽고, 실수로 망가뜨리기 훨씬 어려운 방향으로 만들어 줍니다.
“금지했다”는 게 무슨 뜻인가요?
우리는 useEffect를 직접 호출하지 않습니다. 마운트 시점에 외부 시스템과 동기화를 해야 하는 드문 경우에만 useMountEffect()를 사용합니다.
function useMountEffect(effect: () => void | (() => void)) {
/* eslint-disable no-restricted-syntax */
useEffect(effect, []);
}대부분의 useEffect 사용은 사실상 React가 이미 더 나은 프리미티브로 제공하고 있는 것들(파생 상태, 이벤트 핸들러, 데이터 페칭 추상화)을 보완하려는 용도로 쓰이고 있습니다.
이제는 에이전트가 코드를 작성하는 시대라서 이 문제는 더 심각해졌습니다. useEffect는 종종 ‘혹시 모르니까’라는 이유로 추가되는데, 이런 선택이 곧 다음 경쟁 상태(race condition)나 무한 루프의 씨앗이 됩니다. 이 훅을 금지하면 로직을 선언적이고 예측 가능하게 작성할 수밖에 없게 됩니다.
힘들게 배운 교훈
우리는 이 규칙에 쉽게 도달하지 못했습니다. 프로덕션에서의 버그를 통해 여기까지 왔습니다.
useEffect 사고 터질 때마다 슬랙에 쌓였던 대화들 예시.
복합적으로 쌓이는 문제들
취약성: 의존성 배열은 결합 관계를 숨깁니다. 겉보기에는 관계없는 리팩터링이라도, 조용히 effect의 동작을 바꿔버릴 수 있습니다.
무한 루프: 특히 의존성 배열을 나중에 “고치면서” 늘릴수록, 상태 업데이트 → 렌더 → effect → 상태 업데이트로 이어지는 루프를 만들기가 너무 쉽습니다.
의존성 지옥: effect 체인(A가 상태를 세팅하면 그걸 트리거로 B가 실행되는 구조)은 시간 축을 따라 흐르는 제어 흐름입니다. 추적하기 어렵고 회귀(리그레션)가 생기기 쉬운 패턴입니다.
디버깅 고통: 명확한 진입점(이벤트 핸들러) 없이 “왜 이게 실행됐지?”, “왜 이게 실행 안 됐지?” 같은 질문을 계속 던지게 됩니다.
문화적인 밈
이제 useEffect는 React 커뮤니티 전반에서 돌아다니는 농담 소재가 되었습니다.
공식 React 팀의 시각
이건 단순히 우리 팀 내부의 선호 문제만이 아닙니다. React에는 You Might Not Need an Effect 라는 전체 가이드가 따로 있을 정도입니다.
문제의 핵심은, useEffect 때문에 많은 팀들이 명시적인 이벤트 기반 로직에서 암묵적인 동기화 로직으로 이동해 버렸다는 점입니다. 명확한 이벤트에 반응하는 대신, 의존성 배열을 통해 값들과 부수 효과 사이의 관계를 관리하게 된 것입니다.
해결책
아래는 우리가 대부분의 useEffect 사용을 대체하기 위해 도입한 다섯 가지 패턴입니다.
규칙 1: 상태를 “동기화”하지 말고, “파생”시키세요
다른 상태로부터 state를 세팅하는 effect는 대부분 불필요하며, 렌더 횟수만 늘리고 합니다.
// ❌ BAD: 렌더가 두 번 일어남 - 처음 한 번은 오래된 상태, 그 다음에 필터링된 상태
function ProductList() {
const [products, setProducts] = useState([]);
const [filteredProducts, setFilteredProducts] = useState([]);
useEffect(() => {
setFilteredProducts(products.filter((p) => p.inStock));
}, [products]);
}
// ✅ GOOD: 한 번의 렌더 안에서 바로 계산
function ProductList() {
const [products, setProducts] = useState([]);
const filteredProducts = products.filter((p) => p.inStock);
}이 패턴은 루프를 만들 위험도 안고 있습니다:
// ❌ BAD: 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]);
}
// ✅ GOOD: effect가 전혀 필요 없음
function Cart({ subtotal }) {
const tax = subtotal * 0.1;
const total = subtotal + tax;
}이상 징후 체크:
- 지금
useEffect(() => setX(deriveFromY(y)), [y])를 쓰려 하고 있다 - 어떤
state가 다른state나props를 그대로 따라가기만 한다
규칙 2: 데이터 페칭 라이브러리를 사용하세요
effect로 데이터 페칭을 하면 경합 상태(race condition)와 중복 캐싱 로직을 만들기 쉽습니다.
// ❌ BAD: 레이스 컨디션 발생 가능
function ProductPage({ productId }) {
const [product, setProduct] = useState(null);
useEffect(() => {
fetchProduct(productId).then(setProduct);
}, [productId]);
}
// ✅ GOOD: 쿼리 라이브러리가 취소/캐싱/신선도 관리를 대신 처리
function ProductPage({ productId }) {
const { data: product } = useQuery(['product', productId], () =>
fetchProduct(productId)
);
}이상 징후 체크:
- effect 안에서
fetch(…)를 호출한 뒤setState(…)를 하고 있다 - 캐싱, 재시도, 취소, stale 처리 등을 직접 다시 구현하고 있다
규칙 3: effect가 아니라 이벤트 핸들러를 쓰세요
사용자가 버튼을 클릭했다면, 그때 해야 할 일을 핸들러 안에서 바로 수행하세요.
// ❌ BAD: 액션을 중계하는 용도로 effect를 사용
function LikeButton() {
const [liked, setLiked] = useState(false);
useEffect(() => {
if (liked) {
postLike();
setLiked(false);
}
}, [liked]);
return <button onClick={() => setLiked(true)}>Like</button>;
}
// ✅ GOOD: 직접적인 이벤트 기반 액션
function LikeButton() {
return <button onClick={() => postLike()}>Like</button>;
}이상 징후 체크:
- effect가 실제 일을 하기 위해 상태를 플래그처럼 사용하고 있다
- “플래그 세팅 → effect 실행 → 플래그 리셋” 메커니즘을 만들고 있다
규칙 4: 1회성 외부 동기화에는 useMountEffect를 사용하세요
useMountEffect는 단순히 useEffect(…, [])를 이름 있는 훅으로 감싼 것뿐이지만, 의도를 명확히 드러내고 컴포넌트 안에서 즉흥적으로 effect를 쓰는 것을 막아 줍니다.
function useMountEffect(callback: () => void | (() => void)) {
useEffect(callback, []);
}좋은 사용 사례:
- DOM 연동(포커스, 스크롤)
- 서드파티 위젯 생명주기
- 브라우저 API 구독
유용한 패턴 하나는 조건부 마운트입니다.
// ❌ BAD: effect 내부에서 가드를 두는 방식
function VideoPlayer({ isLoading }) {
useEffect(() => {
if (!isLoading) playVideo();
}, [isLoading]);
}
// ✅ GOOD: 전제 조건이 충족될 때만 마운트
function VideoPlayerWrapper({ isLoading }) {
if (isLoading) return <LoadingScreen />;
return <VideoPlayer />;
}
function VideoPlayer() {
useMountEffect(() => playVideo());
}
// ✅ ALSO GOOD: 껍데기는 유지하고 인스턴스만 조건부로 마운트
function VideoPlayerInstance() {
useMountEffect(() => playVideo());
}
function VideoPlayerContainer({ isLoading }) {
return (
<>
<VideoPlayerShell isLoading={isLoading} />
{!isLoading && <VideoPlayerInstance />}
</>
);
}이상 징후 체크:
- 외부 시스템과 동기화를 하고 있다
- 행동 자체가 “마운트 시 설정, 언마운트 시 정리”에 자연스럽게 가깝다
규칙 5: 의존성 조율이 아니라 key로 리셋하세요
// ❌ BAD: 리마운트 동작을 흉내 내려는 effect
function VideoPlayer({ videoId }) {
useEffect(() => {
loadVideo(videoId);
}, [videoId]);
}
// ✅ GOOD: key로 깔끔한 리마운트를 강제
function VideoPlayer({ videoId }) {
useMountEffect(() => {
loadVideo(videoId);
});
}
function VideoPlayerWrapper({ videoId }) {
return <VideoPlayer key={videoId} videoId={videoId} />;
}요구사항이 “ID가 바뀔 때마다 완전히 새로 시작하고 싶다”라면, React의 리마운트 시맨틱을 그대로 사용하는 것이 좋습니다.
이상 징후 체크:
- 어떤 ID/prop이 바뀔 때마다 로컬 state를 리셋하는 용도로만 effect를 쓰고 있다
- 각 엔티티마다 이 컴포넌트가 완전히 새 인스턴스처럼 동작하길 원한다
중첩을 위한 강제 장치
useEffect를 직접 금지하는 규칙은 더 깔끔한 트리 구조를 만들기 위한 일종의 “강제 장치”로 작동합니다. 부모 컴포넌트가 오케스트레이션과 생명주기 경계를 책임지고, 자식 컴포넌트는 전제 조건이 이미 충족되어 있다고 가정할 수 있습니다. 그 결과 컴포넌트는 더 단순해지고, 숨은 부수 효과도 줄어듭니다.
이건 사실상 Unix 철학을 React 컴포넌트에 적용한 것과 같습니다. 각 유닛은 한 가지 일만 하고, 조율은 명확한 경계에서 이루어지도록 하는 식입니다.
어떤 종류의 버그를 선택할 것인가
어떤 팀도 버그가 전혀 없는 소프트웨어를 배포하지는 못합니다. 중요한 건, 어떤 종류의 실패 양식을 선택하느냐입니다.
useMountEffect에서 오는 실패는 보통 이분법적이고 명확하게 드러납니다(한 번 실행됐거나, 아예 실행 안 됐거나). 반면 useEffect를 직접 쓰다가 생기는 실패는 점진적으로 악화되는 경우가 많고, 딱 죽지는 않더라도 플래키한 동작, 성능 문제, 루프 현상 같은 모습으로 먼저 나타나다가 나중에야 큰 장애로 이어지곤 합니다.
당신도 이렇게 하는 게 좋습니다
우리는 대규모 앱에서 “직접 useEffect 금지” 규칙을 적용한 코드베이스를 운영한 뒤, 무한 루프가 줄어들고, 경쟁 상태로 인한 회귀도 줄었으며, 제어 흐름을 따라가기가 쉬워져 신입 온보딩 속도 또한 빨라지는 경험을 했습니다.
처음에는 이 규칙이 다소 극단적으로 느껴졌습니다. 지금은 기본적인 엔지니어링 가드레일처럼 느껴질 뿐입니다.
이 규칙을 도입하는 방법
린트 규칙과 AGENTS.md에 명확한 에이전트 가이드를 추가하여 이를 강제하세요. 기존의 모든 useEffect 사용 사례를 한 번에 고치고 싶다면, 우리의 새 Missions 제품을 사용해 각 위반 사례를 일괄 수정할 수 있습니다. 이 글을 여러분의 드로이드들이 참고할 수 있는 레퍼런스 가이드로 활용하면 됩니다.

