useEffect 범벅에서 벗어나기: 진짜 필요한 곳에만 쓰는 법

useEffect 범벅에서 벗어나기: 진짜 필요한 곳에만 쓰는 법

태그
React
최종 수정일
Last updated March 28, 2026
Date
Mar 28, 2026
Published
Published
이 글에서는 useEffect가 남용되는 흔한 pattern을 짚고, 진짜 필요한 경우와 그렇지 않은 경우를 실무 코드와 함께 정리합니다.
React Hooks가 도입된 지 꽤 되었지만, 여전히 많은 codebase에서 useEffect가 범벅인 component를 쉽게 만날 수 있습니다. component 하나에 useEffect가 서너 개씩 붙어 있고, 어떤 순서로 실행되는지 머릿속으로 시뮬레이션해야 하는 코드 말입니다.
그러다 보면 class component 시절의 componentDidMount, componentDidUpdate가 그리워지기도 합니다. 이름만 봐도 "언제 실행되는지" 명확했으니까요. 하지만 그 향수에는 함정이 있습니다.
notion image

class lifecycle이 그리운 진짜 이유

class lifecycle은 시점 기준으로 코드를 배치했습니다. mount 시에는 componentDidMount, update 시에는 componentDidUpdate. 직관적이었습니다.
문제는 관심사가 흩어진다는 점입니다. 예를 들어 채팅방 구독 로직 하나를 구현하려면, componentDidMount에서 subscribe, componentDidUpdate에서 re-subscribe, componentWillUnmount에서 unsubscribe를 각각 작성해야 합니다. 하나의 관심사가 세 군데로 분산됩니다.
class component에서 시점 기준으로 관심사가 섞이는 예시입니다.
componentDidMount() { this.subscribe(this.props.roomId); // 채팅 구독 this.fetchUser(this.props.userId); // 전혀 다른 관심사가 같은 시점에 묶임 }
useEffect는 이 문제를 해결하기 위해 설계되었습니다. 하나의 관심사를 한 블록에 모을 수 있습니다.
구독과 해제를 한 곳에서 관리하는 Hook pattern입니다.
useEffect(() => { const conn = subscribe(roomId); return () => conn.unsubscribe(); }, [roomId]);
class lifecycle이 그리운 것은 "명시적인 실행 시점"이 그리운 것이지, model 자체가 더 나았던 것은 아닙니다.
notion image

진짜 문제: useEffect에 다 넣는 습관

useEffect가 문제를 해결해주는 도구라고 배우다 보면, 자연스럽게 무엇이든 useEffect 안에 넣는 습관이 생깁니다. 하지만 useEffect가 범벅이 되는 대부분의 경우, 사실 useEffect가 필요 없습니다.
React 공식 문서에서도 이 점을 반복해서 강조합니다.
"Effects are an escape hatch from the React paradigm. They let you 'step outside' of React and synchronize your components with some external system. If there is no external system involved, you shouldn't need an Effect." -- React 공식 문서, You Might Not Need an Effect
핵심 판별 기준은 하나입니다. "이 로직의 원인이 React render인가, 외부 시스템인가?"
원인이 user event라면 event handler에서 처리합니다. 원인이 props/state 변경이고 결과가 또 다른 state라면 설계를 다시 생각합니다. 원인이 React 바깥 세계와의 동기화라면 그때 useEffect를 씁니다.
이 기준을 바탕으로, 흔히 보이는 anti-pattern부터 살펴보겠습니다.

useEffect가 필요 없는 경우

rendering 중 계산 가능한 파생 state
가장 흔한 anti-pattern입니다. 다른 state에서 파생되는 값을 별도의 state로 관리하면서 useEffect로 동기화하는 코드입니다.
state와 useEffect로 불필요하게 동기화하는 코드입니다.
const [items, setItems] = useState<Item[]>([]); const [filtered, setFiltered] = useState<Item[]>([]); useEffect(() => { setFiltered(items.filter(i => i.active)); }, [items]);
items가 바뀌면 첫 번째 render가 돌고, useEffect에서 setFiltered가 호출되면서 두 번째 render가 또 발생합니다. 완전히 불필요한 cycle입니다.
render 중에 바로 계산하면 render 한 번으로 끝납니다.
const filtered = items.filter(i => i.active); // 비용이 크다면 useMemo로 감싸기 const filtered = useMemo(() => items.filter(i => i.active), [items]);
state가 아니라 계산이면, 그냥 계산하면 됩니다.
event 반응을 useEffect로 우회하는 경우
개선 전 코드입니다.
const [query, setQuery] = useState(''); useEffect(() => { if (query) { analytics.track('search', { query }); } }, [query]);
query가 바뀌는 원인이 user 입력이라면, 그 시점에서 바로 처리하는 것이 맞습니다. useEffect를 거치면 "왜 이 tracking이 찍혔지?"를 추적할 때 간접 참조가 하나 더 생깁니다.
event handler에서 직접 처리하는 개선 후 코드입니다.
const handleSearch = (value: string) => { setQuery(value); analytics.track('search', { query: value }); };
원인이 event라면, event handler에서 끝내는 것이 원칙입니다.
props 변경 시 state reset
개선 전 코드입니다.
const EditForm = ({ userId }: { userId: string }) => { const [name, setName] = useState(''); useEffect(() => { setName(''); }, [userId]); return <input value={name} onChange={e => setName(e.target.value)} />; };
React의 reconciliation이 이미 해주는 일을 수동으로 관리하고 있습니다. key를 바꾸면 component가 통째로 remount되면서 state도 자연스럽게 초기화됩니다.
<EditForm key={userId} userId={userId} />
state 간 연쇄 동기화
이것은 useEffect chain의 최악의 형태입니다.
const [country, setCountry] = useState('KR'); const [city, setCity] = useState('Seoul'); const [district, setDistrict] = useState(''); useEffect(() => { setCity(getDefaultCity(country)); }, [country]); useEffect(() => { setDistrict(getDefaultDistrict(city)); }, [city]);
country 변경 후 render, city 변경 후 render, district 변경 후 render. 총 3번의 render가 발생합니다.
event handler에서 한 번에 처리하면 React batching 덕분에 render가 1번만 발생합니다.
const handleCountryChange = (next: string) => { const nextCity = getDefaultCity(next); const nextDistrict = getDefaultDistrict(nextCity); setCountry(next); setCity(nextCity); setDistrict(nextDistrict); };
state 간 연쇄 동기화가 보이면, event가 발생하는 지점으로 로직을 끌어올리는 것이 답입니다.
notion image

진짜 필요한 pattern: 외부 시스템 동기화

anti-pattern을 걷어냈다면, 이제 useEffect가 적절한 영역을 살펴보겠습니다. 공통점은 하나입니다. React가 관리하지 않는 외부 시스템과의 동기화입니다.
"Effects let you run some code after rendering so that you can synchronize your component with some system outside of React." -- React 공식 문서, Synchronizing with Effects
browser API / DOM event 구독
const useWindowSize = () => { const [size, setSize] = useState({ w: innerWidth, h: innerHeight }); useEffect(() => { const handler = () => setSize({ w: innerWidth, h: innerHeight }); window.addEventListener('resize', handler); return () => window.removeEventListener('resize', handler); }, []); return size; };
window는 React 바깥의 외부 시스템입니다. React가 관리할 수 없으므로 useEffect로 구독-해제 cycle을 직접 잡아주는 것이 맞습니다. 참고로 더 정석적인 접근은 useSyncExternalStore를 사용하는 것입니다. useSyncExternalStore는 React 18에서 도입된 Hook으로, 외부 store의 값을 React render와 안전하게 동기화하기 위한 전용 API입니다.
WebSocket / 실시간 구독
const useChatMessages = (roomId: string) => { const [messages, setMessages] = useState<Message[]>([]); useEffect(() => { const ws = new WebSocket(`wss://api.example.com/rooms/${roomId}`); ws.onmessage = (e) => { setMessages(prev => [...prev, JSON.parse(e.data)]); }; return () => ws.close(); }, [roomId]); return messages; };
roomId가 바뀌면 이전 socket을 닫고 새 socket을 여는 흐름이 cleanup 함수 하나로 깔끔하게 표현됩니다. useEffect의 설계 의도에 정확히 맞는 케이스입니다.
서드파티 라이브러리 연동
const useMap = (ref: RefObject<HTMLDivElement>, center: LatLng) => { useEffect(() => { if (!ref.current) return; const map = new mapboxgl.Map({ container: ref.current, center: [center.lng, center.lat], }); return () => map.remove(); }, [center.lat, center.lng]); };
Mapbox처럼 자체 DOM을 관리하는 라이브러리는 React 입장에서 외부 시스템입니다. imperative한 API를 React의 선언적 render cycle에 맞춰 동기화하는 것이 정확히 useEffect의 역할입니다.
document.title 같은 browser state 동기화
const useDocumentTitle = (title: string) => { useEffect(() => { document.title = title; }, [title]); };
단순하지만 정당한 사용입니다. DOM이라는 외부 시스템과의 동기화이기 때문입니다.

useEffect를 쓰기 전 checklist

코드에 useEffect를 추가하려는 순간, 아래 질문들을 먼저 던져보시기 바랍니다.
첫째, event handler에서 처리할 수 있는지 확인합니다. user action이 원인이라면 onClick, onChange 안에서 직접 처리하면 됩니다.
둘째, render 중에 계산할 수 있는지 확인합니다. 파생 state는 useMemo나 단순 계산으로 해결합니다.
셋째, state 구조를 재설계하면 없앨 수 있는지 확인합니다. state A에서 state B로의 연쇄 동기화는 대부분 설계 문제입니다.
넷째, data fetching이라면 React Query나 SWR을 사용하고 있는지 확인합니다. 이미 해결된 문제를 직접 useEffect로 다시 풀 필요가 없습니다.
그래도 useEffect가 필요하다면, custom Hook으로 추출합니다. useEffect가 component에 직접 드러나는 것 자체가 code smell입니다. useRoomSubscription(roomId) 같은 Hook으로 감싸면 component는 "무엇을" 하는지만 선언하게 됩니다.
notion image

마무리

useEffect를 줄이는 방향은 class component로의 회귀가 아닙니다. 오히려 "useEffect를 아예 안 쓰는 설계"에 가깝습니다. 외부 시스템과의 동기화가 아닌 모든 곳에서 useEffect를 제거하고, 남은 것마저 custom Hook 안에 캡슐화하면, component는 선언적이고 읽기 쉬운 형태로 돌아옵니다.