클라우드 Mac에서 검증하는 iOS HTTP 재시도와 취소 경계
서버가 429를 반환했는데 클라이언트는 곧바로 세 번 더 요청을 보냅니다. 사용자가 이미 로그아웃했는데도 이전 요청이 대기를 마치고 다시 전송됩니다. 이런 문제는 타임아웃을 늘린다고 해결되지 않습니다. 클라우드 Mac에서 iOS 네트워크 계층을 검증할 때는 먼저 재시도를 세 가지 경계로 나눠야 합니다. 응답을 어떻게 해석할지, 언제 다시 보낼 수 있는지, 취소 후 전송을 막을 책임은 어디에 있는지입니다. 테스트에서 실제로 수십 초씩 기다릴 필요는 없습니다.
재시도를 허용할 요청부터 정의하기
읽기 전용 GET 요청부터 시작합니다. 이 예제에서는 429와 503만 처리합니다. 다른 상태 코드와 전송 오류에는 별도 정책을 마련하고, 모든 실패를 하나의 루프에 넣지 마세요. 최초 전송은 재시도 횟수에 포함하지 않습니다. 최대 세 번 재시도한다는 것은 총 전송 횟수가 최대 네 번이라는 뜻입니다.
쓰기 요청은 메서드 이름만으로 안전성을 판단할 수 없습니다. 서버가 멱등성 프로토콜을 지원하고, 같은 비즈니스 작업에 동일한 멱등성 키를 재사용하며, 요청 본문을 다시 읽을 수 있을 때만 자동 재전송을 고려해야 합니다. 스트리밍 업로드라면 스트림을 다시 생성할 수 있는지도 확인해야 합니다.
타임아웃은 클라이언트가 결과를 받지 못했다는 뜻일 뿐, 서버에서 쓰기 작업을 완료하지 않았다는 뜻은 아닙니다. 로컬 작업을 취소해도 이미 서버에 도달한 작업까지 되돌릴 수는 없습니다.
재시도 예산은 논리적 요청마다 독립적으로 관리합니다. 네트워크 함수에 진입할 때마다 횟수를 초기화하면 안 됩니다. 바깥쪽과 안쪽 계층이 각각 세 번씩 재시도하면 실제 요청 수가 크게 늘어날 수 있습니다.
응답 헤더를 명확한 대기 제약으로 변환하기
Retry-After에는 두 가지 형식의 값이 올 수 있습니다. 음이 아닌 정수 초와 HTTP 날짜입니다. 정수는 ASCII 숫자만 허용하고, 음수·소수·오버플로는 거부합니다. 날짜를 파싱할 때는 영문 로케일과 GMT를 고정해야 하며, 노드의 현재 언어 설정에 의존해서는 안 됩니다. 다음은 일반적인 IMF-fixdate 형식을 처리하는 예제입니다.
import Foundation
func serverDelay(_ raw: String?, now: Date) -> TimeInterval? {
guard let raw else { return nil }
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty,
text.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }) {
guard let seconds = UInt64(text) else { return nil }
return TimeInterval(seconds)
}
let parser = DateFormatter()
parser.locale = Locale(identifier: "en_US_POSIX")
parser.timeZone = TimeZone(secondsFromGMT: 0)
parser.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'"
parser.isLenient = false
guard let date = parser.date(from: text),
parser.string(from: date) == text else {
return nil
}
return max(0, date.timeIntervalSince(now))
}
이 파서는 널리 쓰이는 형식을 엄격하게 처리할 뿐, 과거의 모든 HTTP 날짜 형식과 호환되는 구현은 아닙니다. API 계약상 이전 날짜 형식도 받아야 한다면 별도의 파싱 분기와 테스트 픽스처를 추가해야 합니다. 이 예제에서는 값이 유효하지 않으면 로컬 백오프로 대체하고 파싱 실패 원인을 기록합니다.
now를 고정한 뒤 과거·현재·미래 날짜를 각각 확인합니다. 실행 중 대기에는 연속 시계를 사용합니다. 벽시계는 날짜를 남은 초로 변환하는 데만 사용해, 시스템 시간 보정이 이미 시작된 대기에 영향을 주지 않도록 합니다.
백오프는 순수 함수로 계산하고, 함수 안에서는 대기하지 않기
다음 내용을 앞의 파싱 함수 뒤에 이어 붙여 retry-check.swift로 저장합니다. retryIndex는 0부터 시작합니다. 기본 지연은 차례대로 1, 2, 4초이며, 기본값에 비례하는 지터를 적용합니다. 난수 입력은 호출자가 제공합니다.
enum Decision: Equatable {
case stop
case deferred
case retry(TimeInterval)
}
func decide(
status: Int,
replayable: Bool,
retryIndex: Int,
server: TimeInterval?,
unit: Double
) -> Decision {
guard replayable,
[429, 503].contains(status),
(0..<3).contains(retryIndex),
unit.isFinite,
(0...1).contains(unit) else {
return .stop
}
let base = pow(2.0, Double(retryIndex))
let local = base * (0.5 + 0.5 * unit)
if let server, !server.isFinite || server < 0 {
return .stop
}
let delay = max(local, server ?? 0)
guard delay <= 30 else { return .deferred }
return .retry(delay)
}
precondition(decide(
status: 503, replayable: true,
retryIndex: 0, server: nil, unit: 0
) == .retry(0.5))
precondition(decide(
status: 429, replayable: true,
retryIndex: 1, server: 10, unit: 1
) == .retry(10))
precondition(decide(
status: 429, replayable: true,
retryIndex: 0, server: 60, unit: 0
) == .deferred)
precondition(decide(
status: 503, replayable: true,
retryIndex: 3, server: nil, unit: 1
) == .stop)
precondition(decide(
status: 503, replayable: false,
retryIndex: 0, server: nil, unit: 1
) == .stop)
Swift 툴체인이 설정된 노드에서 실행합니다.
swift retry-check.swift
정상 종료되면 검증 조건을 통과한 것입니다. 검사를 비활성화하는 최적화 옵션은 추가하지 마세요. 여기서 30초는 예제에서 정한 자동 대기 상한일 뿐, 프로토콜이 규정한 값이 아닙니다. 서버가 더 긴 대기를 요구하면 연기 상태를 반환해야 합니다. 30초로 잘라서 일찍 재전송해서는 안 됩니다.
지연 값뿐 아니라 취소 경계도 검증하기
실행기는 판단 결과만 받아 주입 가능한 대기 함수를 호출합니다. 프로덕션에서는 취소 가능한 Task.sleep을 사용할 수 있습니다. 테스트에서는 직접 제어할 수 있는 일시 중단 지점을 사용하고, “대기에 진입했다”는 알림을 제공해야 합니다. 짧게 잠든 뒤 작업이 어디까지 진행됐는지 추측하는 방식은 피하세요.
최소한 다음 세 가지 순서를 검증합니다.
- 대기 전 취소: 전송 횟수가 늘어나지 않아야 합니다.
- 대기 중 취소: 대기에서 발생한 취소 오류가 상위로 전파되어야 하며, 일반 실패의 재시도 경로로 들어가면 안 됩니다.
- 대기가 끝난 직후 취소: 전송 전에 작업의 취소 상태와 세션 세대 번호를 다시 확인해야 합니다.
세션 세대 번호는 로그인 상태가 바뀔 때마다 증가하는 식별자입니다. 이전 요청은 성공 응답을 받았더라도 UI나 캐시를 갱신하기 전에 반드시 세대 번호를 비교해야 합니다. 취소 확인과 실제 요청 전송 사이에는 여전히 경쟁 상태가 존재하므로, 취소하면 요청이 서버에 절대 도달하지 않는다고 보장할 수는 없습니다.
실패 증거를 병합 전 점검 항목으로 만들기
파서에는 값 없음, 공백, 음수, 소수, 지나치게 큰 정수, 날짜 경계에 대한 테스트를 추가합니다. 스케줄러는 재시도 인덱스 0부터 3까지, 난수의 양 끝값 0과 1, 서버 대기 시간이 상한과 정확히 같거나 상한을 초과하는 경우를 모두 다뤄야 합니다.
통합 테스트에는 정해진 응답을 순서대로 반환하는 전송 테스트 대역을 사용합니다. 503, 429, 200을 차례로 반환하게 하고, 매번 전송하기 전에 계획된 지연을 확인합니다. 여기에 취소, 재전송할 수 없는 본문, 재시도 예산 소진 시나리오를 추가합니다. 실제 공개 API를 호출해 호출 제한을 유발하지 말고, 테스트 환경에서 응답 헤더가 지정한 시간만큼 실제로 기다리게 하지 마세요.
로그에는 논리적 요청 식별자, 상태 코드, 재시도 인덱스, 계획된 지연, 종료 사유만 남깁니다. 인증 헤더나 본문은 기록하지 않습니다. 최종 검증 기준은 네 가지입니다. 너무 일찍 재전송하지 않는지, 예산을 초과해 전송하지 않는지, 취소를 일반 오류로 취급하지 않는지, 재전송할 수 없는 요청이 루프에 들어가지 않는지 확인합니다. 이 조건을 충족한 다음에 백오프 매개변수를 조정할 필요가 있는지 논의하세요.
자주 묻는 질문
429나 503을 받으면 무조건 재시도해야 하나요?
아닙니다. 안전하게 재전송할 수 있는 요청인지, 재시도 예산이 남았는지, 서버의 대기 지시를 지킬 수 있는지 확인해야 합니다. 쓰기 요청에는 서버가 보장하는 멱등성 규약과 재전송 가능한 본문이 필요합니다.
Retry-After가 대기 상한을 넘으면 줄여도 되나요?
줄인 뒤 재전송하면 서버가 요구한 시점보다 일찍 요청하게 됩니다. 연기 상태를 상위 계층에 반환하고 작업 저장이나 사용자 안내로 처리해야 합니다.
실제로 기다리지 않고 백오프를 테스트하려면 어떻게 하나요?
현재 시각, 난수, 대기 실행기를 주입합니다. 순수 함수에서는 예정 지연을 비교하고, 실행기에서는 제어 가능한 중단 지점에서 취소해 전송 횟수가 늘지 않는지 확인합니다.
이번 패키징에 사용할 전용 Mac이 필요하신가요?
필요한 기간에 맞춰 대여 기간을 선택하세요. Mac 한 대를 단독으로 사용하며, macOS 그래픽 인터페이스와 명령줄을 모두 이용할 수 있습니다. 기종, 노드 및 이용 가능 여부는 콘솔에 실시간으로 표시되는 정보를 기준으로 합니다.