ACK 전에 꺼진 간식 자판기 · 끊긴 연결 뒤의 처리 기록 · 이론
연결 복구와 업무 복구는 다르다
한 줄 요약
끊긴 연결은 다시 열 수 있지만, 이미 한 일을 다시 할지 결정하려면 업무 결과와 처리 커서를 같은 트랜잭션으로 남겨야 합니다.
왜 이게 필요했나
우주 정거장의 간식 자판기가 이벤트를 받습니다. EVENT 0 7은 재고를 7개 늘리라는 뜻입니다. 수신기가 재고를 저장한 순간 전원이 꺼져 ACK를 못 보냈습니다. 발행자에게는 “처리했지만 답이 사라진 경우”와 “아예 처리하지 못한 경우”가 똑같이 보입니다. TCP 재연결 성공만으로 두 상황을 구별할 수 없습니다. 불확실한 이벤트를 재전송하되, 수신 쪽이 이미 처리한 이벤트를 알아보는 설계가 필요합니다.
앞 코스의 Cursor는 실행 중인 객체의 기억입니다. 프로세스를 새로 시작하면 그 기억도 사라집니다. 이번에는 SQLite 파일에 순번과 합계를 남기고, 실제 수신 프로세스를 ACK 직전에 종료합니다. 두 번째 프로세스가 같은 파일을 열어 이어받는지 살펴봅니다. 결제 서버를 만드는 실습이 아니라, 한 스트림의 순서 있는 재고 변화량으로 복구 경계를 드러내는 실험입니다.
어떻게 동작하나
바이트를 먼저 계약으로 바꾼다
연습 프로토콜은 ASCII EVENT, 공백, 순번, 공백, 변화량, LF 한 개입니다. 예를 들어 EVENT 0 7 뒤 개행은 한 이벤트입니다. 순번은 0부터 시작하는 연속 정수, 변화량은 -1000부터 1000까지입니다. 앞자리 0, + 부호, -0, CRLF, 개행 없는 마지막 조각은 허용하지 않습니다. 엄격한 문법은 “숫자로 바뀌었으니 됐다”와 “약속한 메시지다”를 구별하도록 저자가 정한 것입니다. 다른 프로토콜이 CRLF를 허용하면 그 계약을 따라야 합니다.
이 메시지는 WebSocket이나 SSE가 아닙니다. 두 표준은 각각 별도의 프레임·재연결 규칙을 갖습니다. 여기서는 TCP 바이트 스트림 위에 작은 줄 프로토콜을 얹어 처리 의미에 집중합니다. 한 줄 상한은 64바이트이며 open_connection의 StreamReader limit도 64로 설정합니다. 잘못된 메시지를 읽은 뒤 무작정 다음 줄부터 계속하면 실제로 빠진 명령을 숨길 수 있어 연결을 종료합니다. HTTP 인증, TLS, 사용자별 권한은 구현하지 않으므로 인터넷에 그대로 공개하면 안 됩니다.
업무 효과와 커서를 한곳에 둔다
checkpoint 테이블의 단 하나인 id=1 행은 last와 total을 저장합니다. 아직 처리한 것이 없으면 last=-1, total=0입니다. ledger는 seq를 기본키로 삼아 처리한 delta를 보관합니다. 이번 실습은 ledger를 삭제하지 않으며 처리 이력의 장기 보관·압축 정책은 별도로 설계해야 합니다.
apply_event는 BEGIN IMMEDIATE로 쓰기 트랜잭션을 열고 현재 last를 읽습니다. seq가 last+1이면 ledger에 기록하고 total을 바꾸고 last를 전진시킨 뒤 COMMIT합니다. 중간 오류에는 ROLLBACK하고 원래 오류를 호출자에게 돌려줍니다. 실습의 fault 훅은 total을 바꾼 뒤 last를 바꾸기 전에 실행됩니다. 검사기는 이때 일부 변경을 같은 연결에서 관찰하고 오류를 주입한 다음, 다른 연결에서는 아무 변경도 남지 않았는지 확인합니다. 별도 프로세스를 이 훅에서 즉시 종료하여 finally가 실행되지 않는 경우도 검사합니다. 단순히 “예외를 잡았다”는 출력으로 원자성을 대신하지 않습니다.
같은 seq와 delta가 다시 오면 결과를 더하지 않고 False를 반환합니다. 같은 seq인데 delta가 다르면 Conflict입니다. 이벤트 ID를 재사용하면서 내용을 바꾸는 발행자를 중복이라고 조용히 받아들이지 않기 위해서입니다. seq가 last+1보다 크면 Gap입니다. 4까지 처리했는데 6을 받아 last=6으로 건너뛰면 나중에 도착한 5를 이미 처리했다고 오해하게 됩니다. 일단 멈추고 빠진 구간을 복구해야 합니다.
여기서는 sqlite3.connect의 isolation_level=None으로 자동 BEGIN을 끄고 SQL로 BEGIN·COMMIT·ROLLBACK을 명시합니다. Python 3.12의 autocommit=False 방식과 섞어 중첩 BEGIN을 만들지 마세요. with con은 연결 자체를 닫아 주는 문법이 아니므로 소유자가 finally에서 close합니다. open_store는 기존 행을 초기값으로 덮어쓰지 않습니다. 재시작 때 last=-1로 초기화하면 파일을 쓴 의미가 없어집니다.
ACK는 커밋 뒤에, 재개는 커서 다음부터
consume_line은 파싱과 apply_event가 성공한 뒤 ACK 순번과 LF를 반환합니다. 같은 내용의 중복도 ACK합니다. 결과를 다시 더하지 않으면서 발행자가 재시도를 끝낼 수 있게 하는 것입니다. 아직 커밋하지 않았는데 ACK를 먼저 보냈다가 죽으면 발행자가 이벤트를 지워 버릴 수 있습니다. 반대로 커밋 뒤 ACK가 사라지는 경우에는 중복이 생길 수 있지만 처리 이력으로 걸러 낼 수 있습니다.
run_client는 저장소를 열고 TCP로 연결하여 RESUME last와 LF를 보냅니다. 재접속은 새 run_client 호출로 명시적으로 수행합니다. 무한 자동 재시도 루프를 숨겨 넣지 않습니다. 최종 검사에서 첫 수신기는 0과 1을 저장한 뒤 ACK 1 전에 os._exit로 종료됩니다. 두 번째 프로세스는 RESUME 1을 보내고 서버가 일부러 다시 보낸 1과 새 이벤트 2를 받습니다. 서버에서 관찰한 요청·ACK, 독립 연결로 읽은 ledger와 합계를 함께 대조합니다. 실행 중 객체를 바꿔치기한 모의 재시작이 아닙니다.
보관된 로그 밖이면 복구 방법이 달라진다
replay는 발행자 쪽의 유한 로그에서 커서보다 큰 항목을 최대 limit개 돌려줍니다. 현재 보관 시작이 10이면 last=9는 10부터 읽을 수 있지만 last=8은 필요한 9가 없어 ResyncRequired입니다. 최신 항목만 주고 성공이라고 하면 조용한 손실입니다. 실제 제품은 일관된 스냅샷과 그 스냅샷의 커서부터 다시 동기화하거나 명시적인 복구 실패를 제공해야 합니다. 이 실습은 스냅샷 생성·설치 자체를 구현하지 않습니다.
빈 로그는 보관 정보 없이 “처음부터 비어 있었다”와 “전부 잘렸다”를 구별할 수 없어 이 API에서는 ValueError입니다. 입력은 1–128개 연속 항목, 배치는 1–16개로 제한합니다. 실제 로그 서비스에는 보관 하한·상한 같은 별도 메타데이터가 필요합니다. Redis XREAD도 지정한 ID보다 뒤의 항목을 읽지만, 이 실습의 정수 순번과 예외를 Redis의 실제 동작이라고 해석하면 안 됩니다.
현장에서 만나는 모습
알림·협업 화면·AI 스트리밍은 연결이 길게 유지되어도 배포와 네트워크 변경으로 끊깁니다. 재연결과 재생, 처리 커서, 보관 정책은 함께 설계해야 합니다. 연결 수가 늘면 재시도에 지수 백오프·지터·최대 횟수, 계정별 자원 제한도 필요합니다. 이번에는 두 번의 접속과 작은 로그만 시험하므로 인터넷 지연·대규모 처리량의 증거로 사용하지 않습니다.
같은 SQLite 트랜잭션에 든 합계와 커서의 원자성이 외부 메일·결제 API까지 묶어 주지는 않습니다. DB 커밋과 HTTP 요청 사이에도 새로운 장애 구간이 생깁니다. 그런 시스템에서는 수신 측 멱등성 키, outbox 같은 설계와 재조정 절차를 별도로 배워야 합니다. “ACK가 있으니 분산 시스템 전체에서 정확히 한 번”이라는 설명은 틀립니다.
실습 파일은 프로세스 재시작 동안에는 남지만 LabHub 세션 종료 시 사라집니다. 이번 프로세스 장애 검사는 호스트 전원 손실, 디스크 고장, 백업 복구를 시험한 것이 아닙니다. SQLite의 내구성 역시 파일시스템과 동기화 동작에 대한 전제를 갖습니다. 관측한 실패 유형과 보장하지 않은 유형을 구분해 보고하세요.
다음 실습에서 할 것
프레임 파싱 → 저장소 → 원자적 처리 → 보관 범위 판단 → 커밋 뒤 ACK → 연결 회수 → 실제 프로세스 재접속 순서로 client.py를 완성합니다. timeout은 각 네트워크 await의 대기 상한이지 전체 세션·DB 작업을 포함한 총 기한이 아닙니다. SQLite 호출은 동기식이며 이 작은 실험은 단일 수신자입니다. 운영 이벤트 루프에서 긴 DB 작업을 그대로 실행하는 설계는 피해야 합니다.
공식 문서로 더 읽기
- [Python sqlite3 트랜잭션 제어](https://docs.python.org/3.12/library/sqlite3.html#transaction-control): 연결 모드와 명시적 커밋을 구분합니다.
- [SQLite 트랜잭션](https://www.sqlite.org/lang_transaction.html): BEGIN IMMEDIATE와 동시 쓰기 제약을 확인합니다.
- [SQLite 원자적 커밋](https://www.sqlite.org/atomiccommit.html): 원자성의 구현과 저장장치에 대한 전제를 읽습니다.
- [Python asyncio Streams](https://docs.python.org/3.12/library/asyncio-stream.html): read 제한, drain, close와 wait_closed의 책임을 확인합니다.
- [Redis XREAD](https://redis.io/docs/latest/commands/xread/): 마지막 읽은 ID와 그 뒤의 재생이라는 실제 API를 비교합니다.