LabHub
배우기 러닝패스 코스

ACK 전에 꺼진 간식 자판기 · 새 학기 재고를 어제 번호로 읽지 않는다 · 이론

새 학기 재고를 어제 번호로 읽지 않는다

LabHub 에서 이어서 보기

한 줄 요약

번호가 같아도 다른 세대의 이벤트일 수 있습니다. 복구는 허용한 세대의 스냅샷을 설치하고 그 다음 번호부터 유한하게 따라가는 과정입니다.

왜 이게 필요했나

학교 축제 첫날의 재고 기록은 800번까지 갔습니다. 둘째 날에는 창고를 새로 열고 로그 번호를 0부터 시작했습니다. 전광판에 남은 800이라는 숫자만 보내면 서버는 앞으로 일어날 이벤트까지 이미 처리했다고 오해할 수 있습니다. 혹은 복제본이 새 로그를 과거의 중복으로 버릴 수 있습니다. 번호가 유일한 범위를 명시하지 않았기 때문에 생기는 문제입니다.

여기서는 epoch를 day-A나 day-B 같은 세대 식별자로 사용합니다. 하나의 세대 안에서는 번호를 재사용하지 않고, 명시적으로 새 세대를 시작할 때만 다른 epoch를 부여합니다. 실습의 문자열은 설명을 쉽게 하는 예시이지 날짜만 같으면 안전하다는 뜻이 아닙니다. 실제 시스템은 복원·재생성·운영 환경을 구별하는 식별 정책이 필요합니다. epoch를 임의 사용자 입력으로 고르게 두면 다른 고객의 상태를 가져오는 보안 문제가 될 수도 있습니다.

어떻게 동작하나

install_snapshot에는 snapshot 외에 expected_epoch가 들어갑니다. snapshot.epoch가 expected_epoch와 같아야 설치할 수 있습니다. expected_epoch는 이미 신뢰한 연결 설정에서 전달하는 값이며, 검사하려는 JSON에서 그대로 꺼내면 검사가 순환합니다. 올바른 형식의 문자열이라는 사실도 그 창고를 읽을 권한이 있다는 증거는 아닙니다. 인증·권한·전송 보호는 실제 서비스가 별도로 보장해야 합니다.

동일 세대의 더 오래된 스냅샷은 Conflict로 거절합니다. 같은 last인데 total이 다르면 역시 충돌입니다. 동일 세대·동일 last·동일 total이면 재설치할 필요가 없어 False를 반환하고 남아 있는 개별 이벤트도 지우지 않습니다. 허용된 다른 세대의 스냅샷이라면 번호가 작아도 교체할 수 있습니다. day-A의 800보다 day-B의 0이 작다는 숫자 비교만으로 새 세대를 거절해서는 안 됩니다.

스냅샷을 설치하면 그 이전 개별 이벤트는 더 이상 복제본에 남아 있지 않습니다. 예를 들어 total=13, last=5인 스냅샷은 0부터 5까지의 총합을 알려 줄 뿐, 4번의 delta가 무엇인지는 알려 주지 않습니다. 뒤늦게 (4,2)가 들어왔을 때 같은 내용의 중복이라고 증명할 수 없으므로 CoveredBySnapshot으로 분류합니다. 모든 과거 번호를 조용히 무시하면 내용 충돌을 숨길 수 있습니다. 반대로 남겨 둔 개별 이벤트의 번호와 delta가 모두 같으면 검증 가능한 중복이며 효과를 다시 더하지 않습니다.

새 배치는 연속 번호여야 합니다. 마지막 적용이 5라면 첫 신규 번호는 6입니다. 6과 8만 온 배치를 받아 8까지 커서를 올리면 7이 사라집니다. 또 6의 효과를 커밋한 다음 8에서 오류를 내면 호출자가 배치 전체가 실패했다고 재시도할 때 혼란이 생깁니다. 본 실습은 최대 16개짜리 배치 전체를 하나의 트랜잭션으로 적용하고, 형식과 내부 순서를 먼저 검사합니다. 처리 기록과 재고와 마지막 번호를 같은 경계에 묶습니다.

현장에서 만나는 모습

sync_once는 한번의 동기화 시도입니다. 서버 세대가 기대값인지 확인하고, 복제본 커서로 재생을 요청합니다. 범위 밖이거나 세대가 다르면 스냅샷을 받아 설치한 뒤 snapshot.last보다 큰 이벤트만 읽습니다. 최대 16개를 적용한 상태를 반환하며 최신 서버 상태에 완전히 도달했다고 약속하지 않습니다. 호출자는 결과 커서와 관측 시점의 서버 커서를 비교하고, 계속 따라갈지 사용자에게 지연을 표시할지 결정합니다.

특히 스냅샷을 설치하는 사이 원본 로그가 다시 잘릴 수 있습니다. 처음에 스냅샷이 last=20을 담았는데 설치 후 원본이 21번을 쓰고 그것까지 삭제했다면 21을 재생할 수 없습니다. 이 경우 이번 sync_once는 ResyncRequired를 다시 전달합니다. 이미 설치한 last=20의 유효한 상태를 지우지 않고, 다음 유한 호출에서 더 새로운 스냅샷을 받게 합니다. 실패를 숨기거나 catch 안에서 끝없이 재시도하면 원본의 빠른 보관 절단을 따라잡지 못한 채 자원만 씁니다.

sync_files는 서로 다른 두 로컬 파일을 열고 닫는 경계를 책임집니다. 경로 문자열이 달라도 하드링크로 같은 파일일 수 있으므로 실제 파일 정체성도 확인합니다. 원본이 없으면 빈 DB를 만들어 복구가 성공한 것처럼 진행하지 않습니다. 복제본 열기에 실패해도 이미 연 원본 연결은 닫습니다. 파일 경로는 학습자가 통제하는 일회용 폴더라는 전제이며, 공격자가 동시에 링크를 바꾸는 운영 서버의 경로 경쟁 방어까지 구현한 것은 아닙니다.

다음 실습에서 할 것

8단계 종합 실습에서 apply_batch·sync_once·sync_files를 완성합니다. 마지막에는 실제 자식 프로세스를 종료 코드 73으로 끝내고 독립 연결로 DB를 다시 엽니다. 커밋 전후마다 커서·재고·개별 이벤트가 이전 전체 또는 새 전체인지 비교합니다. 같은 파일의 재시작은 검증하지만 디스크 소실, 서버 간 합의, 자동 장애 전환, TLS와 사용자 권한은 검증하지 않습니다. 다음 과제로 확장할 때도 검증한 경계를 넓히는 작업이 필요합니다.

참고: [Python sqlite3 연결과 트랜잭션](https://docs.python.org/3.12/library/sqlite3.html), [Python os.path.samefile](https://docs.python.org/3.12/library/os.path.html#os.path.samefile). epoch·충돌 분류·유한 복구 루프는 이번 학습 프로토콜의 계약입니다.