2026년 08월 10일

버튼을 두 번 눌렀을 뿐인데 데이터가 두 개 생기는 이유

사용자가 폼을 작성하고 ‘제출’ 버튼을 눌렀는데, 잠깐 반응이 없는 것 같아서 한 번 더 눌렀습니다. 그 결과 데이터베이스에는 동일한 내용의 레코드가 두 개 생겼습니다. 개발자 입장에서는 당연히 막아야 할 버그처럼 보이지만, 이 문제는 생각보다 다양한 원인에서 비롯됩니다. 단순히 버튼을 비활성화하는 것만으로 해결되지 않는 경우도 많습니다.

이 글에서는 중복 요청이 발생하는 근본적인 원인을 설명하고, 프론트엔드와 백엔드 각각에서 사용할 수 있는 방지 방법을 정리합니다.

왜 버튼을 두 번 누르면 데이터가 두 개 생길까

결론부터 말하면, 서버는 요청이 두 번 들어오면 두 번 처리합니다. 서버 입장에서는 첫 번째 요청과 두 번째 요청이 완전히 동일하더라도, 각각을 독립적인 새로운 요청으로 인식합니다. ‘이미 처리한 것과 같은 내용이니 무시하겠다’는 판단을 서버가 스스로 하지는 않습니다. 그 판단 로직을 개발자가 직접 구현하지 않으면 중복 처리가 발생합니다.

사용자가 버튼을 빠르게 두 번 누르는 상황은 생각보다 자주 일어납니다. 응답이 느릴 때, 버튼 클릭 피드백이 없을 때, 혹은 단순한 실수로도 발생합니다. 모바일 환경에서는 터치 이벤트가 두 번 감지되는 경우도 있습니다. 이를 사용자의 잘못이라고 볼 수는 없으며, 시스템이 방어적으로 설계되어야 합니다.

문제가 발생하는 구체적인 흐름

중복 요청 문제를 제대로 이해하려면, 클릭 이벤트가 실제로 어떤 경로를 거치는지 파악해야 합니다.

  1. 사용자가 버튼을 클릭합니다.
  2. 브라우저에서 클릭 이벤트가 발생하고, 자바스크립트가 API 요청을 보냅니다.
  3. 요청이 네트워크를 통해 서버에 도달합니다.
  4. 서버가 요청을 처리하고 데이터베이스에 기록합니다.
  5. 응답이 클라이언트로 반환됩니다.

이 흐름에서 2번과 3번 사이에 딜레이가 생기는 경우, 사용자는 ‘아무 반응이 없다’고 느끼고 버튼을 다시 누릅니다. 그러면 첫 번째 요청이 아직 처리 중인 상태에서 두 번째 요청이 서버에 도달하게 됩니다. 두 요청 모두 유효한 요청이므로 서버는 각각 처리하고, 결과적으로 데이터가 두 개 저장됩니다.

프론트엔드에서 방지하는 방법

프론트엔드에서의 방지는 가장 빠르게 적용할 수 있는 방법입니다. 사용자의 행동을 제한하거나, 요청이 이미 진행 중임을 알리는 방식이 여기에 해당합니다.

버튼 비활성화 처리

가장 널리 쓰이는 방법입니다. 버튼을 클릭한 순간 해당 버튼을 disabled 상태로 전환하고, 요청이 완료된 이후에 다시 활성화합니다. 이렇게 하면 요청이 진행되는 동안 사용자가 버튼을 다시 누를 수 없습니다.

주의할 점은, 요청이 실패하거나 오류가 발생했을 때도 버튼을 반드시 다시 활성화해야 한다는 것입니다. try-catch-finally 구조나 Promise의 .finally() 메서드를 사용하면 성공과 실패 여부와 관계없이 버튼 상태를 복원할 수 있습니다.

로딩 상태 표시

버튼 비활성화와 함께 로딩 스피너나 ‘처리 중’ 텍스트를 보여주는 것이 좋습니다. 사용자가 ‘반응이 없다’고 느끼지 않도록 시각적 피드백을 제공하면, 불필요한 재시도 자체를 줄일 수 있습니다. UX와 기술적 방지를 동시에 해결하는 방법입니다.

디바운스(Debounce) 적용

디바운스는 연속으로 발생하는 이벤트 중 마지막 이벤트만 처리하는 기법입니다. 예를 들어 300ms 이내에 두 번 클릭이 발생하면, 첫 번째 클릭은 무시하고 마지막 클릭만 처리합니다. 검색 자동완성처럼 빠른 입력을 다룰 때 자주 쓰이지만, 폼 제출 버튼에도 적용할 수 있습니다.

단, 디바운스는 짧은 시간 안에 들어오는 연속 클릭을 막는 데 유효합니다. 사용자가 의도적으로 느린 속도로 두 번 클릭하는 경우까지 막지는 못하므로, 버튼 비활성화와 함께 사용하는 것이 더 안전합니다.

요청 중복 방지 플래그

자바스크립트에서 요청 진행 여부를 추적하는 불리언 변수를 사용하는 방법도 있습니다. isLoading이나 isSubmitting 같은 플래그를 두고, 이 값이 true인 동안은 새로운 요청을 시작하지 않도록 처리합니다. React, Vue 등의 상태 관리에서 자연스럽게 구현할 수 있는 패턴입니다.

