오프라인 큐에 멱등성이 필요한 이유

오프라인 큐에 멱등성이 필요한 이유

한눈에 보기

모바일 오프라인 큐는 요청을 정확히 한 번 전달할 수 없다. 서버가 처리한 직후 응답을 잃거나, 전송 중 앱이 종료되거나, worker가 중복 실행되면 같은 작업이 다시 전송된다. 사용자 의도를 만든 순간 안정적인 operationId를 발급하고 모든 재시도에 재사용한다. 서버는 사용자 범위의 operation ID와 요청 fingerprint를 저장해 같은 의도에는 같은 결과를 반환한다. 로컬 큐는 pending → sending → done뿐 아니라 lease 만료 복구와 영구 실패를 가진 상태 기계로 관리한다.

목차

응답을 받지 못했다고 서버가 실패한 것은 아니다

사용자가 오프라인 상태에서 기록 하나를 추가했다고 하자. 앱은 로컬에 먼저 보여 주고 연결이 돌아오면 서버로 보낸다.

{
  "type": "CREATE_ENTRY",
  "payload": {
    "value": 120,
    "recordedAt": "2026-01-13T09:10:00+09:00"
  }
}

서버 요청에서 가장 곤란한 순간은 요청이 실패한 때가 아니라 결과를 모르는 때다.

sequenceDiagram
    participant App
    participant API
    participant DB

    App->>API: CREATE_ENTRY
    API->>DB: row insert
    DB-->>API: commit success
    API--xApp: 응답 전달 중 연결 끊김
    Note over App: timeout, 성공 여부를 모름
    App->>API: 같은 CREATE_ENTRY 재전송
    API->>DB: 두 번째 row insert

앱에는 timeout으로 보이지만 첫 번째 insert는 이미 commit됐다. 단순 재시도는 기록을 두 개 만든다. 결제, 포인트 지급, 알림 발송이라면 영향은 더 크다.

연결 상태를 확인한 뒤 한 번만 보내도 해결되지 않는다. Wi-Fi가 연결됐다는 사실은 요청 전체 왕복을 보장하지 않고, 전송 중 언제든 앱 process가 종료될 수 있다. 자세한 네트워크 판정 문제는 앱의 네트워크 상태를 신뢰하면 안 되는 이유에서 다룬다.

불확실성의 핵심

Client는 응답을 받지 못했을 때 “서버가 실행하지 않았다”와 “실행했지만 응답만 잃었다”를 구분할 수 없다.

이 글의 식별자와 도메인은 실제 프로젝트 코드가 아닌 가상 기록 앱을 위한 예시다.

Exactly Once 대신 At Least Once와 멱등한 효과

분산된 두 실행 환경에서 정확히 한 번 전달을 약속하기는 어렵다. 대신 전달은 적어도 한 번 일어날 수 있게 재시도하고, 여러 번 전달돼도 업무 효과는 한 번만 발생하게 만든다.

전달 보장: at least once
업무 효과: idempotent
관찰 결과: 사용자는 한 번 실행된 것으로 봄

멱등성은 HTTP method 이름만으로 자동으로 생기지 않는다. PUT /entries/42가 같은 최종 상태를 만든다면 자연스럽게 멱등할 수 있지만, 외부 알림이나 audit event까지 매번 발행하면 전체 업무 효과는 멱등하지 않을 수 있다.

flowchart LR
    A[동일 operation 재전송] --> B[API idempotency gate]
    B -->|처음| C[Domain transaction]
    C --> D[결과 저장]
    B -->|중복| E[저장된 결과 반환]
    D --> F[Client 완료]
    E --> F

API response만 같게 만드는 것이 아니라 DB 변경, event 발행, 외부 API 호출까지 어느 범위를 한 번으로 볼지 정해야 한다.

사용자 의도와 전송 시도를 구분하기

사용자가 저장 버튼을 한 번 누른 것이 operation이고, worker가 network로 보내는 각 시도가 attempt다.

Operation
operationId = op_demo_7
createdAt = 09:10:00
payload = value 120

Attempt 1
startedAt = 09:10:03
result = timeout

Attempt 2
startedAt = 09:11:10
result = 200 replayed

attempt마다 새 operation ID를 만들면 서버는 두 요청이 같은 사용자 의도인지 알 수 없다.

