Idempotency State Transition Operation ID Optimistic Locking Domain Invariant

같은 학습 완료 요청이 두 번 들어왔다. 이것은 더블 클릭일까, 네트워크 재시도일까, 아니면 사용자가 같은 문제를 실제로 두 번 푼 것일까?

payload만 비교해서는 답할 수 없다. 이 글에서는 하나의 학습 시도에 identity를 부여하고, 기록의 생성부터 완료까지를 상태 전이로 다루는 방법을 살펴본다. 그 과정에서 멱등성이 해결하는 문제와 해결하지 못하는 문제의 경계도 함께 구분해보자.

같은 payload가 같은 학습을 뜻하지는 않는다

사용자가 콘텐츠 하나를 끝낼 때마다 완료 기록을 만든다고 해보자. 중복을 막기 위해 가장 먼저 떠올릴 수 있는 기준은 userId + contentId다.

같은 userId + contentId가 이미 존재함 -> 중복 요청으로 판단

이 규칙은 더블 클릭과 재시도로 생기는 중복 기록을 막아준다. 하지만 사용자가 같은 콘텐츠를 다시 학습한 경우에도 두 번째 기록을 버린다. 반대로 완료 요청이 올 때마다 새 기록을 만들면 정상적인 재학습은 보존할 수 있지만, 응답 유실로 인한 재시도까지 새로운 학습으로 저장된다.

완료 요청의 내용이 같다는 사실만으로는 두 요청이 같은 의도에서 나왔는지 알 수 없다. 필요한 것은 payload 비교가 아니라 하나의 학습 시도를 식별할 identity다.

학습 시도를 시작부터 만든다

해결 방향은 단순하다. 완료 요청이 도착했을 때 기록을 만드는 대신, 사용자가 학습을 시작할 때 ContentInteraction을 만든다. 하나의 ContentInteraction은 특정 콘텐츠에 대한 한 번의 학습 시도를 나타낸다.

이제 학습 기록을 다음 세 단계로 나눠 하나씩 구현해보자.

  1. 시작: ContentInteraction을 만들고 학습 시도의 ID를 발급한다.
  2. 수정: 같은 학습 시도의 진행 상태를 갱신한다.
  3. 완료: 해당 학습 시도를 COMPLETED로 전이한다.

같은 ID에 완료 요청이 반복되면 재시도로 처리하고, 사용자가 같은 콘텐츠를 다시 학습하면 새로운 ID를 발급한다. 서버는 payload를 보고 두 요청의 관계를 추측할 필요 없이, 어떤 학습 시도를 다루는지 ID로 판단할 수 있다.

이 글에서 사용하는 상태 이름과 API 모양은 설명을 위한 개념 예시다. 실제 도메인의 상태와 endpoint는 각 서비스의 규칙에 맞게 정해야 한다.

1. 시작: 클라이언트의 의도와 서버의 리소스를 연결한다

아직 서버 리소스가 없는 생성 요청은 재시도를 식별하기가 특히 어렵다. 첫 번째 요청으로 ContentInteraction이 생성됐지만 응답이 클라이언트에 도착하지 않았을 수 있기 때문이다. 클라이언트는 interactionId를 모르므로 같은 생성 요청을 다시 보낼 수밖에 없다.

이때 클라이언트가 학습을 시작하기 직전에 operationId를 만들고, 재시도할 때도 같은 값을 보낸다.

POST /content-interactions

{
  "operationId": "01K5...",
  "contentId": "content-42"
}

서버는 처음 보는 operationId라면 ContentInteraction을 생성하고 interactionId를 반환한다. 이미 처리한 operationId라면 새로 만들지 않고 이전에 생성한 interactionId를 반환한다.

client operationId
  -> create
  -> server interactionId
  -> update / complete

여기서 두 ID의 역할은 다르다.

  • operationId는 “이 생성 의도가 이전 요청과 같은가?”를 식별한다.
  • interactionId는 생성된 뒤 “어떤 학습 시도를 변경하는가?”를 식별한다.

서버는 operationId와 생성 결과를 원자적으로 저장해야 한다. 둘을 따로 저장하면 리소스는 생성됐지만 operation 처리 결과는 남지 않는 실패 구간이 생긴다. 개념적인 처리 순서는 다음과 같다.

트랜잭션 시작
  -> operationId 처리 이력 확인
  -> 이미 처리했다면 기존 interactionId 반환
  -> 진행 중인 학습 시도 생성 가능 여부 확인
  -> ContentInteraction과 operation 결과 저장
트랜잭션 종료

같은 operationId에 다른 contentId가 들어오면 기존 결과를 조용히 반환해서는 안 된다. 클라이언트가 키를 잘못 재사용한 것이므로 요청의 주요 필드로 만든 fingerprint를 비교하고 conflict를 반환하는 편이 안전하다.

또 하나 주의할 점이 있다. operationId의 유일성은 같은 생성 요청의 반복만 막는다. 서로 다른 두 operationId가 동시에 들어오는 상황까지 막아주지는 않는다.

