SageMaker Feature Store BatchWriteRecord 재시도, 부분 성공에서 중복 쓰기를 막는 법
SageMaker Feature Store BatchWriteRecord 호출이 HTTP 200으로 끝났는데 일부 레코드가 보이지 않는다면, 원래 배치 전체를 다시 보내기 전에 먼저 응답 본문을 나눠 봐야 한다. 이 API는 성공한 요청에서도 레코드별 Errors와 UnprocessedEntries를 돌려줄 수 있다. 어느 목록에도 없는 entry는 이미 성공했으므로 다음 요청에서 제외해야 한다.
이 글은 2026년 8월 29일 AWS 공식 문서와 출시 해설을 대조하고, Ubuntu 계열 Linux와 Python 3.12.3의 fake client에서 두 재시도 방식을 비교한 결과를 바탕으로 한다. AWS 계정이나 실제 Feature Store에는 쓰지 않았으며, 여기서 막는 “중복”은 offline store의 exactly-once가 아니라 성공한 entry의 클라이언트 재전송이다.
20초 핵심 요약
- 무엇:
BatchWriteRecord응답에서 이미 성공한 entry를 빼고 다음 재시도 목록을 만든다. - 왜: 원본 4개를 통째로 재시도한 비교에서는 성공 entry도 두 번 전송됐고, validation error까지 반복됐다.
- 어떻게:
UnprocessedEntries전부와 allowlist에 든Errors만 지수 백오프로 다시 보내고, 나머지는 terminal 또는 DLQ로 격리한다.
HTTP 200은 배치 전체 성공 판정이 아니다
BatchWriteRecord API Reference에 따르면 한 요청은 1~25개 entry를 받는다. 여러 feature group을 한 요청에 넣을 수 있지만 25개 제한은 그룹별이 아니라 요청 전체 합계다. 서비스가 요청을 받아들이면 HTTP 200을 반환하면서 응답 본문에 다음 두 실패 채널을 함께 담을 수 있다.
Errors: 쓰기에 실패한 원래Entry,ErrorCode,ErrorMessage가 들어간다.UnprocessedEntries: 처리되지 않은 원래 entry가 들어가며, API Reference가 재시도 가능하다고 명시한다.
성공 목록은 따로 없다. AWS 출시 해설이 설명하듯 원본 Entries 가운데 위 두 목록 어디에도 나타나지 않은 항목이 성공한 entry다. 따라서 HTTP status만으로 성공을 기록하거나 원본 배열을 그대로 retry queue에 넣으면 부분 성공의 의미를 잃는다.
요청 자체가 400·403·404·500·503으로 실패하는 경우는 레코드별 부분 실패와 별개다. timeout이나 connection reset처럼 응답을 받지 못한 상황도 이 문서만으로 일부 commit 여부를 판정할 수 없으므로 “전체 재전송이 안전하다”고 단정할 수 없다.

