← Study Hub

16 · 분산 시스템과 모바일 클라이언트

신뢰할 수 없는 네트워크 위에서 재시도 안전성·중복 방지·멱등성·시간 신뢰를 다루는 실전 원리
진행률
0 / 0 완료

← 챕터 목록

모바일 앱은 신뢰할 수 없는 네트워크 위에서 도는 분산 시스템의 한 노드다. 이 챕터는 "요청은 갔는데 응답이 안 온다", "결제가 두 번 될 수도 있다", "기기 시계를 못 믿는다" 같은, 서버-클라이언트 사이에서 반드시 마주치는 네 가지 P0 문제를 CS 원리부터 iOS 구현까지 관통한다.

Q1. 요청은 성공했지만 응답을 받지 못했을 때 재시도해도 되는가?

🔑 30초 답변

"응답을 못 받았다"는 건 서버가 처리했는지 모른다는 뜻이지 실패했다는 뜻이 아니다. 네트워크는 요청·응답 어느 쪽도 유실할 수 있으므로 타임아웃만으로는 성공/실패를 구분할 수 없다. 따라서 재시도 안전성은 연산의 성질에 달려 있다. 조회처럼 부수효과가 없는 멱등(idempotent) 연산은 마음껏 재시도해도 되고, 결제·주문처럼 부수효과가 있는 연산은 서버가 중복을 걸러낼 장치(idempotency key 등)를 갖춘 경우에만 재시도한다. 그 장치가 없으면 재시도는 곧 이중 실행 위험이다.

CS 원리

분산 시스템에서 원격 호출은 로컬 함수 호출과 근본적으로 다르다. 응답이 오지 않는 상황은 최소 세 가지로 갈린다. (1) 요청이 서버에 도달하지 못함, (2) 서버가 처리했지만 응답이 돌아오는 길에 유실됨, (3) 서버가 아직 처리 중인데 클라이언트 타임아웃이 먼저 만료됨. 클라이언트는 이 셋을 구분할 수 없다 — 이것이 Two Generals' Problem의 실무 버전이다.

그래서 전달 보장(delivery guarantee)은 세 등급으로 나뉜다. at-most-once(재시도 안 함, 유실 가능), at-least-once(성공할 때까지 재시도, 중복 가능), exactly-once. 네트워크만으로 exactly-once는 불가능하고, 현실의 exactly-once는 "at-least-once 전달 + 수신 측 멱등 처리(중복 제거)"의 조합으로 만든다. 즉 재시도의 안전성은 전송 계층이 아니라 애플리케이션의 멱등성이 결정한다.

HTTP 메서드에는 의미론적 계약이 있다. RFC 9110은 GET·HEAD·PUT·DELETE를 멱등으로, POST를 비멱등으로 정의한다. 여기서 멱등이란 "같은 요청을 N번 보내도 서버 상태에 미치는 효과가 1번과 같다"는 의미다(응답 바디까지 같아야 한다는 뜻은 아니다).

iOS에서는

URLSession이 스스로 재시도하는 경우는 좁다. 재사용하려던 keep-alive(persistent) 연결이 요청 바이트가 나가기 전에 이미 닫혀 있었던 경우처럼, 서버에 아무것도 도달하지 않은 것이 확실한 상황에 한해 새 연결로 다시 시도할 수 있다. 반면 요청을 이미 전송한 뒤 응답 대기 중 타임아웃(NSURLErrorTimedOut)이나 네트워크 끊김(NSURLErrorNetworkConnectionLost)은 자동으로 재시도하지 않는다. 처리됐을 수도 있기 때문이다. 이 판단을 프레임워크에 떠넘기지 말고 앱 계층에서 명시적으로 해야 한다.

구조적 동시성에서는 취소와 재시도를 분리해서 다뤄야 한다. Task.sleep 기반 백오프 중에도 취소가 전파되도록 try Task.checkCancellation()을 넣고, 재시도 판단은 오류 코드와 메서드 멱등성에 근거한다.

