SaaS를 만들다 보면 생각보다 자주 마주치는 문제가 있다.
> “같은 요청이 두 번 들어오면 어떻게 하지?”
사용자가 버튼을 두 번 누를 수도 있고, 네트워크 오류 때문에 클라이언트가 재시도할 수도 있다. Stripe, Notion, GitHub, Slack 같은 외부 서비스의 webhook은 동일한 이벤트를 여러 번 보낼 수도 있다. 백엔드 worker가 실패 후 같은 job을 다시 실행할 수도 있다.
이때 서버가 같은 요청을 매번 새로운 작업으로 처리하면 데이터가 쉽게 꼬인다.
예를 들어 다음과 같은 문제가 생긴다.
- 같은 결제가 두 번 생성된다.
- 같은 환자 기록이 중복 저장된다.
- 같은 알림이 여러 번 발송된다.
- 같은 webhook 이벤트가 반복 처리된다.
- 같은 예약 슬롯이 두 명에게 배정된다.
- 외부 데이터 동기화 시 같은 고객이 여러 row로 쌓인다.
이 문제를 막기 위한 핵심 개념이 **데이터 멱등성(idempotency)**이다.
---
## 1. 멱등성이란 무엇인가?
멱등성은 같은 작업을 여러 번 수행해도 최종 결과가 한 번 수행한 것과 같아야 한다는 성질이다.
예를 들어 다음 요청은 멱등하지 않다.
```http
POST /patients
Content-Type: application/json
{
"name": "Kim",
"birthDate": "1999-01-01"
}
```
이 요청이 세 번 들어오면 DB에는 환자 row가 세 개 생길 수 있다.
```txt
id=1, Kim
id=2, Kim
id=3, Kim
```
반면 다음 요청은 멱등하게 만들기 쉽다.
```http
PUT /patients/HOSPITAL-123
Content-Type: application/json
{
"name": "Kim",
"birthDate": "1999-01-01"
}
```
`HOSPITAL-123`이라는 고정된 식별자를 기준으로 데이터를 저장하면, 같은 요청이 여러 번 들어와도 최종적으로는 하나의 환자 데이터만 유지된다.
```txt
external_id=HOSPITAL-123, Kim
```
즉, 멱등성의 본질은 단순하다.
> 같은 요청이 여러 번 들어와도 시스템의 최종 상태가 의도한 대로 유지되게 만드는 것.
---
## 2. 멱등성이 필요한 순간
멱등성은 이론적인 개념이 아니라 실제 서비스에서 매우 자주 필요하다.
특히 다음 상황에서는 거의 필수다.
### 외부 API 동기화
Notion, Google Calendar, CRM, EMR-like system, Stripe customer 데이터를 가져와서 우리 DB에 저장하는 경우가 있다.
이때 매번 insert만 하면 같은 외부 객체가 계속 중복 생성된다.
```txt
notion_page_id=abc123
notion_page_id=abc123
notion_page_id=abc123
```
따라서 외부 데이터에는 반드시 외부 식별자, 즉 `external_id`를 두는 것이 좋다.
예를 들면 다음과 같다.
```txt
notion_page_id
stripe_customer_id
stripe_subscription_id
google_event_id
hospital_patient_id
gym_member_id
```
---
### Webhook 처리
Stripe, GitHub, Slack, Notion 같은 서비스는 같은 이벤트를 여러 번 보낼 수 있다.
예를 들어 Stripe webhook에서 다음 이벤트가 들어왔다고 하자.
```txt
event_id=evt_123
type=invoice.paid
```
서버가 이 이벤트를 받을 때마다 매번 결제 완료 처리를 하면 문제가 생긴다.
따라서 이미 처리한 `event_id`인지 확인하고, 이미 처리했다면 무시해야 한다.
---
### 결제, 주문, 크레딧 차감
결제나 크레딧 차감은 특히 위험하다.
사용자가 결제 버튼을 눌렀는데 네트워크 오류가 발생했다고 하자. 클라이언트는 같은 요청을 다시 보낼 수 있다. 이때 서버가 같은 결제를 두 번 생성하면 실제 금전적 문제가 생긴다.
이런 작업은 단순 upsert만으로는 부족하고, 요청 단위의 `idempotency_key`를 별도로 관리하는 것이 좋다.
---
### 알림 발송
이메일, SMS, FCM push notification도 멱등성이 필요하다.
같은 이벤트에 대해 알림이 여러 번 나가면 사용자는 서비스가 불안정하다고 느낀다.
```txt
send_push:user_123:appointment_reminder_2026_06_30
```
같은 알림 key가 이미 처리되었다면 다시 보내지 않도록 해야 한다.
---
## 3. Upsert: 가장 대표적인 멱등성 구현 방식
가장 많이 쓰는 방식은 **upsert**다.
Upsert는 update와 insert를 합친 개념이다.
```txt
이미 있으면 update
없으면 insert
```
PostgreSQL에서는 보통 `ON CONFLICT DO UPDATE`로 구현한다.
```sql
INSERT INTO patients (
external_id,
name,
birth_date,
updated_at
)
VALUES (
'HOSPITAL-123',
'Kim',
'1999-01-01',
now()
)
ON CONFLICT (external_id)
DO UPDATE SET
name = EXCLUDED.name,
birth_date = EXCLUDED.birth_date,
updated_at = now();
```
이 쿼리는 다음과 같이 동작한다.
```txt
external_id = HOSPITAL-123 이 없으면 insert
external_id = HOSPITAL-123 이 이미 있으면 update
```
Supabase에서는 다음처럼 쓸 수 있다.
```ts
await supabase
.from("patients")
.upsert(
{
external_id: "HOSPITAL-123",
name: "Kim",
birth_date: "1999-01-01",
updated_at: new Date().toISOString(),
},
{
onConflict: "external_id",
}
);
```
단, 중요한 조건이 있다.
`onConflict`로 지정한 컬럼에는 반드시 unique constraint가 있어야 한다.
```sql
CREATE UNIQUE INDEX patients_external_id_key
ON patients (external_id);
```
이 unique constraint가 없으면 DB는 어떤 row를 기준으로 충돌을 판단해야 하는지 알 수 없다.
---
## 4. Upsert가 적합한 경우
Upsert는 특히 **현재 상태 테이블**에 적합하다.
예를 들어 다음 데이터는 현재 상태를 최신화하는 것이 중요하다.
```txt
고객 프로필
구독 상태
Notion page 상태
Google Calendar event 상태
헬스장 회원 정보
환자 기본 정보
외부 CRM contact
```
이런 데이터는 같은 외부 ID를 기준으로 최신 상태만 유지하면 된다.
```txt
external_id = source object id
```
그래서 구조는 보통 이렇게 잡는다.
```sql
CREATE TABLE customers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
external_id text NOT NULL UNIQUE,
name text,
email text,
status text,
source_updated_at timestamptz,
synced_at timestamptz DEFAULT now()
);
```
그리고 외부 API에서 데이터를 가져올 때마다 upsert한다.
```sql
INSERT INTO customers (
external_id,
name,
email,
status,
source_updated_at,
synced_at
)
VALUES (
'notion_page_abc123',
'Kim',
'
[email protected]',
'active',
'2026-06-30T10:00:00Z',
now()
)
ON CONFLICT (external_id)
DO UPDATE SET
name = EXCLUDED.name,
email = EXCLUDED.email,
status = EXCLUDED.status,
source_updated_at = EXCLUDED.source_updated_at,
synced_at = now();
```
---
## 5. Upsert만으로 충분하지 않은 경우
하지만 모든 문제를 upsert로 해결하면 안 된다.
Upsert는 “현재 상태를 덮어써도 되는 데이터”에는 좋지만, 다음과 같은 상황에서는 위험할 수 있다.
```txt
결제 생성
주문 생성
크레딧 차감
포인트 적립/차감
의료 기록
감사 로그
알림 발송
예약 슬롯 배정
동시 편집
```
이런 경우에는 다른 멱등성 패턴이 필요하다.
---
## 6. Unique constraint + insert 실패 처리
가장 단순한 중복 방지 방식은 unique constraint를 걸고 insert 실패를 처리하는 것이다.
```sql
CREATE UNIQUE INDEX users_email_key
ON users (email);
```
그다음에는 그냥 insert를 시도한다.
```sql
INSERT INTO users (email, name)
VALUES ('
[email protected]', 'Kim');
```
이미 같은 email이 있다면 DB가 에러를 낸다.
```txt
duplicate key value violates unique constraint
```
이 에러를 서버에서 잡아서 “이미 존재하는 사용자”로 처리하면 된다.
이 방식은 다음 상황에 적합하다.
```txt
회원가입
초대 코드 사용
쿠폰 발급
최초 리소스 생성
중복되면 안 되는 엔티티 생성
```
이런 경우 upsert를 쓰면 오히려 위험할 수 있다. 이미 존재하는 사용자를 조용히 업데이트해버릴 수 있기 때문이다.
---
## 7. Insert only + ON CONFLICT DO NOTHING
Webhook 이벤트처럼 “이미 처리했으면 무시하면 되는 데이터”에는 `ON CONFLICT DO NOTHING`이 좋다.
```sql
CREATE TABLE processed_events (
event_id text PRIMARY KEY,
event_type text,
processed_at timestamptz DEFAULT now()
);
```
이벤트 처리 전에 먼저 insert를 시도한다.
```sql
INSERT INTO processed_events (
event_id,
event_type
)
VALUES (
'evt_123',
'invoice.paid'
)
ON CONFLICT (event_id) DO NOTHING;
```
처음 들어온 이벤트라면 insert된다. 이미 처리한 이벤트라면 아무 일도 일어나지 않는다.
실제 처리 흐름은 다음과 같이 만들 수 있다.
```txt
1. processed_events에 event_id insert 시도
2. insert 성공 → 실제 비즈니스 로직 실행
3. insert 실패 또는 affected row 0 → 이미 처리한 이벤트이므로 skip
```
이 방식은 다음에 적합하다.
```txt
Stripe webhook event
GitHub webhook delivery
Slack event
Notion event
Google Calendar sync event
```
단, 주의할 점이 있다. `DO NOTHING`은 최신 상태를 반영하지 않는다. 말 그대로 중복 이벤트를 무시하는 방식이다. 따라서 현재 상태 업데이트가 필요한 경우에는 upsert와 조합해야 한다.
---
## 8. Idempotency key 테이블
결제, 주문, 크레딧 차감처럼 부작용이 큰 작업은 `idempotency_key`를 별도 테이블로 관리하는 것이 좋다.
예를 들어 클라이언트가 결제 생성 요청을 보낼 때 다음 header를 함께 보낸다.
```http
POST /payments
Idempotency-Key: pay_user_123_20260630_001
```
서버는 이 key를 기준으로 요청이 이미 처리되었는지 확인한다.
```sql
CREATE TABLE idempotency_keys (
key text PRIMARY KEY,
request_hash text NOT NULL,
status text NOT NULL,
response jsonb,
created_at timestamptz DEFAULT now()
);
```
처리 흐름은 다음과 같다.
```txt
1. idempotency_key가 있는지 조회한다.
2. 없으면 key를 insert하고 작업을 시작한다.
3. 작업이 성공하면 response를 저장한다.
4. 같은 key로 다시 요청이 오면 작업을 재실행하지 않고 저장된 response를 반환한다.
5. 같은 key인데 request body가 다르면 에러를 반환한다.
```
여기서 `request_hash`가 중요하다.
같은 key로 완전히 다른 요청을 보내는 경우를 막아야 하기 때문이다.
예를 들어 다음 두 요청은 같은 idempotency key를 쓰면 안 된다.
```json
{
"amount": 10000,
"currency": "KRW"
}
```
```json
{
"amount": 50000,
"currency": "KRW"
}
```
따라서 서버는 request body를 hash로 저장해두고, 같은 key가 다시 들어왔을 때 hash가 다르면 거절해야 한다.
```txt
같은 key + 같은 request_hash → 기존 response 반환
같은 key + 다른 request_hash → 409 Conflict
```
---
## 9. State machine으로 상태 전이 제한하기
상태값이 있는 데이터는 허용된 순서대로만 바뀌게 해야 한다.
예를 들어 주문 상태가 다음과 같다고 하자.
```txt
pending → paid → fulfilled
pending → cancelled
```
결제 완료 처리는 다음처럼 만들 수 있다.
```sql
UPDATE orders
SET status = 'paid',
paid_at = now()
WHERE id = 'order_123'
AND status = 'pending';
```
이 쿼리의 장점은 이미 `paid`인 주문에 같은 결제 완료 이벤트가 다시 들어와도 아무 변화가 없다는 것이다.
```txt
pending 상태일 때만 paid로 변경
이미 paid면 업데이트되지 않음
```
이 방식은 다음에 적합하다.
```txt
결제 상태
구독 상태
배송 상태
예약 상태
검사 의뢰 상태
환자 onboarding 상태
```
의료 SaaS에서는 특히 상태 전이가 중요하다. 예를 들어 환자의 검사 의뢰 상태, 동의서 제출 상태, 진료 전 설문 완료 상태가 아무 API에서나 되돌아가면 audit 문제가 생길 수 있다.
---
## 10. Optimistic locking으로 동시 수정 막기
동시에 여러 사용자가 같은 데이터를 수정할 수 있다면 optimistic locking이 필요하다.
테이블에 `version` 컬럼을 둔다.
```sql
CREATE TABLE documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
content text,
version integer NOT NULL DEFAULT 1,
updated_at timestamptz DEFAULT now()
);
```
사용자가 문서를 읽었을 때 version이 3이었다고 하자.
수정할 때는 version이 여전히 3인 경우에만 업데이트한다.
```sql
UPDATE documents
SET content = 'new content',
version = version + 1,
updated_at = now()
WHERE id = 'doc_123'
AND version = 3;
```
만약 다른 사용자가 먼저 수정해서 version이 4가 되었다면 이 update는 실패한다.
```txt
affected rows = 0
```
이 경우 서버는 충돌을 알려야 한다.
```txt
409 Conflict
```
이 방식은 다음에 적합하다.
```txt
의사 메모
관리자 설정
동시 편집 문서
예약 수정
CRM 고객 정보 수정
```
Upsert는 동시 수정 충돌을 조용히 덮어쓸 수 있다. 따라서 사용자의 직접 편집 데이터에는 upsert보다 optimistic locking이 더 안전한 경우가 많다.
---
## 11. Append-only event log
감사 추적이 중요한 데이터는 덮어쓰지 말고 사건을 계속 쌓는 방식이 좋다.
예를 들어 환자 증상 기록을 생각해보자.
나쁜 방식은 하나의 row를 계속 update하는 것이다.
```txt
patient.symptom = "pain"
patient.symptom = "nausea"
patient.symptom = "fatigue"
```
이렇게 하면 과거 상태가 사라진다.
더 좋은 방식은 이벤트를 계속 쌓는 것이다.
```sql
CREATE TABLE patient_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id uuid NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
occurred_at timestamptz NOT NULL,
created_at timestamptz DEFAULT now()
);
```
예시는 다음과 같다.
```sql
INSERT INTO patient_events (
patient_id,
event_type,
payload,
occurred_at
)
VALUES (
'patient_123',
'symptom_logged',
'{"symptom": "nausea", "grade": 2}'::jsonb,
'2026-06-30T10:00:00Z'
);
```
이 방식은 다음에 적합하다.
```txt
환자 증상 기록
복약 기록
의료 상담 기록
결제 ledger
포인트 ledger
알림 발송 이력
감사 로그
```
현재 상태가 필요하면 별도의 projection table을 둔다.
```txt
patient_events → patient_current_status
```
즉, 원본 이벤트는 append-only로 보존하고, 조회 성능을 위해 현재 상태 테이블만 따로 업데이트하는 구조다.
---
## 12. Soft delete
삭제도 멱등성의 관점에서 설계해야 한다.
실제 row를 삭제해버리면 같은 외부 데이터가 다시 들어왔을 때 과거 이력이 끊긴다.
대신 `deleted_at`을 두고 soft delete하는 것이 좋다.
```sql
UPDATE customers
SET deleted_at = now()
WHERE id = 'customer_123';
```
복구는 다음처럼 한다.
```sql
UPDATE customers
SET deleted_at = NULL
WHERE external_id = 'notion_page_abc123';
```
이 방식은 다음에 적합하다.
```txt
CRM 고객 삭제/복구
회원 탈퇴 후 재가입
구독 해지 후 재활성화
헬스장 회원 비활성화
외부 데이터 archive 처리
```
---
## 13. Hash 비교 후 변경된 경우만 업데이트
외부 API 데이터를 대량으로 동기화할 때는 매번 update를 치는 것도 비용이다.
이때는 payload의 hash를 저장해두고, hash가 바뀐 경우에만 업데이트할 수 있다.
```txt
source_hash = sha256(JSON.stringify(payload))
```
테이블에는 hash 컬럼을 둔다.
```sql
ALTER TABLE customers
ADD COLUMN source_hash text;
```
동기화 로직은 다음과 같다.
```txt
1. 외부 payload를 정규화한다.
2. 정규화된 payload의 hash를 만든다.
3. 기존 source_hash와 비교한다.
4. 같으면 skip한다.
5. 다르면 update한다.
```
이 방식은 다음에 적합하다.
```txt
Notion DB sync
Google Calendar sync
CRM contact sync
상품 데이터 sync
가격 데이터 sync
대량 profile sync
```
장점은 불필요한 DB write, trigger 실행, webhook 발행을 줄일 수 있다는 점이다.
---
## 14. Queue deduplication
백그라운드 작업은 DB에 도달하기 전에 queue 단계에서 중복 제거를 할 수 있다.
예를 들어 다음 job key를 만든다.
```txt
sync_notion_page:page_123
send_push:user_456:campaign_789
generate_report:patient_111:2026-06
```
같은 job key가 이미 queue에 있거나 최근 처리되었다면 새 job을 넣지 않는다.
이 방식은 다음에 적합하다.
```txt
대량 동기화
AI 리포트 생성
푸시 알림 발송
이메일 발송
파일 변환
이미지 처리
문서 생성
```
중요한 점은 queue deduplication은 DB 멱등성을 대체하지 않는다는 것이다.
큐에서 중복을 줄여도 worker가 실패 후 재시도될 수 있다. 따라서 중요한 작업은 queue deduplication과 DB-level idempotency를 함께 써야 한다.
---
## 15. Transaction lock / advisory lock
동시에 같은 리소스를 처리하면 안 되는 경우에는 lock이 필요하다.
예를 들어 같은 예약 슬롯에 두 명이 동시에 예약 요청을 보낸다고 하자.
단순히 “비어 있으면 예약” 로직을 애플리케이션에서만 처리하면 race condition이 생길 수 있다.
PostgreSQL에서는 advisory lock을 사용할 수 있다.
```sql
SELECT pg_advisory_xact_lock(hashtext('appointment_slot_123'));
```
이 transaction이 끝날 때까지 같은 key의 lock을 잡으려는 다른 transaction은 대기한다.
이 방식은 다음에 적합하다.
```txt
예약 슬롯 배정
재고 차감
포인트 차감
크레딧 차감
같은 환자 리포트 중복 생성 방지
동시 결제 처리
```
단, lock은 신중하게 써야 한다. 너무 넓은 범위를 잠그면 성능 병목이 생긴다. 가능하면 lock key를 구체적으로 잡아야 한다.
```txt
나쁜 예: all_appointments
좋은 예: appointment_slot_123
```
---
## 16. 실제 구현 조합
실전에서는 하나의 패턴만 쓰는 경우보다 여러 패턴을 조합하는 경우가 많다.
### 외부 데이터 동기화
```txt
unique external_id
+ upsert
+ source_updated_at 비교
+ hash 비교
```
추천 구조:
```sql
CREATE TABLE external_contacts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source text NOT NULL,
external_id text NOT NULL,
name text,
email text,
source_updated_at timestamptz,
source_hash text,
synced_at timestamptz DEFAULT now(),
UNIQUE (source, external_id)
);
```
여기서 `source`와 `external_id`를 묶어서 unique를 잡는 것이 좋다.
```txt
source=notion, external_id=abc123
source=stripe, external_id=abc123
```
서로 다른 source에서 같은 external_id가 나올 수 있기 때문이다.
---
### Webhook 처리
```txt
processed_events 테이블
+ ON CONFLICT DO NOTHING
+ business logic transaction
+ 현재 상태 upsert
```
구조는 다음과 같다.
```sql
CREATE TABLE processed_events (
source text NOT NULL,
event_id text NOT NULL,
event_type text,
processed_at timestamptz DEFAULT now(),
PRIMARY KEY (source, event_id)
);
```
처리 흐름:
```txt
1. processed_events에 source + event_id insert
2. 이미 있으면 skip
3. 처음이면 비즈니스 로직 실행
4. 필요한 현재 상태 테이블 upsert
```
---
### 결제 생성
```txt
idempotency_key
+ request_hash
+ transaction
+ state machine
+ provider payment_id unique
```
추천 구조:
```sql
CREATE TABLE payments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
provider text NOT NULL,
provider_payment_id text UNIQUE,
amount integer NOT NULL,
currency text NOT NULL,
status text NOT NULL,
created_at timestamptz DEFAULT now()
);
CREATE TABLE idempotency_keys (
key text PRIMARY KEY,
request_hash text NOT NULL,
status text NOT NULL,
response jsonb,
created_at timestamptz DEFAULT now()
);
```
결제는 외부 provider와 통신하기 때문에 특히 조심해야 한다.
같은 요청이 재시도되었을 때 외부 provider에 결제 생성 요청이 다시 나가면 안 된다.
---
### 의료 기록 / 환자 로그
```txt
append-only event log
+ idempotency_key
+ projection table
+ audit trail
```
예를 들어 항암일기 같은 서비스에서는 환자 입력 데이터를 단순히 현재 상태로 덮어쓰기보다 이벤트로 보존하는 것이 좋다.
```sql
CREATE TABLE patient_diary_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id uuid NOT NULL,
idempotency_key text UNIQUE,
event_type text NOT NULL,
payload jsonb NOT NULL,
occurred_at timestamptz NOT NULL,
created_at timestamptz DEFAULT now()
);
```
환자가 같은 증상 기록 요청을 재시도하더라도 `idempotency_key`가 같으면 중복 저장하지 않는다.
---
## 17. 패턴 선택 기준
정리하면 다음과 같다.
|상황|추천 패턴|
|---|---|
|외부 데이터 최신화|upsert|
|중복 생성 방지|unique constraint + insert error|
|중복 이벤트 무시|insert only + `DO NOTHING`|
|결제/주문 재시도|idempotency key|
|상태 순서 보장|state machine|
|동시 수정 충돌 방지|optimistic locking|
|의료/감사 이력 보존|append-only event log|
|삭제/복구|soft delete|
|대량 sync 최적화|hash 비교|
|백그라운드 작업 중복 방지|queue deduplication|
|동시 실행 방지|transaction/advisory lock|
---
## 18. 내가 생각하는 기본 원칙
실제 SaaS에서는 다음 원칙으로 시작하면 좋다.
### 1. 모든 외부 데이터에는 source와 external_id를 둔다
```txt
source + external_id
```
예를 들어 Notion page를 가져온다면 다음처럼 저장한다.
```txt
source = notion
external_id = page_id
```
Stripe customer라면 다음처럼 저장한다.
```txt
source = stripe
external_id = cus_xxx
```
그리고 DB에는 unique constraint를 건다.
```sql
UNIQUE (source, external_id)
```
---
### 2. 현재 상태와 이벤트 로그를 분리한다
현재 상태 테이블은 upsert에 적합하다.
```txt
customers
subscriptions
patient_profiles
appointment_status
```
반면 이벤트 로그는 append-only가 적합하다.
```txt
payment_events
patient_diary_events
notification_events
audit_logs
```
현재 상태와 이력을 같은 테이블에서 억지로 해결하려고 하면 구조가 복잡해진다.
---
### 3. 부작용이 있는 작업에는 idempotency key를 둔다
다음 작업에는 idempotency key가 있어야 한다.
```txt
결제 생성
구독 생성
크레딧 차감
포인트 차감
이메일 발송
SMS 발송
푸시 알림 발송
AI 리포트 생성
외부 API write
```
이런 작업은 “같은 요청이 다시 들어오면 같은 결과를 반환한다”는 원칙으로 설계해야 한다.
---
### 4. 상태값은 아무렇게나 update하지 않는다
상태값은 반드시 허용된 전이만 가능해야 한다.
```txt
pending → paid
paid → refunded
pending → cancelled
```
다음처럼 조건을 걸어 업데이트해야 한다.
```sql
UPDATE orders
SET status = 'paid'
WHERE id = 'order_123'
AND status = 'pending';
```
이렇게 해야 같은 이벤트가 여러 번 와도 안전하다.
---
### 5. DB constraint를 신뢰한다
중복 방지를 애플리케이션 코드에만 맡기면 race condition이 생길 수 있다.
나쁜 방식:
```txt
1. SELECT로 존재 여부 확인
2. 없으면 INSERT
```
동시에 두 요청이 들어오면 둘 다 “없다”고 판단하고 insert할 수 있다.
좋은 방식:
```txt
DB unique constraint
+ transaction
+ ON CONFLICT
```
최종적인 무결성은 DB가 보장하게 해야 한다.
---
## 19. 결론
데이터 멱등성은 단순히 “중복 방지”가 아니다.
실제 의미는 다음에 가깝다.
> 재시도, 중복 요청, webhook 재전송, worker 재실행, 동시성 상황에서도 데이터의 최종 상태와 비즈니스 의미가 깨지지 않게 만드는 설계 원칙.
Upsert는 가장 대표적인 구현 방식이지만, 모든 문제의 답은 아니다.
현재 상태를 최신화하는 데이터에는 upsert가 좋다.
하지만 결제, 주문, 크레딧 차감, 의료 기록, 알림 발송, 예약 슬롯 같은 영역에서는 각각 다른 패턴이 필요하다.
실무적으로는 다음 조합을 기본값으로 가져가면 된다.
```txt
현재 상태 테이블 → upsert
이벤트/로그 테이블 → append-only 또는 insert only
Webhook 처리 → processed_events + DO NOTHING
결제/크레딧/알림 → idempotency_key
상태값 변경 → state machine
동시 수정 → optimistic locking
동시 실행 방지 → transaction/advisory lock
외부 데이터 sync → source + external_id + hash
```
결국 좋은 백엔드 설계는 “정상 요청”보다 “같은 요청이 두 번 들어왔을 때” 더 잘 드러난다.
서비스가 커질수록 장애는 피할 수 없다. 네트워크는 실패하고, webhook은 중복되고, worker는 재시도되고, 사용자는 버튼을 여러 번 누른다.
그래서 중요한 질문은 이것이다.
> 이 요청이 두 번 실행되어도 괜찮은가?
이 질문에 명확히 답할 수 있다면, 그 시스템은 이미 훨씬 더 안전한 방향으로 설계되고 있는 것이다.