왜 우리는 exhaustive-deps를 빈번하게 사용하게 될까?
Introduce
eslint-disable의 기술적 의미
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
// 컴포넌트 마운트 시 1회 실행하려는 비동기 작업
}, []);
react-hooks/exhaustive-deps 규칙을 비활성화하는 이 주석은 React 프로젝트에서 가장 흔하게 발견되는 기술적 부채 신호 중 하나일 것이다. 이 주석은 종종 "규칙이 틀렸거나, 내 상황이 특별해서"라는 가정하에 추가되지만, 근본적으로는 React의 데이터 플로우 모델과 개발자의 의도 사이에 불일치가 발생했음을 의미한다.
이 글에서는 왜 이러한 불일치가 발생하는지, useEffect와 exhaustive-deps 규칙의 핵심 원리가 무엇인지 기술적으로 분석하고, 규칙을 비활성화하는 대신 더 나은 아키텍처 패턴을 통해 문제를 해결하는 방법을 알아보자.
1. useEffect는 라이프사이클이 아니다
useEffect를 클래스 컴포넌트의 componentDidMount, componentDidUpdate 등 생명주기(Lifecycle) 메서드의 대체재로 이해하는 것은 흔한 오해의 시작이다. 이 비유는 useEffect가 시간의 흐름에 따라 동작한다고 착각하게 만든다.
useEffect의 정확한 모델은 상태(State)와 외부 시스템(Side Effect) 간의 동기화이다.
useEffect는 React의 상태 관리 시스템(Props, State)과 외부 시스템(DOM, Web API, 서드파티 라이브러리 등)의 상태를 일치시키는 선언적 메커니즘이다.
이 관점에서 의존성 배열의 의미는 명확해진다.
useEffect(fn, [a, b])
: a 또는 b가 변경될 때마다, 이들의 최신 값을 사용하여 외부 시스템과 동기화를 다시 수행하라.useEffect(fn, [])
: 어떤 상태 변경에도 반응하지 말고, 컴포넌트가 렌더링 트리에 처음 추가될 때 단 한 번만 동기화를 수행하라.useEffect(fn)
: 모든 렌더링 후에 동기화를 수행하라 (사실상 거의 사용되지 않음).
우리의 질문은 "이 코드는 언제 실행되는가?"가 아니라, 이 부수 효과는 어떤 상태 값들과 동기화되어야 하는가?가 되어야 한다.
useEffect와 useLayoutEffect의 실행 시점
useEffect를 깊이 이해하려면, 실행 시점이 다른 useLayoutEffect와의 차이를 알아야 한다.
- useEffect (비동기 실행): React가 렌더링 결과를 DOM에 반영하고, 브라우저가 화면을 페인트(paint)한 후에 비동기적으로 실행된다. 이 방식은 브라우저 렌더링을 차단하지 않으므로 사용자 경험에 유리하다. 데이터 페칭, 구독 설정, 로깅 등 대부분의 부수 효과에 적합하다.
- useLayoutEffect (동기 실행): React가 렌더링 결과를 DOM에 반영했지만, 브라우저가 화면을 페인트하기 전에 동기적으로 실행된다. 이 Hook의 실행이 완료될 때까지 브라우저 페인팅은 차단된다. 따라서 DOM의 레이아웃을 읽어와서(예: 요소의 크기나 위치) 동기적으로 리렌더링을 유발해야 할 때, 즉 화면이 깜빡이는 현상(flicker)을 막아야 할 때만 제한적으로 사용해야 한다.
결론은 항상 useEffect를 기본으로 사용하고, 화면 깜빡임 문제가 발생했을 때만 useLayoutEffect로의 전환을 고려하는 것이 좋다.
2. exhaustive-deps 규칙의 기술적 목표
이 규칙의 목표는 단 하나, 동기화의 무결성 보장이다. React가 렌더링 간의 값들을 비교하여 변경을 감지하듯이, useEffect는 의존성 배열의 값들을 비교하여 부수 효과의 재실행 여부를 결정한다.
Stale Closure 문제의 방지
JavaScript 클로저는 함수가 생성될 당시의 렉시컬 스코프에 있는 변수들을 '기억'한다. useEffect의 콜백 함수 역시 클로저다.
const [count, setCount] = useState(0);
useEffect(() => {
// 이 콜백 함수는 첫 렌더링 시점에 생성됨
// 이때 count의 값은 0이며, 이 클로저는 0을 영원히 기억한다.
const timer = setInterval(() => {
console.log(`Stale count: ${count}`); // 항상 0
}, 1000);
return () => clearInterval(timer);
}, []); // 의존성 배열이 비어있어 함수가 재생성되지 않음
exhaustive-deps
규칙은 이 콜백 함수가 의존하는 count가 변경될 때마다 함수를 재생성하도록 강제한다. 새로운 함수는 새로운 스코프를 가지므로, 최신 count 값을 정상적으로 '기억'할 수 있다. 이것이 Stale Closure를 방지하는 원리이다.
3. 그렇다면 왜 우리는 규칙을 어기게 될까?
이론은 완벽해 보이지만, 언제나 그렇듯이 현실의 코드는 복잡하다.
1. 동기화와 일회성 이벤트의 혼동
컴포넌트 마운트 시 단 한 번만 실행하고 싶은 '이벤트'성 작업은 useEffect의 '동기화' 모델과 본질적으로 충돌한다.
export const useFormInitializer = (initialData?: UserProfile) => {
const formStore = useFormStore();
const initializedRef = useRef(false);
useEffect(() => {
if (!initializedRef.current) {
initializedRef.current = true;
formStore.setData(initialData);
}
}, [initialData, formStore]); // 규칙을 따르면 formStore가 추가됨
};
여기서 우리가 원하는 건 "상태 동기화"가 아니라 마운트 시점의 초기화 이벤트이다.
ESLint는 formStore도 의존성 배열에 넣으라고 경고한다. 하지만 formStore를 넣으면 어떻게 될까?
- formStore.setData()가 호출되어 formStore 객체(또는 그 내부 값)가 변경된다.
- 변경된 formStore 때문에 useEffect가 다시 실행된다.
- 다시 formStore.setData()가 호출된다.
- 무한 루프 🤪
만약 위 코드에서 formStore의 setData가 formStore 객체 자체를 변경(예: 새로운 상태 객체 반환)한다면, useEffect가 다시 실행되어 무한 루프가 발생한다. 여기서 개발자의 의도는 formStore의 상태와 동기화하는 것이 아니라, formStore의 메서드를 사용하는 일회성 이벤트를 발생시키는 것이다. 이 불일치가 eslint-disable을 유발한다.
2. 참조 안정성(Referential Stability) 문제
JavaScript에서 객체와 함수는 렌더링마다 새로 생성되므로, 이전 렌더링의 값과 내용은 같더라도 참조(메모리 주소)가 다르다.
function MyComponent({ id }) {
// fetchData 함수는 매 렌더링마다 새로운 참조값을 가진다.
const fetchData = () => { /* ... */ };
useEffect(() => {
fetchData();
}, [fetchData]); // 참조가 매번 바뀌므로 effect가 불필요하게 재실행됨
}
useCallback과 useMemo는 이 문제를 해결하기 위한 도구이지만, 이들 역시 자신의 의존성 배열을 가진다. 이는 의존성 문제를 다른 곳으로 전가하는 '의존성 체인'을 만들 뿐, 근본적인 해결책이 아닐 수 있다.
4. 의존성 문제 해결을 위한 아키텍처 패턴
1. 함수 및 로직의 내부화 (Colocation)
가장 바람직한 해결책으로 볼 수 있다. 부수 효과에 필요한 모든 로직을 useEffect 내부로 옮겨, 외부 의존성을 최소화한다.
useEffect(() => {
const fetchData = () => { /* id를 사용하는 로직 */ };
fetchData();
}, [id]); // 이제 effect는 안정적인 원시값 id에만 의존
2. 함수형 업데이트 활용
Stale Closure로 인한 상태 업데이트 문제를 해결하는 가장 효과적인 방법이다. setState에 값을 직접 전달하는 대신, 최신 상태를 인자로 받는 함수를 전달한다.
// `messages`에 대한 의존성 없이도 안전하게 상태 업데이트 가능
setMessages(prevMessages => [...prevMessages, newMessage]);
이 방식은 React에게 상태 업데이트의 의도만 전달하고, 실제 실행은 React가 최신 상태 값으로 처리하도록 위임하여 클로저 문제를 우회한다.
3. useReducer를 통한 상태 로직 분리
여러 상태가 복잡하게 얽혀있고, 상태를 변경하는 로직이 다양한 경우 useReducer가 효과적이다. dispatch 함수는 React에 의해 참조 안정성이 보장되므로 의존성 배열에 추가할 필요가 없다.
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
// 복잡한 로직이 state 값들에만 의존하게 됨
autoSaveToServer(state.name, state.email);
}, [state.name, state.email]);
4. useRef를 이용한 이벤트 핸들러 안정화
useEffect가 '동기화'가 아닌 '이벤트 리스너 등록'과 같은 단일 설정 목적으로 사용될 때, 콜백 함수의 참조 안정성 문제를 해결하기 위한 '탈출구'이다.
const onScrollRef = useRef(onScroll);
onScrollRef.current = onScroll; // 매 렌더링마다 최신 함수로 교체
useEffect(() => {
const handler = (e) => onScrollRef.current(e);
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
}, []); // 의도적으로 의존성 배열을 비움
이 패턴은 선언적 모델을 벗어나므로, 다른 방법을 찾을 수 없을때만 신중하게 사용해야 한다.
5. 의존성 친화적인 커스텀 훅 설계
커스텀 훅을 설계할 때는 훅을 사용하는 개발자의 입장을 고려해야 한다. 훅이 반환하는 값의 참조 안정성을 보장하지 않으면, 소비하는 쪽에서 의존성 배열 문제를 야기할 수 있다.
// 나쁜 예: 반환되는 함수와 객체가 매번 새로 생성됨
export function useCounter() {
const [count, setCount] = useState(0);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
return { count, increment, decrement }; // 이 객체는 매번 새로운 참조
}
// 좋은 예: 반환 값의 참조 안정성을 보장
export function useCounter() {
const [count, setCount] = useState(0);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
return useMemo(() => ({
count,
increment,
decrement
}), [count, increment, decrement]);
}
5. eslint-disable 사용 전 기술적 검토 목록
규칙 비활성화는 최후의 수단이며, 반드시 그 결정에 대한 기술적 근거가 있어야 한다.
- Effect의 목적 분석 : 이 효과는 특정 상태들과 지속적으로 동기화되어야 하는가, 아니면 단 한 번만 실행되는 이벤트인가?
- Stale Closure 위험 평가 : 의존성 배열에서 제외된 값이 부수 효과 내에서 사용될 때, 오래된 값을 참조하여 발생하는 버그의 가능성은 없는가? 함수형 업데이트로 해결 가능한가?
- 참조 안정성 검토 : 의존성이 불안정한 함수나 객체인가? useCallback, useMemo를 적용했는가? 또는 로직을 Effect 내부로 옮길 수는 없는가?
- 상태 구조 재설계 : 여러 useEffect가 연쇄적으로 반응하는가? 이는 파생 상태(useMemo)로 해결할 수 있는 문제일 수 있다. 상태 설계 자체의 재검토가 필요하다.
6. 결론: 규칙의 '의도'에 기반한 설계
exhaustive-deps 규칙은 React의 선언적 데이터 플로우와 상태 동기화 모델을 개발자가 올바르게 따르도록 유도하는 중요한 가이드이다. 대부분의 경우, 이 규칙을 위반해야 하는 상황은 useEffect의 오용이나 잘못된 상태 설계에서 비롯된다.
// eslint-disable-next-line react-hooks/exhaustive-deps
// REASON: This effect sets up a non-reactive event listener.
// The callback is stabilized via a ref to prevent re-subscriptions,
// and potential stale closures within the callback are handled internally.
useEffect(() => { /* ... */ }, []);
eslint-disable 주석은 단순한 규칙 무시가 아닌, React의 동기화 모델을 이해하고 있으나, 현재 구현의 목적은 의도적으로 이를 벗어난다는 명확한 설계 결정의 문서화여야 한다. 규칙의 본질적 '의도'를 이해하고, 그에 맞는 최적의 아키텍처를 선택하는 것이 예측 가능하고 유지보수하기 쉬운 코드를 작성하는 길이다.