목차
- 오늘 만난 문제 — SlowDown과 boto3 기본값
- 새로 알게 된 것 — Jitter의 네 가지 형태
- 실전 코드 — Python boto3와 Java SDK v2
- 메모 — 운영하며 알게 된 것
Lambda에서 S3 ListObjectsV2를 1만 건 가까이 돌리다가 SlowDown 에러가 떴다. boto3 기본 설정으로는 절반쯤에서 멈춘다. AWS SDK 재시도 전략 Jitter를 제대로 이해하지 못한 채 max_attempts만 늘리고 있었다는 걸 오늘에야 정리했다. 이거 왜 아무도 안 알려주는지 모르겠다.
오늘 만난 문제 — SlowDown과 boto3 기본값
따라서, 코드는 단순하다. asyncio로 100개씩 묶어서 list_objects_v2를 호출하는 구조다. 로컬에서 작은 버킷으로 테스트할 땐 멀쩡했다. 운영 버킷(파일 약 800만 개)에 붙이자마자 로그가 쌓이기 시작했다.
botocore.exceptions.ClientError: An error occurred (SlowDown)
when calling the ListObjectsV2 operation (reached max retries: 4):
Please reduce your request rate.
반면, 처음엔 max_attempts만 늘리면 될 줄 알았다. Config(retries={'max_attempts': 10, 'mode': 'standard'})로 바꿔도 결과는 비슷했다. 오히려 더 안 좋아진 구간도 있었다. 동시에 5개 워커가 retry를 시작하면, 같은 시점에 다시 몰려서 또 SlowDown을 받는다. 이게 thundering herd라는 걸 책으로만 봤지 실제로 마주친 건 처음이다.
문제의 본질은 재시도 횟수가 아니라 재시도 시점이었다.
boto3의 세 가지 retry mode
(botocore 1.34.x, 2026-05 기준)
| mode | 재시도 | 백오프 방식 |
|---|---|---|
| legacy | 5회 | exponential, jitter 없음 |
| standard | 3회 | exponential + equal jitter |
| adaptive | 3회 + 토큰 버킷 | exponential + jitter + 클라이언트 rate limit |
즉, (출처: boto3 Retries 공식 문서, botocore/retries/standard.py)
legacy를 standard로만 바꿔도 jitter가 들어간다. 핵심은 standard가 equal jitter를 사용한다는 점이다. legacy가 여전히 기본값인 클라이언트가 적지 않으니 한 번 확인할 가치가 있다.
새로 알게 된 것 — Jitter의 네 가지 형태
AWS Architecture Blog의 "Exponential Backoff And Jitter" 글(2015 공개, 2026년 현재까지 권장 패턴으로 유지)에서 네 가지 백오프를 비교한다.
- No Jitter:
sleep = base * 2^n - Full Jitter:
sleep = random(0, base * 2^n) - Equal Jitter:
sleep = base * 2^n / 2 + random(0, base * 2^n / 2) - Decorrelated Jitter:
sleep = min(cap, random(base, prev * 3))
수치 시뮬레이션으로 확인한 게 인상적이었다. 동시에 100개 클라이언트가 재시도할 때 No Jitter는 거의 동일한 시점에 다시 몰린다. Full Jitter는 평균 완료 시간이 가장 짧지만, 일부 요청은 운이 나쁘면 너무 일찍 재시도해서 한 번 더 throttle을 맞기도 한다.
그래서, :::tip 실무에서는 Full Jitter가 단순하면서도 효과가 좋다. boto3 standard 모드가 equal jitter인 이유는 단순함과 보장성 사이의 절충으로 보인다. Java SDK v2는 기본이 Full Jitter다. 둘 다 "공식 문서에서 상세히 강조하지 않는" 부분이라 클라이언트 라이브러리별로 따로 봐야 한다. :::
Equal vs Full — 뭘 골라야 하나
예를 들어, 운영하면서 체감한 차이는 이렇다. Equal Jitter는 최소 대기 시간이 보장된다. base * 2^n / 2 이전엔 절대 재시도가 안 일어난다. 안정적이지만 클러스터 전체로 보면 약간 느리다.
Full Jitter는 평균은 빠르지만 변동이 크다. 100개 워커 중 한두 개는 거의 0초 대기로 재시도가 일어나서 한 번 더 throttle을 맞는다. 그래도 전체 처리량은 Full Jitter가 더 좋게 나오는 경향이 있다.
이처럼, 개인적으로 짧은 람다 핸들러에는 Equal Jitter, 시간 여유 있는 배치 잡에는 Full Jitter가 무난해 보인다.
실전 코드 — Python boto3와 Java SDK v2
boto3 — adaptive 모드를 기본으로
adaptive 모드를 적극적으로 쓰는 게 결론이다.
import boto3
from botocore.config import Config
# adaptive 모드 — 토큰 버킷으로 클라이언트 사이드 throttle
config = Config(
retries={
"max_attempts": 8, # 기본 3회는 대량 호출에 부족
"mode": "adaptive", # standard 대신 adaptive
},
# connection pool도 같이 늘려야 의미가 있다
max_pool_connections=50,
)
s3 = boto3.client("s3", config=config)
response = s3.list_objects_v2(Bucket="my-bucket", Prefix="logs/")
즉, adaptive 모드는 throttle 응답을 받으면 자체적으로 요청 속도를 낮춘다. botocore.retries.adaptive 모듈의 ClientRateLimiter가 token bucket을 관리한다. 5xx나 throttling 응답을 받을 때마다 토큰이 차감되고, 회복할 때까지 클라이언트가 스스로 멈춘다.
Custom Jitter — adaptive로 부족할 때
특히, 병렬 워커가 많은 경우엔 SDK가 주는 jitter만으로 부족하다. 외부 wrapper를 두는 패턴을 쓴다.
import asyncio
import random
from botocore.exceptions import ClientError
# Full Jitter 직접 구현
async def call_with_jitter(func, *args, max_attempts=6, base=0.5, cap=20.0, **kwargs):
last_err = None
for attempt in range(max_attempts):
try:
return await func(*args, **kwargs)
except ClientError as e:
code = e.response["Error"]["Code"]
# 재시도 가능한 에러만 처리
if code not in ("SlowDown", "ThrottlingException", "RequestLimitExceeded"):
raise
last_err = e
# Full Jitter — random(0, base * 2^attempt)
sleep_for = random.uniform(0, min(cap, base * (2 ** attempt)))
await asyncio.sleep(sleep_for)
raise last_err
게다가, SDK 내부 retry와 외부 wrapper가 동시에 동작하면 중복으로 기다리게 된다. 외부에서 jitter를 직접 관리할 거면 SDK retry는 standard 모드에 max_attempts=2 정도로 낮추는 게 맞다. 이걸 모르고 두 번 기다리는 코드를 본 적이 있다.
Java SDK v2 — 같은 개념, 다른 API
(software.amazon.awssdk 2.25.x 기준)
그런데, Java SDK v2는 RetryPolicy와 BackoffStrategy가 분리되어 있다. 기본은 Full Jitter라서 보통은 그냥 쓰면 된다.
import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
import software.amazon.awssdk.core.retry.RetryPolicy;
import software.amazon.awssdk.core.retry.RetryMode;
import software.amazon.awssdk.core.retry.backoff.FullJitterBackoffStrategy;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.Duration;
S3Client s3 = S3Client.builder()
.overrideConfiguration(ClientOverrideConfiguration.builder()
.retryPolicy(RetryPolicy.builder()
.numRetries(8)
.backoffStrategy(FullJitterBackoffStrategy.builder()
.baseDelay(Duration.ofMillis(500))
.maxBackoffTime(Duration.ofSeconds(20))
.build())
.build())
.build())
.build();
adaptive 모드는 RetryMode.ADAPTIVE로 지정한다. 다만 v2의 adaptive는 boto3와 구현이 다르고 더 보수적이라는 평가가 많다. (참고: aws-sdk-java-v2 GitHub #3503 관련 논의)
.retryPolicy(RetryPolicy.forRetryMode(RetryMode.ADAPTIVE))
메모 — 운영하며 알게 된 것
DynamoDB와 S3는 throttle 응답 코드가 다르다. DynamoDB는 ProvisionedThroughputExceededException, S3는 SlowDown이나 HTTP 503. 메시지가 달라서 wrapper에서 둘 다 잡으려면 코드 매칭 리스트를 명시해야 한다. 이걸 안 하면 throttle인데 그냥 raise 돼서 작업이 죽는다.
CloudWatch 메트릭으로 retry 횟수를 추적하지 않으면 jitter가 잘 동작하는지 모른다. boto3는 client.meta.events에 hook을 걸어서 retry 발생을 카운트할 수 있다.
# retry 발생 카운트 — needs-retry 이벤트 사용
retry_count = 0
def _on_retry(response, **kwargs):
global retry_count
if response is None:
return
retry_count += 1
s3.meta.events.register("needs-retry.*.*", _on_retry)
마지막으로, S3는 prefix 단위로 partition을 나눈다. 같은 prefix에 초당 3,500 PUT 또는 5,500 GET을 넘으면 retry로도 못 막는다. 이건 prefix 설계 문제이지 SDK 설정으로 풀 수 없다. (출처: S3 Performance Guidelines 공식 문서)
당장 손볼 게 있다면 세 가지다. retry mode를 adaptive로 바꾸기, max_attempts를 6~8로 올리기, 그리고 prefix 분산을 점검하기. 개인적으로는 standard에 max_attempts=6만 잘 줘도 80%는 해결된다고 본다.
관련 글
- AWS RDS Proxy 설정 Lambda 연결 풀링 — 87 vs 1000 문제 해결 – Lambda 동시 실행 1000개와 PostgreSQL max_connections 87이 만나는 지점에서 ‘too many connect…
- ECS Fargate GitHub Actions runner 설정 — 비싸다는 통념을 다시 보다 – Fargate는 비싸다는 말이 정설처럼 돈다. ephemeral runner 구조에서는 그 통념이 깨지는 지점이 있다. ECS Fargat…
- Kubernetes VPA 설정으로 OOMKilled 해결: EKS 실무 가이드와 47→3건 감소 사례 – FastAPI 워커에서 주간 47건씩 발생하던 OOMKilled를 Kubernetes VPA 설정으로 3건까지 줄였다. HPA·수동 튜닝과…