React 렌더링 모델 이해하고, 중복 요청 막기

React 렌더링 모델 이해하고, 중복 요청 막기

React의 렌더링 모델 이해하고, Button 컴포넌트의 중복 요청 막는 방법

10 min read

앱을 이용하다보면 버튼을 두세 번 누르는 건 꽤 자연스러운 행동이에요.(사용자들은 모두 급하기에...)

그래서 이번 포스트에서는 버튼 중복 클릭으로 인한 중복 요청을 막기 위해, React가 상태와 렌더를 처리하는 방식을 따라가며 근본적으로 해결해본 과정을 적어봤어요.

최근 실제 서비스에서 Sentry에 같은 종류의 중복 요청 에러가 꾸준히 올라오고 있다는 걸 알게 됐어요.

우선 실제 데이터를 먼저 봤어요. 이슈를 펼쳐보니 중복요청 건수만 3일 동안 132건이 발생하고 있었어요. 한두 명이 잘못 누른 게 아니라, 비슷한 행동 패턴이 여러 유저에서 반복되고 있다는 신호였고요.

특정 유저의 패턴은 조금 더 자세히 들여다보니

10:22:35.915  버튼 클릭 #1
10:22:36.498  버튼 클릭 #2  (+583ms)
10:22:37.306  버튼 클릭 #3  (+808ms)
──────────────────────────────────────────────
10:22:38.034  POST /submit → 201 ✅ (성공)
10:22:38.193  POST /submit → 500 ❌ (159ms 후 중복 요청)

유저는 버튼을 3번 연타했고, POST는 2번 나갔어요. 첫 요청이 성공한 뒤 159ms 만에 두 번째 요청이 따라 들어가서 "이미 처리됨"으로 거부됐고요.

사용자가 직접적인 에러를 마주하지는 않았겠지만, 기존 버튼은 중복 요청을 가능케하여 사용자 경험을 저하시켰고, 서버 입장에서도 불필요한 요청으로 인한 리소스, 그리고 데이터까지 오염될 수 있는 상황이었어요.

가장 먼저 떠오르는 건 useState로 처리 중 상태를 들고 있는 방법이에요.

const SubmitButton = () => {
  const [isSubmitting, setIsSubmitting] = useState(false)
  const { mutate } = useSubmit()

  const handleClick = () => {
    if (isSubmitting) return
    setIsSubmitting(true)
    mutate(undefined, {
      onSettled: () => setIsSubmitting(false),
    })
  }

  return (
    <button disabled={isSubmitting} onClick={handleClick}>
      제출
    </button>
  )
}

언뜻 충분해 보이는데, 연타 케이스에서는 여전히 새요. 이유를 이해하려면 React가 상태 업데이트를 어떻게 처리하는지 한 단계 들여다볼 필요가 있어요.

React의 렌더링은 비동기적으로 스케줄링돼요. setState를 호출하면 그 자리에서 바로 값이 바뀌는 게 아니라, "다음 렌더에서 이 값으로 바꿔주세요"라는 요청을 큐에 넣어둬요. 그리고 React는 현재 실행 중인 작업(이벤트 핸들러, effect 등)이 끝난 뒤에야 큐를 정리하고 리렌더를 돌려요.

React 18부터는 여기에 automatic batching까지 적용돼요. 이전 버전에서는 React가 관리하는 이벤트 핸들러 안에서만 batch가 동작했는데, 18부터는 Promise·setTimeout·네이티브 이벤트까지 포함해서 하나의 동기 작업 안에서 일어난 모든 상태 업데이트를 한 번의 리렌더로 묶어 처리해요.

문제는 "같은 동기 작업 안에서 두 번째 클릭이 들어오는" 경우예요.

같은 틱 안에서

클릭 #1 진입
setIsSubmitting(true)  // 큐에 적재만 됨, 아직 리렌더 X
mutate() 호출
  → 핸들러 종료

