데이터베이스 레코드 중복을 방지하는 안정적인 방법

작성자

카테고리:

← 피드로
DEV Community · Rekan Othman · 2026-08-01 개발(SW)

Every transactional system must answer one question:

If the same request arrives twice, will the database contain one record or two?

Duplicate requests happen for many reasons — and a double-click is only the most visible one:

  • Double-clicks on a save button
  • Browser retries after a timeout
  • Network interruptions and reconnects
  • Reverse proxy or load balancer retries
  • Mobile clients resending after losing signal
  • Frontend bugs firing a request twice
  • API consumers retrying failed calls
  • Webhook providers resending events

You cannot prevent duplicate requests. You can only prevent duplicate records. The answer is idempotency, enforced by the database.

Throughout this article, keep one distinction in mind: Django handles validation. PostgreSQL guarantees correctness. We’ll implement the pattern in an invoice module as our example, but it applies to any create endpoint.

The Problem

A user saves an invoice. The request is slow, the request is retried — by the user, the browser, or the network — and now two identical invoices exist. Reports, inventory, and accounting are all wrong. The same failure can hit orders, payments, registrations, or any other module that creates data.

Why Client-Side Prevention Isn’t Enough

Disabling the save button while a request is in flight is good UX and stops most accidental double-clicks:

<button :disabled="saving" @click="save">Save</button>

Enter fullscreen mode Exit fullscreen mode

But it does nothing against network retries, refreshes, proxies, or other API consumers. The frontend can reduce duplicates; it can never guarantee anything.

What the frontend can usefully do is give each operation an identity. Generate an idempotency key when the operation begins — not when the request is sent — and reuse it for every attempt:

data() {
  return {
    invoice: {},
    idempotencyKey: crypto.randomUUID(), // once per operation
  };
},
methods: {
  async save() {
    await axios.post('/api/invoices/', this.invoice, {
      headers: { 'X-Idempotency-Token': this.idempotencyKey },
    });
  },
  newInvoice() {
    this.invoice = {};
    this.idempotencyKey = crypto.randomUUID(); // reset only here
  },
},

Enter fullscreen mode Exit fullscreen mode

One operation, one key. If you generate the key per request (for example, in a global Axios interceptor), every retry looks like a new operation and the server cannot deduplicate it.

Why Checking First Doesn’t Work

The intuitive backend approach is to check before inserting:

if not Invoice.objects.filter(idempotency_key=token).exists():
    Invoice.objects.create(idempotency_key=token, ...)

Enter fullscreen mode Exit fullscreen mode

Under concurrency, this fails:

Request A arrives
    ↓
exists() → false
                        Request B arrives
                            ↓
                        exists() → false
A inserts row
                        B inserts row   ← duplicate

Enter fullscreen mode Exit fullscreen mode

Both requests pass the check before either inserts. This “check-then-act” race applies equally to cache-based deduplication (cache.get() then cache.set()). No application-level check can fix this, because the check and the insert are two separate steps — and another request can run between them.

A note on get_or_create(): many Django developers reach for it here, but it doesn’t replace idempotency. Internally it’s still a lookup followed by an insert, and it only helps when the lookup fields already represent business uniqueness. An idempotency key is different: it identifies a single client operation, regardless of the record’s contents.

Let PostgreSQL Guarantee Uniqueness

The fix is to move the guarantee into the database:

class Invoice(models.Model):
    idempotency_key = models.UUIDField(unique=True, db_index=True)
    # ... your fields

Enter fullscreen mode Exit fullscreen mode

Why does this work when application checks don’t? Because PostgreSQL performs the uniqueness check and the insert as a single atomic operation inside the database engine. Two concurrent transactions cannot both insert the same unique value — one succeeds, the other fails with an IntegrityError. There is no gap between the check and the insert, so a race condition is impossible.

So instead of treating IntegrityError as a failure, we treat it as a signal that the work is already done:

from django.db import IntegrityError, transaction

def create(self, request):
    token = request.headers.get("X-Idempotency-Token")
    if not token:
        return Response({"detail": "Idempotency token required."}, status=400)

    serializer = InvoiceSerializer(data=request.data)
    serializer.is_valid(raise_exception=True)

    try:
        with transaction.atomic():
            invoice = serializer.save(idempotency_key=token)
    except IntegrityError:
        invoice = Invoice.objects.get(idempotency_key=token)
        return Response(InvoiceSerializer(invoice).data, status=200)

    return Response(InvoiceSerializer(invoice).data, status=201)

Enter fullscreen mode Exit fullscreen mode

First request: 201 Created. Any replay: 200 OK with the same record. Django validated the request; PostgreSQL guaranteed the correctness.

Add Business Constraints

One layer of protection is good; two are better. Most records also have a natural uniqueness rule of their own. Enforce it as a second constraint:

class Meta:
    constraints = [
        models.UniqueConstraint(
            fields=["terminal_id", "number"],
            name="unique_invoice_per_terminal",
        )
    ]

Enter fullscreen mode Exit fullscreen mode

Even if a future bug bypasses the idempotency key, PostgreSQL still refuses the duplicate. Constraints cost nothing and protect you from bugs you haven’t written yet.

The Reusable Architecture

The complete flow, reusable in any module:

Client
    ↓  generate one idempotency key per operation
Django
    ↓  validate the request
PostgreSQL
    ↓  unique constraint enforces exactly-once insert
IntegrityError
    ↓  caught by Django
Return the existing resource (200 OK)

Enter fullscreen mode Exit fullscreen mode

Every create endpoint in your system can follow this exact shape.

When Should You Use This?

Use idempotency for anything where a duplicate causes damage:

  • Invoices, orders, and purchase orders
  • Payments and money transfers
  • Inventory movements and shipments
  • Ticket creation and registrations

Skip it for operations that are naturally safe to repeat:

  • Search endpoints
  • Reports and analytics
  • Any read-only API

Performance

A unique index check is a single, extremely fast lookup — it takes microseconds. The constraint adds no meaningful overhead to inserts, and PostgreSQL handles this pattern at high write volumes without queues, distributed locks, or any special infrastructure. For the vast majority of applications, performance is simply not a concern here.

Key Takeaway

A transactional system should never rely on timing, client behavior, or network conditions to maintain data integrity — those are unpredictable. The database is the only component capable of guaranteeing uniqueness under concurrency. Generate one idempotency key per operation, enforce it with a PostgreSQL unique constraint, and catch IntegrityError to return the existing record. Build your API around that guarantee, and duplicate record bugs disappear permanently.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다