← 전체 글로 돌아가기

Next.js

React 폼에서 disabled 버튼만 믿었다가 중복 제출을 만든 실수

버튼 비활성화만으로 중복 제출을 막으려다 실패한 상황과 수정한 기준을 정리했다.

클릭 두 번이 아니라 요청 두 번이었다

문의 폼을 만들면서 제출 중에는 버튼을 disabled로 바꿨다. 화면에서는 버튼이 회색으로 바뀌어서 괜찮아 보였다. 그런데 서버 로그에는 같은 내용이 두 번 들어온 기록이 있었다.

처음에는 사용자가 정말 빠르게 두 번 눌렀다고 생각했다. 하지만 네트워크 탭을 보니 첫 요청이 끝나기 전에 두 번째 요청이 이미 나가고 있었다. 상태 변경이 렌더링에 반영되기 전 짧은 틈이 있었다.

기존 코드의 빈틈

대충 이런 형태였다.

const [submitting, setSubmitting] = useState(false)

async function onSubmit(event: React.FormEvent) {
  event.preventDefault()
  setSubmitting(true)
  await fetch('/api/contact', { method: 'POST', body: formData })
  setSubmitting(false)
}

버튼에는 disabled={submitting}을 넣었지만, 함수 자체에는 이미 제출 중인지 막는 조건이 없었다. UI 상태는 사용자에게 보여 주는 장치이지, 요청을 막는 마지막 방어선은 아니었다.

수정한 방식

나는 useRef로 즉시 확인할 수 있는 잠금 값을 하나 더 두었다. 상태는 화면 표시용, ref는 함수 재진입 방지용으로 역할을 나눴다.

const [submitting, setSubmitting] = useState(false)
const submittingRef = useRef(false)

async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
  event.preventDefault()

  if (submittingRef.current) return
  submittingRef.current = true
  setSubmitting(true)

  try {
    const formData = new FormData(event.currentTarget)
    await fetch('/api/contact', { method: 'POST', body: formData })
  } finally {
    submittingRef.current = false
    setSubmitting(false)
  }
}

서버에서도 같은 내용이 여러 번 들어올 수 있다는 전제로 처리했다. 클라이언트에서 막는 것은 사용자 경험을 위한 1차 방어일 뿐이라고 보는 게 맞았다.

같이 바꾼 기준

  • 제출 함수 시작 부분에서 재진입을 먼저 막는다.
  • 실패해도 잠금이 풀리도록 finally를 쓴다.
  • 버튼 비활성화는 요청 방지보다 상태 안내로 본다.
  • 결제나 가입처럼 민감한 작업은 서버에서 idempotency key를 고려한다.
  • 개발 환경에서 네트워크 속도를 느리게 해 두고 두 번 클릭 테스트를 한다.

짧은 결론

버튼이 회색으로 바뀌면 안전해 보인다. 하지만 실제 기준은 “요청을 만드는 함수가 두 번 실행되어도 괜찮은가”였다. 그 질문으로 보니 폼 코드에서 막아야 할 위치가 훨씬 분명해졌다.