CRUD 앱에서 중복 레코드를 방지하려면 데이터베이스 고유 제약, 멱등성 키, 이중 제출을 막는 UI 상태 같은 다층 방어가 필요합니다.

중복 레코드는 앱이 동일한 항목을 두 번 저장할 때 발생합니다. 결제 하나에 대해 두 개의 주문이 생기거나, 동일한 가입 흐름에서 두 개의 계정이 만들어지거나, 동일한 내용의 지원 티켓이 두 번 생성되는 식입니다. CRUD 앱에서 중복은 개별 행만 보면 정상처럼 보이지만, 데이터를 전체적으로 보면 잘못된 상태입니다.
대부분의 중복은 정상적인 동작에서 시작됩니다. 페이지가 느리게 느껴져서 누군가가 Create를 두 번 클릭합니다. 모바일에서는 더블 탭을 놓치기 쉽습니다. 버튼이 여전히 활성화되어 있고 진행 중이라는 명확한 표시가 없으면 신중한 사용자도 다시 시도할 수 있습니다.
그다음엔 네트워크와 서버의 혼란이 있습니다. 요청이 타임아웃되어 자동으로 재전송될 수 있습니다. 클라이언트 라이브러리가 첫 시도가 실패했다고 판단해 POST를 반복할 수 있습니다. 첫 요청은 성공했지만 응답이 사라져서 사용자가 다시 시도해 두 번째 복사본이 만들어질 수도 있습니다.
한 계층만으로는 문제를 해결할 수 없습니다. 각 계층은 전체 이야기를 부분적으로만 보기 때문입니다. UI는 우발적 이중 제출을 줄일 수 있지만 불안정한 연결의 재시도를 막을 수는 없습니다. 서버는 반복을 감지할 수 있지만 “이것은 같은 생성 요청”이라고 신뢰성 있게 인식할 방법이 필요합니다. 데이터베이스는 규칙을 강제할 수 있지만 “같은 것”이 무엇인지 정의해야 합니다.
목표는 간단합니다: 같은 요청이 두 번 오더라도 생성이 안전하게 동작하게 만드는 것. 두 번째 시도는 아무 작업도 하지 않거나, 이미 생성되었다는 깔끔한 응답을 주거나, 통제된 충돌을 반환해야 하며, 두 번째 행을 만들어서는 안 됩니다.
많은 팀이 중복을 데이터베이스 문제로만 봅니다. 실제로는 동일한 생성 동작이 여러 번 트리거되는 초기 단계에서 중복이 발생하는 경우가 많습니다.
사용자가 Create를 클릭했는데 아무 반응이 없어 다시 클릭합니다. 또는 Enter를 누른 뒤 바로 버튼을 클릭하기도 합니다. 모바일에서는 빠른 두 번의 탭, 터치와 클릭 이벤트의 중복, 또는 같은 제스처가 두 번 기록되는 경우가 있습니다.
사용자가 한 번만 제출하더라도 네트워크가 요청을 반복할 수 있습니다. 타임아웃이 재시도를 촉발할 수 있습니다. 오프라인 앱은 “저장”을 큐에 넣고 재연결 시 재전송할 수 있습니다. 일부 HTTP 라이브러리는 특정 오류에서 자동으로 재시도하며, 중복 행이 생길 때까지 이를 알아차리지 못할 수 있습니다.
서버는 일부러 작업을 반복하기도 합니다. 작업 큐는 실패한 작업을 재시도합니다. 웹훅 제공자는 특히 엔드포인트가 느리거나 2xx가 아닌 상태를 반환하면 같은 이벤트를 여러 번 전달할 수 있습니다. 이러한 이벤트로 생성 로직이 트리거된다면 중복이 발생할 것을 가정해야 합니다.
동시성은 가장 교묘한 중복을 만듭니다. 두 개의 탭이 밀리초 단위로 같은 양식을 제출할 수 있습니다. 서버가 “이미 있는가?”를 체크한 뒤 삽입하는 방식이면 두 요청 모두가 삽입 전에 체크를 통과할 수 있습니다.
클라이언트, 네트워크, 서버를 별개의 반복 원천으로 취급하세요. 세 곳 모두에 방어가 필요합니다.
중복을 막을 수 있는 한 군데를 원한다면 규칙을 데이터베이스에 두세요. UI 수정과 서버 검사도 도움이 되지만 재시도, 지연, 동시 사용자의 경우에는 실패할 수 있습니다. 데이터베이스의 고유 제약은 최종 권위자입니다.
사람들이 레코드를 어떻게 생각하는지에 맞는 현실적인 고유 규칙을 먼저 선택하세요. 일반적인 예:
전체 이름처럼 보이기만 고유한 필드에는 주의하세요.
규칙을 정했으면 고유 제약(또는 고유 인덱스)으로 강제하세요. 이렇게 하면 두 요청이 동시에 도착하더라도 데이터베이스가 침입을 거부합니다.
제약이 작동했을 때 사용자가 어떤 경험을 해야 할지도 결정하세요. 생성이 항상 잘못된 경우라면 명확한 메시지로 차단하세요(“해당 이메일은 이미 사용 중입니다”). 재시도가 흔하고 레코드가 이미 있는 경우에는 재시도를 성공으로 처리하고 기존 레코드를 반환하는 편이 나을 수 있습니다(“주문이 이미 생성되었습니다”).
생성이 실제로 "생성 또는 재사용(create or reuse)"이라면 upsert가 가장 깔끔한 패턴일 수 있습니다. 예: "이메일로 고객 생성"은 새 행을 삽입하거나 기존 행을 반환합니다. 이는 비즈니스 의미와 맞을 때만 사용하세요. 같은 키에 대해 약간 다른 페이로드가 올 수 있다면 어떤 필드를 업데이트할지, 어떤 필드는 그대로 유지할지 결정해야 합니다.
고유 제약이 멱등성 키나 좋은 UI 상태를 대체하지는 않지만, 나머지 방어들이 의지할 수 있는 강력한 보호막을 제공합니다.
멱등성 키는 "이 주문을 한 번만 생성" 같은 한 사용자 의도를 나타내는 고유 토큰입니다. 같은 요청이 다시 전송되면(이중 클릭, 네트워크 재시도, 모바일 재개) 서버는 이를 새로운 생성으로 보지 않고 재시도로 처리합니다.
클라이언트가 첫 시도가 성공했는지 모를 때 생성 엔드포인트를 안전하게 만드는 가장 실용적인 도구 중 하나입니다.
중복이 비용이 크거나 혼란을 주는 엔드포인트(주문, 송장, 결제, 초대, 구독, 이메일/웹훅을 트리거하는 폼 등)에 특히 유용합니다.
재시도 시 서버는 첫 성공 시의 원래 결과를 동일하게 반환해야 합니다. 이를 위해 (사용자 또는 계정) + 엔드포인트 + 멱등성 키로 키된 작은 멱등성 레코드를 저장하세요. 결과(레코드 ID, 응답 본문)와 “진행 중” 상태를 함께 저장하면 거의 동시에 들어오는 두 요청이 두 개의 행을 만들지 못하게 막을 수 있습니다.
멱등성 레코드는 현실적인 재시도를 커버할 만큼 충분히 오래 보관하세요. 일반적인 기준은 24시간입니다. 결제의 경우 48~72시간을 보관하는 팀도 많습니다. TTL을 두어 저장소를 제한하고 재시도 가능 기간과 맞추세요.
Koder.ai 같은 채팅 기반 빌더로 API를 생성하더라도 멱등성은 명시적으로 처리해야 합니다: 클라이언트가 보낸 키(헤더나 필드)를 받아들이고 서버에서 "같은 키는 같은 결과"를 강제하세요.
멱등성은 생성 요청을 반복해도 안전하게 만듭니다. 클라이언트가 타임아웃 때문에 재시도하거나 사용자가 두 번 클릭하면 서버는 두 번째 시도에서 두 번째 행을 만들지 않고 같은 결과를 반환합니다.
Idempotency-Key)가 잘 맞지만 JSON 본문으로 보내도 됩니다.핵심은 "체크 + 저장"이 동시성 하에서 안전해야 한다는 점입니다. 실제로는 (scope, key)에 대한 고유 제약을 멱등성 레코드에 걸고 충돌을 재사용 신호로 처리합니다.
if idempotency_record exists for (user_id, key):
return saved_response
else:
begin transaction
create the row
save response under (user_id, key)
commit
return response
예: 고객이 "Create invoice"를 눌러 앱이 키 abc123을 보내고 서버가 invoice inv_1007을 만들면, 전화가 신호를 잃고 재시도해도 서버는 inv_1007 응답을 반환합니다. inv_1008이 되지 않습니다.
테스트할 때는 "더블 클릭"에서 멈추지 마세요. 클라이언트에서 타임아웃됐지만 서버에서 완료된 요청을 시뮬레이션하고 같은 키로 재시도해보세요.
서버 측 방어가 중요하지만 많은 중복은 사람의 행동에서 시작됩니다. 좋은 UI는 안전한 경로를 명확히 합니다.
사용자가 제출하자마자 제출 버튼을 비활성화하세요. 유효성 검사 후나 요청이 시작된 뒤가 아니라 첫 클릭 시에 비활성화하세요. 폼을 여러 컨트롤(버튼과 Enter)로 제출할 수 있다면 단일 버튼만 잠그지 말고 폼 전체 상태를 잠그세요.
작동 중인지 알려주는 분명한 진행 상태를 표시하세요. 간단한 "저장 중..." 라벨이나 스피너면 충분합니다. 레이아웃을 안정적으로 유지해 버튼이 튀어 나가는 일이 없도록 하세요. 버튼이 움직이면 두 번째 클릭을 유발할 수 있습니다.
간단한 규칙 몇 가지가 대부분의 이중 제출을 막습니다: 제출 핸들러 시작 시 isSubmitting 플래그를 설정하고, 그 값이 true인 동안 새로운 제출을 무시하며(클릭과 Enter 모두), 실제 응답을 받을 때까지 플래그를 해제하지 마세요.
느린 응답이 많은 앱에서 문제 발생 빈도가 높습니다. 고정된 타이머(예: 2초 후)에 버튼을 재활성화하면 첫 요청이 아직 진행 중일 때 사용자가 다시 제출할 수 있습니다. 시도는 실제로 완료되었을 때만 재활성화하세요.
성공 후에는 재제출 가능성을 줄이세요. 생성된 레코드 페이지나 목록으로 이동시키거나, 생성 성공 상태를 분명히 보여주고 생성된 레코드를 표시하세요. 같은 채워진 폼을 화면에 두고 버튼을 활성화된 상태로 두지 마세요.
끈질긴 중복 버그는 일상적이지만 "이상해 보이는" 행동에서 옵니다: 두 개의 탭, 새로고침, 또는 신호를 잃는 휴대폰.
먼저 고유성의 범위를 정확히 정하세요. "고유"는 보통 "데이터베이스 전체에서 고유"를 의미하지 않습니다. 사용자당 하나, 워크스페이스당 하나, 테넌트당 하나일 수 있습니다. 외부 시스템과 동기화한다면 외부 소스별 고유성도 필요합니다. 안전한 접근은 정확한 문장으로 적어두는 것입니다(예: "테넌트별 연도당 하나의 송장 번호"). 그 다음 이를 강제하세요.
다중 탭 행동은 고전적인 함정입니다. UI 로딩 상태는 한 탭에서 도움이 되지만 탭 간에는 아무 영향이 없습니다. 이럴 때는 서버 측 방어가 여전히 필요합니다.
뒤로 가기와 새로고침도 우발적 재제출을 유발할 수 있습니다. 생성 성공 후 사용자는 확인하려고 새로고침하거나, 뒤로 가서 여전히 편집 가능한 폼을 다시 제출할 수 있습니다. 생성 후에는 원래 폼 대신 생성된 보기로 전환하고 서버가 재실행을 안전하게 처리하도록 하세요.
모바일은 중단이 잦습니다: 백그라운드로 전환, 불안정한 네트워크, 자동 재시도. 요청이 성공했지만 앱이 응답을 받지 못해 재개 시 다시 시도할 수 있습니다.
가장 흔한 실패 모드는 UI만을 유일한 방어선으로 여기는 것입니다. 버튼 비활성화와 스피너는 도움이 되지만 새로고침, 불안정한 모바일 네트워크, 사용자가 두 번째 탭을 여는 경우, 또는 클라이언트 버그를 커버하지 못합니다. 서버와 데이터베이스는 여전히 “이 생성은 이미 일어났다”고 말할 수 있어야 합니다.
또 다른 함정은 잘못된 필드를 고유성 대상으로 선택하는 것입니다. 성(last name), 반올림된 타임스탬프, 자유 형식 제목처럼 실제로 고유하지 않은 필드에 고유 제약을 걸면 정상적인 레코드를 차단하게 됩니다. 대신 외부 제공자 ID 같은 진짜 식별자나 범위별 규칙(사용자별, 날짜별, 부모 레코드별 고유)을 사용하세요.
멱등성 키도 잘못 구현하기 쉽습니다. 클라이언트가 재시도마다 새로운 키를 생성하면 매번 새 생성이 됩니다. 첫 클릭부터 모든 재시도까지 같은 키를 유지하세요.
또한 재시도에 대해 반환하는 값을 주의하세요. 첫 요청이 레코드를 생성했다면 재시도는 같은 결과(또는 적어도 같은 레코드 ID)를 반환해야지, 사용자가 다시 시도하게 만드는 모호한 오류를 반환해선 안 됩니다.
고유 제약이 중복을 차단했다면 "문제가 발생했습니다"로 숨기지 마세요. 평이한 언어로 상황을 설명하세요: "이 송장 번호는 이미 존재합니다. 원본을 유지하고 두 번째 생성은 하지 않았습니다." 같은 식으로요.
릴리스 전에 중복 생성 경로에 대해 특별히 빠르게 점검하세요. 가장 좋은 결과는 여러 방어를 겹쳐 두어 클릭 한 번이 빠졌거나 재시도가 있어도 두 행이 생기지 않게 하는 것입니다.
세 가지를 확인하세요:
실용적인 체감 검사: 폼을 열고 빠르게 두 번 제출한 뒤, 제출 중간에 새로고침하고 다시 시도하세요. 두 개의 레코드를 만들 수 있다면 실제 사용자도 만들 수 있습니다.
작은 송장 앱을 상상해보세요. 사용자가 새 송장을 작성하고 Create를 탭합니다. 네트워크가 느리고 화면이 바로 바뀌지 않아 다시 Create를 탭합니다.
UI 보호만 있으면 버튼을 비활성화하고 스피너를 보여줄 수 있습니다. 그것도 도움이 되지만 충분하지 않습니다. 일부 기기에서는 더블 탭이 여전히 통과되거나 타임아웃 후 재시도가 발생하거나 두 탭에서 제출할 수 있습니다.
데이터베이스 고유 제약만 있으면 정확한 중복은 막을 수 있지만 사용자 경험이 거칠어질 수 있습니다. 첫 요청은 성공했고 두 번째 요청이 제약에 걸려 사용자는 오류를 보게 됩니다. 하지만 실제로는 송장이 생성되어 있습니다.
깔끔한 결과는 멱등성 + 고유 제약입니다:
두 번째 탭에서 탭했을 때 간단한 UI 메시지: "송장이 생성되었습니다 - 중복 제출을 무시하고 첫 요청을 유지했습니다."
기본을 갖춘 뒤 다음으로 얻을 수 있는 이득은 가시성, 정리, 일관성에 관한 것입니다.
생성 경로 주변에 가벼운 로깅을 추가해 실제 사용자 동작과 재시도를 구분할 수 있게 하세요. 멱등성 키, 관련된 고유 필드, 결과(생성 vs 기존 반환 vs 거부)를 로깅하면 됩니다. 시작은 복잡한 툴이 필요 없습니다.
이미 중복이 존재한다면 명확한 규칙과 감사 기록으로 정리하세요. 예를 들어 가장 오래된 레코드를 "승자"로 두고 관련 행(결제, 라인 아이템)을 재연결한 뒤 나머지는 병합된 것으로 표시하세요. 삭제하는 대신 병합하면 지원과 리포팅이 훨씬 쉬워집니다.
고유성과 멱등성 규칙을 한 곳에 문서화하세요: 무엇이 어떤 범위에서 고유한지, 멱등성 키는 얼마나 오래 보관하는지, 오류는 어떻게 보이는지, UI는 재시도 시 어떻게 동작해야 하는지. 이렇게 하면 새로운 엔드포인트가 안전장치를 우회하지 못하게 합니다.
Koder.ai에서 CRUD 화면을 빠르게 만들고 있다면(koder.ai), 이러한 동작들을 기본 템플릿의 일부로 만드는 것이 좋습니다: 스키마에 고유 제약, API에 멱등성 생성 엔드포인트, UI에 명확한 로딩 상태를 포함하세요. 이렇게 하면 속도가 데이터 정리를 해치지 않습니다.
중복 레코드는 동일한 실세계 항목이 두 번 저장될 때를 말합니다. 예를 들어 하나의 결제를 위한 두 개의 주문이나 동일한 문제에 대한 두 개의 티켓이 있을 수 있습니다. 일반적으로는 각 행이 정상처럼 보이지만 전체 데이터를 보면 잘못된 상태로 나타납니다. 보통 사용자 이중 제출, 재시도 또는 동시 요청으로 인해 동일한 “생성” 동작이 여러 번 실행되며 발생합니다.
사용자가 한 번만 클릭했더라도 두 번째 생성이 사용자 눈에 띄지 않게 발생할 수 있습니다. 예를 들어 모바일의 더블 탭, Enter와 버튼 클릭의 중복, 또는 네트워크 타임아웃 후 클라이언트가 자동으로 재시도하는 경우입니다. 서버는 "POST는 한 번만"이라는 가정을 할 수 없습니다.
신뢰할 수는 없습니다. 버튼을 비활성화하고 "저장 중..."을 표시하면 우발적 이중 제출을 줄여주지만, 불안정한 네트워크에서의 재시도, 새로고침, 다중 탭, 백그라운드 작업 또는 웹훅 재전달을 막지는 못합니다. 서버와 데이터베이스 수준의 방어가 필요합니다.
데이터베이스 고유 제약은 동시에 두 행이 삽입되는 것을 막는 최후의 방어선입니다. 실세계에서 사람들이 생각하는 고유 규칙(종종 테넌트나 워크스페이스별로 범위를 한정)을 정의하고 데이터베이스에서 직접 강제하는 것이 가장 효과적입니다.
두 방식은 다른 문제를 해결합니다. 고유 제약은 필드 규칙(예: 송장 번호)에 기반해 중복을 차단하고, 멱등성 키는 특정 생성 시도를 반복해도 같은 결과를 반환하게 합니다. 둘을 함께 쓰면 재시도에 대한 경험이 좋아지고 중복 삽입도 보호됩니다.
사용자 의도(예: “Create” 버튼을 누르는 한 번의 동작)에 대해 하나의 키를 생성하세요. 그 의도에 대한 모든 재시도에 동일한 키를 재사용하고, 요청마다 키를 전송하세요. 키는 타임아웃과 앱 재개 시에도 안정적이어야 하지만 다른 생성 시도에 재사용하면 안 됩니다.
서버는 범위(예: 사용자 또는 계정), 엔드포인트, 멱등성 키로 키를 저장하고 처음 성공한 요청에 대해 반환한 응답을 함께 보관해야 합니다. 같은 키가 다시 오면 새 행을 만들지 말고 처음 저장한 응답(같은 생성된 레코드 ID 포함)을 반환하세요.
멱등성은 동시성에서 안전해야 합니다. 일반적으로는 (범위, 키)에 대한 고유 제약을 멱등성 레코드에 걸어 두고, 충돌이 발생하면 저장된 결과를 재사용하도록 처리합니다. 이렇게 하면 거의 동시에 들어온 두 요청이 둘 다 "첫 번째"인 상황을 피할 수 있습니다.
현실적인 재시도를 커버할 만큼 보관하세요. 일반적인 기준은 약 24시간입니다. 결제 같은 흐름은 48~72시간을 보관하는 팀도 있습니다. TTL을 두어 저장소가 무한히 늘어나지 않게 하고, 클라이언트가 재시도할 수 있는 기간과 맞추세요.
같은 의도임이 분명할 때는 재시도를 성공으로 처리해 원래 생성된 레코드(같은 ID)를 반환하는 것이 좋습니다. 이메일처럼 실제로 고유해야 하는 항목이라면 명확한 충돌 메시지를 보여주고 어떤 항목이 이미 존재하는지 설명하세요.