leo.dev
frontend

useMutation 반환 객체를 deps에 넣어 만든 무한 루프

2026.05.0815 min read reactreact-queryuseeffect

업무요청 드로어를 열면 댓글 읽음 처리 요청이 멈추지 않고 나갔다. 드로어를 열어둔 동안 같은 요청이 반복되면서 서버가 429 Too Many Requests를 뱉으며 거절하기 시작했다.

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

react-hooks/exhaustive-deps는 “effect 안에서 쓰는 모든 값을 의존성 배열에 넣어라”라고 한다. 대개는 맞는 규칙이라 시킨 대로 markAsRead를 deps에 넣었는데, 그렇게 하니 effect가 멈추지 않았다.

React는 배열에 든 값 중 하나라도 지난 렌더와 달라졌으면 effect를 다시 실행하고, 전부 같으면 건너뛴다. 그러니 배열은 “이 값이 달라지면 effect를 다시 돌려라”라는 목록이다. 반면 린트 규칙은 effect 본문이 그 값을 읽는지만 보고 배열을 채운다.

이 effect가 읽는 값은 isOpen, requestId, markAsRead 세 개라 규칙은 그걸 다 넣게 한다. 그런데 읽음 처리를 다시 보내야 하는 때는 드로어가 열리거나 대상 요청이 바뀔 때뿐이니, 다시 돌 조건은 앞의 두 가지다. markAsRead는 요청을 보내는 수단일 뿐 다시 보낼 이유가 아닌데도 배열에 들어갔다. 대개는 effect가 읽는 값과 다시 돌 조건이 같아서 규칙을 그대로 따라도 문제가 안 나는데, 여기서는 둘이 어긋났다.

무한 루프가 되는 순서

1. 드로어 열림 → effect 실행 → markAsRead.mutate()
2. mutation 상태가 바뀜 → 컴포넌트 리렌더
3. 리렌더로 useMarkCommentsAsRead() 재호출 → useMutation()이 새 객체 반환
4. markAsRead가 새 객체로 바뀜 → deps "변경됨" → effect 재실행 → 1번으로
(성공한 요청은 onSuccess의 invalidateQueries → refetch로 2번을 한 번 더 만든다)

이렇게 되려면 두 조건이 필요하다. effect가 리렌더를 일으켜야 하고, 그 리렌더가 deps에 든 값을 바꿔야 한다. 여기서 deps에 든 값은 markAsRead이고, 둘 다 useMutation 안에서 일어나는 일이다. 설치된 @tanstack/react-query 5.90.20에서 옵션 갱신과 에러 throw를 뺀 나머지가 이렇다.

@tanstack/react-query 5.90.20 · useMutation.ts
const [observer] = React.useState(() => new MutationObserver(client, options))
const result = React.useSyncExternalStore(
React.useCallback(
(onStoreChange) =>
observer.subscribe(notifyManager.batchCalls(onStoreChange)),
[observer]
),
() => observer.getCurrentResult(),
() => observer.getCurrentResult()
)
const mutate = React.useCallback(
(variables, mutateOptions) => {
observer.mutate(variables, mutateOptions).catch(noop)
},
[observer]
)
return { ...result, mutate, mutateAsync: result.mutate }

리렌더는 위쪽 useSyncExternalStore가 만든다. 이 훅은 React 밖에서 관리되는 상태를 컴포넌트에 연결하고, 인자 세 개는 레퍼런스 이름으로 구독 함수(subscribe), 값을 읽는 함수(getSnapshot), 서버 렌더링에서 값을 읽는 함수(getServerSnapshot)다. 여기서는 앞의 두 함수에 observer.subscribeobserver.getCurrentResult를 넘긴다.

useMutation()isPending·error를 돌려주려면 mutation 상태를 구독해야 하니 이 코드가 있다. mutate()를 부르면 상태가 pending으로 갔다가 성공이나 실패로 다시 바뀌고, 그때마다 구독한 컴포넌트가 리렌더된다. onSuccess가 비어 있든 요청이 실패하든 이 리렌더는 나고, 성공한 요청은 invalidateQueries → refetch로 리렌더를 한 번 더 만들 뿐이다.