백엔드에서 방지하는 방법

프론트엔드 방어는 중요하지만, 이것만으로는 충분하지 않습니다. 사용자가 직접 API를 호출하거나, 네트워크 환경에 따라 요청이 재전송되는 경우 등 프론트엔드를 우회하는 상황이 얼마든지 생길 수 있습니다. 따라서 백엔드에서도 중복 요청을 방어해야 합니다.

멱등성(Idempotency) 설계

멱등성이란 동일한 요청을 여러 번 실행해도 결과가 달라지지 않는 성질을 말합니다. REST API 설계에서 중요한 개념으로, GET, PUT, DELETE 메서드는 기본적으로 멱등성을 가지도록 설계합니다. 반면 POST는 기본적으로 멱등성이 없기 때문에 중복 요청에 가장 취약합니다.

멱등성을 확보하는 한 가지 방법은 클라이언트가 각 요청에 고유한 식별자(Idempotency Key)를 포함시키는 것입니다. 서버는 이 키를 확인하여 이미 처리한 요청이면 동일한 응답을 반환하고 새로운 처리를 하지 않습니다. 결제 시스템에서 특히 널리 쓰이는 방식입니다.

데이터베이스 유니크 제약 조건

비즈니스 로직에서 중복이 허용되지 않는 데이터라면, 데이터베이스 레벨에서 유니크 제약 조건을 설정하는 것이 효과적입니다. 예를 들어 같은 사용자가 같은 날짜에 같은 항목을 두 번 예약할 수 없다면, 해당 조합에 유니크 인덱스를 걸 수 있습니다. 두 번째 요청이 들어오면 데이터베이스 자체에서 삽입을 거부하고 오류를 반환합니다.

이 방법은 애플리케이션 로직과 독립적으로 동작하므로, 여러 서버 인스턴스가 동시에 같은 요청을 처리하는 분산 환경에서도 안정적으로 중복을 방지합니다.

처리 상태 추적

요청을 처음 받았을 때 데이터베이스나 캐시에 ‘처리 중’ 상태를 기록하고, 동일한 요청이 다시 들어오면 이미 처리 중임을 응답하는 방식입니다. 처리가 완료되면 상태를 ‘완료’로 변경합니다.

이 방식은 구현이 다소 복잡하지만, 긴 시간이 걸리는 작업(이메일 발송, 결제 처리, 파일 생성 등)에서 특히 유효합니다. Redis 같은 인메모리 데이터베이스를 활용하면 빠르고 효율적으로 구현할 수 있습니다.

두 방법을 함께 써야 하는 이유

프론트엔드 방어와 백엔드 방어는 서로 보완적인 관계입니다. 어느 한쪽만으로는 모든 상황을 커버할 수 없습니다.

방어 위치장점한계
프론트엔드즉각적인 사용자 피드백 가능, 불필요한 네트워크 요청 차단API를 직접 호출하는 경우 우회 가능
백엔드모든 경로의 요청에 대해 방어 가능구현 복잡도가 높아질 수 있음

실제 서비스에서는 프론트엔드에서 버튼 비활성화와 로딩 상태로 사용자 경험을 개선하면서, 백엔드에서는 데이터베이스 제약 조건이나 멱등성 키로 최종 방어선을 구축하는 조합이 일반적입니다.

언제 어떤 방법을 선택해야 할까

모든 상황에 맞는 단일 해결책은 없습니다. 서비스의 성격과 중복 요청이 발생했을 때의 영향 정도에 따라 방지 수준을 결정해야 합니다.

  • 중복이 발생해도 큰 문제가 없는 경우: 버튼 비활성화만으로도 충분합니다.
  • 중복 데이터가 생기면 안 되는 경우(회원가입, 주문 등): 데이터베이스 유니크 제약 조건을 반드시 적용합니다.
  • 금전적 처리가 포함된 경우(결제, 포인트 차감 등): 멱등성 키와 처리 상태 추적을 함께 사용하는 것이 안전합니다.

서비스의 중요도가 높을수록 여러 방어 층을 겹쳐서 구성하는 것이 바람직합니다. 하나의 방어 로직이 실패하더라도 다음 단계에서 걸러질 수 있도록 설계하는 것입니다.

정리

버튼을 두 번 눌러 데이터가 두 개 생기는 현상은 단순한 UI 버그가 아닙니다. 서버가 요청을 받는 방식, 네트워크 지연, 사용자 행동 패턴이 복합적으로 작용한 결과입니다. 이 문제를 해결하려면 사용자 경험 차원에서 명확한 피드백을 제공하고, 기술적으로는 프론트엔드와 백엔드 양쪽에서 방어 로직을 갖춰야 합니다.

중복 제출 방지는 완성된 서비스라면 기본적으로 갖춰야 할 요소입니다. 특히 데이터 무결성이 중요한 기능에서는 처음 설계 단계부터 이 문제를 염두에 두고 구조를 잡는 것이 나중에 발생하는 오류와 대응 비용을 줄이는 데 효과적입니다.