클라우드 Mac에서 iOS 오프라인 쓰기 큐의 멱등 재전송 검증

클라우드 Mac에서 iOS 오프라인 쓰기 큐의 멱등 재전송 검증

지하철에서 사용자가 티켓을 제출했지만, 화면에는 네트워크 오류가 표시됐다. 다시 인터넷에 연결되자 iOS 앱이 요청을 재전송했고, 서버에는 티켓이 두 건 생성됐다. 원인이 반드시 큐에 있는 것은 아니다. 첫 요청으로 이미 기록이 저장됐지만 성공 응답만 휴대전화에 도착하지 않았을 수 있다. 오프라인 쓰기를 검증하려면 ‘요청 실패’와 ‘쓰기 실패’를 구분해야 한다.

작업 하나의 식별 기준부터 정의하기

사용자의 제출 한 번을 작업 ID, 요청 본문, 멱등성 키, 생성 시간, 상태를 포함한 영속 작업 하나로 저장한다. 본문은 큐에 넣을 때 확정한다. 재시도할 때는 시도 횟수와 상태만 바꿀 수 있으며, 멱등성 키를 새로 생성해서는 안 된다. 사용자가 명시적으로 ‘새로 만들기’를 다시 눌렀을 때만 별개의 작업으로 취급한다.

서버는 멱등성 키에 따라 처리 결과를 저장해야 한다. 키와 본문이 모두 같으면 기존 결과를 반환하고, 키는 같은데 본문이 다르면 조용히 덮어쓰지 말고 거부해야 한다. 클라이언트는 명확한 성공 결과를 받은 뒤에만 작업을 제거할 수 있다. 시간 초과, 연결 끊김, 서버 오류는 모두 결과를 알 수 없는 상태이므로 이를 근거로 작업을 삭제해서는 안 된다.

검증 목표는 ‘요청을 단 한 번만 보내는 것’이 아니라 ‘동일한 사용자 작업을 여러 번 보내더라도 비즈니스 레코드는 하나만 생성되는 것’이다.

로컬 테스트 API로 응답 손실 재현하기

아래 테스트 API는 특정 키를 처음 받으면 레코드를 먼저 저장한 뒤 503을 반환한다. 같은 키를 다시 받으면 저장된 결과를 반환한다. 클라이언트가 응답만으로 쓰기 성공 여부를 판단할 수 없는 상황을 의도적으로 재현한 것이다. 코드를 클라우드 Mac의 fault_server.py에 저장한다.

import json
from http.server import BaseHTTPRequestHandler, HTTPServer

records = {}
attempts = {}

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/items":
            self.send_error(404)
            return
        key = self.headers.get("Idempotency-Key")
        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length)
        if not key or not body:
            self.send_error(400)
            return
        try:
            payload = json.loads(body)
        except json.JSONDecodeError:
            self.send_error(400)
            return
        attempts[key] = attempts.get(key, 0) + 1
        if key in records and records[key]["body"] != payload:
            self.send_error(409)
            return
        if key not in records:
            records[key] = {"id": len(records) + 1, "body": payload}
            self.send_error(503)
            return
        result = json.dumps({
            "id": records[key]["id"],
            "attempts": attempts[key],
            "record_count": len(records)
        }).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(result)))
        self.end_headers()
        self.wfile.write(result)

HTTPServer(("127.0.0.1", 8081), Handler).serve_forever()

python3 fault_server.py를 실행한다. iOS 시뮬레이터의 앱에서는 테스트 환경 API를 http://127.0.0.1:8081/items로 지정할 수 있다. 이 서버는 로컬 루프백 주소에서만 수신하므로 포트를 외부 네트워크에 직접 개방하지 않는다. 테스트 API는 프로세스 메모리를 사용하므로 스크립트를 다시 시작하면 레코드가 지워진다. 따라서 테스트 앱을 다시 시작할 때 스크립트는 다시 시작하지 않는다. 실제 서비스에서는 멱등성 레코드도 영속화하고, 동시 요청에 대해서는 데이터베이스의 고유 제약 조건으로 원자성을 보장해야 한다.

계층별로 네 가지 검증 시나리오 실행하기

먼저 요청 전송 계층에 교체 가능한 진입점을 마련한다. 큐 단위 테스트에서는 전송 계층의 테스트 대역이 연결 끊김 오류를 반환하도록 하고, 작업이 영속 저장소에 남아 있으며 본문과 키가 바뀌지 않았는지 확인한다. 이렇게 하면 클라우드 Mac 전체의 네트워크를 끊지 않아도 되고, 같은 기기에서 실행 중인 다른 작업에도 영향을 주지 않는다.

이어서 로컬 테스트 API를 연결하고 아래 항목을 하나씩 확인한다. 항목마다 새 키를 사용한다. 테스트 중에는 작업 ID, 키, 전송 횟수, 서버 반환값을 기록하되, 로그에 요청의 민감한 필드는 남기지 않는다.

시나리오 수행할 작업 통과 조건
최초 제출 작업을 보내고 503 수신 작업이 남아 있고 키가 바뀌지 않음
응답 손실 후 재시도 기존 작업을 다시 보냄 200 반환, record_count가 1
앱 재시작 최초 503 이후 앱을 종료했다가 다시 시작하고 큐 복원 기존 키를 그대로 사용하며 레코드는 여전히 한 건
본문이 의도치 않게 변경됨 같은 키로 다른 본문을 보냄 409 반환, 작업을 조사 대기 상태로 전환

시뮬레이터 점검 항목

먼저 앱이 실제로 테스트 API를 사용 중인지 확인한다. 그다음 깨끗한 기준 상태를 만들기 위해 테스트 앱 자체의 데이터를 지운다. 공유 시뮬레이터에 있는 다른 프로젝트의 데이터까지 지우지 않는다. 최초 제출 후에는 로컬 작업이 남아 있는지 확인한다. 앱 프로세스를 종료한 뒤 다시 시작해 큐 복원을 유도하고, 두 번째 요청에도 같은 키가 담겼는지 살펴본다. 앱이 키를 메모리에만 보관한다면 대개 이 단계에서 문제가 드러난다.

이 스크립트는 단일 프로세스가 받은 요청만 집계하며 조회 API는 제공하지 않는다. 따라서 두 번째 응답의 attempts와 record_count로 해당 테스트의 결과를 검증한다. 여러 차례의 테스트 실행을 아우르는 통계가 필요하다면 프로세스 메모리의 숫자를 운영 지표로 취급하지 말고 테스트 픽스처에 영속 저장소를 추가해야 한다.

큐의 재시도 중단 조건 명확히 하기

재시도할 때 모든 상태 코드를 똑같이 처리해서는 안 된다. 연결 끊김, 시간 초과, 예시의 503에서는 기존 작업을 유지하고 나중에 재시도할 수 있다. 409는 같은 키에 연결된 본문이 충돌한다는 뜻이므로 자동 재전송을 멈추고 진단 정보를 남겨야 한다. 명확한 성공 응답을 받았을 때만 작업을 완료로 표시할 수 있다. 실제 API에서는 그 밖의 클라이언트 오류도 API 계약에 따라 각각 판단해야 하며, 무조건 재시도해서는 안 된다.

동시 전송도 제한한다. 동일한 작업에는 전송자가 하나만 있어야 한다. 포그라운드 복귀와 백그라운드 동기화가 모두 큐를 구동할 수 있다면, 먼저 영속 저장 계층에서 해당 작업의 전송 권한을 획득한다. 클라이언트 측 잠금으로는 응답 손실을 없앨 수 없으므로 서버 역시 독립적으로 멱등성을 보장해야 한다. 재시도 간격에는 상한이 있는 백오프를 적용해 네트워크 복구 직후 모든 작업이 한꺼번에 몰리지 않도록 할 수 있다. 다만 백오프 때문에 작업 ID, 키, 본문이 바뀌어서는 안 된다.

마무리 점검

병합 전에 수동으로 한 번 더 검증한다. 오류를 받은 뒤 앱을 종료하고 다시 열어 재전송을 기다린다. 화면에 레코드가 하나만 나타나는지, 로컬 작업이 최종적으로 완료 상태가 되는지, 서버 결과의 record_count가 여전히 1인지 확인한다. 이어서 실제 ‘새로 만들기’를 한 번 더 눌러 새 키와 새 레코드가 생성되는지 확인한다. 전자는 기존 작업을 안전하게 재전송할 수 있음을, 후자는 멱등성 규칙이 사용자의 새 작업까지 막지는 않음을 보여준다.

자주 묻는 질문

503 응답을 받으면 새 작업을 만들어 재시도해도 되나요?

안 됩니다. 이미 저장됐을 수 있으므로 기존 작업과 멱등성 키를 유지한 채 재시도해야 중복 생성을 막을 수 있습니다.

테스트하려면 Mac의 네트워크를 실제로 끊어야 하나요?

아니요. 주입 가능한 전송 계층으로 오프라인 적재를 테스트하고, 로컬 서버로 응답 유실 후 HTTP 재전송을 검증할 수 있습니다.

다음 실험을 위한 나만의 Mac

필요에 따라 전용 Mac mini, 노드 및 요금제를 선택하세요. 모델과 이용 가능 여부는 콘솔에서 실시간으로 확인할 수 있습니다.

클라우드 Mac 대여 옵션 보기