전체 배치 재시도는 성공 항목까지 다시 보냈다
비교 fixture는 succeeded, invalid, throttled, unprocessed 네 entry로 고정했다. fake client의 첫 호출은 성공 항목을 응답 목록에서 빼고, 나머지를 각각 ValidationError, ThrottlingException, UnprocessedEntries로 반환했다. 두 번째 호출은 성공 응답으로 만들었다.
원본 네 항목을 통째로 다시 보낸 방식의 결과는 다음과 같다.
$ python3 output/runs/20260828T231005Z-613c79849d/topic-d5bb84c7dc215449/_retry_probe.py naive
mode=naive
calls=[['succeeded', 'invalid', 'throttled', 'unprocessed'], ['succeeded', 'invalid', 'throttled', 'unprocessed']]
transmissions={'succeeded': 2, 'invalid': 2, 'throttled': 2, 'unprocessed': 2}
duplicate_success_transmissions=1
AssertionError: already-successful entry was retried
[exit 1]
성공한 succeeded가 두 번째 호출에 다시 들어갔고, 같은 payload로는 고쳐지지 않는 invalid도 반복됐다. 반면 응답에서 재시도 대상을 선택한 방식에는 두 항목만 남았다.
$ python3 output/runs/20260828T231005Z-613c79849d/topic-d5bb84c7dc215449/_retry_probe.py selective
mode=selective
calls=[['succeeded', 'invalid', 'throttled', 'unprocessed'], ['throttled', 'unprocessed']]
transmissions={'succeeded': 1, 'invalid': 1, 'throttled': 2, 'unprocessed': 2}
duplicate_success_transmissions=0
[exit 0]
같은 입력과 fake 응답에서 선택 방식을 바꾸자 succeeded와 invalid 전송은 각각 2회에서 1회로 줄었고, throttled와 unprocessed는 2회를 유지했다. 이는 AWS 저장 결과나 성능 측정이 아니라, 다음 요청에 넣을 entry를 고르는 로직의 결정론적 비교다.
Errors 전체가 아니라 승인한 코드만 다시 보낸다
AWS API Reference는 UnprocessedEntries를 재시도할 수 있다고 명시하지만, BatchWriteRecordError 문서는 ErrorCode를 문자열로 정의할 뿐 완전한 코드 목록과 재시도 가능 여부 표를 제공하지 않는다. AWS 출시 해설에도 “retriable error만 재시도”라는 설명과 Errors를 다시 제출한다는 축약 표현이 함께 있다. 안전한 구현은 모든 Errors를 같은 것으로 취급하지 않는 것이다.
아래 코드는 응답을 pending과 terminal로 나누는 최소 예시다. ThrottlingException, InternalFailure, ServiceUnavailable은 통제 비교를 위한 allowlist 예시이며 AWS가 보장한 완전한 목록이 아니다. 실제 관측 코드와 최신 SDK 문서에 맞춰 운영 정책을 검토해야 한다.
RETRIABLE_ERROR_CODES = {
"ThrottlingException",
"InternalFailure",
"ServiceUnavailable",
}
def select_retry_entries(response):
pending = list(response.get("UnprocessedEntries", []))
terminal = []
for error in response.get("Errors", []):
if error.get("ErrorCode") in RETRIABLE_ERROR_CODES:
pending.append(error["Entry"])
else:
terminal.append(error)
return pending, terminal
ValidationError처럼 payload나 schema를 고쳐야 하는 오류는 같은 입력을 시간만 두고 다시 보내도 해결되지 않는다. allowlist 밖의 코드는 자동 재시도하지 않고 원래 entry와 오류 메시지를 보존한 채 terminal 처리하고, 운영 알림이나 dead-letter queue(DLQ)로 넘기는 편이 낫다.
다음 호출에는 pending만 넣고 시도 횟수를 제한한다
재시도 루프의 핵심은 client.batch_write_record(Entries=pending)이다. 첫 요청 뒤 원본 entries가 아니라 직전 응답에서 계산한 pending만 넘겨야 성공 항목과 terminal 오류가 자연스럽게 빠진다.
import random
import time
def batch_write_with_partial_retry(client, entries, max_attempts=5):
pending = list(entries)
terminal = []
for attempt in range(max_attempts):
response = client.batch_write_record(Entries=pending)
pending, newly_terminal = select_retry_entries(response)
terminal.extend(newly_terminal)
if not pending:
return {"terminal_errors": terminal, "exhausted": []}
if attempt + 1 < max_attempts:
cap = min(8.0, 0.25 * (2 ** attempt))
time.sleep(random.uniform(0, cap))
return {"terminal_errors": terminal, "exhausted": pending}
AWS 해설은 재시도 가능한 오류에 exponential backoff를 권하지만 횟수, 초기 지연, 상한, jitter 수치까지 정해 주지는 않는다. 위의 max_attempts=5, 0.25초 시작값, 8초 상한은 예시다. 운영 환경의 지연 예산과 처리량에 맞춰 정하고, 무한 루프 대신 소진된 entry를 원본 payload·마지막 오류·시도 횟수와 함께 보존해야 한다.
호출 전에는 Entries를 요청 전체 기준 25개 이하로 나눈다. 재시도 로그에는 원본 전체보다 이번 pending의 식별자를 남겨야 성공 레코드가 다시 들어갔는지 확인하기 쉽다.
EventTime ordering은 exactly-once를 대신하지 않는다
BatchWriteRecord는 AWS 출시 해설에 설명된 대로 PutRecord와 같은 EventTime ordering을 따른다. PutRecord API Reference에 따르면 새 record의 EventTime이 기존보다 크면 online과 offline store에 쓰이고, 그렇지 않으면 offline store가 있을 때 historical record로 기록된다. 오래된 재시도가 최신 online 값을 덮는 문제를 막는 장치다.
하지만 이 ordering은 동일 요청의 부작용을 정확히 한 번으로 제한하는 멱등성 계약이 아니다. 공식 요청 형식에는 idempotency token이 없고, 동일 EventTime을 반복 전송했을 때 offline store의 중복 제거를 보장한다는 설명도 없다. 재시도할 때 EventTime을 현재 시각으로 바꾸지 말고 원래 값을 보존하되, ordering을 이유로 성공 항목까지 다시 보내서는 안 된다.
이번 비교에서 확인한 값도 duplicate_success_transmissions=0까지다. 실제 offline row 수, 응답을 잃은 요청의 commit 상태, SDK의 자동 재시도 범위는 후속 격리 계정 검증이 필요하다.
ListRecords는 삭제 완료를 증명하는 snapshot이 아니다
ListRecords API Reference에 따르면 이 API는 OnlineStore의 전체 feature value가 아니라 record identifier를 페이지별로 돌려준다. MaxResults 범위는 1~100이고 InMemory tier에서는 엄격한 반환 개수가 아니라 hint다. 첫 요청은 토큰 없이 시작한다. 응답에 NextToken이 있으면 같은 feature group 요청에 그 값을 그대로 전달하고, 토큰이 없을 때 종료한다.
토큰은 해석하거나 직접 만들 대상이 아니다. AWS 출시 해설에 따르면 결과 순서가 보장되지 않고, 페이지를 순회하는 동안 쓰기나 삭제가 일어나면 identifier 중복이나 누락이 생길 수 있다. 토큰도 특정 feature group과 account에 묶인다.
따라서 seen = set()으로 중복 후속 처리를 막을 수는 있지만 누락된 identifier를 복원할 수는 없다. 삭제나 규정 준수 감사처럼 완전성이 필요한 작업이라면 한 번의 live scan을 완료 증거로 쓰지 말아야 한다. 동시 변경을 멈출 수 있는 구간, 반복 scan의 수렴 확인, 별도의 authoritative inventory 가운데 시스템에 맞는 방법이 필요하다.
AWS CLI의 --starting-token·--max-items와 SDK service API의 NextToken도 같은 추상화로 섞지 않는다. CLI에서 잘린 응답을 이어갈 때는 CLI 문서가 돌려준 토큰을 CLI 방식대로 사용한다.
장애가 반복될 때 먼저 확인할 네 지점
- HTTP 200인데 일부 데이터가 없다면 응답의
Errors와UnprocessedEntries를 저장하고 검사한다. status code 하나를 배치 성공 지표로 삼지 않는다. - 성공 record의 전송 로그가 반복된다면 다음 호출이 원본
Entries를 쓰는지, 선택 함수가 만든pending을 쓰는지 비교한다. - validation error가 계속된다면 동일 payload의 backoff를 멈추고 terminal로 격리한 뒤 schema나 payload를 수정해 새 작업으로 재개한다.
- throttle과 unprocessed가 소진되지 않는다면 최대 시도와 상한이 있는 backoff+jitter를 적용하고 exhausted 항목을 DLQ와 알림으로 보낸다.
위험한 자동 재시도를 되돌리는 방법은 저장된 record를 곧바로 삭제하는 것이 아니다. 먼저 재시도 worker를 중지하고, terminal·exhausted 항목을 격리하며, 원본 payload와 오류를 보존한다. Feature Store 삭제는 EventTime과 HardDelete 의미 및 데이터 손실 위험을 별도로 검토해야 하므로 이 흐름의 자동 롤백으로 묶지 않는다.
부분 성공 처리의 운영 기준은 간단하다. 성공한 entry는 종료하고, UnprocessedEntries는 재시도하며, Errors는 검토한 코드 정책으로 나눈다. 실제 AWS 응답 코드와 timeout 뒤 commit 상태를 격리 환경에서 확인할 수 없다면 전체 배치 자동 재시도는 보류하는 편이 안전하다.
전송 계층 전체의 멱등성 키와 TTL까지 설계해야 한다면 재시도와 멱등성 설계에서 TTL을 정하는 기준을 이어서 확인할 수 있다.
제휴·협찬은 없다.