도메인 규칙이 “한 범위 안에서 진행 중인 ContentInteraction은 최대 하나”라면, 그 범위를 먼저 명확히 정하고 트랜잭션과 DB 제약 또는 잠금으로 원자적으로 보장해야 한다. 이것은 멱등성이 아니라 domain invariant와 동시성 제어의 문제다.

2. 수정: 중복 요청과 오래된 요청은 다른 문제다

생성 후에는 interactionId로 진행 상태를 수정할 수 있다. 이때 동일한 수정 요청을 반복 적용하는 것과 서로 다른 수정 요청의 순서가 바뀌는 것을 구분해야 한다.

예를 들어 서버가 현재 version: 7인 학습 기록을 보고 두 수정 요청을 받았다고 해보자.

요청 A: version 7을 기준으로 progress를 60으로 변경
요청 B: version 7을 기준으로 progress를 80으로 변경

네트워크 사정으로 B가 먼저 도착하고 A가 나중에 도착할 수 있다. 두 요청을 모두 그대로 반영하면 진행률이 80에서 60으로 퇴행한다. 이것은 중복 제거만으로 해결할 수 없다.

수정 요청에 expectedVersion을 포함하고, 현재 version과 일치할 때만 변경하면 오래된 요청을 거부할 수 있다.

-- 개념을 설명하기 위한 의사 쿼리
UPDATE content_interaction
SET progress = :progress,
    version = version + 1
WHERE id = :interactionId
  AND status = 'ONGOING'
  AND version = :expectedVersion;

갱신된 행이 없다면 이미 다른 변경이 반영됐거나 더 이상 수정할 수 없는 상태다. 서버는 최신 상태를 확인한 뒤 conflict를 반환하고, 클라이언트가 새 상태를 기준으로 다음 행동을 결정하게 한다.

다만 version은 요청의 순서를 검증할 뿐, 성공한 요청의 응답이 유실된 뒤 들어온 재시도를 알아보지는 못한다. 첫 번째 요청이 version을 8로 올렸다면 같은 요청의 재시도는 version 불일치로 보일 수 있다.

수정 API를 멱등적인 절대값 설정으로 설계해 같은 목표 상태의 반복을 안전하게 처리할 수도 있다. 요청별 결과 재현이나 부수 효과 제어까지 필요하다면 수정 의도에도 별도의 operationId를 부여하고 처리 결과를 저장해야 한다.

PATCH /content-interactions/interaction-123

{
  "operationId": "update-01K5...",
  "expectedVersion": 7,
  "progress": 60
}

이 경우 서버는 operation 처리 이력을 먼저 확인한다. 이미 성공한 요청의 재시도라면 저장된 결과를 반환하고, 처음 보는 요청이라면 version을 검사해 수정한다. 정리하면 interactionId는 변경 대상을, operationId는 변경 의도를, version은 그 의도가 만들어진 시점을 구분한다.

3. 완료: 상태 전이와 부수 효과를 하나의 경계에 둔다

완료는 ONGOING -> COMPLETED라는 상태 전이다. 같은 interactionId로 완료 요청이 여러 번 오더라도 이 전이는 한 번만 일어나야 한다.

-- 개념을 설명하기 위한 의사 쿼리
UPDATE content_interaction
SET status = 'COMPLETED',
    completed_at = :now,
    version = version + 1
WHERE id = :interactionId
  AND status = 'ONGOING';

한 행이 갱신됐다면 이번 요청이 실제 전이를 일으킨 것이다. 갱신된 행이 없고 이미 COMPLETED라면 기존 완료 결과를 반환할 수 있다. 존재하지 않거나 완료할 수 없는 다른 상태라면 그에 맞는 오류를 반환한다.

여기까지만 보면 간단하지만, 완료 시 보상 지급이나 다음 콘텐츠 해금, 이벤트 발행이 뒤따른다면 상태만 멱등적으로 바꾸는 것으로는 부족하다.

상태는 COMPLETED로 변경됨
  -> 프로세스 중단
  -> 완료 이벤트는 기록되지 않음

반대로 이벤트를 먼저 발행하면 이벤트는 전달됐지만 상태 전이가 실패할 수 있다. 따라서 실제 전이를 일으킨 트랜잭션 안에서 후속 작업의 실행 의도도 함께 기록해야 한다. 트랜잭셔널 outbox나 interactionId + effectType에 유일성을 둔 effect 기록은 이를 구현하는 선택지다. 어떤 방식을 쓰든 핵심 조건은 다음 두 가지다.

  • 실제 상태 전이가 일어난 경우에만 부수 효과의 실행 의도를 만든다.
  • 같은 실행 의도가 다시 전달돼도 소비자가 같은 효과를 중복 적용하지 않는다.

이 구조가 네트워크에서 요청이나 이벤트를 정확히 한 번만 전달해주는 것은 아니다. 전달은 반복될 수 있다. 대신 반복된 전달이 같은 상태와 같은 효과로 귀결되도록 만든다.