// 잘못된 방식: 재시도마다 새 ID
Future<void> sendWithRetry(CreateEntry payload) async {
  for (var attempt = 0; attempt < 3; attempt++) {
    await api.create(
      operationId: uuid.v4(),
      payload: payload,
    );
  }
}

ID는 큐 항목을 만들 때 한 번 생성한다.

final operation = OfflineOperation(
  operationId: uuid.v4(),
  accountId: currentAccount.id,
  type: OfflineOperationType.createEntry,
  payloadVersion: 1,
  payload: CreateEntryPayload(
    localEntryId: localEntryId,
    value: 120,
    recordedAt: clock.now(),
  ),
  createdAt: clock.now(),
);

worker는 몇 번 재시도하더라도 operation.operationId를 그대로 보낸다.

Operation ID는 최초 생성 시점에 고정하기

좋은 operation ID는 다음 성질을 가진다.

device-7:entry:1042처럼 기기 ID와 local counter를 조합할 수도 있지만 reinstall, backup 복원, 여러 기기에서 충돌 정책이 필요하다. 충분한 무작위 UUID를 생성하고 별도 컬럼에 account와 entity를 보관하는 편이 단순하다.

POST /v1/entries HTTP/1.1
Authorization: Bearer access-token
Idempotency-Key: 8b62d456-demo-4d4e
Content-Type: application/json

{
  "schemaVersion": 1,
  "clientEntryId": "entry-local-demo-12",
  "value": 120,
  "recordedAt": "2026-01-13T00:10:00Z"
}

operation ID를 URL이나 로그에 사용자 식별 정보와 함께 노출하지 않는다. 서버 로그에는 필요하면 축약하거나 hash한 correlation 값을 사용한다.

로컬 변경과 큐 삽입을 한 Transaction으로 묶기

offline-first UI는 로컬 entity를 먼저 저장하고 화면에 보여 준다. entity 저장과 queue 삽입이 서로 다른 transaction이면 한쪽만 성공할 수 있다.

sequenceDiagram
    participant UI
    participant LocalDB

    UI->>LocalDB: entry 저장
    LocalDB-->>UI: commit
    Note over UI: queue 저장 전 앱 종료
    Note over LocalDB: 화면에는 있지만 서버로 보낼 작업 없음

두 쓰기를 같은 local DB transaction으로 묶는다.

BEGIN;

INSERT INTO entries (
  local_id,
  value,
  recorded_at,
  sync_state
) VALUES (
  :local_id,
  :value,
  :recorded_at,
  'pending'
);

INSERT INTO offline_operations (
  operation_id,
  account_id,
  operation_type,
  payload_version,
  payload_json,
  state,
  attempt_count,
  next_attempt_at,
  created_at
) VALUES (
  :operation_id,
  :account_id,
  'create_entry',
  1,
  :payload_json,
  'pending',
  0,
  :now,
  :now
);

COMMIT;

SQLite를 사용한다면 실제 schema와 transaction API에 맞게 구현한다. 핵심 invariant는 “로컬에 pending entity가 보이면 이를 서버로 보낼 operation이 반드시 있다”는 것이다.

큐 항목을 상태 기계로 관리하기

boolean isSynced 하나로는 전송 중, 재시도 대기, 영구 실패를 표현하기 어렵다.

stateDiagram-v2
    [*] --> Pending
    Pending --> Sending: lease 획득
    Sending --> Done: 성공 또는 replay
    Sending --> RetryWaiting: 일시 실패
    RetryWaiting --> Pending: nextAttemptAt 도달
    Sending --> PermanentFailed: 영구 실패
    Sending --> Pending: lease 만료 복구
    PermanentFailed --> Pending: 사용자 수정 후 새 시도
    Done --> [*]

예시 schema:

CREATE TABLE offline_operations (
  operation_id TEXT PRIMARY KEY,
  account_id TEXT NOT NULL,
  operation_type TEXT NOT NULL,
  payload_version INTEGER NOT NULL,
  payload_json TEXT NOT NULL,
  payload_fingerprint TEXT NOT NULL,
  state TEXT NOT NULL,
  attempt_count INTEGER NOT NULL DEFAULT 0,
  next_attempt_at INTEGER NOT NULL,
  lease_owner TEXT,
  lease_expires_at INTEGER,
  last_error_code TEXT,
  created_at INTEGER NOT NULL,
  completed_at INTEGER
);

CREATE INDEX idx_offline_operations_ready
ON offline_operations (
  account_id,
  state,
  next_attempt_at,
  created_at
);

