Skip to content

필사 모드: 멱등성과 재시도: 신뢰할 수 있는 API

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며 — 응답을 못 받은 결제

한 가지 장면에서 시작합시다. 사용자가 결제 버튼을 누릅니다. 여러분의 서버는 요청을 받아 카드사에 청구하고, 주문을 생성하고, 응답을 돌려보냅니다. 그런데 그 응답이 사용자에게 도착하기 직전에 네트워크가 끊깁니다. 사용자의 화면에는 "요청 실패"가 뜹니다. 사용자는 당연히 다시 버튼을 누릅니다.

이제 위험한 질문이 생깁니다. 결제가 두 번 될까요?

이 질문이 이 글의 전부입니다. 분산 시스템에서 네트워크는 신뢰할 수 없고, 신뢰할 수 없는 네트워크 위에서 우리는 반드시 재시도를 하며, 재시도는 필연적으로 "이미 처리됐는데 응답만 잃어버린" 상황을 만듭니다. 이 상황에서도 시스템이 올바르게 동작하게 하는 핵심 개념이 **멱등성(idempotency)**입니다. 이 글은 멱등성이 무엇인지부터 시작해, 안전한 메서드와 그렇지 않은 메서드, POST를 안전하게 만드는 멱등성 키, "정확히 한 번"이라는 흔한 오해, 그리고 재시도를 제대로 하는 법까지 짚습니다.

멱등성이란 무엇인가

멱등성은 수학에서 온 말입니다. 어떤 연산을 여러 번 적용해도 한 번 적용한 것과 결과가 같다면, 그 연산은 멱등하다고 합니다. 절댓값 함수를 생각하면 쉽습니다. 어떤 수에 절댓값을 한 번 취하든 열 번 취하든 결과는 같습니다.

API의 맥락에서 멱등성은 이렇게 번역됩니다. 같은 요청을 한 번 보내든 여러 번 보내든, 서버의 최종 상태가 같다면 그 요청은 멱등합니다. 여기서 중요한 것은 "응답이 매번 똑같아야 한다"가 아니라 "부수 효과(side effect)가 한 번만 일어나야 한다"는 점입니다.

예를 들어 봅시다.

  • "사용자 42의 이메일을 a@b.com으로 설정하라"는 멱등합니다. 몇 번을 보내도 결과는 이메일이 a@b.com인 상태 하나뿐입니다.
  • "사용자 42의 잔액을 100원 증가시켜라"는 멱등하지 않습니다. 두 번 보내면 200원이 증가합니다.

이 구분이 결제 예시의 핵심입니다. "결제하라"는 본질적으로 증가 연산에 가깝기 때문에, 아무 장치 없이는 멱등하지 않습니다. 재시도가 그대로 이중 청구로 이어집니다. 우리의 목표는 이 비멱등 연산을 멱등하게 만드는 것입니다.

안전한 메서드와 멱등한 메서드

HTTP는 이 개념을 메서드 수준에서 이미 규정하고 있습니다. 두 가지 성질을 구분해야 합니다.

안전(safe)한 메서드는 서버의 상태를 바꾸지 않는, 즉 읽기 전용인 메서드입니다. GET, HEAD, OPTIONS가 여기 속합니다. 안전한 메서드는 아무리 많이 호출해도 데이터를 바꾸지 않으므로 마음껏 재시도해도 됩니다.

멱등(idempotent)한 메서드는 여러 번 호출해도 서버의 상태가 한 번 호출한 것과 같은 메서드입니다. 안전한 메서드는 당연히 멱등하지만, 멱등하다고 안전한 것은 아닙니다.

HTTP 명세가 정한 각 메서드의 성질을 표로 정리하면 이렇습니다.

메서드안전멱등전형적 의미
GET리소스 조회
HEAD헤더만 조회
OPTIONS통신 옵션 조회
PUT아니오리소스를 통째로 대체(설정)
DELETE아니오리소스 삭제
POST아니오아니오새 리소스 생성 등
PATCH아니오경우에 따라부분 수정

