Next.js
React 폼 제출이 두 번 실행된 원인을 버튼 상태로 좁힌 기록
엔터키와 클릭이 겹친 폼에서 중복 요청이 발생한 상황을 재현하고 제출 상태를 한 곳에서 관리한 과정
증상이 먼저 이상했다
문의 폼에서 버튼을 한 번 눌렀는데 같은 데이터가 두 개 생겼다. 서버 로그에는 거의 같은 시각의 POST가 두 건 찍혔다. Network 탭에서 클릭과 Enter를 각각 시험해 보니 type="submit" 버튼의 클릭 핸들러와 form의 submit 핸들러가 저장 함수를 동시에 호출하고 있었다.
재현 조건을 작게 만들기
저장 로직을 onSubmit 한 곳으로 모으고 요청 중 상태를 추가했다.
function ContactForm() {
const [saving, setSaving] = useState(false)
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (saving) return
setSaving(true)
try {
await fetch('/api/contact', { method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({message: 'hello'}) })
} finally { setSaving(false) }
}
return <form onSubmit={handleSubmit}><button type="submit" disabled={saving}>{saving ? '저장 중...' : '저장'}</button></form>
}
클라이언트만 믿지 않기
버튼 비활성화는 빠른 더블 클릭을 줄이지만 여러 탭이나 네트워크 재전송까지 막지는 못한다. 생성 API에는 idempotency key를 보내고 서버에서 이미 처리한 키를 거절하도록 했다. finally를 사용해 실패해도 버튼이 영원히 잠기지 않게 한 것도 확인했다.
확인한 체크리스트
- 저장 함수가
onClick과onSubmit에 중복 연결되지 않았는가 - 제출 중 상태가 버튼에 반영되는가
- 실패 뒤 다시 제출할 수 있는가
- 서버가 중복 요청을 최종 방어하는가
클릭, Enter, 빠른 더블 클릭을 각각 반복한 뒤 생성 건수가 의도와 맞았다. 폼의 “한 번”은 화면이 아니라 서버에서도 보장해야 하는 조건이었다.