ACK 전에 꺼진 간식 자판기 · 두 번 온 주문에 간식은 한 번만 · 이론
두 번 온 주문에 간식은 한 번만
한 줄 요약
중복 메시지를 받지 않는 것이 아니라, 같은 업무 ID의 효과를 다시 적용하지 않도록 수신 기록과 업무를 함께 커밋합니다.
왜 이게 필요했나
창고가 간식 7개를 준비하고 ACK를 보냈지만 접수기의 연결이 끊겼습니다. 접수기에는 두 가설이 남습니다. 창고가 아무것도 못 했을 수도 있고, 이미 했는데 응답만 사라졌을 수도 있습니다. 타임아웃을 더 길게 주면 이 구간이 완전히 사라지는 것은 아닙니다. 언젠가 연결은 끊기고 프로세스는 재시작합니다. 확실한 실패와 결과를 모르는 실패를 같은 것으로 취급하면 중복 출고나 누락으로 이어집니다.
접수기가 불확실한 항목을 다시 보내려면 창고가 같은 업무를 알아볼 수 있어야 합니다. 메모리 set에 ID를 넣으면 실행 중에는 중복을 막을 수 있지만 다음 프로세스에는 기억이 없습니다. 파일에 ID만 먼저 쓴 뒤 재고를 갱신하면 중간 종료 때 재고가 빠지고, 재고를 먼저 쓴 뒤 ID를 기록하면 재고가 두 번 늘 수 있습니다. 수신 쪽에도 하나의 원자적 경계가 필요합니다.
어떻게 동작하나
inbox는 업무와 같이 저장한다
창고의 inbox에는 id와 qty가 들어갑니다. stock의 단일 행에는 누적 수량 total이 있습니다. receive는 BEGIN IMMEDIATE로 쓰기 트랜잭션을 열고 같은 ID의 기록을 찾습니다. 처음 보는 ID라면 inbox를 삽입하고 total을 늘린 뒤 커밋합니다. 같은 ID와 같은 수량이면 이미 적용된 업무이므로 다시 늘리지 않고 끝냅니다. 같은 ID지만 다른 수량이면 Conflict로 거절하고 이전 업무를 보존합니다.
이때 receive의 반환값 True는 이번 호출에서 새 효과를 적용했다는 뜻입니다. False는 유효한 중복을 확인했다는 뜻이지 실패가 아닙니다. HTTP 수신기는 두 경우 모두 같은 ID의 accepted=true ACK를 반환합니다. 새 효과가 없었다는 이유로 중복 요청에 계속 실패를 응답하면 발행자는 영원히 재시도합니다. 내부 함수의 “새로 적용했는가”와 통신 응답의 “이 업무를 받아들였는가”를 섞지 않는 것이 중요합니다.
처음 snack-7 / 7 → inbox 1행, total=7, 새 적용 True, ACK 성공다시 snack-7 / 7 → inbox 1행, total=7, 새 적용 False, ACK 성공다시 snack-7 / 8 → 내용 충돌, 기존 total=7 보존, 성공 ACK 없음다른 snack-8 / 7 → inbox 2행, total=14, 별개 업무 ACK 성공이 표는 내용이 같다는 이유로 다른 업무를 합치지 않음을 보여 줍니다. SHA-256 같은 내용 지문을 붙여도 동일 업무를 정의하는 책임이 사라지지 않습니다. 지문은 같은 키 아래 내용이 달라졌는지 비교하는 수단이지 고객이 두 번 주문했는지 결정하는 업무 규칙이 아닙니다. 이 작은 실습에서는 내용이 정수 수량 하나라 직접 비교합니다. 구조화된 내용으로 넓힐 때는 정규화와 스키마 버전도 고려해야 합니다.
언제 ACK를 만들어도 되는가
ACK는 receive의 커밋이 끝난 뒤에 만듭니다. HTTP 상태 200만으로 발행자가 요청한 업무가 확인됐다고 보지는 않습니다. 응답 JSON의 id가 요청한 id와 같은지, accepted가 정확한 불리언 True인지 확인합니다. 문자열 "true"나 숫자 1을 진릿값으로 통과시키지 않습니다. 다른 주문의 성공 응답을 이 주문의 증거로 사용할 수 없기 때문입니다. 실제 서비스에서는 인증된 상대인지와 응답의 프로토콜 버전도 별도로 확인해야 합니다.
이번 서버는 교육용으로 127.0.0.1의 임시 포트에서만 동작합니다. 인증과 TLS가 없고 외부 공개용 API가 아닙니다. HTTP를 쓴 이유는 함수 호출 사이의 모의 오류만 보지 않고 실제 요청·응답과 연결 종료를 관찰하기 위해서입니다. 동일 호스트의 독립 DB 두 개를 네트워크 너머의 두 서비스라고 확대 해석하지 않습니다. 공용망 지연이나 서버 간 시계 오차, 장치 손실은 이 시험에 포함되지 않습니다.
같은 ID가 동시에 오면 어떨까
중복 확인과 쓰기가 한 트랜잭션에 있어야 하는 또 다른 이유는 경쟁입니다. 두 요청이 모두 “아직 없음”을 읽고 각각 업무를 실행한 뒤 나중에 ID만 저장하려 하면 검사와 실행 사이에 틈이 생깁니다. SQLite의 BEGIN IMMEDIATE는 쓰기 경쟁을 직렬화하는 데 사용됩니다. 다른 쓰기가 이미 진행 중이면 대기하거나 잠금 오류가 날 수 있습니다. 그것을 “이 업무가 완료됐다”는 뜻으로 바꾸지 않습니다.
고유 키 제약은 중복 행을 막는 데 도움이 되지만 별도의 외부 부작용까지 되돌려 주지는 않습니다. inbox 삽입과 같은 트랜잭션에서 stock을 바꾸는 이 실습의 성질을 외부 결제 호출에 그대로 붙일 수 없습니다. 외부 작업은 상대의 멱등성 계약, 보상, 상태 조회 같은 별도 설계가 필요합니다. 또한 수신 ID를 언제까지 보관할지 정하지 않으면 오래된 재전송이 새 업무로 처리될 수 있습니다.
현장에서 만나는 모습
알림, 고객 데이터 연동, 실시간 이벤트 소비자는 “재전송은 드물다”는 가정에서 자주 실패합니다. 배포 직후 프로세스가 교체되거나 프록시가 응답을 끊으면 정상 업무에도 재전송이 생깁니다. 통신 요청 수와 실제 업무 적용 수를 따로 관측하면 이를 구분할 수 있습니다. 요청 2개에 업무 1개는 중복 방지가 작동한 정상 결과일 수 있습니다. 요청 1개에 업무 0개를 단순히 평균 지연이 낮다는 지표로 가릴 수는 없습니다.
오류 로그에 업무 ID를 남기는 것은 도움이 되지만 고객 이름·주소·결제 정보까지 무조건 남기는 것은 다른 위험을 만듭니다. 이 실습은 가상 ID와 수량만 사용합니다. 운영 설계에서는 필요한 상관관계만 남기고 보존 기간과 접근 권한을 정해야 합니다. 재전송 내용을 모두 로그로 덤프하는 방법으로 멱등성 저장소를 대신하지 마세요.
다음 확인에서 할 것
바로 뒤 퀴즈에서 유효한 중복, 내용 충돌, 별개 업무와 ACK 의미를 구분합니다. 다음 모듈에서는 발행자 완료 표식이 어디에 있어야 하는지와 실패한 배치의 재개 순서를 배웁니다. 실습에서는 inbox 삽입 뒤, total 갱신 뒤, 커밋 뒤에 프로세스를 끝내 각각 독립 연결로 상태를 읽습니다.
공식 문서로 더 읽기
- [SQLite Transaction](https://www.sqlite.org/lang_transaction.html): 동시 쓰기와 BEGIN IMMEDIATE의 동작을 확인합니다.
- [Python sqlite3 트랜잭션 제어](https://docs.python.org/3.12/library/sqlite3.html#transaction-control): 명시적 SQL 트랜잭션과 연결 수명을 읽습니다.