deps에 든 값은 마지막 줄이 바꾼다. 중괄호로 쓴 객체 리터럴은 평가될 때마다 객체를 새로 만들고, ...resultresult를 그대로 돌려주는 게 아니라 그 프로퍼티들을 방금 만든 객체에 복사해 넣는다. useMutation()은 렌더마다 호출되니 이 return 문도 렌더마다 실행되고, 리렌더가 날 때마다 markAsRead는 안에 든 값은 같고 정체만 다른 새 객체가 된다.

deps 비교에 쓰이는 Object.is

새 객체가 만들어지는 것과 effect가 다시 도는 것 사이에는 한 단계가 더 있다. React가 그 객체를 “바뀐 값”으로 판정해야 effect가 다시 돈다. 안에 든 값이 같아도 지난 렌더의 객체와 같은 객체는 아닌데, 이 다름을 정체(Identity)가 다르다고 한다.

React 문서가 규정하는 deps 비교 방식은 Object.is인데, MDN에 따르면 이 함수는 두 값이 같은 값인지를 판정하고 ===와 거의 동일하며 NaN-0을 다루는 규칙만 다르다. 객체를 넘기면 안에 든 프로퍼티는 하나도 들여다보지 않고, 두 변수가 같은 객체를 가리키는지만 본다.

const a = { count: 1 }
const b = { count: 1 }
Object.is(a, b) // false — 모양은 같지만 다른 객체
Object.is(a, a) // true

그래서 “내용이 똑같은 새 객체”는 React에게 “바뀐 값”이다. useEffect 레퍼런스에 배열 자체가 이렇게 나와 있다.

The list of dependencies must have a constant number of items and be written inline like [dep1, dep2, dep3]. React will compare each dependency with its previous value using the Object.is comparison.

useEffect, React Documentation

문서는 이 지점을 타입별로 나눠 설명한다. React는 숫자와 문자열은 내용(Content)으로 비교하니 달라질 일이 없지만, 객체와 함수는 정체(Identity)로 비교해서 내용이 같아도 다른 값으로 본다.

렌더 중에 만든 객체를 effect가 읽으면 매 렌더 재동기화가 걸린다는 것도 의존성 제거 문서에 그대로 적혀 있다. 이 effect가 정확히 그 경우였다.

이건 useMutation만의 문제가 아니라 렌더마다 새 정체를 만드는 모든 값의 문제다. exhaustive-deps는 이 구분을 안 한다. 쓰는 값이면 안정적이든 아니든 넣게 하는 것이 그 규칙의 원칙이다.

조건만 남긴 deps

그래서 규칙이 채운 배열을 그대로 두지 않고, 다시 돌아야 할 조건만 남겼다. 앞에서 센 대로 조건은 isOpenrequestId 두 개이고, markAsRead는 거기 들어갈 값이 아니다. 규칙은 그 줄에서만 껐다.

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

disable 없이 푸는 방법

eslint-disable-line 없이 끝내는 길도 있었다. 앞에서 본 소스에서 mutate 하나만 떼어 보면 반환 객체 전체와 사정이 다르다. useCallback으로 감싸여 있고 의존성이 [observer]뿐인데, observeruseState의 지연 초기화로 컴포넌트 생애 동안 한 번만 만들어진다. mutate는 렌더 사이에 정체가 유지된다.

그러면 이렇게 쓸 수 있다.

const { mutate: markAsRead } = useMarkCommentsAsRead()
useEffect(() => {
if (isOpen && requestId) markAsRead(requestId)
}, [isOpen, requestId, markAsRead]) // disable 불필요

이름은 앞의 예제와 같은 markAsRead지만 담긴 값이 다르다. 앞에서는 useMutation()이 돌려준 객체였고, 여기서는 그 객체 안의 mutate 함수다. deps에 markAsRead를 넣어도 그 값이 안 바뀌니 루프가 성립하지 않고, exhaustive-deps도 만족한다.

429가 났을 때는 여기까지 오지 않고 deps를 좁히는 선에서 끝냈다. mutate의 정체가 렌더 사이에 유지된다는 걸 그때는 몰랐고, 드로어를 열 때 읽음 처리 요청이 한 번만 나가는 것까지 확인하고 마무리했다.

두 방식은 동작이 같지만 기대는 게 다르다. 좁힌 deps는 “useMutation() 반환 객체가 바뀌어도 다시 돌 필요 없다”는 내 판단이 맞아야 성립하고, 구조 분해는 그 판단을 필요 없게 만든다. 라이브러리가 정체가 유지되는 값을 이미 주고 있으니 그걸 받는 게 낫다고 보고, 이 글을 쓰면서 eslint-disable 주석은 지우면서 훅을 구조 분해 쪽으로 바꿨다.

React 문서가 권하는 처리

여기서 쓴 방법은 의존성 제거 문서가 객체·함수 의존성을 두고 권하는 것들 중 하나다. 문서가 드는 방법은 이렇다.

  • props나 state에 안 걸리는 객체·함수면 컴포넌트 밖으로 올려 반응형 값이 아님을 린터에 증명한다
  • 걸리면 effect 안에서 만든다
  • 밖에서 받은 객체는 const { roomId, serverUrl } = options처럼 필요한 원시 필드만 꺼내 쓴다
  • 최신 값을 읽되 그 값 때문에 다시 돌 필요는 없는 로직은 useEffectEvent로 뺀다

의존성이 [observer]라는 것도 정확히 읽어야 한다. 안정성은 mutate가 특별해서가 아니라 observer가 안 바뀌기 때문이고, 그래서 이 논리는 “라이브러리가 useCallback을 걸어둔 값”에만 해당한다. 문서에 명시된 보장이 아니라 구현에서 확인한 사실이므로, 이런 걸 근거로 삼을 땐 버전을 같이 적어두는 게 안전하다.

이 로직을 effect에 둔 이유

위 코드의 eslint-disable-line 주석을 달고 나면 의심할 게 하나 더 있었다. 규칙을 끄는 건 대개 설계가 어긋났다는 신호라, 이 로직이 effect에 있는 게 맞는지부터 봐야 했다. 처음엔 effect에 둘 게 아니라고 봤다. “드로어가 열린다”는 건 상태가 아니라 한 번 일어나는 사건으로 보였고, 그러면 목록에서 행을 클릭해 드로어를 여는 핸들러 안에서 읽음 처리 요청까지 같이 보내면 된다고 생각했다.

You Might Not Need an Effect의 기준은 사건이냐 상태냐가 아니라 왜 실행되느냐였다.

When you choose whether to put some logic into an event handler or an Effect, the main question you need to answer is what kind of logic it is from the user’s perspective. If this logic is caused by a particular interaction, keep it in the event handler. If it’s caused by the user seeing the component on the screen, keep it in the Effect.

You Might Not Need an Effect, React Documentation

문서는 같은 POST 요청도 이유에 따라 구분한다. 애널리틱스 요청은 폼이 표시되었기 때문에 보내는 것이라 Effect에 남고, /api/register POST는 사용자가 버튼을 누른 그 상호작용에서만 일어나야 하니 핸들러에 둔다.

댓글을 읽음으로 표시하는 이유는 사용자가 무슨 버튼을 눌러서가 아니라 드로어가 화면에 떴기 때문이다. 드로어를 여는 경로도 목록 클릭 하나가 아니라, 링크로 바로 열리거나 URL 상태로 복원돼도 읽음 처리는 일어나야 한다. 이벤트 핸들러에 두면 열리는 경로마다 같은 호출을 붙여야 한다.

그러니 effect인 게 맞았고, 틀린 건 deps뿐이었다.

