이어 받기를 했는데 새 요청이 실패했다. 그 사실 때문에 이전에 받은 자료까지 목록에서 잃어버려야 할까. 보충 수집 코드를 다시 읽으며, 자료를 남기는 판단과 이번 시도를 보고하는 판단을 나눠 보았다.
이어받는다는 말에는 두 가지 기대가 들어 있었다
과거 작업 대화에는 모두 내려받았는지 묻는 질문과 이어 받자는 요청이 함께 남아 있다. 당시 답변도 이미 저장한 자료, 남은 대기 항목, 실패한 항목을 구분하고 있었다. 이번에는 그 기록에 나오는 보충 수집기의 병합 코드를 읽었다. 과거의 수집 수치를 오늘의 완료 상태로 옮기지 않고, 이어 받기를 구현한 판단에 집중했다.
사용자는 이어 받기를 누르면 앞서 받은 것을 계속 사용할 수 있기를 기대한다. 동시에 이번에 새로 받아 보려던 자료에 문제가 있다면 그것도 알고 싶어 한다. 예전의 성공을 지키는 일과 이번의 실패를 알려 주는 일이 한 화면에서 함께 성립해야 한다. 하나의 성공·실패 표시만으로는 이 기대를 설명하기 어려웠다.
이 문제를 최신 결과가 이전 결과를 덮어쓰는 식으로 처리하면 어떤 일이 생길까. 이전에 파일을 저장했던 항목이 재시도에서 실패하면서, 그 항목을 찾는 목록에서도 사라질 수 있다. 반대로 예전 성공만 남기면 이번 요청도 성공한 것처럼 읽힌다. 나는 현재 사용할 자료를 고르는 목록과 최근 시도의 결과를 서로 다른 정보로 다루는 쪽을 택했다.
새 기록이라는 이유만으로 바꾸지 않는다
확인한 구현은 이전 목록을 자원 주소로 묶어 놓고 이번 보충 결과를 순서대로 병합한다. 새 기록에 저장 경로가 있으면 새 기록을 선택한다. 새 기록에 경로가 없어도 이전 항목 자체가 없거나 이전 항목에도 경로가 없다면 새 기록을 남긴다. 이전 항목에 경로가 있고 새 항목에는 없을 때는 이전 기록을 유지한다.
이를 사용자의 말로 옮기면 이렇다. 새로 받은 자료가 있으면 그것을 쓴다. 새로 받지 못했지만 앞서 저장한 기록이 있으면 그것을 남긴다. 쓸 자료가 한 번도 없었다면 이번 실패도 목록에서 사라지지 않게 한다. 여기서 보존하는 것은 이전에 선택한 자료의 기록이지, 실패한 재시도가 성공했다는 표시가 아니다.
이 조건은 시간순으로 가장 최근 행을 고르는 방식보다 의도를 잘 드러낸다. 다만 원래 조건이 직접 보는 것은 경로 필드의 존재다. 그 경로에 파일이 지금도 있는지, 내용이 온전한지, 현재 서비스의 최신 자료인지는 이 병합 조건만으로 확인하지 않는다. 경로가 있는 기록을 유지했다는 설명과 파일의 현재 상태를 검증했다는 설명을 구분해야 한다.
이번 시도의 원장을 먼저 남긴 이유
보충 수집기는 병합하기 전에 이번 시도의 전체 기록을 별도 보고서로 저장한다. 그다음 이전 기록과 합친 목록을 만들고 통합 보고서를 쓴다. 두 자료를 함께 읽으면 같은 항목에 대해 이전 저장 기록을 선택한 이유와 이번에 실패한 사실을 함께 확인할 수 있다. 통합 목록만 남겼다면 잃어버릴 정보를 먼저 보존하는 순서다.
예를 들어 이전에 저장한 안내 문서가 있고 이번 요청에는 실패했다고 하자. 통합 목록은 이전 저장 기록을 가리킬 수 있다. 보충 원장에는 이번 실패가 그대로 남는다. 이때 운영 문구는 ‘이전 저장본 유지 · 이번 가져오기 실패’ 정도가 맞다. ‘최신 자료로 갱신’이라고 쓰면 실제로 하지 못한 일을 약속하게 된다. 이 문구는 이번 글에서 제안하는 표시이며 원래 화면에 구현했다고 주장하는 내용은 아니다.
별도 원장이 있다고 모든 실행의 영구 이력이 생기는 것도 아니다. 확인한 구현은 정해진 보충 보고서 이름에 쓰고, 실행마다 고유한 이력 보관소를 만드는 구조는 아니다. 장기 감사가 필요하다면 실행 식별자와 기록 시각을 부여해 별도 보존하고, 그 기록을 근거로 통합 목록을 다시 만들 수 있는지 확인하는 단계가 더 필요하다.
남아 있는 자료 수와 이번 실행의 결과가 달랐다
병합 뒤에는 선택된 통합 기록으로 저장·실패 개수를 다시 계산한다. 반면 작업 상태는 병합 전에 이번 보충 시도의 실패 여부로 정했고, 종료 코드도 보충 시도의 실패 개수를 사용한다. 따라서 이전 저장 기록이 남아 통합 목록의 실패가 줄었더라도, 이번 실행이 부분 완료로 끝나거나 실패를 뜻하는 종료 코드를 반환할 수 있다.
이것을 숫자의 모순이라고 보고 한쪽을 다른 쪽에 맞추면 중요한 신호를 지울 수 있다. 통합 저장 수는 목록에서 선택한 기록의 상태를 설명하고, 보충 실패 수는 이번 실행에서 해결하지 못한 요청을 설명한다. 상태가 가리키는 범위를 항목 이름에 붙이면 해석이 쉬워진다. ‘선택된 저장 기록’, ‘이번 시도 실패’, ‘추가 확인 필요’를 따로 보여 주는 방식이다.
원래 수집기의 다른 경로에는 이미 저장한 HTML이 있으면 그 바이트를 반환하는 처리도 있다. 기록에 성공 상태가 있더라도 매번 새 네트워크 응답을 받았다는 뜻으로 넓혀 읽지 않았다. 재사용한 캐시, 이전 저장 기록, 이번에 새로 받은 자료는 출처가 다르다. 작업을 빨리 이어 가는 장점은 살리면서, 무엇을 새로 확인했는지는 별도로 남겨야 한다.
공유 예제에는 실제 바이트와 선택 이유를 남겼다
이 판단을 다른 프로그램에서도 살펴볼 수 있도록 독립적인 메모리 예제를 만들었다. 아래 구현은 실제 자원 주소나 저장 경로를 받지 않는다. 만든 자원 식별자와 saved·failed·skipped 결과, 호출자가 제공한 합성 바이트를 받는다. 같은 입력 목록에 식별자가 중복되면 조용히 마지막 행을 고르지 않고 거부한다. 원래 주소 기반 병합 코드를 그대로 복제한 구현이 아니다.
saved 입력은 bytes여야 하고 기존 바이트 영수증 모듈의 비교 함수를 거친다. 그 모듈이 계산한 길이와 SHA를 선택된 자료의 영수증으로 돌려준다. 최신 saved가 있으면 그것을 고르고, 그렇지 않으면 이전 saved를 고른다. 이전과 최신 결과, 선택한 쪽, 실패 뒤 이전 자료를 유지했는지를 각각 남긴다. skipped는 실패로 바꾸어 표시하지 않는다.
여기서 영수증이 맞는다는 것은 제공된 바이트와 전달한 길이가 맞는다는 뜻이다. 길이도 호출자가 전달하므로 원격 전송을 독립적으로 관찰한 증거는 아니다. 이 예제는 파일을 찾거나 쓰지 않고, 신선도·원격 출처·전원 장애 후 보존을 보장하지 않는다. 입력 목록은 각각 64개, 각 바이트 자료는 기존 모듈 기준 64KiB로 제한했다. 큰 파일이나 실제 주소 병합에는 별도의 연결 설계가 필요하다.
코드와 구현 살펴보기
독립 합성 병합 예제 · 전체 코드
"""Independent synthetic merge; place next to file_receipt.py from linked ZIP."""
from copy import deepcopy
import re
from file_receipt import compare_download_bytes
def merge_inventory(previous, latest):
def index(rows):
if type(rows) is not list or len(rows) > 64:
raise ValueError('BOUNDED_RECORD_LIST')
out = {}
for row in rows:
if type(row) is not dict or set(row) != {'id', 'outcome', 'data'}:
raise ValueError('EXACT_RECORD_FIELDS')
key = row['id']
if type(key) is not str or not re.fullmatch(r'[a-z][a-z0-9_-]{0,39}', key):
raise ValueError('RESOURCE_ID')
if key in out:
raise ValueError('DUPLICATE_RESOURCE_ID')
if row['outcome'] not in ('saved', 'failed', 'skipped'):
raise ValueError('OUTCOME')
if row['outcome'] == 'saved':
if type(row['data']) is not bytes:
raise ValueError('SAVED_BYTES_REQUIRED')
compare_download_bytes(row['data'], len(row['data']), True)
elif row['data'] is not None:
raise ValueError('FAILED_OR_SKIPPED_BYTES_MUST_BE_ABSENT')
out[key] = row
return out
a, b = index(previous), index(latest)
result = []
for key in sorted(a.keys() | b.keys()):
old, new = a.get(key), b.get(key)
chosen = (new if new and new['outcome'] == 'saved'
else old if old and old['outcome'] == 'saved' else None)
source = ('latest' if chosen is not None and chosen is new
else 'previous' if chosen is not None else 'none')
receipt = (compare_download_bytes(chosen['data'], len(chosen['data']), True)
if chosen else None)
result.append({
'id': key,
'selected_from': source,
'selected_receipt': receipt,
'previous_outcome': old['outcome'] if old else None,
'latest_outcome': new['outcome'] if new else None,
'retained_after_failed_attempt': (
source == 'previous' and new is not None and new['outcome'] == 'failed'
),
'fresh_network_response_verified': False,
'performs_fetch_or_write': False,
})
return deepcopy(result)
실패·같은 길이의 변경·건너뛰기를 실제로 시험했다
기존 공개 ZIP을 다시 내려받아 5,859바이트와 SHA를 확인하고 추출한 뒤, 포함된 시험 36개를 실행했다. 그 모듈을 이용하는 이번 병합 예제에는 별도의 시험 22개를 작성해 통과시켰다. 이는 합성 코드의 시험 수이며 원래 수집기의 시험이나 외부 사이트의 수집 성공 수가 아니다.
새 시험에서는 이전 성공 뒤 최신 실패가 오면 이전 SHA가 남는지, 같은 길이의 새 바이트가 들어와도 새 SHA로 교체되는지, 이전 실패를 새 성공이 회복하는지 확인했다. 새 실패만 있는 항목에는 자료가 생기지 않아야 한다. 최신 시도가 없는 경우와 skipped인 경우도 실패로 꾸미지 않게 했다. 중복 식별자, 잘못된 결과, 허용하지 않은 필드, 자료 크기 상한, 빈 바이트, 입력 보존과 원문 바이트의 출력 제외도 확인했다.
설명용으로 실행한 기록은 다섯 상황이다. 아래에는 이전 저장 뒤 최신 실패가 온 한 사례의 실제 합성 출력을 실었다. selected_from은 previous이고 latest_outcome은 failed다. fresh_network_response_verified와 performs_fetch_or_write는 false로 남는다. 자료를 유지하는 선택과 새 실행의 결과를 한 기록에서 함께 읽을 수 있는지가 이번 검증의 중심이었다.
코드와 구현 살펴보기
실제로 실행한 합성 결과
[
{
"id": "asset",
"selected_from": "previous",
"selected_receipt": {
"verified": true,
"reason": "supplied_bytes_match",
"size": 3,
"sha256": "cba06b5736faf67e54b07b561eae94395e774c517a7d910a54369e1263ccfbd4",
"expected_digest_checked": false,
"network_origin_verified": false,
"power_loss_durability_verified": false
},
"previous_outcome": "saved",
"latest_outcome": "failed",
"retained_after_failed_attempt": true,
"fresh_network_response_verified": false,
"performs_fetch_or_write": false
}
]다른 프로그램에 붙일 때 먼저 정할 것
서재의 기존 바이트 영수증 ZIP에서 file_receipt.py를 가져와, 위 전체 코드를 merge_inventory.py로 같은 폴더에 저장하면 된다. 이 글의 병합 코드는 본문에 추가한 예제이며 기존 ZIP의 내용물을 바꾸거나 그 안에 넣지 않았다. 아래 호출 예시는 파일이나 사이트에 접근하지 않는 작은 재현 입력이다. 실행 결과에서 선택한 쪽과 최신 결과를 먼저 읽으면 된다.
실제 프로그램에는 자원 식별 기준, 저장 파일을 읽어 확인할 시점, 재시도 원장 보존 방식이 더 필요하다. 같은 자료를 가리키는 주소가 달라지는 경우에는 주소 정규화 정책도 정해야 한다. 오래된 자료를 계속 써도 되는 업무인지, 재고·가격·권한처럼 최신 확인이 필요한 업무인지에 따라 이전 자료를 보여 줄 조건은 달라진다. 예제의 보존 규칙을 그 판단 없이 모든 업무에 적용하지 않는다.
앞선 ZIP 검증 글에서는 묶음 파일을 열 수 있다는 것과 참조 자료가 모두 들어 있다는 것을 나눴다. 이번에는 그다음에 이어 받는 과정에서 어떤 기록을 선택하고 최신 실패를 어디에 남길지 살펴봤다. 원래 수집과 브라우저는 다시 실행하지 않았고, 앞선 참조 누락을 복구했다는 주장도 하지 않는다. 기존 자료를 계속 쓰면서 새 시도의 문제를 놓치지 않는 보고 구조가 필요하다면 이 두 글과 예제를 함께 출발점으로 삼을 수 있다.
코드와 구현 살펴보기
최소 재현 호출
from merge_inventory import merge_inventory
previous = [{'id': 'guide', 'outcome': 'saved', 'data': b'earlier'}]
latest = [{'id': 'guide', 'outcome': 'failed', 'data': None}]
result = merge_inventory(previous, latest)[0]
assert result['selected_from'] == 'previous'
assert result['latest_outcome'] == 'failed'
assert result['retained_after_failed_attempt'] is True
print(result)