done row를 바로 지울지 잠시 보관할지는 UI reconciliation과 진단 요구에 따라 정한다. 무기한 쌓이지 않게 retention이 필요하다.

Lease로 중단된 Sending 상태 복구하기

worker가 row를 sending으로 바꾼 직후 앱이 종료되면 그 row를 영원히 건너뛰어서는 안 된다. 전송 소유권에 만료 시간을 둔다.

UPDATE offline_operations
SET
  state = 'sending',
  lease_owner = :worker_id,
  lease_expires_at = :lease_expires_at,
  attempt_count = attempt_count + 1
WHERE operation_id = (
  SELECT operation_id
  FROM offline_operations
  WHERE account_id = :account_id
    AND (
      state = 'pending'
      OR (
        state = 'sending'
        AND lease_expires_at < :now
      )
    )
    AND next_attempt_at <= :now
  ORDER BY created_at
  LIMIT 1
)
RETURNING *;

실제 SQLite version에서 RETURNING 지원 여부와 동시 transaction 동작을 확인한다. 지원하지 않으면 select와 conditional update를 transaction으로 묶고 변경 row 수를 확인한다.

lease는 network timeout보다 충분히 길되 영구 lock이 되지 않게 한다. lease가 만료됐다고 첫 worker가 실제로 중단됐다는 보장은 없다. 두 worker가 잠시 겹칠 수 있으므로 서버 멱등성이 여전히 필요하다.

로컬 Lock만으로 부족한 이유

앱 내부 mutex는 process 종료 후 사라지고, background worker와 foreground sync가 다른 lifecycle에서 겹칠 수 있다. lease는 복구를 돕지만 중복 전달 가능성을 제거하지 않는다.

서버에 Idempotency Record 저장하기

서버는 인증된 actor와 operation ID를 unique key로 사용한다.

CREATE TABLE idempotency_records (
  actor_id UUID NOT NULL,
  operation_id UUID NOT NULL,
  operation_type VARCHAR(80) NOT NULL,
  request_fingerprint CHAR(64) NOT NULL,
  status VARCHAR(20) NOT NULL,
  response_status INTEGER,
  response_body JSONB,
  resource_id UUID,
  created_at TIMESTAMPTZ NOT NULL,
  completed_at TIMESTAMPTZ,
  expires_at TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (actor_id, operation_id)
);

actor 범위를 넣지 않으면 서로 다른 사용자가 우연히 같은 UUID를 보냈을 때 결과가 섞일 수 있다. 반대로 전역 unique operation ID 정책을 사용할 수도 있지만 인증 주체와 요청 권한 검증은 별도로 해야 한다.

처리 흐름:

async function createEntry(
  actor: Actor,
  request: CreateEntryRequest,
  operationId: string,
): Promise<ApiResponse> {
  const fingerprint = canonicalFingerprint(request);

  return database.transaction(async (tx) => {
    const reservation = await idempotency.reserve(tx, {
      actorId: actor.id,
      operationId,
      operationType: "CREATE_ENTRY",
      fingerprint,
    });

    if (reservation.kind === "completed") {
      return reservation.savedResponse;
    }

    if (reservation.kind === "payload_conflict") {
      throw new ConflictError(
        "IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD",
      );
    }

    const entry = await entries.insert(tx, {
      actorId: actor.id,
      clientEntryId: request.clientEntryId,
      value: request.value,
      recordedAt: request.recordedAt,
    });

    const response = {
      status: 201,
      body: {
        entryId: entry.id,
        clientEntryId: request.clientEntryId,
      },
    };

    await idempotency.complete(tx, reservation, response);
    return response;
  });
}

domain write와 idempotency 완료를 같은 transaction에 넣으면 둘 중 하나만 commit되는 상태를 줄일 수 있다. 외부 결제 API처럼 같은 DB transaction에 넣을 수 없는 부수효과는 해당 서비스의 idempotency key와 outbox·상태 기계를 함께 사용해야 한다.

동시에 같은 Operation이 도착하는 경우

두 attempt가 동시에 서버에 도착할 수 있다. “먼저 조회하고 없으면 insert”만 하면 race가 발생한다.

sequenceDiagram
    participant A as Request A
    participant B as Request B
    participant DB

    A->>DB: record 없음 확인
    B->>DB: record 없음 확인
    A->>DB: domain insert
    B->>DB: domain insert