여기서 PUT과 DELETE가 왜 멱등한지 짚어볼 만합니다. PUT은 "이 리소스를 이 값으로 만들어라"라는 설정 연산입니다. 같은 PUT을 여러 번 보내도 리소스는 그 값 하나로 유지됩니다. DELETE도 마찬가지입니다. 이미 지워진 것을 또 지우라고 해도 최종 상태는 "없음"으로 같습니다(응답 코드는 404로 달라질 수 있지만, 상태는 같습니다. 멱등성은 상태에 관한 것이지 응답 코드에 관한 것이 아닙니다).

문제는 언제나 POST입니다. POST는 "새것을 만들어라"는 의미로 자주 쓰이고, 이것은 본질적으로 멱등하지 않습니다. 재시도할 때마다 새 주문, 새 결제, 새 댓글이 생깁니다. HTTP 상태 코드가 각각 무엇을 뜻하는지 헷갈린다면, 이 사이트의 HTTP 상태 코드 도구에서 코드별 의미를 정리해 볼 수 있습니다.

POST를 위한 멱등성 키

POST를 멱등하게 만드는 표준적인 방법이 **멱등성 키(idempotency key)**입니다. 아이디어는 단순합니다. 클라이언트가 요청마다 고유한 키(대개 UUID)를 하나 만들어 헤더에 실어 보냅니다. 서버는 이 키를 기억해 두었다가, 같은 키로 요청이 또 오면 새로 처리하지 않고 저장해 둔 첫 응답을 그대로 돌려줍니다.

  1차 요청
  클라이언트 --(Idempotency-Key: abc-123, 결제 요청)--> 서버
                                                        |
                                                    처음 보는 키
                                                    -> 실제로 결제 처리
                                                    -> 결과를 abc-123에 저장
  클라이언트 <----------(200 OK, 주문 #500)------------ 서버

  (응답 유실, 클라이언트가 재시도)

  2차 요청 (같은 키)
  클라이언트 --(Idempotency-Key: abc-123, 결제 요청)--> 서버
                                                        |
                                                    이미 본 키
                                                    -> 결제 다시 안 함
                                                    -> 저장된 결과 반환
  클라이언트 <----------(200 OK, 주문 #500)------------ 서버

핵심은 두 번째 요청에서 실제 결제가 다시 일어나지 않는다는 것입니다. 사용자는 두 번 눌렀지만 청구는 한 번만 됩니다. Stripe, PayPal 같은 결제 API가 정확히 이 방식을 씁니다.

구현할 때 신경 써야 할 세부가 몇 가지 있습니다.

  • 키의 저장과 만료. 키-응답 매핑을 어딘가(예: Redis나 DB)에 저장해야 합니다. 영원히 보관할 수는 없으니 보통 24시간 같은 만료를 둡니다.
  • 동시성 처리. 같은 키의 요청 두 개가 거의 동시에 도착하면 문제가 생깁니다. 첫 요청이 아직 처리 중인데 두 번째가 들어오면, 둘 다 "처음 보는 키"로 착각해 이중 처리할 수 있습니다. 그래서 키를 받는 순간 잠금(lock)을 걸거나, 데이터베이스의 유니크 제약으로 "이 키는 이미 진행 중"임을 원자적으로 표시해야 합니다.
  • 키와 요청 내용의 일치 검증. 같은 키인데 요청 본문이 다르면 이는 클라이언트의 실수이거나 공격입니다. 서버는 이를 감지해 거부하는 편이 안전합니다.
  • 응답의 저장. 성공 응답뿐 아니라 실패의 성격도 신중히 다뤄야 합니다. 일시적 실패(예: DB 타임아웃)는 재시도가 진짜로 다시 시도되어야 하지만, 확정적 실패(예: 잔액 부족)는 캐시해 같은 답을 주는 편이 낫습니다.

"정확히 한 번"이라는 신화

여기서 분산 시스템의 가장 흔한 오해를 정면으로 다뤄야 합니다. 많은 사람이 "정확히 한 번(exactly-once) 전달"을 목표로 삼거나, 어떤 시스템이 그것을 제공한다고 믿습니다. 결론부터 말하면, 네트워크를 가로지르는 전달에서 순수한 의미의 "정확히 한 번"은 불가능에 가깝습니다.

왜 그럴까요? 메시지를 보내는 쪽은 두 가지 전략밖에 없습니다.

  • 최대 한 번(at-most-once): 보내고 잊는다. 확인 응답을 기다리지 않는다. 유실될 수 있지만 중복은 없다.
  • 적어도 한 번(at-least-once): 확인 응답을 받을 때까지 재시도한다. 유실은 없지만 중복이 생길 수 있다.

문제의 근원은 아까 그 결제 장면과 같습니다. 보낸 쪽이 "확인 응답을 못 받았을 때", 그것이 "메시지가 도착하지 못한 것"인지 "메시지는 도착했는데 확인 응답만 유실된 것"인지 구별할 방법이 없습니다. 그래서 유실을 막으려면 재시도해야 하고(적어도 한 번), 재시도는 중복을 낳습니다.

그렇다면 우리가 흔히 "정확히 한 번처럼 동작한다"고 말하는 시스템들은 무엇을 하는 걸까요? 답은 이것입니다.

정확히 한 번 = 적어도 한 번 전달 + 수신 측의 중복 제거(dedup)

즉 전달 자체는 여전히 "적어도 한 번"입니다. 대신 받는 쪽이 이미 처리한 메시지를 식별해 두 번째부터는 무시합니다. 이 중복 제거를 가능하게 하는 것이 바로 앞에서 본 멱등성입니다. 메시지에 고유 ID를 붙이고, 처리한 ID를 기록해 두면, 같은 메시지가 다시 와도 결과가 바뀌지 않습니다.

핵심 교훈은 이것입니다. "정확히 한 번"을 인프라가 마법처럼 보장해 주기를 기다리지 말고, 여러분의 소비자를 멱등하게 만드세요. 그러면 전달이 몇 번이 되든 결과는 한 번 처리한 것과 같아집니다. 이 원리는 API뿐 아니라 메시지 큐 전반에 적용됩니다. 큐 시스템들이 어떻게 이 "적어도 한 번"과 중복을 다루는지 눈으로 보고 싶다면, 이 사이트의 메시지 큐 놀이터에서 각 방식의 전달 보장을 시각화해 볼 수 있습니다.

재시도를 제대로 하는 법 — 지수 백오프

이제 재시도 자체를 잘하는 법으로 넘어갑니다. "실패하면 다시 보낸다"는 단순해 보이지만, 순진하게 구현하면 오히려 시스템을 무너뜨립니다.

가장 나쁜 방법은 즉시, 고정 간격으로, 무한히 재시도하는 것입니다. 서버가 잠깐 과부하로 느려졌을 때, 모든 클라이언트가 실패를 감지하고 곧바로 다시, 또다시 때리면, 겨우 버티던 서버는 완전히 주저앉습니다. 재시도가 장애를 치료하기는커녕 악화시킵니다.

첫 번째 개선은 **지수 백오프(exponential backoff)**입니다. 재시도 간격을 매번 두 배씩 늘립니다. 1초, 2초, 4초, 8초... 이렇게 하면 실패가 계속될수록 재시도 압력이 지수적으로 줄어들어, 힘겨워하는 서버에 숨 쉴 틈을 줍니다.

  시도 1 실패 --> 1초 대기
  시도 2 실패 --> 2초 대기
  시도 3 실패 --> 4초 대기
  시도 4 실패 --> 8초 대기
  ...상한(예: 30초)에서 멈춤, 최대 재시도 횟수 도달 시 포기

여기에 반드시 두 가지를 덧붙여야 합니다.

  • 상한(cap): 간격이 무한정 커지지 않도록 최대 대기 시간을 둡니다(예: 30초).
  • 최대 재시도 횟수와 포기: 영원히 재시도하면 안 됩니다. 일정 횟수 후에는 포기하고, 실패를 데드레터 큐(dead-letter queue)로 보내거나 사용자에게 알립니다.

그리고 아무 실패나 재시도해서는 안 됩니다. 재시도가 의미 있는 것은 일시적(transient) 오류뿐입니다. 네트워크 타임아웃, 503 Service Unavailable, 429 Too Many Requests 같은 것은 다시 시도하면 성공할 수 있습니다. 반면 400 Bad Request, 401 Unauthorized, 404 Not Found 같은 확정적 오류는 몇 번을 다시 보내도 결과가 같으므로 재시도는 낭비입니다.

천둥 소리 무리 — 지터가 필요한 이유

지수 백오프만으로는 부족합니다. 미묘하지만 치명적인 문제가 하나 남아 있습니다. 바로 **천둥 소리 무리(thundering herd)**입니다.

상황을 그려봅시다. 어떤 서버가 잠시 다운됩니다. 그 순간 1만 개의 클라이언트가 동시에 실패를 감지합니다. 모두가 똑같은 지수 백오프 규칙을 따릅니다. 1초 뒤 재시도, 실패하면 2초 뒤, 그다음 4초 뒤. 문제는 모두가 정확히 같은 순간에 재시도한다는 것입니다. 서버가 막 회복하려는 찰나에 1만 개의 요청이 동시에 쏟아지고, 서버는 다시 쓰러집니다. 그리고 이 파도는 2초, 4초, 8초 뒤에도 똑같이 반복됩니다. 동기화된 재시도가 서버를 주기적으로 두들겨 패는 것입니다.

해결책은 지터(jitter), 즉 무작위성을 더하는 것입니다. 각 클라이언트가 계산된 대기 시간에 약간의 무작위 값을 섞으면, 재시도 시점이 시간축에 고르게 퍼집니다. 1만 개의 요청이 한 점에 몰리는 대신 넓게 흩어져, 서버가 회복할 여유를 갖습니다.

  지터 없음: 모두 같은 순간에 재시도
     |||||||||                    |||||||||
  ---+---------+---------+------  (서버가 파도에 맞아 계속 쓰러짐)

  지터 있음: 재시도가 흩어짐
     | | ||  |  | ||   |  |  |
  ---+---------+---------+------  (부하가 고르게 퍼짐)

지터를 넣는 방식에는 몇 가지가 있습니다. 가장 널리 권장되는 것은 **완전 지터(full jitter)**로, "0부터 계산된 백오프 값 사이의 무작위 시간"을 기다리는 방식입니다. 의사코드로 보면 이렇습니다.

import random

def backoff_with_jitter(attempt, base=1.0, cap=30.0):
    # 지수적으로 커지되 상한을 넘지 않게
    exp = min(cap, base * (2 ** attempt))
    # 0 ~ exp 사이 무작위 (완전 지터)
    return random.uniform(0, exp)

# 예: 실패한 시도 번호마다 대기 시간이 달라진다
for attempt in range(5):
    wait = backoff_with_jitter(attempt)
    print(f"attempt {attempt}: wait {wait:.2f}s")

이 단순한 무작위화가 대규모 시스템의 안정성에 주는 효과는 놀랄 만큼 큽니다. AWS 아키텍처 블로그의 유명한 글이 이 "지수 백오프 + 지터" 조합을 표준 처방으로 제시한 이후, 사실상 모든 신뢰성 있는 클라이언트의 기본 패턴이 되었습니다.

서버 쪽에서 할 일 — 재시도를 견디는 설계

지금까지는 주로 클라이언트 관점이었습니다. 신뢰할 수 있는 API를 만들려면 서버도 재시도를 견디도록 설계되어야 합니다.

모든 쓰기 엔드포인트를 멱등하게. 앞서 본 멱등성 키를 결제뿐 아니라 상태를 바꾸는 중요한 POST 전반에 적용하는 것을 고려하세요. 그러면 클라이언트가 안심하고 재시도할 수 있습니다.

Retry-After로 재시도 시점을 알려주기. 서버가 과부하이거나 요청을 제한(429)할 때, Retry-After 헤더로 "언제 다시 오라"를 알려줄 수 있습니다. 눈치 빠른 클라이언트는 이 신호를 존중해 불필요한 조기 재시도를 삼갑니다.

부하 제한(rate limiting)과 부하 차단(load shedding). 재시도 폭풍이 몰려와도 서버가 스스로를 지키려면, 감당할 수 있는 만큼만 받고 나머지는 빠르게 429로 거절하는 편이 낫습니다. 모든 요청을 붙잡고 느리게 처리하다 다 같이 죽는 것보다, 일부를 빨리 거절하고 나머지를 살리는 것이 전체 가용성에 이롭습니다.

서킷 브레이커(circuit breaker). 의존하는 하위 서비스가 계속 실패하면, 매 요청마다 그 서비스를 때려 상황을 악화시키는 대신 잠시 "차단" 상태로 전환해 빠르게 실패를 반환합니다. 일정 시간 뒤 조심스레 다시 시도해 회복 여부를 살핍니다. 이는 장애가 시스템 전체로 번지는 것을 막는 장치입니다.

흔한 함정 정리

마지막으로 실무에서 자주 걸리는 지점을 압축합니다.

  • 비멱등 연산을 아무 보호 없이 재시도 — 이중 결제, 중복 주문의 전형적 원인입니다. 멱등성 키로 감싸세요.
  • 모든 오류를 재시도 — 400, 401, 404 같은 확정적 오류는 재시도해도 소용없고 자원만 낭비합니다. 재시도할 오류를 구별하세요.
  • 지터 없는 고정 백오프 — 천둥 소리 무리를 부릅니다. 무작위성을 반드시 넣으세요.
  • 무한 재시도 — 상한과 최대 횟수 없이 재시도하면 실패한 요청이 시스템에 영원히 쌓입니다. 포기 지점과 데드레터 큐를 두세요.
  • 멱등성 키의 동시성 무시 — 거의 동시에 도착한 같은 키를 원자적으로 다루지 않으면 이중 처리가 뚫립니다.
  • "정확히 한 번"에 대한 맹신 — 인프라가 그것을 보장해 주리라 기대하지 말고, 소비자를 멱등하게 만들어 스스로 보장하세요.

마치며

신뢰할 수 있는 API의 비결은 "실패하지 않는 것"이 아닙니다. 네트워크는 언젠가 반드시 실패하고, 그러면 우리는 반드시 재시도합니다. 진짜 비결은 재시도가 일어나도 올바른 결과가 나오게 만드는 것입니다. 그 중심에 멱등성이 있습니다. 쓰기 연산을 멱등하게 만들면, "이미 처리됐는데 응답만 잃어버린" 그 골치 아픈 상황이 더는 재앙이 아니라 그냥 한 번 더 온 무해한 요청이 됩니다.

여기에 지수 백오프와 지터로 재시도의 리듬을 다스리고, 서버 쪽에서 부하 제한과 서킷 브레이커로 폭풍을 견디면, 여러분의 시스템은 불안정한 네트워크 위에서도 흔들리지 않고 서 있게 됩니다. "정확히 한 번"이라는 신화를 좇는 대신 "적어도 한 번 + 멱등한 처리"라는 견고한 현실을 택하는 것, 그것이 신뢰할 수 있는 API의 진짜 토대입니다.

참고 자료

현재 단락 (1/104)

한 가지 장면에서 시작합시다. 사용자가 결제 버튼을 누릅니다. 여러분의 서버는 요청을 받아 카드사에 청구하고, 주문을 생성하고, 응답을 돌려보냅니다. 그런데 그 응답이 사용자에게 ...

작성 글자: 0원문 글자: 6,944작성 단락: 0/104