되돌릴 수 없는 변경 · 중단된 배치를 이어서 끝내기 · 이론
중단된 배치를 이어서 끝내기
한 줄 요약
체크포인트는 화면에 띄우는 퍼센트가 아니라, 승인된 목록의 어느 부분을 업무 변경과 감사까지 함께 확정했는지 나타내는 약속입니다.
왜 이게 필요했나
외계인 디저트 축제가 폭우로 취소됐습니다. 고객은 미결제 주문 40건을 취소해 달라고 합니다. 앞 수업에서는 전부 성공하거나 전부 롤백하는 변경을 만들었습니다. 이번에는 담당자가 “10건씩 확정해도 좋고, 뒤쪽에서 문제가 생기면 이미 끝난 10건은 유지해 달라”고 승인했습니다. 그 한 문장 때문에 트랜잭션 경계가 달라집니다. 성능을 위해 개발자가 임의로 쪼갠 것이 아닙니다.
한꺼번에 오래 잠그면 다른 담당자가 주문을 처리하기 어려워집니다. 반대로 한 행마다 커밋하면 청크의 다섯 번째 행에서 문제가 생겨도 앞 네 행은 되돌릴 수 없습니다. 적절한 크기는 DB 속도만으로 정하지 않습니다. 부분 완료를 고객이 이해할 수 있는지, 어느 지점에서 중단해도 일관된 설명을 할 수 있는지부터 묻습니다. 이 실습의 10건은 교육용 선택이며 모든 운영 시스템의 권장값이 아닙니다.
어떻게 동작하나
1. 승인 목록과 실행 결과를 분리한다
등록할 때 job_id, tenant, 각 주문의 id·revision·qty, chunk_size를 저장합니다. 승인 목록은 ID순으로 정규화하며 실행 중에는 바꾸지 않습니다. 같은 작업 ID로 목록 순서만 바꿔 재등록하면 같은 요청입니다. 같은 ID에 다른 고객·수량·청크 크기를 붙이면 충돌입니다. 조용히 덮어쓰면 예전 진행률이 새 목록을 가리키게 됩니다.
진행 중 현재 pending 주문을 다시 검색해 목록을 만들면 어떨까요? 첫 청크가 끝난 뒤 새 주문이 들어오면, 처음에는 없던 주문이 취소 대상에 섞일 수 있습니다. 주문이 삭제되면 OFFSET의 의미도 바뀝니다. 따라서 여기서는 WHERE 결과의 몇 번째 행이 아니라, 동결한 승인 배열의 next_index를 사용합니다. 데이터가 40건에서 39건으로 바뀌어도 승인 자체를 39건으로 고쳐 쓰지 않습니다.
2. 작업 행을 잠그고 다음 청크를 고른다
두 작업자가 동시에 next_index=10을 읽으면 둘 다 두 번째 청크를 잡을 수 있습니다. jobs의 해당 행을 SELECT FOR UPDATE로 읽고 트랜잭션이 끝날 때까지 소유하면, 같은 작업의 다음 구간 선택을 직렬화할 수 있습니다. PostgreSQL의 행 잠금은 트랜잭션 종료 시 해제되며, 일반 조회 전체를 막는 전역 자물쇠가 아닙니다. [공식 행 잠금 문서](https://www.postgresql.org/docs/16/explicit-locking.html)를 보세요.
이 설계에서 워커 두 개가 같은 작업의 서로 다른 청크를 동시에 처리하지는 않습니다. 두 워커는 장애 뒤 이어받기와 중복 선택 방지를 확인하기 위한 것입니다. 더 큰 병렬 처리량이 필요하면 청크별 임대·만료·소유권 세대 등의 별도 설계가 필요합니다. 현재 코드에 그런 보장이 있다고 설명하지 마세요.
3. 하나의 청크 안에 세 기록을 묶는다
청크가 11–20번 주문이라면 아래 변화가 한 트랜잭션입니다.
| 저장 대상 | 확정할 내용 | 따로 커밋하면 생기는 문제 |
| --- | --- | --- |
| orders | 승인 버전의 pending을 cancelled로 바꾸고 버전 증가 | 주문만 바뀌고 재개 위치는 그대로 |
| job_audit | 순번·ID·전후 버전·수량 | 감사만 있고 실제 주문은 미변경 |
| jobs.next_index | 다음 구간의 시작인 20 | 실행하지 않은 주문을 완료로 오인 |
UPDATE 조건에는 고객·ID·버전·수량·상태를 모두 넣습니다. RETURNING은 실제 변경된 행의 새 값을 줍니다. 일치하는 행이 없을 때 SQL 자체가 오류를 내는 것은 아니므로, 프로그램이 Conflict로 바꾸어 청크 전체를 롤백해야 합니다. [UPDATE 공식 설명](https://www.postgresql.org/docs/16/sql-update.html)의 반환값과 영향 행 수를 함께 읽으세요.
Python의 낮은 단계 함수도 트랜잭션을 쓰지만, 바깥 run_chunk 안에서 호출될 때 전체를 조기 커밋하면 안 됩니다. psycopg의 중첩 transaction 문맥은 SAVEPOINT로 동작합니다. 이 수업은 autocommit=True인 연결에서 바깥 트랜잭션 경계를 명시적으로 여는 방식을 씁니다. [psycopg 트랜잭션 안내](https://www.psycopg.org/psycopg3/docs/basic/transactions.html)와 자신의 코드에서 가장 바깥 문맥이 어디인지 대조하세요.
4. 재개 전에 진행률을 의심한다
next_index가 20이라고 모두 믿으면 안 됩니다. 감사의 순번과 ID·전후 버전·수량이 승인 배열 앞 20개와 정확히 일치해야 합니다. 20행이 있다는 사실만으로는 다른 ID가 끼어 있는지 알 수 없습니다. 이 실습은 감사가 없는 진행률, 청크 중간을 가리키는 진행률, 목록 길이를 넘는 진행률을 거절합니다. 손상된 기록을 자동으로 “수선”하며 주문을 더 바꾸지는 않습니다.
첫 청크가 확정된 뒤 둘째 청크의 15번 주문이 다른 담당자에 의해 수정되면, 둘째 청크의 11–14번 변경까지 함께 롤백합니다. 첫 청크의 기록과 15번의 후속 변경은 유지합니다. 새 revision을 읽어 그 자리에서 승인을 다시 만드는 것은 재시도가 아니라 새 업무 결정입니다. 중단하고 남은 범위를 설명한 뒤 새 승인을 받아야 합니다.
현장에서 만나는 모습
응답을 잃었을 때 다시 호출하면 무엇이 반환될까
청크 커밋은 성공했는데 클라이언트가 결과를 받기 전에 종료됐다고 합시다. 같은 작업을 재개하면 방금 끝난 청크의 답을 재생하는 대신 다음 청크를 처리합니다. 이 API의 계약은 “청크 N 실행 요청”이 아니라 “이 작업의 다음 미완료 청크 진행”이기 때문입니다. 따라서 반환된 processed만 합쳐 전체 작업의 완료 증거로 삼으면 안 됩니다. 전체 이력은 DB의 감사와 체크포인트에서 읽습니다.
max_chunks는 한 번의 drain 호출이 시도할 청크 호출 수입니다. 각 호출의 SQL 시간 한도나 전체 경과 시간과 같지 않습니다. 다른 워커가 대부분을 끝냈으면 내 호출은 처리 ID가 빈 완료 응답을 받을 수도 있습니다. 정상 완료면 즉시 멈추고, 충돌이나 연결 오류를 무조건 다시 시도하지 않습니다. 앞 수업에서 배운 오류 분류를 잊지 마세요.
완료와 현재 상태를 구분해 고객에게 설명한다
과거에 완료한 주문이 나중에 다시 수정되거나 삭제돼도 그때의 완료 사실은 사라지지 않습니다. 보고서는 승인 ID, 확정 ID, 미처리 ID를 나누고 확정 ID의 현재 상태를 matching·drifted·missing으로 나눕니다. 같은 수량의 다른 주문을 찾았다고 missing을 지우면 안 됩니다. 진행률과 현재 행을 하나의 SELECT로 읽으면 Read Committed의 문장 단위 스냅샷에서 대조할 수 있습니다. [격리 수준 공식 문서](https://www.postgresql.org/docs/16/transaction-iso.html)를 참고하세요.
FDE는 고객의 업무 조건을 구현의 불변식으로 바꾸고, 실패했을 때 무엇까지 확정됐는지 설명해야 합니다. 확인한 [Palantir FDE 공고](https://jobs.lever.co/palantir/dab396d4-2f14-4796-aac0-0d82883dccf0)는 고객 문제 이해와 실제 해결책 구현을 강조합니다. 이 가상 사례는 그 역량을 연습하기 위한 저자 설계이지, 해당 공고가 PostgreSQL이나 이 구현을 필수로 요구한다는 뜻은 아닙니다.
다음 실습에서 할 것
40건 승인과 10건 청크로 시작해 다른 고객·불연속 ID·마지막 짧은 청크에서도 동작하게 만듭니다. 실제 클라이언트를 주문 변경 뒤, 감사 뒤, 체크포인트 뒤, 커밋 뒤 네 지점에서 종료하고 새 연결로 기록을 확인합니다. 두 프로세스가 같은 작업을 이어 가며 정확한 승인 ID가 한 번씩만 처리됐는지 독립 SQL로 대조합니다.
전제도 분명히 하세요. 정상 주문 쓰기는 revision을 증가시키고 확정한 승인·감사는 불변입니다. 실습 DB 계정의 악의적 직접 수정까지 막는 권한 설계는 별도입니다. 서버는 살아 있는 채로 클라이언트만 종료하므로 서버 전원 장애 내구성, 외부 결제 환불, 무제한 처리량까지 검증한 것이 아닙니다.