응답 미수신 시 재시도 판단 (Swift)
func shouldRetry(_ error: URLError, method: HTTPMethod) -> Bool {
    switch error.code {
    // 요청이 서버에 닿기 전 실패 → 부수효과 없음, 항상 안전
    case .cannotFindHost, .cannotConnectToHost, .notConnectedToInternet:
        return true
    // 이미 전송한 뒤 응답을 못 받음 → 서버가 처리했을 수 있다
    case .timedOut, .networkConnectionLost:
        return method.isIdempotent   // 멱등일 때만 재시도
    default:
        return false
    }
}

func send(_ req: URLRequest, method: HTTPMethod, maxAttempts: Int = 3) async throws -> Data {
    var attempt = 0
    while true {
        do {
            let (data, resp) = try await URLSession.shared.data(for: req)
            guard let http = resp as? HTTPURLResponse else { return data }
            if (200...299).contains(http.statusCode) { return data }
            // 429(rate limit)는 서버가 처리 안 함 → 항상 재시도 안전.
            // 5xx는 처리 여부 불확실 → 멱등일 때만 재시도(비멱등이면 이중 실행 위험).
            // 4xx(429 제외)는 재시도해도 결과가 같음 → 재시도 무의미.
            let retryStatus = http.statusCode == 429
                || ((500...599).contains(http.statusCode) && method.isIdempotent)
            guard retryStatus, attempt + 1 < maxAttempts else {
                throw URLError(.badServerResponse)   // 재시도 불가·소진 → 상위로
            }
        } catch let e as URLError where shouldRetry(e, method: method) && attempt + 1 < maxAttempts {
            // 전송 계층 오류 재시도 → 아래 공통 백오프로 진입
        }
        // 여기 도달 = 재시도 확정 (성공·포기는 위에서 return/throw로 빠져나감)
        attempt += 1
        try Task.checkCancellation()
        let backoff = pow(2.0, Double(attempt)) * 0.2
        let jitter  = Double.random(in: 0...0.1)      // thundering herd 방지
        try await Task.sleep(nanoseconds: UInt64((backoff + jitter) * 1_000_000_000))
    }
}

실험 · 도구

재현: macOS Network Link Conditioner(Additional Tools for Xcode에 포함)나 iOS 기기의 개발자 설정에서 100% Loss 프로파일을 켜고, 서버 앞단에 프록시(mitmproxy)를 두어 요청은 통과시키되 응답만 드롭한다. 클라이언트는 타임아웃을 보지만 서버 로그에는 요청이 정상 처리된 것으로 남는다 — 바로 "성공했지만 응답 미수신" 상태다.

관찰 지표: 서버 측에서 같은 논리적 연산의 실행 횟수를 카운트한다. 멱등 키 없이 재시도를 켜면 이 카운트가 클라이언트 재시도 횟수만큼 증가하는 것을 볼 수 있다. Xcode의 Network Instrument로 요청이 실제로 전송됐는지(bytes sent > 0), 언제 타임아웃됐는지를 확인한다.

프로젝트 적용

⚠️ 흔한 오해

"타임아웃 = 실패니까 안전하게 재시도하면 된다"는 틀렸다. 타임아웃은 실패가 아니라 결과를 모름(unknown)이다. 서버는 이미 정상 처리하고 커밋까지 마쳤을 수 있다. 마찬가지로 "GET은 안전하고 POST는 위험하다"도 정확하지 않다 — 위험한 건 메서드가 아니라 부수효과의 유무와 중복 제거 장치의 유무다. 멱등 키를 갖춘 POST는 재시도해도 안전하고, 서버가 조회 때마다 로그를 남긴다면 그 GET조차 부수효과가 있다.

꼬리 질문

Q2. 결제·송금·주문 요청의 중복 실행을 어떻게 방지하는가?

🔑 30초 답변

중복 방지의 핵심은 클라이언트 재시도를 막는 게 아니라, 서버가 재시도를 알아보고 한 번만 반영하도록 만드는 것이다. 표준 해법은 중복 제거(deduplication) 토큰이다. 클라이언트가 각 논리적 연산에 고유한 idempotency key를 붙여 보내고, 서버는 그 키를 같은 트랜잭션 안에서 유니크 제약으로 저장한다. 같은 키의 두 번째 요청은 새로 실행하지 않고 처음 결과를 그대로 되돌려준다. 이렇게 하면 재시도가 몇 번 오든 부수효과는 정확히 한 번 일어난다. 클라이언트 측 버튼 비활성화는 UX 보정일 뿐 진짜 방어선은 서버다.

CS 원리

중복 실행은 두 층위에서 발생한다. (1) 사용자가 여러 번 누름(더블 탭, 조급한 재시도), (2) 시스템이 재시도함(네트워크 타임아웃 후 자동/수동 재전송). 둘 다 결국 "같은 의도의 요청이 서버에 2회 이상 도착"으로 수렴하므로, 방어선은 수신 측에서 같은 의도를 동일한 것으로 인식하는 데 있다.

이를 위해선 각 요청에 안정적인 신원(stable identity)이 있어야 한다. 서버가 매번 생성하는 auto-increment ID로는 안 된다 — 재시도된 요청은 클라이언트 입장에서 "같은 것"이지만 서버는 새 요청으로 볼 것이기 때문이다. 그래서 클라이언트가 미리 부여한 키가 필요하다. 서버는 이 키에 유니크 제약을 걸고, 비즈니스 로직 실행과 키 저장을 하나의 원자적 트랜잭션으로 묶는다. 키 저장이 커밋되면 부수효과도 커밋되고, 롤백되면 둘 다 사라진다 — 이 원자성이 깨지면 "돈은 나갔는데 키는 저장 안 됨" 같은 최악의 틈이 생긴다.

첫 요청과 재시도가 같은 key로 도착한다 클라이언트 key = A1B2 ① 첫 요청 ③ 재시도(같은 key) 서버 (단일 트랜잭션) key UNIQUE 삽입 시도 결제 실행 + 결과 저장 ② 200 + 결과 (실제 결제 1회) 재시도는 삽입이 막혀 저장된 결과를 그대로 반환 UNIQUE 위반 → 실행 스킵 저장된 결과 조회 ④ 200 + 같은 결과 (결제 추가 없음)
같은 idempotency key로 온 재시도는 유니크 제약에 걸려 결제 로직을 다시 타지 않고, 처음 저장해 둔 응답을 되돌려준다. 부수효과는 정확히 한 번.

iOS에서는

클라이언트가 할 일은 두 가지다. (1) 요청을 만드는 순간 키를 한 번 생성해 붙이고, (2) 재시도할 때 그 키를 재사용한다. iOS에선 UUID()로 키를 만들고 커스텀 헤더(Idempotency-Key)에 실어 보낸다. 중요한 건 재시도 루프가 매번 새 키를 만들면 서버 입장에선 서로 다른 요청이 되어 방어가 무너진다는 점 — 키는 요청 객체에 고정돼야 한다.

더불어 오프라인 큐를 쓴다면(예: 앱이 죽었다 살아나도 결제를 완주해야 하는 경우), 키를 요청 페이로드와 함께 디스크에 영속화해야 앱 재시작 후에도 같은 키로 재시도할 수 있다. 메모리에만 있으면 재실행 = 새 키 = 이중 결제 위험.

키를 요청에 고정하고 재시도 전체에서 재사용 (Swift)
struct PaymentRequest {
    let amount: Decimal
    let idempotencyKey: String   // 생성 시 한 번만 부여, 이후 불변

    init(amount: Decimal) {
        self.amount = amount
        self.idempotencyKey = UUID().uuidString
    }
}

func submit(_ payment: PaymentRequest) async throws -> Receipt {
    var req = URLRequest(url: paymentsURL)
    req.httpMethod = "POST"
    // 재시도해도 이 헤더 값은 동일 → 서버가 중복으로 인식
    req.setValue(payment.idempotencyKey, forHTTPHeaderField: "Idempotency-Key")
    req.httpBody = try JSONEncoder().encode(payment)
    let data = try await sendWithRetry(req)   // 내부에서 몇 번을 재시도해도 key 불변
    return try JSONDecoder().decode(Receipt.self, from: data)
}

UX 층 방어(버튼 즉시 비활성화, 진행 중 스피너, 디바운스)는 정상 흐름에서 더블 탭을 줄여 주지만, 앱 강제 종료·네트워크 재시도까지 막지는 못한다. 그래서 이건 보조이고 서버 키가 본진이다.

