leo.dev
frontend

의존성 배열의 기준: 무엇이 아니라 언제

2026.05.084 min read reactreact-queryuseeffect

react-hooks/exhaustive-deps는 “effect 안에서 쓰는 모든 값을 의존성 배열에 넣어라”라고 한다. 대개 맞는 규칙이다. 그런데 이 규칙은 “무엇을 쓰나”를 “언제 다시 도나”의 대용으로 삼는다. 보통은 둘이 일치한다. 일치하지 않는 지점에서, effect는 자기 자신을 먹기 시작한다.

업무요청 드로어를 열 때마다 서버가 429 Too Many Requests를 뱉던 버그가 정확히 그 지점을 드러냈다.

effect가 자기를 먹는다

WorkRequestDrawer.tsx
const markAsRead = useMarkCommentsAsRead(); // useMutation() 반환값
useEffect(() => {
if (isOpen && requestId) {
markAsRead.mutate(requestId);
}
}, [isOpen, requestId, markAsRead]); // ← markAsRead가 deps에 포함

규칙대로 markAsRead를 deps에 넣었다. “effect가 쓰는 값이니까.” 그런데 이 effect는 멈추질 않았다.

1. 드로어 열림 → effect 실행 → markAsRead.mutate()
2. mutation 성공 → onSuccess에서 invalidateQueries()
3. 쿼리 무효화 → refetch → 컴포넌트 리렌더
4. 리렌더로 useMarkCommentsAsRead() 재호출 → useMutation()이 새 객체 반환
5. markAsRead 참조가 바뀜 → deps "변경됨" → effect 재실행 → 1번으로

effect가 렌더를 유발하고(invalidateQueries → refetch → 리렌더), 그 렌더가 deps의 참조를 바꾸고, 바뀐 deps가 effect를 다시 부른다. 자기를 먹는 뱀이다. 두 조건이 겹쳐야 터진다. deps 중 하나가 렌더마다 참조가 바뀌고, effect가 렌더를 유발한다. exhaustive-deps가 시킨 그대로 했는데 무한 루프가 됐다.

Object.is는 모양이 아니라 정체를 본다

useMutation()이 반환하는 객체는 렌더마다 새 참조다. 기능은 같아도 정체(identity)가 다르다. useEffect의 의존성 비교는 얕은 Object.is다. 값의 모양이 같은지가 아니라 같은 객체인지를 본다. 그래서 “내용이 똑같은 새 객체”는 React에게 “바뀐 값”이다.

이건 useMutation만의 문제가 아니라 렌더마다 새 정체를 만드는 모든 값의 문제다.

참조가 불안정한 값이유안정적인 값
useMutation() 반환 객체렌더마다 새 참조useQueryClient() 반환값
인라인 객체 {} · 배열 []렌더마다 새 참조useState setter
인라인 함수 () => {}렌더마다 새 참조useCallback/useMemo 결과

deps에 넣어도 안전한 값은 “정체가 렌더 사이에 유지되는” 값뿐이다. exhaustive-deps는 이 구분을 안 한다. 쓰는 값이면 안정적이든 아니든 넣으라고만 한다.

deps는 “언제”의 목록이다

고치는 방법은 deps의 의미를 다시 묻는 데서 나온다. 의존성 배열은 “effect가 무엇을 쓰는가”의 목록이 아니라 **“effect가 언제 다시 실행되어야 하는가”**의 목록이다.

이 effect의 의도는 “드로어가 열리거나 대상 요청이 바뀔 때 읽음 처리한다”였다. 그러면 조건은 isOpenrequestId뿐이다. markAsRead어떻게 실행하는가의 수단이지 언제 실행하는가의 조건이 아니다. 수단을 조건 자리에 넣은 게 버그였다.

WorkRequestDrawer.tsx
useEffect(() => {
if (isOpen && requestId) {
markAsRead.mutate(requestId);
}
}, [isOpen, requestId]); // eslint-disable-line react-hooks/exhaustive-deps

eslint-disable이 찜찜하다면 그 직감이 맞다. 규칙을 끄는 건 신호다. 더 날카로운 질문은 “이게 애초에 effect일 일인가”다. “드로어가 열린다”는 건 렌더와 동기화할 상태라기보다 한 번 일어나는 사건에 가깝다. 그런 로직은 effect보다 이벤트 경로(드로어를 여는 그 동작)에 두는 게 맞을 때가 많다. 여기선 isOpen prop으로 열림을 받는 구조라 effect로 두되 deps를 의도대로 좁혔지만, 무한 루프가 한 번 났다는 건 “이 effect의 트리거가 정말 렌더 동기화인가”를 되묻으라는 신호이기도 하다.

React Compiler를 쓰면 달라지나

요즘이면 한 번쯤 묻는다. React Compiler가 알아서 메모이즈하니 이런 deps 문제도 사라지는 것 아닌가. 절반만 맞다. 컴파일러는 자기가 만드는 값(컴포넌트 안에서 계산한 객체·함수)의 참조를 안정화해 useMemo·useCallback을 상당 부분 대신한다. 하지만 useMutation()처럼 라이브러리가 렌더마다 돌려주는 객체의 참조까지 보장하진 않는다. 더 근본적으로, 컴파일러는 useEffect deps의 의미를 바꾸지 않는다. “이 값이 바뀌면 effect를 다시 돌려야 하나”는 여전히 사람이 판단한다. 메모이제이션이 줄여주는 건 손으로 거는 안정화지, deps를 무엇으로 채울지의 결정이 아니다.

그래서

exhaustive-deps는 좋은 휴리스틱이지 법칙이 아니다. “쓰는 값을 다 넣어라”는 “언제 다시 도나”의 근사치고, 그 근사는 참조가 불안정한 값렌더를 유발하는 effect가 만나는 교차점에서 깨진다. 그 교차점을 알아보면 규칙을 따를 때와 끌 때가 구분된다. deps 앞에서 던질 질문은 “이 값을 effect가 쓰나”가 아니라 “이 값이 바뀌면 effect를 다시 돌려야 하나”다.

↑↓ 이동 열기esc 닫기