// 여기서 React가 리렌더하기 전에 중복 클릭이 가능!!

클릭 #2 진입               // 큐에 쌓여 있던 이벤트가 즉시 디스패치
if (isSubmitting)      // 여전히 false — state는 다음 렌더에서야 갱신
  → 가드 통과 → mutate() 또 호출 ❌

[리렌더]
  → 이제서야 isSubmitting = true
  → button이 disabled로 바뀜  // 이미 늦음 ㅜㅡㅜ

원인은 두 가지예요.

  • state는 "다음 렌더"에서야 새 값이 돼요. 같은 렌더 사이클 안에서 setIsSubmitting(true)를 호출했더라도, 그 직후 코드에서 isSubmitting을 읽으면 여전히 이전 값(false)이에요.
  • DOM은 그 리렌더 다음에야 바뀌어요. 즉, disabled 속성이 실제로 <button>에 붙기까지 스케줄링 → 리렌더 → commit → 페인트 의 단계를 거쳐야 해요. 그동안 브라우저 이벤트 큐는 멈춰주지 않고요.

useState 가드는 "다음 렌더 이후에 들어오는 클릭"은 막지만, 렌더가 끝나기 전에 이미 큐에 들어와 있던 클릭은 그대로 통과시켜요.

"그냥 useMutation의 isPending 쓰면 되지않을까?"

react-query를 쓴다면 isPending를 쓰면 되지 않을까? 생각할 수 있어요.

하지만 이것도 역시 문제가 있어요.

const SubmitButton = () => {
  const { mutate, isPending } = useSubmit()

  return (
    <button disabled={isPending} onClick={() => mutate(payload)}>
      제출
    </button>
  )
}

isPending도 마찬가지로 react-query가 내부에서 관리하는 React state예요. mutate()를 호출하면 내부적으로 setState가 일어나는데, 이것도 똑같이 automatic batching에 묶여요.

클릭 #1mutate()      // 내부 setState(isPending=true) 큐에 적재
       → 아직 리렌더 X    // button은 여전히 활성 상태

클릭 #2 → 큐에 쌓여 있던 클릭이 핸들러로 들어옴
mutate() 또 호출 ❌

isPending은 "요청이 진행 중"이라는 상태 표현으로는 훌륭하지만, "다음 클릭을 즉시 막는다"는 DOM 동기화 목적에는 맞지 않아요.

상태가 아니라 즉시 변경되는 가변 값이 필요해요. 그럴 때 쓰는 게 useRef예요.

const SubmitButton = () => {
  const isProcessingRef = useRef(false)
  const [isSubmitting, setIsSubmitting] = useState(false)
  const { mutate } = useSubmit()

  const handleClick = () => {
    if (isProcessingRef.current) return
    isProcessingRef.current = true
    setIsSubmitting(true)

    mutate(undefined, {
      onSettled: () => {
        isProcessingRef.current = false
        setIsSubmitting(false)
      },
    })
  }

  return (
    <button disabled={isSubmitting} onClick={handleClick}>
      제출
    </button>
  )
}

useRefuseState와 다른 핵심은 렌더 사이클 바깥에 사는 가변 컨테이너라는 점이에요.

  • useState는 값을 바꾸려면 setter를 호출해야 하고, 그 setter는 스케줄링 → 리렌더 → commit 을 거쳐야 새 값이 적용돼요. 즉 값이 바뀌는 시점이 다음 렌더에 묶여 있어요.
  • useRef가 돌려주는 객체는 렌더와 무관하게 같은 자리에 머무는 평범한 자바스크립트 객체예요. ref.current = true는 그냥 객체의 프로퍼티를 바꾸는 대입이라, 그 줄에서 즉시 새 값이 돼요. 리렌더도, batching도 거치지 않아요.

ref 가드는 자바스크립트 함수가 두 번 실행되는 것은 막아줘요. 근데 여전히 못 막는 게 두 가지 있어요.

1) 버튼은 클릭 가능한 상태로 떠 있음

isSubmittingtrue로 바뀌어 disabled가 적용되려면 리렌더가 끝나야 해요. 그 전까지는 HTML <button> 자체가 활성 상태라 클릭 이벤트가 계속 발사돼요. ref 가드는 그저 들어오는 이벤트를 뒤에서 무시할 뿐이고요.

2) 같은 틱 안의 이벤트 큐

브라우저는 짧은 간격의 터치 이벤트를 큐에 쌓아두고 차례대로 핸들러에 흘려보내요. 첫 클릭이 핸들러에 진입한 뒤에도 두 번째 클릭은 이미 큐에 들어와 있을 수 있어요. ref 가드는 같은 핸들러가 재진입하는 걸 막아주지만, 이벤트 큐에 이미 적재된 클릭이 핸들러로 들어오기 전 단계에서는 손쓸 수가 없어요.

결국 우리가 원하는 건 버튼 자체가 즉시 disabled가 되는 것이에요. 그러려면 setIsSubmitting(true)가 한 틱 늦지 않고, 호출 직후에 DOM에 반영돼야 해요.

React 18의 flushSync는 정확히 그 일을 해요. 배치 최적화를 우회하고 상태 업데이트를 동기적으로 flush해서, 호출이 끝나는 시점에 DOM이 갱신돼 있어요.

import { flushSync } from 'react-dom'

const SubmitButton = () => {
  const isProcessingRef = useRef(false)
  const [isSubmitting, setIsSubmitting] = useState(false)
  const { mutate } = useSubmit()

  const handleClick = () => {
    if (isProcessingRef.current) return

    isProcessingRef.current = true
    flushSync(() => setIsSubmitting(true)) // 즉시 리렌더 → 버튼 disabled

    mutate(undefined, {
      onSettled: () => {
        isProcessingRef.current = false
        setIsSubmitting(false)
      },
    })
  }

  return (
    <button disabled={isSubmitting} onClick={handleClick}>
      제출
    </button>
  )
}

flushSync를 쓰면 React가 원래 모아서 한 번에 처리하던 렌더를 그 자리에서 강제로 돌리게 돼요.

하지만 배치로 묶이는 덕분에 누리던 성능 이점을 그만큼 깎아먹는거라서 남용하면 좋지 않아요.

그래서 저는 잠그는 쪽(true)에만 flushSync를 걸었어요. 이 한 시점만 DOM에 즉시 반영되면 충분하고, 푸는 쪽(false)은 그냥 다음 렌더에서 자연스럽게 풀려도 사용자 경험에는 큰 차이가 없기 때문이에요. (최대한 컨트롤 가능하고 예측 가능한 부분에만 사용하기)

이제 사용자가 한 번 클릭하게 되면 다음과 같이 흘러가게 돼요.

유저 클릭
  → isProcessingRef = true             // 같은 핸들러 재진입 차단
flushSync(setIsSubmitting(true))   // 리렌더를 그 자리에서 강제
DOM 갱신                            // button이 disabled로 바뀜
mutate() 호출
  → 이후 클릭은 disabled 상태라 이벤트가 아예 발생하지 않음

사실 처음엔 가볍게 생각했는데, 파고들다 보니 결국 React가 상태를 어떻게 갱신하고, 그 갱신이 언제 DOM에 반영되는지를 모르면 절대 끝나지 않는 문제더라고요.

useState는 왜 한 틱 늦는지, useRef는 왜 그 갭을 메워주는지, 리액트 18버전에서 flushSync가 굳이 왜 생겼는지, ... 요런걸 파악해야 정말 화면속 사용자 경험을 완벽하게 컨트롤할 수 있다고 생각해요.

이번 기회에 추상화 뒤에 가려진 리액트 내부에 대해서 한 번 톺아보는건 어떨까요 ㅎ.ㅎ