프로젝트 적용

⚠️ 흔한 오해

"프론트에서 더블 탭만 막으면 중복은 안 난다"는 착각이다. 사용자 더블 탭은 중복의 한 원인일 뿐이고, 네트워크 타임아웃 후 자동 재시도·앱 재시작 후 큐 재실행은 UI를 우회한다. 또 하나: "서버에서 요청이 올 때마다 최근 N초 내 같은 금액 결제가 있으면 막자"는 시간 윈도우 방식은 위험하다 — 사용자가 진짜로 같은 금액을 두 번 결제하려는 정당한 경우를 잘못 차단한다. 신원은 시간이 아니라 명시적 키로 판별해야 한다.

꼬리 질문

Q3. Idempotency key는 무엇이며 누가 생성하고 얼마나 유지해야 하는가?

🔑 30초 답변

Idempotency key는 "이 요청은 이 논리적 연산 하나를 가리킨다"고 못 박는 클라이언트 발급 고유 식별자다. 반드시 요청을 시작하는 쪽(클라이언트)이 생성해야 한다 — 재시도들이 같은 신원을 공유해야 하는데, 그 신원을 아는 건 재시도를 하는 클라이언트뿐이기 때문이다. 서버가 발급하면 재시도 때 물어볼 곳이 없다. 값은 충돌 확률이 무시할 만한 것(UUIDv4 등)을 쓴다. 유지 기간은 "재시도가 현실적으로 올 수 있는 창"을 덮으면 충분하다 — 보통 24시간~며칠. Stripe는 24시간 동안 키를 보관한 뒤 만료한다. 영원히 보관할 필요는 없고, 만료 후 같은 키가 재사용되면 새 연산으로 처리된다.

CS 원리

Idempotency key의 본질은 연산에 붙는 지문이다. "요청 자체"의 식별자(전송 계층의 request-id, 재전송마다 바뀔 수 있음)와 구분해야 한다 — idempotency key는 여러 번의 전송이 하나의 의도임을 나타내는 상위 개념이다.

생성 주체가 클라이언트여야 하는 이유는 인과적이다. 중복은 "같은 의도를 여러 번 보냄"에서 나오고, 그 "같음"을 아는 유일한 주체는 의도를 가진 발신자다. 서버는 두 요청이 우연히 같은지 원래 같은 것의 재시도인지 스스로 알 수 없다. 그래서 발신자가 키로 명시해 준다.

보관 기간은 trade-off다. 길수록 늦은 재시도도 안전하게 걸러내지만 저장 비용이 늘고, 짧으면 저장은 싸지만 만료 후 도착한 재시도를 새 연산으로 오인해 중복 실행할 수 있다. 그래서 기준은 "재시도가 현실적으로 도달할 수 있는 최대 시간"(클라이언트 재시도 상한 + 오프라인 큐 잔존 시간 + 여유)을 덮는 것이다.

iOS에서는

키 생성은 UUID().uuidString이 기본이다(122비트 랜덤, 충돌 무시 가능). 정렬 가능성이 필요하면 시간 접두를 붙인 UUIDv7 스타일을 직접 만들 수 있지만, 중복 제거 용도만이라면 v4로 충분하다. 결제·주문 화면에 진입할 때(또는 요청 객체를 만들 때) 키를 부여하고, 사용자가 여러 번 제출을 시도해도 같은 화면 세션 = 같은 키가 되도록 한다.

키의 수명 관리는 클라이언트에서도 중요하다. 오프라인 큐/영속 저장을 쓸 때 키를 언제 폐기할지는 서버 보관 기간과 맞춰야 한다. 성공 응답을 받으면 큐에서 제거하고, 서버가 키를 만료했을 만큼 오래된 대기 요청은 사용자에게 재확인을 받아 새 키로 다시 시작한다.

클라이언트 생성 (권장)서버 생성
재시도가 같은 키 공유가능 — 발신자가 키를 쥐고 있음불가 — 재시도 때 물어볼 곳 없음
오프라인/앱 재시작 후 재시도가능 — 키를 로컬 영속화불가
키 값 형식UUIDv4 등 클라이언트가 결정서버 스킴
서버가 최종 방어선키 저장 + 유니크 제약동일

