이 글에서 사용하는 주요 용어 정의
| 용어 | 정의 |
|---|---|
| 타임아웃 | 정한 시간 안에 응답을 받지 못해 호출 측이 대기를 끝낸 상태입니다. |
| 재시도 | 완료 여부가 불분명하거나 실패한 요청을 다시 보내는 동작입니다. |
| 멱등성 | 같은 요청을 반복해도 의도된 서버 효과가 한 번 수행했을 때와 같은 성질입니다. |
| 멱등성 키 | 서버가 동일 작업의 반복 요청을 구분하도록 보내는 식별자입니다. |
| 백오프 | 재시도 사이에 대기 시간을 두는 방식입니다. |
| Jitter | 요청이 동시에 몰리지 않도록 대기 시간에 더하는 무작위성입니다. |
API 재시도를 구현할 때는 중복 실행이 안전한지 확인한 뒤, 재시도할 오류와 대기 시간·시도 횟수를 정해야 합니다. 타임아웃은 응답을 받지 못했다는 뜻이며, 서버 작업의 실패를 확정하는 신호는 아닙니다.
기준일은 2026년 9월 8일입니다. 작업 생성 API를 예로 들어 멱등성과 백오프의 역할을 설명하고, 외부 서비스 없이 실행하는 Python 모의 코드를 제공합니다. 예제는 단일 프로세스의 동작을 보여주며 운영 분산 시스템 구현은 아닙니다.
응답을 못 받았어도 작업은 생성됐을 수 있습니다
클라이언트가 기다리기를 끝낸 시점과 서버가 작업을 저장한 시점은 다를 수 있습니다. 따라서 같은 생성 요청을 다시 보내기 전에, 이미 처리된 요청을 서버가 어떻게 구분하는지 확인해야 합니다.
설명을 위해 다음 순서를 가정하겠습니다. 클라이언트는 CSV 요약 작업을 요청하고, 서버는 작업 1개를 저장합니다. 응답이 전달되지 않아 클라이언트에는 타임아웃이 발생합니다. 이때 새 요청으로 다시 생성하면 서버에는 같은 목적의 작업이 2개 남을 수 있습니다.
| 시점 | 클라이언트가 아는 상태 | 서버 상태 |
|---|---|---|
| 최초 요청 전송 | 응답 대기 | 작업 생성 시작 |
| 서버 저장 후 응답 유실 | 성공 여부 불명 | 작업 1개 생성 완료 |
| 새 작업으로 재요청 | 다시 응답 대기 | 중복 작업 생성 가능 |
이 타임라인은 이 글의 모의 상황입니다. HTTP 표준은 비멱등 요청을 자동으로 재시도할 때 요청의 의미가 실제로 멱등적이거나 원래 요청이 적용되지 않았음을 알 수 있는 등의 안전성 판단이 필요하다고 설명합니다. RFC 9110 §9.2.2
멱등성은 반복 응답의 본문과 상태 코드가 언제나 같다는 뜻도 아닙니다. 판단 기준은 의도된 서버 효과입니다. 작업 생성 API에 대해서는 기존 작업을 돌려주는지, 상태 조회로 결과를 확인하는지 등 해당 API의 계약을 읽어야 합니다.
같은 작업의 재시도에는 같은 키를 사용합니다
멱등성 키를 지원하는 API라면 최초 호출 전에 키를 만들고, 같은 작업을 재시도하는 동안 유지해야 합니다. 재시도 루프 안에서 키를 새로 만들면 서버는 요청을 다른 작업으로 취급할 수 있습니다.
키를 UUID로 생성하는 것만으로 중복 방지가 완성되지는 않습니다. 서버가 키를 받아 이전 처리와 연결하고, 같은 키에 다른 요청 내용이 들어왔을 때 어떻게 처리할지 정해야 합니다. 예를 들어 Amazon ECS는 클라이언트 토큰을 활용하며 같은 토큰에 다른 파라미터가 들어오는 경우 충돌 규칙을 둡니다. Amazon ECS: Ensuring idempotency
자체 API를 설계한다면 다음 항목을 함께 정하는 방식을 제안합니다. 키의 유효 범위를 사용자와 작업 단위 중 어디까지 둘지, 어떤 요청 필드를 비교할지, 처리 중인 요청에 무엇을 반환할지, 결과를 얼마 동안 보관할지입니다.
보관기간이 끝난 뒤 같은 키가 다시 오면 새 작업으로 처리할 수도 있고 거절할 수도 있습니다. 이 부분은 서비스의 정책이므로 다른 제품의 보관 규칙을 그대로 적용하면 안 됩니다. 클라이언트는 자신이 사용하는 API의 규칙에 맞춰 재시도 가능 기간도 정해야 합니다.
백오프는 대기 정책이고, 멱등성은 중복 효과에 관한 조건입니다
대기 시간을 늘려도 이미 생성된 작업을 중복으로 만들 수 있습니다. 반대로 중복 방지가 되더라도 재시도가 한꺼번에 몰리면 부하가 생길 수 있으므로 두 문제를 함께 다뤄야 합니다.
AWS는 잦은 재시도가 서비스 저하를 유발할 수 있으며 백오프 패턴에서 멱등성을 고려해야 한다고 안내합니다. Jitter는 대기 시간에 무작위성을 넣어 재시도의 동시 집중을 분산하는 접근입니다. AWS: Retry with backoff pattern, AWS Builders’ Library: Timeouts, retries, and backoff with jitter
아래 예제는 지수적으로 늘어나는 대기 상한에서 무작위 시간을 선택합니다. 숫자는 실행 흐름을 설명하기 위한 자체 설정이며 서비스 운영 권장값이 아닙니다. 최대 시도 횟수 3회는 최초 호출 1회와 추가 재시도 2회를 합친 값입니다.
오류 분류 역시 API별로 정해야 합니다. 이 예제는 의도적으로 발생시킨 TimeoutError만 재시도하고, 같은 키에 다른 내용이 들어온 충돌은 바로 중단합니다. 이를 모든 타임아웃과 모든 서버 오류에 적용하는 일반 규칙으로 사용해서는 안 됩니다.
Python으로 응답 유실과 중복 생성을 비교하기
다음 코드는 Python 표준 라이브러리만 사용합니다. retry_demo.py로 저장하면 외부 API·네트워크·추가 패키지 없이 실행할 수 있습니다. 이 글 작성 과정에서 아래 세 시나리오를 실제로 실행하고 assertion 통과를 확인했습니다.
서버 역할의 MockServer는 새 작업을 먼저 저장한 뒤 첫 응답이 유실된 것처럼 예외를 발생시킵니다. 같은 키와 같은 내용으로 다시 호출하면 저장된 결과를 돌려줍니다.
import json
import random
from uuid import uuid4
class MockServer:
def __init__(self):
self.saved = {}
self.jobs_created = 0
def create_job(self, key, payload):
fingerprint = json.dumps(payload, sort_keys=True)
if key in self.saved:
old_fingerprint, result = self.saved[key]
if fingerprint != old_fingerprint:
raise ValueError("same key, different payload")
return result
self.jobs_created += 1
result = {"job_id": self.jobs_created}
self.saved[key] = (fingerprint, result)
# 서버 처리는 완료됐지만 첫 응답은 유실됐다고 가정합니다.
raise TimeoutError("response lost after creation")
def retry(call, max_attempts=3, sleep=lambda seconds: None):
if max_attempts < 1:
raise ValueError("max_attempts must be at least 1")
for attempt in range(1, max_attempts + 1):
try:
return call()
except TimeoutError:
if attempt == max_attempts:
raise
ceiling = min(2.0, 0.1 * 2 ** (attempt - 1))
sleep(random.uniform(0, ceiling))
server = MockServer()
key = str(uuid4()) # 같은 작업의 재시도에서는 계속 재사용합니다.
payload = {"kind": "csv-summary"}
waits = []
result = retry(
lambda: server.create_job(key, payload),
sleep=waits.append,
)
assert result == {"job_id": 1}
assert server.jobs_created == 1
assert len(waits) == 1 and 0 <= waits[0] <= 0.1
print("same key: created=1, waits=1")
try:
retry(lambda: server.create_job(key, {"kind": "other"}))
except ValueError:
print("payload conflict: rejected")
else:
raise AssertionError("conflict was not rejected")
# 재시도 때 키를 새로 만들면 같은 작업이 다시 생성됩니다.
duplicate_server = MockServer()
try:
retry(lambda: duplicate_server.create_job(str(uuid4()), payload))
except TimeoutError:
pass
assert duplicate_server.jobs_created == 3
print("new key each attempt: created=3, stopped at 3 attempts")
실행 명령은 다음과 같습니다.
python retry_demo.py
실제 실행에서 다음 출력과 정상 종료를 확인했습니다.
same key: created=1, waits=1
payload conflict: rejected
new key each attempt: created=3, stopped at 3 attempts
같은 키를 유지하면 저장한 작업을 다시 찾습니다
첫 시나리오에서는 최초 호출이 작업을 저장한 뒤 타임아웃을 발생시킵니다. 두 번째 호출은 같은 키를 전달하므로 기존 결과를 반환합니다. 호출은 반복됐지만 jobs_created는 1로 남습니다.
대기 함수로 waits.append를 전달했기 때문에 실제로 잠들지는 않습니다. 계산된 대기 시간을 목록에 기록해 호출 횟수와 범위를 확인했습니다. 기본 sleep도 대기를 생략하는 데모용 함수이며, 실제 지연을 넣으려면 time.sleep처럼 대기하는 함수를 전달해야 합니다.
같은 키에 다른 내용을 보내면 중단합니다
두 번째 시나리오는 기존 키로 kind가 다른 요청을 보냅니다. 이 예제의 서버는 ValueError를 발생시키고, 재시도 함수는 해당 예외를 잡지 않으므로 호출이 중단됩니다. 충돌에 같은 재시도 정책을 적용해 반복 호출하지 않는 구조입니다.
json.dumps(..., sort_keys=True)는 이 작은 JSON 요청을 비교하는 자체 방식입니다. 실제 API의 의미상 동등성, 파일 업로드나 다른 형식의 요청 비교 규칙을 해결하는 범용 지문 구현은 아닙니다.
매번 새 키를 만들면 최대 횟수까지 중복 생성됩니다
세 번째 시나리오는 재시도할 때마다 UUID를 생성합니다. 모의 서버는 모두 새 요청으로 받아 3개를 생성하고 매번 응답 유실을 발생시킵니다. 최초 호출을 포함한 3회째 타임아웃 뒤에는 예외를 전달하고 종료합니다.
이 결과는 “최대 횟수가 있으니 중복도 안전하다”는 판단이 잘못됐음을 보여주는 데모입니다. 횟수 상한은 반복을 끝내는 조건이고, 같은 작업을 식별하는 키는 별도로 유지해야 합니다.
운영 환경에는 영속 저장과 원자성 설계가 필요합니다
위 서버의 저장소는 메모리 딕셔너리라 프로세스를 재시작하면 사라집니다. 동시 요청과 여러 서버 노드도 다루지 않으므로, 이 코드를 그대로 배포해 중복 작업 방지를 보장할 수는 없습니다.
실제 구현에서는 키 기록과 작업 생성 사이의 경합을 해결해야 합니다. 키를 확인한 뒤 작업을 만들고 결과를 저장하는 여러 단계를 단순히 이어 붙이면, 동시 요청이 모두 확인 단계를 통과하거나 중간 실패로 기록과 실제 효과가 어긋날 수 있습니다. 요청 기록과 실제 부작용을 원자적으로 다룰 범위를 설계해야 합니다.
또한 최대 시도 횟수만으로 전체 대기 시간을 제한하지는 못합니다. 각 호출의 타임아웃과 대기 시간을 함께 고려해 호출자가 기다릴 총 시간의 상한을 정하는 방식을 권합니다. 이 예제는 네트워크 호출과 전체 시간 제한을 구현하지 않았습니다.
현재 API 호출 코드에서 키 생성 위치를 먼저 확인해 보세요. 같은 논리 작업의 재시도에서 키가 유지되는지, 서버가 그 키의 중복 요청을 실제로 처리하는지 확인한 다음 대기 정책과 종료 조건을 맞추면 됩니다.
참고 자료
'n년차 개발자' 카테고리의 다른 글
| 개인 블로그, 정적 사이트 무료 호스팅 방법 정리 (0) | 2026.07.19 |
|---|---|
| AI 시대 개발자 공부법, IDE 없이 개발하는 사람들을 보며 든 생각 (0) | 2026.07.17 |
| GitLab Runner 오프라인, This job is stuck because you don't have any active runners online (0) | 2026.07.16 |
| Nexus 디스크 부족으로 인한 Maven Deploy 실패 (0) | 2026.07.14 |
| 크롤링 트래픽을 어디까지 허용하고 어떻게 통제해야 할까? (0) | 2025.12.15 |