유일한 증상이 429였던 이유

고치는 것보다 알아채는 게 어려웠다. 무한 루프인데도 화면은 멀쩡했기 때문이다. 읽음 처리는 멱등이라 몇 번을 보내도 결과가 같고, invalidateQueries가 부르는 refetch는 같은 데이터를 돌려주니 UI가 깜빡이지도 않는다.

사용자가 볼 수 있는 증상이 없었고, 드로어를 열어둔 동안 레이트 리밋에 걸릴 만큼 요청이 계속 나갔다는 것만 사실이었다.

그래서 이 루프를 알아챈 경로는 우연에 가깝다. 서버가 429 Too Many Requests를 뱉었기 때문에 보였다. 레이트 리밋이 없었으면 이 effect는 조용히 계속 돌았을 것이다.

429가 돌아오기 시작한 뒤에도 루프는 멎지 않았다. 거절된 요청도 mutation 상태를 error로 바꾸니 리렌더가 나고, 그 리렌더가 useMutation() 반환 객체를 또 새로 만든다.

클라이언트가 자기 트래픽을 세는 장치가 없었기 때문에, 서버의 레이트 리밋이 대신 세고 있었다. 이 글을 쓰는 시점에도 그대로다. 그 뒤 프론트에 Sentry를 붙였지만 트레이싱 샘플링을 0으로 두어 에러만 보고하니, 같은 루프가 다시 나도 클라이언트 쪽에서는 안 보인다.

다음엔 API 클라이언트 한 곳에 세는 걸 붙여볼 생각이다. 같은 메서드와 경로로 나가는 요청이 10초에 20번을 넘으면 Sentry로 한 번만 보고하게 두면, 같은 루프가 또 나도 서버 로그가 아니라 Sentry에서 먼저 보인다.

세는 것까지만 하고 요청을 막지는 않는다. 클라이언트에서 막으면 렌더 루프는 그대로 도는데 429만 사라지고, 그러면 이 루프를 알아챌 수 있었던 유일한 신호를 내 손으로 없앤다.

deps에 넣어도 되는 값

이 판정은 useMutation에만 쓰는 게 아니다. 넣어도 되는지는 “이 값이 렌더 사이에 같은 값으로 남는가”로 나뉘고, 남는 값은 이렇다.

넣어도 되는 값왜 같은 값으로 남는가
숫자·문자열·불리언·null내용으로 비교된다. isOpenrequestId가 여기다
useState의 setter, useReducerdispatchReact가 정체 고정을 보장한다
useRef가 돌려주는 ref 객체컴포넌트가 사는 동안 같은 객체다
useCallback·useMemo 결과자기 deps가 안정적일 때만 그렇다
context로 받은 인스턴스(useQueryClient() 반환값)Provider가 매 렌더 새로 만들지만 않으면 유지된다
모듈 스코프에 선언한 상수·함수렌더와 무관해서 반응형 값이 아니다

안 남는 값은 이렇다.

렌더마다 정체가 바뀌는 값왜 바뀌는가
useMutation() 반환 객체반환문이 새 객체 리터럴을 만든다
인라인 객체 {} · 배열 [] · 함수 () => {}리터럴은 평가될 때마다 새로 만들어진다
new Date() · 정규식 리터럴같은 값처럼 보여도 매번 다른 객체다
부모가 인라인으로 내려준 콜백·객체내 컴포넌트에서 만들지 않았을 뿐 사정은 같다

어느 쪽도 아닌 값이 둘 있다. ref.current는 deps에 넣어도 소용이 없다. 값이 바뀌어도 리렌더가 나지 않으니 React가 비교할 기회 자체가 없다. useEffectEvent가 돌려준 함수는 넣으면 안 되는데, 레퍼런스에 정체를 매 렌더 바꾸는 게 의도라고 적혀 있다.