실험 · 도구

재현: 같은 Idempotency-Key로 결제 요청을 연달아 3번 보내고(curl 반복 또는 앱 재시도 강제), 서버 응답이 3번 모두 동일한 상태·바디인지, 실제 결제 레코드가 1개인지 확인한다. 그다음 키만 매번 새로 바꿔 3번 보내면 결제 레코드가 3개로 늘어나는 걸 대비 관찰한다 — 키 재사용이 방어의 전부임을 눈으로 확인하는 실험이다. 만료 검증은 서버 보관 TTL을 짧게(예: 10초) 낮춘 스테이징에서, 만료 후 같은 키로 보내면 새 레코드가 생기는지 본다.

프로젝트 적용

⚠️ 흔한 오해

"서버가 멱등 키를 만들어 응답에 담아 주면 되지 않나?"는 순서가 틀렸다. 서버가 키를 만들려면 이미 요청을 한 번 받아 처리를 시작한 뒤인데, 그 첫 요청 자체가 타임아웃되면 클라이언트는 서버가 준 키를 받지 못한다 — 재시도에 쓸 키가 없다. 또 "멱등 키는 영원히 보관해야 안전하다"도 오해다. 재시도는 유한한 시간 안에 오고, 그 창을 덮는 유한한 TTL이면 충분하다. 무한 보관은 스토리지 낭비이자 오히려 관리 리스크다.

꼬리 질문

Q4. 클라이언트 시간과 서버 시간이 다를 때 무엇을 신뢰해야 하는가?

🔑 30초 답변

보안·정합성이 걸린 결정(토큰 만료, 이벤트 순서, 정산 시각, 쿠폰 유효기간)은 서버 시간을 신뢰한다. 클라이언트 시계는 사용자가 임의로 바꿀 수 있고 표류(drift)하므로 신뢰 경계 밖이다. 다만 서버 시간을 "얼마"로 볼지 정할 때 단순히 벽시계(wall clock) 값 하나를 믿는 것도 위험하다 — 순서·경과 시간을 재는 데는 되감기지 않는 단조 시계(monotonic clock)를, 절대 시각을 표시·기록하는 데는 UTC 기준 벽시계를 쓰고, 클라이언트에는 서버 시간을 내려보내거나 오프셋을 계산해 표시를 보정한다. 요약하면 "믿을 시각은 서버, 재야 할 간격은 단조 시계, 클라이언트 시계는 표시 보정용".

CS 원리

컴퓨터에는 성격이 다른 두 시계가 있다. 벽시계(wall/real-time clock)는 "지금 몇 시"라는 절대 시각을 주지만 NTP 동기화·사용자 설정·윤초로 갑자기 튀거나 뒤로 감길 수 있다. 단조 시계(monotonic clock)는 절대 시각은 모르지만 절대 뒤로 가지 않아 두 시점 사이의 경과 시간을 재는 데 정확하다. 그래서 타임아웃·재시도 간격·성능 측정은 단조 시계로, 로그 타임스탬프·만료 시각은 벽시계로 재는 것이 원칙이다.

분산 환경에선 각 노드의 벽시계가 서로 다르다(clock skew). NTP로 줄여도 수십 ms~수 초의 오차가 남고, 모바일은 더 심하다. 따라서 서로 다른 기계의 벽시계 값을 크기 비교해 순서를 정하면 안 된다 — 이벤트 순서는 시계가 아니라 서버가 매기는 단조 증가 시퀀스논리적 시계(Lamport clock)로 정한다. "시간의 절대 진실"을 가질 노드를 하나(서버)로 정하는 것이 실무의 타협이다.

벽시계: 되감길 수 있다 (표시·기록용) NTP 보정 ↩ 시각이 뒤로 점프 → 간격 계산 오류 단조 시계: 절대 뒤로 안 감 (간격·타임아웃용) 항상 증가만 → 경과 시간 신뢰 가능 신뢰 기준 만료·순서·정산 → 서버 시간 타임아웃·경과 → 단조 시계 화면 표시 → 서버시간 ± 오프셋 클라이언트 벽시계는 신뢰 경계 밖
되감길 수 있는 벽시계로 간격을 재면 틀린다. 순서·간격은 단조 시계로, 절대 신뢰가 필요한 판단은 서버 시간으로.