unique constraint와 atomic reservation이 필요하다.

INSERT INTO idempotency_records (
  actor_id,
  operation_id,
  operation_type,
  request_fingerprint,
  status,
  created_at,
  expires_at
) VALUES (
  :actor_id,
  :operation_id,
  :operation_type,
  :fingerprint,
  'processing',
  NOW(),
  NOW() + INTERVAL '7 days'
)
ON CONFLICT (actor_id, operation_id) DO NOTHING;

insert한 요청만 domain operation을 수행한다. 기존 row가 completed면 저장된 결과를 반환한다. processing이면 짧게 기다릴지, 409/202와 retry hint를 반환할지 정한다. processing row가 worker crash로 영원히 남지 않게 server-side lease나 transaction rollback 구조도 필요하다.

같은 Key에 다른 Payload가 오면 거절하기

operation ID만 보고 기존 response를 반환하면 client bug가 숨겨질 수 있다.

op-42, value=120 → 201 Created
op-42, value=999 → 이전 201 반환?

정규화한 업무 필드의 fingerprint를 저장하고 같은 key에 다른 payload가 오면 conflict로 거절한다.

function canonicalFingerprint(
  request: CreateEntryRequest,
): string {
  return sha256(
    canonicalJson({
      clientEntryId: request.clientEntryId,
      value: request.value,
      recordedAt: normalizeInstant(request.recordedAt),
    }),
  );
}

request timestamp, trace ID처럼 attempt마다 달라지는 metadata는 fingerprint에서 제외한다. 반대로 업무 의미를 바꾸는 필드는 반드시 포함한다. 원본 payload 자체가 민감하면 idempotency table에 전체를 복사하지 않고 fingerprint와 필요한 response만 저장한다.

재시도 가능한 실패와 영구 실패를 나누기

모든 실패를 재시도하면 잘못된 payload가 배터리와 network를 계속 소비한다.

결과 큐 처리
network timeout·연결 끊김 retry waiting
408·429 Retry-After와 backoff 고려
5xx 제한된 지수 backoff
401 session 갱신 한 번 후 재평가
403 permanent failed, 권한 변경 안내
400·422 permanent failed, 사용자 수정 필요
idempotency replay 성공 done
같은 key·다른 payload conflict 구현 오류 또는 permanent failed
SyncDecision classify(SyncFailure failure) {
  return switch (failure) {
    NetworkUnavailable() => const SyncDecision.retry(),
    TimeoutFailure() => const SyncDecision.retry(),
    RateLimited(:final retryAfter) =>
      SyncDecision.retry(after: retryAfter),
    ServerFailure() => const SyncDecision.retry(),
    Unauthorized() => const SyncDecision.refreshSessionOnce(),
    Forbidden() => const SyncDecision.failPermanently(),
    ValidationFailure() => const SyncDecision.needsUserAction(),
    PayloadConflict() => const SyncDecision.failPermanently(),
  };
}

재시도에는 최대 횟수 하나만 두기보다 elapsed time, 오류 종류, 사용자 행동을 함께 본다. jitter가 있는 backoff는 재시도에 지수 백오프와 지터가 필요한 이유와 연결된다.

순서가 있는 작업과 없는 작업 구분하기

같은 entity에 create, update, delete가 쌓이면 무작정 병렬 전송할 수 없다.

op-1 CREATE local-entry-7
op-2 UPDATE local-entry-7 value=130
op-3 DELETE local-entry-7

선택지는 다음과 같다.

서로 다른 entity의 독립 작업은 제한된 concurrency로 병렬화할 수 있다. 전역 FIFO 하나는 단순하지만 한 실패가 전체 queue를 막는 head-of-line blocking을 만든다.

Partition key = accountId + entityType + localEntityId
같은 partition = 순서 보존
다른 partition = 제한된 병렬 처리

queue compaction을 한다면 사용자 의도와 audit 요구를 잃지 않는 작업만 합친다. 결제 두 건을 마지막 값 하나로 합치면 안 된다.

낙관적 UI와 서버 결과를 합치기

로컬 entity는 server ID를 받기 전에도 화면에 보여야 한다.

final class Entry {
  const Entry({
    required this.localId,
    required this.value,
    required this.syncState,
    this.serverId,
    this.syncError,
  });

  final String localId;
  final String? serverId;
  final int value;
  final EntrySyncState syncState;
  final String? syncError;
}