Unlike set functions from useState or refs, Effect Event functions do not have a stable identity. Their identity intentionally changes on every render. (…) The non-stable identity acts as a runtime assertion: if your code incorrectly depends on the function identity, you’ll see the Effect re-running on every render, making the bug obvious.

useEffectEvent, React Documentation

deps에서 빼라고 만든 값이라 린트도 넣는 걸 막는다. 이 프로젝트에 설치된 React 19.2.3에는 실험 접두사 없이 들어 있다.

React Compiler를 쓰면 달라지나

안정적인 값을 손으로 골라 넣는 대신 컴파일러에 맡기면 어떨까. React Compiler는 React 팀이 만든 빌드 타임 도구다. 소개 문서는 이 도구가 평범한 자바스크립트 위에서 돌고 React 규칙을 이해하므로 쓰려고 코드를 다시 쓸 필요가 없다고 적는다.

빌드할 때 컴포넌트와 훅의 코드를 분석해 useMemo·useCallback·React.memo로 손수 걸던 메모이제이션을 대신 끼워 넣는다. 그러면 이런 deps 문제도 컴파일러가 없애주는 것 아닐까.

컴파일러가 정체를 유지해 주는 대상은 자기가 만드는 값, 즉 컴포넌트나 훅 안에서 계산된 객체·함수다. useMutation()이 돌려주는 객체는 다르다. 훅 호출은 렌더마다 실행돼야 하니 컴파일러가 그 반환값을 지난 렌더 것으로 대신할 수 없다. 같은 문서에서 effect 의존성은 예외로 빠져 있다.

However, in some cases developers may need more control over memoization. The useMemo and useCallback hooks can continue to be used with React Compiler as an escape hatch to provide control over which values are memoized. A common use-case for this is if a memoized value is used as an effect dependency, in order to ensure that an effect does not fire repeatedly even when its dependencies do not meaningfully change.

React Compiler, React Documentation

컴파일러를 켜도 useEffect deps의 의미는 그대로다. “이 값이 바뀌면 effect를 다시 돌려야 하나”는 여전히 내가 판단한다. 메모이제이션이 줄여주는 건 손으로 거는 안정화지, deps를 무엇으로 채울지의 결정이 아니다.

같은 질문이 나온 다른 사고

같은 레포의 다른 두 프론트 사고도 결국 같은 질문이었다. 토스트가 두 번 뜬 건 핸들러가 몇 층에서 불리는지를 안 세서였고, 브라우저 User-Agent로 라우트 트리를 고르던 것도 부팅 때 한 번 내린 판정이 세션 내내 고정되기 때문이었다. 세 건 모두 “이 코드가 언제 다시 도는가”를 안 물어서 났다.

같은 판정을 다시 할 때

  • 이 코드를 effect에 둘 것인가. 컴포넌트가 표시되어서 실행되는 코드면 effect에 두고, 특정 상호작용에서만 일어나야 하면 핸들러에 둔다.
  • effect가 리렌더를 일으키는가. 요청을 보내거나 쿼리를 무효화하면 일으킨다.
  • deps에 넣을 값이 렌더 사이에 같은 값으로 남는가. 원시값은 그냥 남지만, 객체와 함수는 어떻게 만들어졌는지 봐야 한다. 앞의 조건과 이게 겹치면 루프가 된다.
  • 라이브러리가 준 값이면 소스를 연다. 정체가 렌더 사이에 유지되는지는 문서에 안 적혀 있는 경우가 많고, useMutation처럼 안정적인 부분만 따로 꺼내 쓸 수 있는 값도 있다. 근거로 삼을 땐 버전을 같이 적는다.

이 판정을 처음 한 게 429가 났을 때였고, 그때는 deps를 좁히고 eslint-disable 주석을 다는 선에서 끝냈다. 지금 그 훅은 mutate만 꺼내 쓰고 주석 없이 [isOpen, requestId, markAsRead]를 그대로 두며, 드로어를 열 때 읽음 처리 요청은 한 번 나간다.

↑↓ 이동 열기esc 닫기