iOS에서는

iOS는 두 시계를 명확히 구분해 제공한다. 절대 시각은 Date()(벽시계, 사용자가 설정에서 바꾸면 그대로 바뀜), 경과 시간은 ContinuousClock/SuspendingClock(Swift Concurrency)이나 DispatchTime/mach_absolute_time(단조)이다. 특히 타임아웃·쿨다운·재시도 간격Date() 차이로 계산하면, 사용자가 시계를 앞당기거나 뒤로 돌리는 순간 로직이 깨진다. 반드시 단조 시계를 써야 한다.

토큰 만료 같은 보안 판단은 클라이언트 시계로 하지 않는다. 서버가 만료를 판정하고, 클라이언트는 401을 받으면 갱신하는 흐름이 정석이다. 클라이언트에서 "곧 만료"를 미리 감지하고 싶다면, 서버 응답의 Date 헤더로 서버-클라이언트 시간 오프셋을 구해 보정한 값을 쓰되, 이는 어디까지나 UX 최적화이고 최종 판정은 서버다.

서버 시간 오프셋 계산 + 단조 시계로 경과 측정 (Swift)
// 1) 간격·타임아웃은 단조 시계로 — 벽시계 변경에 영향 안 받음
let clock = ContinuousClock()
let start = clock.now
try await doWork()
let elapsed = start.duration(to: clock.now)   // 사용자가 시계 바꿔도 정확

// 2) 서버 시간과의 오프셋: 응답의 Date 헤더 사용 (RTT 절반 보정은 근사)
func serverOffset(from response: HTTPURLResponse, requestSent: Date) -> TimeInterval? {
    guard let dateStr = response.value(forHTTPHeaderField: "Date"),
          let serverDate = DateFormatter.httpDate.date(from: dateStr) else { return nil }
    let rtt = Date().timeIntervalSince(requestSent)
    let estimatedServerNow = serverDate.addingTimeInterval(rtt / 2)
    return estimatedServerNow.timeIntervalSince(Date())   // +면 서버가 앞섬
}

// 3) 표시용 '지금'은 로컬 시계에 오프셋을 더해 근사 — 판정은 서버가
func approxServerNow(offset: TimeInterval) -> Date { Date().addingTimeInterval(offset) }

실험 · 도구

재현: 기기 설정에서 자동 시간 설정을 끄고 시계를 하루 앞/뒤로 바꾼 뒤 앱을 돌린다. (1) 토큰 만료 로직이 클라이언트 Date()에 의존하면 즉시 오동작(로그아웃되거나, 만료된 토큰을 유효로 오인)하는 것을 관찰. (2) 타임아웃/쿨다운을 Date() 차이로 구현했다면 시계를 뒤로 돌리는 순간 쿨다운이 영원히 안 끝나는 버그를 재현. (3) ContinuousClock으로 바꾼 버전은 시계 조작과 무관하게 동작함을 대조 확인한다. 서버 오프셋은 mitmproxy로 응답의 Date 헤더를 강제로 틀리게 바꿔 보정 로직이 어떻게 반응하는지 본다.

프로젝트 적용

⚠️ 흔한 오해

"NTP로 동기화하면 모든 기기 시간이 같아지니 클라이언트 시계를 믿어도 된다"는 과신이다. NTP는 오차를 줄일 뿐 없애지 못하고, 사용자는 언제든 자동 설정을 끄고 시계를 바꿀 수 있으며, 배터리 방전으로 초기화되기도 한다. 반대 극단인 "그럼 단조 시계만 쓰면 되지"도 틀렸다 — 단조 시계는 절대 시각을 모르고 기기 재부팅 시 리셋되므로 "몇 시에 일어난 일"이나 "만료 시각"을 표현할 수 없다. 두 시계는 용도가 다르고, 절대 신뢰가 필요한 순간에는 둘 다 아닌 서버가 답이다.

꼬리 질문

출처 · 참고자료