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를 고려한다.
- 개발 환경에서 네트워크 속도를 느리게 해 두고 두 번 클릭 테스트를 한다.
짧은 결론
버튼이 회색으로 바뀌면 안전해 보인다. 하지만 실제 기준은 “요청을 만드는 함수가 두 번 실행되어도 괜찮은가”였다. 그 질문으로 보니 폼 코드에서 막아야 할 위치가 훨씬 분명해졌다.