server response에 clientEntryId를 포함하면 올바른 local row와 합칠 수 있다.

UPDATE entries
SET
  server_id = :server_id,
  sync_state = 'synced',
  sync_error = NULL
WHERE local_id = :client_entry_id;

UPDATE offline_operations
SET
  state = 'done',
  completed_at = :now,
  lease_owner = NULL,
  lease_expires_at = NULL
WHERE operation_id = :operation_id;

이 두 update도 local transaction으로 묶는다. response를 받은 뒤 entity만 synced로 바꾸고 operation row 갱신 전에 종료되더라도 재실행이 안전해야 한다. 서버가 같은 response를 replay하므로 reconciliation을 다시 수행할 수 있다.

Logout과 계정 전환에서 큐 격리하기

계정 A의 pending operation을 계정 B token으로 보내면 데이터 경계가 깨진다. 모든 queue row에 account scope를 넣고 worker가 현재 session과 비교한다.

if (operation.accountId != session.accountId) {
  throw const SyncFailure.accountMismatch();
}

logout 정책을 정한다.

어느 정책이든 이전 account operation이 새 account 화면에 나타나거나 전송되지 않게 한다. queue payload에 credential을 저장하지 않고 전송 시점의 현재 session을 사용한다.

테스트해야 할 실패 시점

정상 요청 성공 test보다 어느 줄에서 process가 종료돼도 복구되는지 확인하는 test가 중요하다.

실패 주입 지점 기대 결과
local entity insert 뒤 queue insert 전 같은 transaction rollback
sending 변경 직후 종료 lease 만료 후 재시도
서버 domain commit 뒤 응답 유실 같은 operation replay
서버 reservation 중 worker 종료 processing 복구 정책
response 수신 뒤 local reconciliation 전 종료 replay response로 다시 합침
같은 key·다른 payload conflict
동일 operation 동시 두 요청 domain effect 한 번
400 payload permanent failed
429 응답 retry hint 이후 실행
logout 중 worker 실행 account mismatch로 중단

Property test로 같은 operation을 임의 횟수와 순서로 보내도 resource가 하나인지 확인할 수 있다.

it("creates one entry for repeated delivery", async () => {
  const operationId = randomOperationId();
  const request = createEntryFixture();

  const responses = await Promise.all(
    Array.from({ length: 5 }, () =>
      api.createEntry(request, operationId),
    ),
  );

  expect(await entries.countByClientId(
    request.clientEntryId,
  )).toBe(1);
  expect(uniqueBodies(responses)).toHaveLength(1);
});

운영 지표와 정리 정책

queue가 있다는 사실만으로 동기화가 건강한지 알 수 없다.

offline_queue_depth{state=pending}
offline_queue_oldest_age_seconds
offline_attempts_total{result=timeout}
idempotency_replays_total{operation=create_entry}
idempotency_conflicts_total
offline_permanent_failures_total{code=validation}

특히 가장 오래된 pending 항목의 age가 중요하다. queue depth가 작아도 한 작업이 며칠간 막혀 있을 수 있다.

정리 정책:

server record를 너무 빨리 지우면 오래 offline이던 앱의 재전송이 새 작업으로 실행될 수 있다.

구현 체크리스트

Operation

Local Queue

Server

Retry와 운영

마무리

오프라인 큐는 network가 돌아왔을 때 요청 목록을 한 번 보내는 기능이 아니다. 요청과 응답 사이 어느 지점에서든 앱과 연결이 끊길 수 있다는 사실을 상태로 관리하는 시스템이다.

Client는 exactly once 전달을 만들려고 하지 않는다. 사용자 의도마다 operation ID를 고정하고, local entity와 queue를 한 transaction으로 저장한다. worker는 lease로 전송 소유권을 얻되 중복 실행 가능성을 인정한다. retryable 오류만 backoff해 다시 보낸다.

서버는 actor와 operation ID의 unique record를 먼저 확보하고, 같은 payload의 재전송에는 이미 저장한 결과를 반환한다. 같은 key에 다른 payload가 오면 조용히 성공시키지 않고 계약 위반으로 드러낸다.

이렇게 local queue의 복구 가능성과 server idempotency가 함께 있을 때 응답 유실, 앱 종료, 중복 worker가 모두 정상적인 재전송 조건이 된다. 멱등성은 retry를 없애는 기능이 아니라 retry를 안전하게 사용할 수 있게 만드는 전제다.

관련 노트

참고 자료