모든 문제를 멱등성이라고 부르지 않는다

중복 요청을 다루다 보면 서로 다른 종류의 실패를 모두 멱등성 문제로 묶기 쉽다. 하지만 방어할 대상이 다르면 필요한 장치도 달라진다.

상황 지켜야 할 것 핵심 개념
같은 완료 명령이 반복됨 한 학습 시도가 한 번만 완료됨 멱등성
서로 다른 생성 요청이 동시에 도착함 진행 중인 학습 시도는 허용된 범위에서 최대 하나 Domain invariant, 동시성 제어
오래된 수정이 나중에 도착함 최신 상태가 과거 값으로 덮이지 않음 Version, transition guard
하위 학습 기록의 완료 순서가 바뀜 상위 학습 단위가 같은 최종 상태에 도달함 수렴성, aggregate invariant
EXPIRED 상태에 cancel 요청이 도착함 종료된 상태가 이전 상태로 되돌아가지 않음 Transition guard

LearningCell은 마지막 이벤트가 아니라 전체 조건을 본다

여러 ContentInteraction을 하나의 LearningCell로 묶고, 모두 완료되면 LearningCell도 완료된다고 해보자.

“목록의 마지막 ContentInteraction 완료 이벤트가 오면 cell을 완료한다”라는 규칙은 이벤트 도착 순서에 의존한다. 마지막 항목의 이벤트가 먼저 도착하고 앞선 항목의 이벤트가 나중에 도착하면, 모든 하위 항목이 완료됐는데도 cell은 완료되지 않을 수 있다.

실제 규칙은 순서가 아니라 조건이다.

required ContentInteraction이 모두 COMPLETED인가?
  -> yes: LearningCell을 COMPLETED로 전이
  -> no: 현재 상태 유지

각 완료를 처리할 때 현재 상태를 기준으로 이 조건을 다시 평가하면 이벤트 순서가 바뀌어도 같은 최종 상태로 수렴할 수 있다. 물론 완료된 cell에 새 interaction을 추가할 수 있는지, 완료 상태를 되돌릴 수 있는지는 별도의 도메인 규칙으로 정해야 한다. 이 규칙이 없다면 “모두 완료”라는 조건 자체가 시간에 따라 달라질 수 있다.

허용되지 않은 전이는 중복 여부와 관계없이 막는다

이미 EXPIRED된 구독에 cancel 요청이 도착했다고 해보자. 이 요청이 처음인지 재시도인지는 핵심이 아니다. EXPIRED -> CANCELED 전이를 도메인이 허용하지 않는다면 상태를 바꾸지 않아야 한다.

상태 전이는 현재 상태와 명령의 조합으로 검증해야 한다. 멱등성 키가 있다는 이유로 허용되지 않은 전이가 안전해지는 것은 아니다.

멱등성의 보장 범위를 운영 규칙으로 남긴다

설계를 코드로 옮길 때는 멱등성의 범위를 명시해야 한다.

  • operationId는 사용자나 tenant를 포함해 어느 범위에서 유일한가?
  • 같은 키에 다른 요청이 오면 어떤 필드로 충돌을 판단하는가?
  • 처리 결과를 얼마나 오래 보관하며, 보관 기간이 지난 재시도는 어떻게 다루는가?
  • 최초 요청과 재시도에 어떤 status code와 response body를 반환하는가?
  • 상태 변경과 부수 효과의 기록은 같은 원자적 경계 안에 있는가?
  • 동시에 들어온 요청을 DB 제약, lock, compare-and-set 중 무엇으로 막는가?

특히 처리 이력의 보관 기간은 곧 멱등성 보장 기간이다. 이력을 지운 뒤 같은 operationId가 들어오면 서버는 과거 요청임을 알아볼 수 없다. 생성된 리소스에 operationId를 계속 보관할지, 별도 저장소에 만료 시간을 둘지, 클라이언트가 재시도할 수 있는 시간을 어디까지로 볼지 함께 결정해야 한다.

정리

멱등성만으로는 사용자의 학습 기록을 지킬 수 없다. 멱등성은 같은 명령이 반복됐을 때 같은 효과로 귀결되게 할 뿐, 두 요청이 같은 학습인지, 늦게 도착한 수정이 유효한지, 동시에 만들어진 기록 중 무엇을 허용할지까지 결정해주지는 않는다.

그래서 학습 시도마다 identity를 부여해야 한다. 그 identity 위에서 생성·수정·완료의 상태 전이를 설계하고, version과 transition guard로 과거 상태로의 역전을 막아야 한다. 여러 기록과 이벤트가 어떤 순서로 도착하더라도 도메인이 요구하는 상태로 수렴하도록 invariant도 함께 지켜야 한다.

멱등성은 그 설계를 구성하는 하나의 도구다. 학습 기록을 보호하는 것은 수많은 엣지 케이스 속에서도 학습이 하나의 시도로 식별되고, 허용된 방향으로만 전이되며, 끝내 의도한 상태에 도달하도록 만드는 전체 설계다.