티스토리 뷰
✅ 에러 종류별 대응 전략
재시도의 핵심은 일시적(transient)과 영구적(permanent) 실패를 구분하는 것입니다.
⭕️ 재시도 대상인 것
- 타임아웃 / 연결 끊김: 재시도 대상입니다. 백오프를 걸어 재시도합니다.
- 서버 에러 5xx(500, 502, 503, 504): 재시도 대상입니다. 특히 503의 경우 서버가 Retry-After 헤더를 주면 그 값을 우선으로 합니다.
- 429(Rate Limit) -> 재시도 대상이지만 반드시 Retry-After을 존중합니다. 무지성으로 재시도하는 경우 밴 당할 위험이 있습니다.
❌ 재시도 대상이 아닌 것
- 4xx 클라이언트 에러(400, 401, 403, 404): 요청 자체가 잘못된 것이기 때문에 재시도할 필요가 없습니다.
- 네트워크 없음(.notConnectedToInternet): 재시도를 하지 말고 NWPathMonitor로 연결 복구를 감지해 재개하거나 요청을 큐에 넣어둡니다.
멱등성(idempotency)
멱등성이란 같은 요청을 여러 번 보내도 결과가 한번 보낸 것과 같은 성질을 의미합니다.
이게 재시도와 엮이는 이유는 재시도는 본질적으로 "같은 요청을 또 보내는" 행위이기 때문입니다.
물론 여기서 주의해야할 점이 있습니다.
클라이언트가 요청을 보내고 응답을 받지 못하는 경우를 생각해봅시다. 이때 두 가지 가능성이 있습니다.
- 요청이 서버에 도달하기 전에 끊긴 경우 -> 서버는 아무런 작업도 하지 않습니다.
- 서버가 처리를 다 끝냈는데 응답이 돌아오는 길에 끊긴 경우 -> 서버는 이미 작업을 처리 완료한 상태입니다.
문제는 클라이언트 입장에선느 이 둘을 구분할 수 없다는 것 입니다. 둘 다 그저 "타임 아웃" 으로 보일 뿐입니다.
GET/PUT/DELETE는 재시도에서 안전하지만 POST는 중복 위험이 있어 서버가 Idempotency Key를 받아주지 않으면 함부로 재시도 해서는 안됩니다.
왜 POST만 중복 위험이 있는지 알아보면
POST: orders를 재시도 했다고 생각해보겠습니다. 만약 서버의 처리는 이미 끝난 상황인데 다시 시도한다고 하면 주분은 2건 중복 생성되며 결제도 두번 될 가능성이 있습니다.
DELETE: 첫 번째로 지워지고 재 시도하는 경우 이미 최종 상태는 "삭제됨"으로 동일합니다. 따라서 안전합니다.
GET: 조회라서 중복되어도 딱히 상관이 없습니다.
PUT: 설정하는 요청이므로 한번 하든 열번 하든 설정은 동일합니다
그럼 Idempotency Key라는건 무엇일까요?
클라이언트는 요청마다 고유 키를 헤더에 담아 보냅니다.
var request = URLRequest(url: ordersURL)
request.httpMethod = "POST"
request.setValue(UUID().uuidString, forHTTPHeaderField: "Idempotency-Key")
우리가 URLRequest를 할때 사용했던 이 코드가 바로 Idempotency Key 를 담는 코드입니다.
핵심은 재시도할 때 같은 키를 재사용하는것 입니다. 그러면 서버는 "이 키는 아까 처리했는데" 하고 새로 만들지 않고 이전 결과를 그대로 돌려줍니다.
Stripe 같은 결제 API가 정확히 이 방식을 사용합니다.
정리하면 POST는 기본적으로 non-idempotent라 무지성 재시도는 위험할 수 있지만 서버가 Idempotency-Key를 지원하는 경우 재시도 하는 요청마다 동일한 키를 실어보내 안전하게 재시도할 수 있습니다.
✅ 백오프
백오프(Backoff)는 "실패하면 다시 시도하기 전에 잠깐 기다리는 것", 그리고 "실패가 반복될수록 그 기다리는 시간을 점점 늘리는 것"을 말합니다.
이는 서버가 잠깐 뻗은 상황에서 서버의 회복을 보장하기 위해 필요한 로직으로 아래에서 더 자세히 설명하도록 하겠습니다.
✅ 지수 백오프 + 지터
재시도의 기본 공식은 delay = base * 2^attempt 이지만 그대로 사용해서는 안됩니다. 여러 클라이언트가 동시에 실패하면 재시도 타이밍이 곂치며 서버 요청이 중복해 일어나는 thundering herd 문제가 발생할 위험이 있습니다. 따라서 우리는 지터(jitter)을 사용하여 요청을 무작위로 흩뜨립니다.
여기서 Thundering herd란 "한꺼번에 몰리는" 문제를 의미합니다.
Thundering herd가 생기는 이유를 알아보면 서버 하나가 잠깐 뻗었다고 가정해보겠습니다(503). 그 순간 이 서버에 요청하던 클라이언트 N개가 동시에 실패합니다.
이제 다들 지수 백오프를 씁니다. 문제는 백오프 공식이 delay = 1 x 2^attempt 처럼 결정론적(deterministic) 이라는 겁니다. 즉, 모두가 똑같은 계산을 합니다.
N개 클라이언트 전부: "1초 뒤에 재시도하자"
→ 정확히 1초 뒤, N개가 동시에 서버를 때림
→ 서버 또 뻗음
→ 전부 "2초 뒤 재시도" → 2초 뒤 또 N개 동시 타격
→ 무한 반복
이때 우리는 백오프를 추가합니다. 즉, 한발 물러서서 서버가 회복할 시간을 보장해주는 것이죠. 대기 시간을 늘리는 방식은 여러가지며 제일 흔한게 2배씩 늘리는 방식입니다. 하지만 백오프를 넣었는데도 재시도 타이밍이 싱크로율 100%로 곂치며 서버가 회복할 틈이 생기지 않는 상황이 바로 Thundering herd 입니다.
이를 지터로 해결하는 방식은 아래와 같습니다.
지터는 각 클라이언트의 대기시간에 무작위성을 섞어 타이밍을 흩뜨립니다.
delay = random(0, 1 x 2^attempt)
이제 N개의 클라이언트가 각자 다른 값을 뽑습니다. 따라서 재 시도가 시간 축에 골고루 퍼지게 되죠.
서버 입장에서는 초당 요청이 확 줄며 회복할 여유 시간이 생기고 한번 성공한 클라이언트는 빠져나가니 부하가 점점 줄어듭니다.
지터 전략 종류
- No Jitter: base x 2^attempt
- Full Jitter: random(0, base x 2^attempt) -> 0부터 상한까지 완전 무작위, 가장 넓게 퍼짐. 가장 무난한 방법
- Equal Jitter: (base x 2^attempt)/2 + random(0, (Base x 2^attempt)/2) -> 절반은 고정, 절반은 랜덤. 최소 대기는 보장하되 겹침은 줄임
✅ 실제 적용
그럼 위에서 알아본 내용을 토대로 실제 재시도 로직을 구현해보도록 하겠습니다.
1. 에러 분류
struct APIError: Error {
let statusCode: Int
let retryAfter: TimeInterval?
}
먼저 서버가 준 HTTP 에러를 담을 그릇을 만들어줍니다. StatusCode는 5xx, 4xx 같은 상태 코드고 retryAfter은 서버가 넘겨주는 Retry-After 헤더로 "N초뒤에 와"를 의미합니다.
extension Error {
var isRetryable: Bool {
if let urlError = self as? URLError {
switch urlError.code {
case .timedOut, .networkConnectionLost,
.cannotConnectToHost, .dnsLookupFailed:
return true
case .notConnectedToInternet:
return false
default:
return false
}
}
if let apiError = self as? APIError {
return [500, 502, 503, 504, 429].contains(apiError.statusCode)
}
return false
}
...
위의 코드는 isRetryable, 즉 재시도할 가치가 있는 에러인지를 판단하는 파라미터 입니다. 재시도 로직이 여기저기 흩어지면 복잡하니 판단 기준을 하나 세워주었습니다. 그리고 에러를 두 계층으로 나누어 주었는데 첫번째는 URLError(네트워크 계층)로 타임아웃, 연결 끊김, DNS 의 문제는 재시도 true, 인터넷 없음 문제는 재시도 false로 설정하였습니다.
두 번째는 APIError(HTTP 계층)로 5xx(서버가 삐끗)와 429만 재시도 대상이고 나머지 4xx의 경우 요청 자체가 틀린 것으로 재시도 false로 설정하였습니다.
var serverRetryAfter: TimeInterval? {
(self as? APIError)?.retryAfter
}
}
그리고 serverRetryAfter은 서버에서 Retry-After 값을 꺼내는 계산 프로퍼티로 이후 대기 시간 계산할 때 사용됩니다.
2. 백오프 + Full Jitter
enum Backoff {
static let base: TimeInterval = 0.5
static let cap: TimeInterval = 30
static func delay(attempt: Int) -> TimeInterval {
let exponential = base * pow(2, Double(attempt - 1)) // 지수 백오프 계산
let capped = min(cap, exponential) // 상한 적용. 말도 안되는 값이 넘어왔을 때를 대비한 처리
return Double.random(in: 0...capped) // 지터: 재시도 타이밍을 흩뜨려트림
}
}
이 부분은 대기시간을 계산하는 함수로 enum으로 만든 이유는 인스턴스 생성 없이 Backoff.delay(...)로 쓰기 위함입니다.
3. 멱등성 정책
enum HTTPMethod: String {
case get = "GET", put = "PUT", delete = "DELETE", post = "POST"
var isInherentlyIdempotent: Bool {
switch self {
case .get, .put, .delete: return true
case .post: return false
}
}
}
해당 파라미터는 요청을 재시도해도 데이터에 문제가 없는지 판별하는 파라미터 입니다. 위에서 설명했다시피 GET/PUT/DELETE는 본질적으로 멱등하므로 몇번을 해도 최종상태는 변하지 않으므로 true, POST 만이 false를 띄고 있습니다.
4. 재시도 핵심
struct RetryPolicy {
var maxAttempts: Int = 4
var timeBudget: TimeInterval = 30
}
actor RetryableAPIClient {
private let session: URLSession
private let policy: RetryPolicy
init(session: URLSession = .shared, policy: RetryPolicy = .init()) {
self.session = session
self.policy = policy
}
이 부분은 재시도 로직의 핵심으로 재시도를 언제 멈출지 두 가지 한도를 설정합니다. 이 둘을 언제든지 밖에서 바꿀 수 있도록 struct로 분리하였으며 이로 인해 사용자 대기화면의 경우 요청을 짧게, 백그라운드 동기화는 길게 같은 식으로 커스텀이 가능해졌습니다.
그리고 RetryableAPIClient는 핵심 로직으로 여러 곳에서 동시에 이 클라이언트를 호출해도 내부 상태를 안전하게 보호하기 위해 Actor로 구현하였습니다. 생성자는 세션과 정책을 받되, 기본값을 주어 인스턴스 생성만으로도 바로 사용할 수 있도록 하였습니다.
func send(
_ method: HTTPMethod,
request: URLRequest
) async throws -> (Data, HTTPURLResponse) {
let deadline = Date().addingTimeInterval(policy.timeBudget)
var attempt = 0
while true {
attempt += 1
do {
return try await perform(request)
} catch {
// (a) 재시도 가능한 에러인가?
guard error.isRetryable else { throw error }
// (b) 이 요청을 재시도해도 안전한가? (멱등성 게이트)
// POST인데 Idempotency-Key가 없으면 재시도 금지 — 중복 생성 위험
let safeToRetry = method.isInherentlyIdempotent
|| request.value(forHTTPHeaderField: "Idempotency-Key") != nil
guard safeToRetry else { throw error }
// (c) 횟수 상한 도달?
guard attempt < policy.maxAttempts else { throw error }
// (d) 대기 시간 계산 — 서버 지시가 최우선, 없으면 jitter 백오프
let wait = error.serverRetryAfter ?? Backoff.delay(attempt: attempt)
// (e) 시간 예산 초과하면 더 기다리지 않고 실패
guard Date().addingTimeInterval(wait) < deadline else { throw error }
try await Task.sleep(for: .seconds(wait))
// 루프 계속 → 동일 request(=동일 Idempotency-Key)로 재시도
}
}
메서드의 내부를 살펴보면 우선 deadline을 설정해줍니다. 그리고 while true를 설정하여 매번 attempt를 1 올리고 요청을 시도하도록 하였습니다.
private func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard (200..<300).contains(http.statusCode) else {
let retryAfter = http.value(forHTTPHeaderField: "Retry-After").flatMap(Double.init)
throw APIError(statusCode: http.statusCode, retryAfter: retryAfter)
}
return (data, http)
}
}
이 부분은 실제로 요청을 전송하는 부분입니다. send의 재시도 로직과 분리하여 응답이 2xx인 경우 데이터를 그대로 돌려주고 아니면 상태 코드와 Retry-After 헤더를 뽑아 APIError로 변환하여 던진 뒤 (a)~(e)의 과정을 거칩니다.
전체 코드는 아래와 같습니다.
import Foundation
// 1. 에러 분류
struct APIError: Error {
let statusCode: Int
let retryAfter: TimeInterval? // 서버가 Retry-After 헤더로 준 값 (없으면 nil)
}
extension Error {
/// 일시적 실패인지 판단
var isRetryable: Bool {
// URL 계층 에러 (타임아웃, 연결 끊김 등)
if let urlError = self as? URLError {
switch urlError.code {
case .timedOut, .networkConnectionLost,
.cannotConnectToHost, .dnsLookupFailed:
return true
case .notConnectedToInternet:
// 재시도로 때리지 말고 연결 복구를 기다리는 게 맞음
return false
default:
return false
}
}
// HTTP 계층 에러 (5xx, 429만 재시도)
if let apiError = self as? APIError {
return [500, 502, 503, 504, 429].contains(apiError.statusCode)
}
return false
}
/// 서버가 Retry-After를 지시했다면 그 값을 꺼냄 (내 백오프보다 우선)
var serverRetryAfter: TimeInterval? {
(self as? APIError)?.retryAfter
}
}
// 2. 백오프 + Full Jitter
enum Backoff {
static let base: TimeInterval = 0.5 // 첫 재시도 기준 대기
static let cap: TimeInterval = 30 // 상한
/// Full Jitter: random(0, min(cap, base * 2^(attempt-1)))
/// - attempt: 1부터 시작 (1회차 재시도 = 1)
static func delay(attempt: Int) -> TimeInterval {
let exponential = base * pow(2, Double(attempt - 1)) // 0.5, 1, 2, 4...
let capped = min(cap, exponential)
return Double.random(in: 0...capped) // 여기서 흩뜨림
}
}
// 3. 멱등성 정책
enum HTTPMethod: String {
case get = "GET", put = "PUT", delete = "DELETE", post = "POST"
/// GET/PUT/DELETE는 재시도 안전
/// POST는 Idempotency-Key가 있을 때만 안전
var isInherentlyIdempotent: Bool {
switch self {
case .get, .put, .delete: return true
case .post: return false
}
}
}
// 4. 재시도 정책 (횟수 + 시간 예산)
struct RetryPolicy {
var maxAttempts: Int = 4 // 최초 시도 포함 총 횟수
var timeBudget: TimeInterval = 30 // 전체 재시도에 쓸 수 있는 총 시간
}
// 5. 재시도 엔진
actor RetryableAPIClient {
private let session: URLSession
private let policy: RetryPolicy
init(session: URLSession = .shared, policy: RetryPolicy = .init()) {
self.session = session
self.policy = policy
}
func send(
_ method: HTTPMethod,
request: URLRequest
) async throws -> (Data, HTTPURLResponse) {
let deadline = Date().addingTimeInterval(policy.timeBudget)
var attempt = 0
while true {
attempt += 1
do {
return try await perform(request)
} catch {
// (a) 재시도 가능한 에러인가?
guard error.isRetryable else { throw error }
// (b) 이 요청을 재시도해도 안전한가? (멱등성 게이트)
// POST인데 Idempotency-Key가 없으면 재시도 금지 — 중복 생성 위험
let safeToRetry = method.isInherentlyIdempotent
|| request.value(forHTTPHeaderField: "Idempotency-Key") != nil
guard safeToRetry else { throw error }
// (c) 횟수 상한 도달?
guard attempt < policy.maxAttempts else { throw error }
// (d) 대기 시간 계산 — 서버 지시가 최우선, 없으면 jitter 백오프
let wait = error.serverRetryAfter ?? Backoff.delay(attempt: attempt)
// (e) 시간 예산 초과하면 더 기다리지 않고 실패
guard Date().addingTimeInterval(wait) < deadline else { throw error }
try await Task.sleep(for: .seconds(wait))
// 루프 계속 → 동일 request(=동일 Idempotency-Key)로 재시도
}
}
}
/// 실제 1회 요청. HTTP 상태코드를 APIError로 변환.
private func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard (200..<300).contains(http.statusCode) else {
let retryAfter = http.value(forHTTPHeaderField: "Retry-After").flatMap(Double.init)
throw APIError(statusCode: http.statusCode, retryAfter: retryAfter)
}
return (data, http)
}
}
실제 사용예시는 아래와 같습니다.
// 사용 예시
func exampleUsage() async {
let client = RetryableAPIClient()
// GET — 멱등이라 그냥 재시도됨
var getReq = URLRequest(url: URL(string: "https://api.example.com/orders/456")!)
getReq.httpMethod = "GET"
_ = try? await client.send(.get, request: getReq)
// POST — 재시도 시 중복 방지를 위해 Idempotency-Key를 "요청 생성 시점에 한 번" 발급.
// 재시도해도 이 키가 그대로 재사용되므로 서버가 중복을 걸러낼 수 있음.
var postReq = URLRequest(url: URL(string: "https://api.example.com/orders")!)
postReq.httpMethod = "POST"
postReq.setValue(UUID().uuidString, forHTTPHeaderField: "Idempotency-Key")
postReq.httpBody = try? JSONEncoder().encode(["item": "coffee"])
_ = try? await client.send(.post, request: postReq)
}
오늘은 이렇게 API 호출 실패 시 재시도에 대해 알아보았습니다
출처
https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/
https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html
https://docs.stripe.com/api/idempotent_requests
https://stripe.com/blog/idempotency
https://docs.stripe.com/error-low-level
https://developer.apple.com/documentation/Foundation/downloading-files-in-the-background
https://developer.apple.com/documentation/foundation/urlsessionconfiguration/background(withidentifier:)
https://developer.apple.com/documentation/foundation/urlsessiondownloaddelegate
https://aws.amazon.com/blogs/database/building-resilient-applications-design-patterns-for-handling-database-outages/
'Swift' 카테고리의 다른 글
| Swift - WebRTC 구현(1) (0) | 2026.07.20 |
|---|---|
| Swift - WebRTC란? (0) | 2026.07.19 |
| Swift - nonisolated 란 (0) | 2026.06.30 |
| Swift - 캐시 지역성(Cache Locality)이란 (0) | 2026.06.29 |
| Swift - Structured Concurrency(구조적 동시성) (0) | 2026.06.25 |
- Total
- Today
- Yesterday
- CD/CI
- Actor
- GradientDescent
- iphone
- 인공신경망
- ViewBuilder
- coredata
- ios
- webrtc
- nonisolated
- opaquetype
- Xcode
- SocialLogin
- Algorithm
- 코어데이터
- 동시성
- BoxedType
- 역전파
- opaque
- AI
- swift
- 알고리즘
- SwiftUI
- CacheLocality
- Tuist
- 스위프트
- kakaomapssdk
- 경사하강법
- tuist v4
- Concurrency
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |
