모바일 앱은 신뢰할 수 없는 네트워크 위에서 도는 분산 시스템의 한 노드다. 이 챕터는 "요청은 갔는데 응답이 안 온다", "결제가 두 번 될 수도 있다", "기기 시계를 못 믿는다" 같은, 서버-클라이언트 사이에서 반드시 마주치는 네 가지 P0 문제를 CS 원리부터 iOS 구현까지 관통한다.
Q1. 요청은 성공했지만 응답을 받지 못했을 때 재시도해도 되는가?
"응답을 못 받았다"는 건 서버가 처리했는지 모른다는 뜻이지 실패했다는 뜻이 아니다. 네트워크는 요청·응답 어느 쪽도 유실할 수 있으므로 타임아웃만으로는 성공/실패를 구분할 수 없다. 따라서 재시도 안전성은 연산의 성질에 달려 있다. 조회처럼 부수효과가 없는 멱등(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은 PUT·DELETE와 safe 메서드(GET·HEAD·OPTIONS·TRACE)를 멱등으로 정의하고, 메서드 레지스트리 표에서 POST와 CONNECT를 비멱등으로 표시한다. 여기서 멱등이란 "같은 요청을 N번 보내도 서버 상태에 미치는 효과가 1번과 같다"는 의미다(응답 바디까지 같아야 한다는 뜻은 아니다).
iOS에서는
URLSession이 스스로 재시도하는 경우는 좁다. 재사용하려던 keep-alive(persistent) 연결이 요청 바이트가 나가기 전에 이미 닫혀 있었던 경우처럼, 서버에 아무것도 도달하지 않은 것이 확실한 상황에 한해 새 연결로 다시 시도할 수 있다. 반면 연결이 끊기는 경우(NSURLErrorNetworkConnectionLost, -1005)에는 Apple 문서(QA1941)상 요청이 멱등이면(GET·PUT 등) URLSession이 스스로 재전송할 수 있다 — 프레임워크는 HTTP 메서드만 보고 판단한다. 그래서 비멱등 연산을 GET·PUT으로 구현하면 앱이 재시도를 걸지 않아도 이중 실행이 날 수 있고, 반대로 POST는 안전하게 재시도할 수 있는 상황에서도 자동 재시도되지 않으므로 재시도 판단은 결국 앱 계층에서 명시적으로 해야 한다. 처리됐을 수도 있기 때문이다. 이 판단을 프레임워크에 떠넘기지 말고 앱 계층에서 명시적으로 해야 한다.
구조적 동시성에서는 취소와 재시도를 분리해서 다뤄야 한다. Task.sleep 기반 백오프 중에도 취소가 전파되도록 try Task.checkCancellation()을 넣고, 재시도 판단은 오류 코드와 메서드 멱등성에 근거한다.
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) // ⚠️ 소폭 가산 지터로는 부족하다 — 모든 클라이언트가 100ms 폭에 몰린다.
// full jitter(0 ~ 지수 상한 균등 추출)로 흩뿌려야 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), 언제 타임아웃됐는지를 확인한다.
프로젝트 적용
① 재시도 정책을 HTTP 메서드가 아니라 "이 엔드포인트가 멱등한가" 플래그로 관리한다. POST여도 서버가 idempotency key를 지원하면 재시도 허용으로 표시.
import Foundation
// "GET은 안전, POST는 위험"은 부정확하다. 판단 기준은 메서드가 아니라
// '이 엔드포인트가 멱등한가'다. POST여도 서버가 멱등 키를 지원하면 안전하다.
struct Endpoint {
let path: String
let method: String
/// 재시도해도 서버 상태의 최종 효과가 같은가
let isIdempotent: Bool
/// 재시도 시 멱등 키를 붙여야 하는가
let requiresIdempotencyKey: Bool
static let fetchFeed = Endpoint(
path: "/feed", method: "GET",
isIdempotent: true, requiresIdempotencyKey: false)
static let createOrder = Endpoint(
path: "/orders", method: "POST",
isIdempotent: true, // ✅ 서버가 멱등 키를 지원한다
requiresIdempotencyKey: true)
static let sendAnalytics = Endpoint(
path: "/events", method: "POST",
isIdempotent: false, // 중복돼도 큰 문제는 없지만
requiresIdempotencyKey: false) // 재시도 대상은 아니다
static let chargePayment = Endpoint(
path: "/payments", method: "POST",
isIdempotent: true,
requiresIdempotencyKey: true) // ⚠️ 키 없이는 절대 재시도 금지
}
// ✅ 재시도 판단을 한 곳에 모은다
func shouldRetry(_ endpoint: Endpoint, status: Int?, error: Error?) -> Bool {
// 408·429·연결 실패처럼 '서버가 처리하지 않은 것이 확실한' 경우는 멱등성과 무관하게 안전하다
if status == 408 || status == 429 { return true }
if error?.code == .cannotConnectToHost { return true }
guard endpoint.isIdempotent else { return false }
if let status {
return status == 408 || status == 429 || (500..<600).contains(status)
}
if let urlError = error as? URLError {
return [.timedOut, .networkConnectionLost, .cannotConnectToHost]
.contains(urlError.code)
}
return false
}
// ✅ 키 누락을 컴파일 타임에 가깝게 잡는다
func send(_ endpoint: Endpoint, key: String?) throws {
if endpoint.requiresIdempotencyKey {
guard key != nil else { throw ClientError.missingIdempotencyKey }
}
}
enum ClientError: Error { case missingIdempotencyKey }② 지수 백오프에 반드시 jitter를 섞는다. 없으면 서버 장애 복구 순간 모든 클라이언트가 동시에 재시도해 다시 무너뜨린다(thundering herd).
import Foundation
// 지터가 없으면 서버 장애 복구 순간 모든 클라이언트가 동시에 재시도해
// 서버를 다시 무너뜨린다(thundering herd).
struct BackoffPolicy {
var base: TimeInterval = 1
var cap: TimeInterval = 30
/// ❌ 지터 없음 — 전 사용자가 정확히 같은 시각에 몰린다
func withoutJitter(attempt: Int) -> Duration {
.seconds(min(base * pow(2, Double(attempt - 1)), cap))
// 1s, 2s, 4s, 8s … 모든 기기가 동일
}
/// ✅ full jitter — 0 ~ 지수 상한 사이 균등 추출 (AWS 권장)
func fullJitter(attempt: Int) -> Duration {
let ceiling = min(base * pow(2, Double(attempt - 1)), cap)
return .seconds(Double.random(in: 0...ceiling))
}
/// ✅ decorrelated jitter — 이전 대기에 기반해 더 고르게 퍼진다
func decorrelated(previous: TimeInterval) -> Duration {
let next = Double.random(in: base...(previous * 3))
return .seconds(min(next, cap))
}
}
// 분포를 눈으로 확인해 보면 차이가 명확하다
func illustrate() {
let policy = BackoffPolicy()
let without = (1...4).map { policy.withoutJitter(attempt: $0) }
let with = (1...4).map { policy.fullJitter(attempt: $0) }
print("지터 없음: \(without)") // 항상 동일 → 동시에 몰린다
print("지터 있음: \(with)") // 매번 다름 → 흩어진다
}
// ⚠️ 지터는 '평균 대기'를 늘리지 않는다. 분산시킬 뿐이다.
// full jitter의 기댓값은 지수 상한의 절반이라 오히려 평균은 짧아진다.③ 재시도 상한(횟수·총 시간)을 둔다. 무한 재시도는 사용자 배터리·데이터를 태우고 서버 부하를 키운다.
import Foundation
// 무한 재시도는 사용자 배터리·데이터를 태우고 서버 부하를 키운다.
// 횟수만 제한하면 백오프가 길어질 때 몇 분을 기다리게 된다.
struct RetryPolicy {
var maxAttempts = 4
var maxTotalDuration: Duration = .seconds(60)
var base: TimeInterval = 1
var cap: TimeInterval = 30
func delay(attempt: Int) -> Duration {
let ceiling = min(base * pow(2, Double(attempt - 1)), cap)
return .seconds(Double.random(in: 0...ceiling))
}
}
func withRetry<T>(_ policy: RetryPolicy = .init(),
operation: (Int) async throws -> T) async throws -> T {
let deadline = ContinuousClock.now + policy.maxTotalDuration
var lastError: Error?
for attempt in 1...policy.maxAttempts {
do {
return try await operation(attempt)
} catch {
lastError = error
}
guard attempt < policy.maxAttempts else { break }
let wait = policy.delay(attempt: attempt)
// ✅ 총 시간 상한을 넘으면 더 기다리지 않는다
guard ContinuousClock.now + wait < deadline else { break }
try await Task.sleep(for: wait)
// ✅ 취소도 존중한다
try Task.checkCancellation()
}
throw lastError ?? ClientError.exhausted
}
// ✅ 사용자 대면 요청과 백그라운드 작업의 정책을 분리한다
extension RetryPolicy {
/// 화면에서 기다린다 — 빨리 포기하고 사용자에게 알린다
static let interactive = RetryPolicy(
maxAttempts: 2, maxTotalDuration: .seconds(10))
/// 오프라인 큐 — 오래 버텨도 된다
static let background = RetryPolicy(
maxAttempts: 8, maxTotalDuration: .seconds(600), cap: 120)
}
enum ClientError: Error { case exhausted }④ 하지 말 것: 결제·주문 같은 비멱등 POST를 idempotency key 없이 타임아웃만 보고 재시도. 이중 결제의 직행 티켓이다.
import Foundation
// 타임아웃은 '실패'가 아니라 '결과를 모름'이다.
// 서버가 이미 결제를 완료했을 수 있는데 재시도하면 이중 결제다.
// ❌ 이중 결제 직행 코드
func chargeBad(_ amount: Decimal) async throws {
for _ in 0..<3 {
do {
_ = try await post("/payments", body: encode(amount))
return
} catch {
continue // 💥 서버는 이미 처리했을 수 있다
}
}
}
// ✅ ① 서버가 멱등 키를 지원하면 — 키를 붙이고 재시도한다
func charge(_ amount: Decimal) async throws -> Receipt {
let key = UUID().uuidString // 루프 밖에서 한 번만
return try await withRetry { _ in
var request = URLRequest(url: paymentsURL)
request.httpMethod = "POST"
request.httpBody = encode(amount)
request.setValue(key, forHTTPHeaderField: "Idempotency-Key")
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(Receipt.self, from: data)
}
}
// ✅ ② 지원하지 않으면 — 재시도하지 말고 '조회'로 확인한다
func chargeWithoutKey(_ amount: Decimal, requestID: String) async throws -> Receipt {
do {
return try await postPayment(amount, requestID: requestID)
} catch is TimeoutError {
// ⚠️ 재시도 금지. 대신 상태를 물어본다.
return try await pollPaymentStatus(requestID: requestID)
}
}
func pollPaymentStatus(requestID: String) async throws -> Receipt {
for _ in 0..<10 {
if let receipt = try await queryPayment(requestID) { return receipt }
try await Task.sleep(for: .seconds(2))
}
throw ClientError.unknownOutcome // 사용자에게 "확인 중"으로 안내
}
// ✅ ③ 그것도 안 되면 사용자에게 명확히 알린다
// "결제 결과를 확인하는 중입니다. 다시 시도하지 마세요."
// → 임의 재시도보다 훨씬 나은 UX다
struct Receipt: Decodable {}
struct TimeoutError: Error {}
enum ClientError: Error { case unknownOutcome }
let paymentsURL = URL(string: "https://api.example.com/payments")!
func encode(_ d: Decimal) -> Data { Data() }
func post(_ p: String, body: Data) async throws -> Data { Data() }
func postPayment(_ a: Decimal, requestID: String) async throws -> Receipt { Receipt() }
func queryPayment(_ id: String) async throws -> Receipt? { nil }
func withRetry<T>(_ op: (Int) async throws -> T) async throws -> T { try await op(1) }"타임아웃 = 실패니까 안전하게 재시도하면 된다"는 틀렸다. 타임아웃은 실패가 아니라 결과를 모름(unknown)이다. 서버는 이미 정상 처리하고 커밋까지 마쳤을 수 있다. 마찬가지로 "GET은 안전하고 POST는 위험하다"도 정확하지 않다 — 위험한 건 메서드가 아니라 부수효과의 유무와 중복 제거 장치의 유무다. 멱등 키를 갖춘 POST는 재시도해도 안전하고, 서버가 조회 때마다 로그를 남긴다면 그 GET조차 부수효과가 있다.
편지를 우체통에 넣었는데 답장이 안 온다고 해 보자. 편지가 가다가 없어졌을 수도 있고, 친구가 다 읽고 답장까지 썼는데 그 답장이 오다가 없어졌을 수도 있고, 친구가 아직 답장을 쓰는 중일 수도 있다. 내 쪽에서 보면 이 셋이 완전히 똑같다. 우편함이 비어 있다는 사실 하나뿐이니까. 그래서 타임아웃은 "실패했다"가 아니라 "어떻게 됐는지 모른다"는 뜻이다. 이걸 실패로 착각하는 순간부터 사고가 시작된다.
그럼 편지를 한 번 더 보내도 될까? 내용이 "잘 지내?"면 두 번 가도 아무 일 없다. 그런데 "내 통장에서 만 원 보내줘"라면 두 번 가는 순간 이만 원이 나간다. 다시 보내도 되는지는 길(네트워크)이 정하는 게 아니라 편지에 뭐라고 썼는지가 정한다. 비유가 살짝 어긋나는 지점 하나: 진짜 우체국과 달리 서버는 "아까 그 편지 또 왔네"를 알아보는 장치를 달 수 있다(Q2의 idempotency key). 그 장치가 달려 있으면 만 원짜리 편지도 마음 놓고 다시 보낼 수 있다.
꼬리 질문
서버가 응답을 보내기 직전에 DB 커밋은 성공했지만 프로세스가 죽었다. 클라이언트가 재시도하면 어떤 순서로 안전을 보장해야 하는가?
핵심은 "부수효과 실행"과 "멱등 키 저장", 그리고 "응답 스냅샷"을 같은 트랜잭션에서 원자적으로 커밋하는 것이다. 그러면 커밋이 성공했다는 사실 자체가 곧 키가 저장됐다는 뜻이므로, 프로세스가 죽어 응답만 유실됐어도 재시도가 오면 유니크 제약에 걸려 다시 실행되지 않고 저장된 결과를 되돌려준다.
반대로 응답 생성이나 키 저장을 트랜잭션 밖으로 빼면, "커밋은 됐는데 키가 없어" 재시도가 두 번째 실행으로 흐르는 틈이 생긴다. 그래서 순서는 실행 + 키 + 응답 스냅샷을 한 번에 커밋 → 그다음 응답 전송이고, 전송 실패는 이미 커밋된 상태를 재현해 회복한다.
재시도 대상이 5xx가 아니라 408 Request Timeout과 425 Too Early라면 각각 어떻게 다르게 처리해야 하는가?
408은 "네가 요청을 다 안 보내서 서버가 대기하다 연결을 닫는다"는 신호다. 서버가 요청을 온전히 받지 못했으니 부수효과가 없고, 비멱등 연산이라도 그대로 재전송해도 안전하다.
425는 TLS 1.3 0-RTT early data로 온 요청을 서버가 "재생(replay) 위험이 있으니 아직 처리하지 않겠다"며 되돌린 것이다. 역시 처리 전이라 재시도 자체는 안전하지만, 반드시 0-RTT를 쓰지 않고 정식 핸드셰이크 이후에 다시 보내야 한다. 둘 다 "미처리 확정"이지만 425만 전송 방식을 바꿔야 하는 점이 다르다.
gRPC/HTTP/2 스트리밍처럼 연결 하나에 여러 요청이 실려 있을 때, 연결 끊김에서 어떤 요청이 서버에 도달했는지 어떻게 판별하는가?
HTTP/2는 요청마다 stream ID를 부여하고, 정상 종료 시 GOAWAY 프레임에 "마지막으로 처리를 시작한 stream ID"를 담아 보낸다. 그보다 큰 ID의 요청은 서버가 손대지 않았음이 보장되므로 안전하게 재시도할 수 있다. GOAWAY 없이 연결이 급사하면 이 경계를 알 수 없어 각 요청을 개별 판단해야 한다.
그래서 gRPC는 RPC를 서비스 설정(service config)의 retryPolicy로 메서드별 재시도 가능 상태 코드를 지정하고 — 멱등 여부는 서비스 소유자가 그 설정으로 표현한다 —, 응답 헤더를 하나도 받지 못한 경우에 한해 자동 재시도하며 이미 스트림 응답이 시작된 호출은 재시도/hedging 대상에서 뺀다. 결국 연결 레벨 신호(GOAWAY)와 요청 레벨 멱등성을 함께 봐야 판별이 완성된다.
Q2. 결제·송금·주문 요청의 중복 실행을 어떻게 방지하는가?
중복 방지의 핵심은 클라이언트 재시도를 막는 게 아니라, 서버가 재시도를 알아보고 한 번만 반영하도록 만드는 것이다. 표준 해법은 중복 제거(deduplication) 토큰이다. 클라이언트가 각 논리적 연산에 고유한 idempotency key를 붙여 보내고, 서버는 그 키를 같은 트랜잭션 안에서 유니크 제약으로 저장한다. 같은 키의 두 번째 요청은 새로 실행하지 않고 처음 결과를 그대로 되돌려준다. 이렇게 하면 재시도가 몇 번 오든 부수효과는 정확히 한 번 일어난다. 클라이언트 측 버튼 비활성화는 UX 보정일 뿐 진짜 방어선은 서버다.
CS 원리
중복 실행은 두 층위에서 발생한다. (1) 사용자가 여러 번 누름(더블 탭, 조급한 재시도), (2) 시스템이 재시도함(네트워크 타임아웃 후 자동/수동 재전송). 둘 다 결국 "같은 의도의 요청이 서버에 2회 이상 도착"으로 수렴하므로, 방어선은 수신 측에서 같은 의도를 동일한 것으로 인식하는 데 있다.
이를 위해선 각 요청에 안정적인 신원(stable identity)이 있어야 한다. 서버가 매번 생성하는 auto-increment ID로는 안 된다 — 재시도된 요청은 클라이언트 입장에서 "같은 것"이지만 서버는 새 요청으로 볼 것이기 때문이다. 그래서 클라이언트가 미리 부여한 키가 필요하다. 서버는 이 키에 유니크 제약을 걸고, 비즈니스 로직 실행과 키 저장을 하나의 원자적 트랜잭션으로 묶는다. 키 저장이 커밋되면 부수효과도 커밋되고, 롤백되면 둘 다 사라진다 — 이 원자성이 깨지면 "돈은 나갔는데 키는 저장 안 됨" 같은 최악의 틈이 생긴다.
iOS에서는
클라이언트가 할 일은 두 가지다. (1) 요청을 만드는 순간 키를 한 번 생성해 붙이고, (2) 재시도할 때 그 키를 재사용한다. iOS에선 UUID()로 키를 만들고 커스텀 헤더(Idempotency-Key)에 실어 보낸다. 중요한 건 재시도 루프가 매번 새 키를 만들면 서버 입장에선 서로 다른 요청이 되어 방어가 무너진다는 점 — 키는 요청 객체에 고정돼야 한다.
더불어 오프라인 큐를 쓴다면(예: 앱이 죽었다 살아나도 결제를 완주해야 하는 경우), 키를 요청 페이로드와 함께 디스크에 영속화해야 앱 재시작 후에도 같은 키로 재시도할 수 있다. 메모리에만 있으면 재실행 = 새 키 = 이중 결제 위험.
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 층 방어(버튼 즉시 비활성화, 진행 중 스피너, 디바운스)는 정상 흐름에서 더블 탭을 줄여 주지만, 앱 강제 종료·네트워크 재시도까지 막지는 못한다. 그래서 이건 보조이고 서버 키가 본진이다.
프로젝트 적용
① 키 유니크 제약과 부수효과를 같은 DB 트랜잭션에 넣는다. 별도 캐시(Redis 등)에 키만 먼저 기록하면 "키는 있는데 결제는 롤백됨" 틈이 생긴다.
-- 서버 측 설계. 키 기록과 실제 효과가 분리되면 그 틈에서 사고가 난다.
-- ❌ 캐시(Redis)에 키만 먼저 기록하고 DB는 따로
-- → "키는 있는데 결제는 롤백됨" 상태가 생긴다.
-- 재시도가 오면 "이미 처리됨"으로 판단해 결제가 영영 안 된다.
-- ✅ 같은 트랜잭션 안에서 키 삽입과 부수효과를 함께 커밋한다
BEGIN;
-- ① 키를 먼저 삽입한다. 유니크 위반이면 중복 요청이다.
INSERT INTO idempotency_key (key, request_fingerprint, created_at, status)
VALUES ($1, $2, now(), 'in_progress');
-- UNIQUE(key) 제약이 동시 요청도 막아 준다
-- ② 실제 부수효과
INSERT INTO payment (order_id, amount, currency)
VALUES ($3, $4, $5)
RETURNING id INTO v_payment_id;
UPDATE account SET balance = balance - $4 WHERE id = $6;
-- ③ 응답을 저장해 재요청 시 그대로 재현할 수 있게 한다
UPDATE idempotency_key
SET status = 'completed', status_code = 201, response_body = $7
WHERE key = $1;
COMMIT;
-- 어디서 실패하든 전부 롤백된다 — 키도, 결제도 남지 않는다
-- ✅ 동시 요청 처리: 두 번째 요청은 유니크 위반으로 실패한다
-- 그때는 첫 요청이 끝날 때까지 기다렸다가 저장된 응답을 돌려준다
SELECT status, status_code, response_body
FROM idempotency_key
WHERE key = $1
; -- ⚠️ 커밋 전 행은 다른 트랜잭션에 아예 안 보여 FOR UPDATE는 기다리지 않는다.
-- 실제 대기는 아래 INSERT가 유니크 인덱스에서 블록될 때 일어난다
-- ⚠️ '위처럼 한 트랜잭션으로 묶으면 크래시 시 전부 롤백되어 'in_progress'가 남지 않는다.
-- 반대로 동시 요청에 '처리 중'을 보여 주려고 키 행을 먼저 커밋하는 2단계 설계를 택하면,
-- 커밋된 채 방치된 'in_progress' 행을 주기적으로 정리해야 한다
DELETE FROM idempotency_key
WHERE status = 'in_progress' AND created_at < now() - interval '10 minutes';② 서버는 같은 키의 재요청에 대해 처음의 상태 코드·바디를 그대로 재현한다. 두 번째에 409를 던지면 클라이언트가 "실패"로 오인해 사용자에게 잘못 표시할 수 있다.
import Foundation
// 두 번째 요청에 409를 던지면 클라이언트가 '실패'로 오인해
// 사용자에게 "결제 실패"를 보여 준다 — 실제로는 성공했는데도.
/* 서버 동작 규약 (클라·서버가 합의해야 한다)
첫 요청 → 처리하고 201 + {"orderId":"ord_1"} 저장
같은 키 재요청 → 저장된 201 + {"orderId":"ord_1"} 를 그대로 반환
(헤더로 재현임을 알려도 좋다: Idempotent-Replay: true)
❌ 두 번째에 이미 완료된 요청의 재현에 409 Conflict → 클라가 실패로 처리한다 (완료 건은 저장된 응답을 그대로 재현해야 한다)
⭕ 첫 요청이 아직 처리 중일 때의 409는 표준 규약이다 → 클라는 잠시 뒤 같은 키로 다시 시도한다
❌ 두 번째에 200이지만 빈 바디 → 클라가 파싱에 실패한다 */
// 클라이언트는 재현 응답도 성공으로 다룬다
func createOrder(_ body: Data, key: String) async throws -> Order {
var request = URLRequest(url: ordersURL)
request.httpMethod = "POST"
request.httpBody = body
request.setValue(key, forHTTPHeaderField: "Idempotency-Key")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else { throw ClientError.invalidResponse }
switch http.statusCode {
case 200, 201:
// ✅ 재현이든 최초든 동일하게 성공 처리
if http.value(forHTTPHeaderField: "Idempotent-Replay") == "true" {
// 필요하면 분석용으로만 기록한다 (사용자에게는 구분해 보이지 않는다)
}
return try JSONDecoder().decode(Order.self, from: data)
case 409:
// ⚠️ 서버가 이렇게 설계돼 있다면 팀과 재협의해야 한다.
// 임시 대응: 조회로 실제 상태를 확인한다
return try await fetchOrderByKey(key)
case 422:
throw ClientError.keyReusedWithDifferentBody
default:
throw ClientError.http(http.statusCode)
}
}
struct Order: Decodable {}
enum ClientError: Error { case invalidResponse, keyReusedWithDifferentBody, http(Int) }
let ordersURL = URL(string: "https://api.example.com/orders")!
func fetchOrderByKey(_ key: String) async throws -> Order { Order() }③ 키에는 요청 지문(fingerprint)을 함께 검증한다. 같은 키인데 금액이 다르면 그건 재시도가 아니라 버그/공격이므로 422로 거부.
import Foundation
import CryptoKit
// 같은 키인데 본문이 다르면 그건 재시도가 아니다.
// 클라이언트 버그이거나 공격이다 — 조용히 처리하면 안 된다.
// 클라이언트: 키와 본문을 한 묶음으로 관리한다
struct IdempotentRequest {
let key: String
let body: Data
/// 서버가 지문을 대조할 수 있게 함께 보낸다
var fingerprint: String {
Data(SHA256.hash(data: body)).base64EncodedString()
}
init(body: Data) {
self.key = UUID().uuidString
self.body = body
}
}
func send(_ request: IdempotentRequest) async throws -> Data {
var urlRequest = URLRequest(url: ordersURL)
urlRequest.httpMethod = "POST"
urlRequest.httpBody = request.body
urlRequest.setValue(request.key, forHTTPHeaderField: "Idempotency-Key")
urlRequest.setValue("sha-256=:\(request.fingerprint):", forHTTPHeaderField: "Content-Digest") // RFC 9530 Structured Fields 형식
let (data, response) = try await URLSession.shared.data(for: urlRequest)
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
if code == 422 {
// ✅ 서버가 "같은 키, 다른 내용"을 거부했다 — 클라 버그다. 로그를 남긴다.
assertionFailure("멱등 키 재사용 시 본문이 달라졌다: \(request.key)")
throw ClientError.keyReusedWithDifferentBody
}
return data
}
/* 서버 측 검증
SELECT request_fingerprint FROM idempotency_key WHERE key = $1;
→ 저장된 지문과 이번 요청의 지문이 다르면 422로 거부한다
이 검증이 잡아 주는 것
· 클라이언트가 재시도 중 본문을 바꾼 버그
· 키를 탈취해 다른 내용을 밀어 넣으려는 시도
· 서로 다른 요청에 같은 키를 재사용한 실수 */
enum ClientError: Error { case keyReusedWithDifferentBody }
let ordersURL = URL(string: "https://api.example.com/orders")!④ 하지 말 것: 클라이언트 버튼 비활성화만 믿기. 하지 말 것: 재시도 때 키 새로 만들기.
import UIKit
// ❌ ① 클라이언트 버튼 비활성화만으로 중복을 막으려는 시도
@MainActor
final class BadCheckoutViewController: UIViewController {
private let payButton = UIButton()
@objc func didTapPay() {
payButton.isEnabled = false // 이걸로 끝이라고 생각한다
Task { try? await charge() }
}
private func charge() async throws {}
}
/* 이 방어를 우회하는 경로들
· 네트워크 타임아웃 후 자동 재시도 (UI를 안 거친다)
· 앱 강제 종료 후 오프라인 큐 재실행
· 리버싱한 클라이언트
· 여러 기기에서 동시 시도
→ 버튼 비활성화는 UX 보정이지 방어선이 아니다. 진짜 방어선은 서버다. */
// ❌ ② 재시도할 때마다 키를 새로 만든다
func retryBad(_ body: Data) async throws {
for _ in 0..<3 {
var r = URLRequest(url: url)
r.httpMethod = "POST"
r.setValue(UUID().uuidString, forHTTPHeaderField: "Idempotency-Key") // 💥
r.httpBody = body
if let _ = try? await URLSession.shared.data(for: r) { return }
}
// 서버 입장에서는 서로 다른 3개의 요청 → 3번 결제된다
}
// ✅ 키는 요청 하나당 하나. 재시도 루프 바깥에서 만든다.
func retryGood(_ body: Data) async throws {
let key = UUID().uuidString // ✅ 여기, 루프 밖
for attempt in 1...3 {
var r = URLRequest(url: url)
r.setValue(key, forHTTPHeaderField: "Idempotency-Key")
r.httpBody = body
if let _ = try? await URLSession.shared.data(for: r) { return }
try await Task.sleep(for: .seconds(pow(2, Double(attempt))))
}
}
// ✅ 오프라인 큐에 저장할 때도 키를 함께 저장한다
struct QueuedRequest: Codable {
let key: String // ✅ 앱을 재시작해도 같은 키
let body: Data
let createdAt: Date
}
let url = URL(string: "https://api.example.com/payments")!"프론트에서 더블 탭만 막으면 중복은 안 난다"는 착각이다. 사용자 더블 탭은 중복의 한 원인일 뿐이고, 네트워크 타임아웃 후 자동 재시도·앱 재시작 후 큐 재실행은 UI를 우회한다. 또 하나: "서버에서 요청이 올 때마다 최근 N초 내 같은 금액 결제가 있으면 막자"는 시간 윈도우 방식은 위험하다 — 사용자가 진짜로 같은 금액을 두 번 결제하려는 정당한 경우를 잘못 차단한다. 신원은 시간이 아니라 명시적 키로 판별해야 한다.
매표소에 신청서를 내는데, 내가 직접 종이 위에 "이 신청서 이름표: A1B2"라고 적어서 낸다고 하자. 직원은 접수 장부에 그 이름표를 적고 티켓을 만들어 준다. 그런데 답을 못 받은 내가 똑같은 신청서를 한 장 더 냈다. 직원은 장부를 훑고 "A1B2? 아까 그거잖아" 하고는 티켓을 새로 만들지 않고 아까 만든 티켓을 그대로 다시 보여 준다. 그래서 신청서를 열 장을 더 내도 티켓은 한 장이다. 재시도를 막은 게 아니라, 재시도를 알아본 것이다.
여기서 제일 자주 미끄러지는 지점은 순서다. "장부에 이름표 적기"와 "티켓 만들기"는 한 손으로 동시에 해야 한다. 이름표만 적어 두고 티켓 만들다 실패하면 진짜 티켓 없는 사람이 생기고, 티켓만 만들고 이름표를 안 적으면 다음 신청서가 티켓을 또 만든다. 그래서 서버는 이 둘을 같은 트랜잭션에 묶는다. 앱에서 버튼을 회색으로 만들어 두 번 못 누르게 하는 건 줄이 엉키지 말라고 안내판을 세우는 정도지, 티켓이 두 장 나오는 걸 막아 주지는 않는다 — 앱이 꺼졌다 켜지거나 네트워크가 알아서 재시도하면 안내판 옆으로 그냥 지나가기 때문이다.
꼬리 질문
DB가 유니크 제약을 지원하지 않는 NoSQL/이벤트 스트림 환경이라면 중복 제거를 어디서 어떻게 구현하는가?
유니크 인덱스가 없으면 조건부 쓰기(conditional put / compare-and-set)가 그 역할을 대신한다. DynamoDB라면 PutItem에 attribute_not_exists(key) 조건을 걸어 키가 없을 때만 삽입하고, 조건 실패가 곧 중복 신호다.
Kafka 같은 이벤트 스트림은 두 겹으로 막는다. 프로듀서 쪽은 idempotent producer(PID + sequence)나 트랜잭션으로 브로커가 재전송을 걸러내고, 컨슈머 쪽은 이미 처리한 키를 상태 저장소(RocksDB 등)에 기록해 dedup한다. 저장소 형태는 달라도 원리는 하나다 — "키의 존재를 원자적으로 확인하고 없을 때만 실행".
결제 서비스와 재고 차감 서비스가 분리된 마이크로서비스일 때, 한쪽만 성공하는 부분 실패를 어떻게 멱등하게 만드는가(saga/outbox)?
2PC(분산 트랜잭션)는 결합도와 가용성 비용이 커서 잘 안 쓰고, 보통 saga로 "각 단계는 로컬 트랜잭션, 실패하면 보상(compensating) 트랜잭션"을 엮는다. 결제는 됐는데 재고 차감이 실패하면 결제를 취소(환불)하는 보상을 실행하는 식이고, 재시도가 안전하도록 각 단계 호출은 반드시 멱등해야 한다.
여기서 "상태 변경"과 "이벤트 발행"을 한 트랜잭션에 묶으려고 outbox 패턴을 쓴다. 이벤트를 outbox 테이블에 함께 커밋하고 별도 릴레이가 읽어 발행하므로 at-least-once로 전달되며, 수신 측이 이벤트 ID로 dedup한다. 결과적으로 "eventual consistency + 멱등 소비"가 exactly-once에 준하는 효과를 만든다.
같은 키의 두 요청이 거의 동시에 서버 두 인스턴스에 도착하는 경쟁 상태에서, 유니크 제약 외에 무엇이 더 필요한가?
유니크 제약 자체는 경쟁의 절반만 푼다 — 두 삽입 중 하나만 성공하고 하나는 위반으로 걸러진다. 남는 문제는 진 쪽이 "이미 처리 중이지만 아직 결과가 커밋되지 않은" 첫 요청의 결과를 어떻게 되돌려주느냐다. 위반 직후 결과를 조회해도 첫 트랜잭션이 진행 중이면 아직 없을 수 있다.
그래서 키 레코드에 in-progress / completed 같은 상태를 두고, in-progress면 짧게 대기 후 재조회하거나 클라이언트에 "잠시 후 재시도(409)"로 응답하는 규약이 필요하다. 또는 SELECT ... FOR UPDATE나 분산 락으로 두 번째 요청을 첫 요청 완료까지 블로킹한다. 요컨대 "유일성"뿐 아니라 "진행 중 상태의 가시성과 대기 규약"이 함께 있어야 한다.
Q3. Idempotency key는 무엇이며 누가 생성하고 얼마나 유지해야 하는가?
Idempotency key는 "이 요청은 이 논리적 연산 하나를 가리킨다"고 못 박는 클라이언트 발급 고유 식별자다. 반드시 요청을 시작하는 쪽(클라이언트)이 생성해야 한다 — 재시도들이 같은 신원을 공유해야 하는데, 그 신원을 아는 건 재시도를 하는 클라이언트뿐이기 때문이다. 서버가 발급하면 재시도 때 물어볼 곳이 없다. 값은 충돌 확률이 무시할 만한 것(UUIDv4 등)을 쓴다. 유지 기간은 "재시도가 현실적으로 올 수 있는 창"을 덮으면 충분하다 — 보통 24시간~며칠. Stripe는 키를 최소 24시간 보관한 뒤 자동으로 제거하며(24시간은 보장 하한이다), 제거된 뒤 같은 키가 오면 새 요청으로 처리한다. 영원히 보관할 필요는 없고, 만료 후 같은 키가 재사용되면 새 연산으로 처리된다.
CS 원리
Idempotency key의 본질은 연산에 붙는 지문이다. "요청 자체"의 식별자(전송 계층의 request-id, 재전송마다 바뀔 수 있음)와 구분해야 한다 — idempotency key는 여러 번의 전송이 하나의 의도임을 나타내는 상위 개념이다.
생성 주체가 클라이언트여야 하는 이유는 인과적이다. 중복은 "같은 의도를 여러 번 보냄"에서 나오고, 그 "같음"을 아는 유일한 주체는 의도를 가진 발신자다. 서버는 두 요청이 우연히 같은지 원래 같은 것의 재시도인지 스스로 알 수 없다. 그래서 발신자가 키로 명시해 준다.
보관 기간은 trade-off다. 길수록 늦은 재시도도 안전하게 걸러내지만 저장 비용이 늘고, 짧으면 저장은 싸지만 만료 후 도착한 재시도를 새 연산으로 오인해 중복 실행할 수 있다. 그래서 기준은 "재시도가 현실적으로 도달할 수 있는 최대 시간"(클라이언트 재시도 상한 + 오프라인 큐 잔존 시간 + 여유)을 덮는 것이다.
iOS에서는
키 생성은 UUID().uuidString이 기본이다(122비트 랜덤, 충돌 무시 가능). 정렬 가능성이 필요하면 시간 접두를 붙인 UUIDv7 스타일을 직접 만들 수 있지만, 중복 제거 용도만이라면 v4로 충분하다. 결제·주문 화면에 진입할 때(또는 요청 객체를 만들 때) 키를 부여하고, 사용자가 여러 번 제출을 시도해도 같은 내용이 바뀌지 않은 하나의 논리적 연산 = 같은 키가 되도록 한다. 사용자가 금액·결제수단 등 요청 내용을 바꿔 다시 제출하면 그건 다른 연산이므로 새 키를 발급해야 한다 — 같은 키에 다른 본문은 서버가 422로 거부하거나 저장해 둔 첫 실패 응답을 그대로 재현하기 때문이다.
키의 수명 관리는 클라이언트에서도 중요하다. 오프라인 큐/영속 저장을 쓸 때 키를 언제 폐기할지는 서버 보관 기간과 맞춰야 한다. 성공 응답을 받으면 큐에서 제거하고, 서버가 키를 만료했을 만큼 오래된 대기 요청은 사용자에게 재확인을 받아 새 키로 다시 시작한다.
| 축 | 클라이언트 생성 (권장) | 서버 생성 |
|---|---|---|
| 재시도가 같은 키 공유 | 가능 — 발신자가 키를 쥐고 있음 | 조건부 — 부수효과 요청과 같은 응답으로 내려주면 불가, 선행 요청으로 미리 발급받아 두면 가능(왕복 1회 추가) |
| 오프라인/앱 재시작 후 재시도 | 가능 — 키를 로컬 영속화 | 불가 |
| 키 값 형식 | UUIDv4 등 클라이언트가 결정 | 서버 스킴 |
| 서버가 최종 방어선 | 키 저장 + 유니크 제약 | 동일 |
실험 · 도구
재현: 같은 Idempotency-Key로 결제 요청을 연달아 3번 보내고(curl 반복 또는 앱 재시도 강제), 서버 응답이 3번 모두 동일한 상태·바디인지, 실제 결제 레코드가 1개인지 확인한다. 그다음 키만 매번 새로 바꿔 3번 보내면 결제 레코드가 3개로 늘어나는 걸 대비 관찰한다 — 키 재사용이 방어의 전부임을 눈으로 확인하는 실험이다. 만료 검증은 서버 보관 TTL을 짧게(예: 10초) 낮춘 스테이징에서, 만료 후 같은 키로 보내면 새 레코드가 생기는지 본다.
프로젝트 적용
① 키는 "요청 시작" 지점에서 단 한 번 만든다. 인터셉터가 모든 요청에 자동으로 새 키를 다는 구현은 재시도마다 키가 바뀌어 위험 — 키 부여는 재시도 계층 바깥이어야 한다.
import Foundation
// 인터셉터가 모든 요청에 자동으로 키를 붙이는 구현은 위험하다.
// 재시도가 인터셉터를 다시 거치면 키가 매번 바뀐다.
// ❌ 재시도 계층 '안쪽'에서 키를 만든다
final class BadInterceptor {
func adapt(_ request: inout URLRequest) {
request.setValue(UUID().uuidString, forHTTPHeaderField: "Idempotency-Key")
// 💥 재시도마다 호출되어 매번 다른 키가 붙는다
}
}
// ✅ 키는 '요청 객체'의 일부로, 만들어질 때 한 번 정해진다
struct APIRequest {
let endpoint: Endpoint
let body: Data
let idempotencyKey: String
init(endpoint: Endpoint, body: Data) {
self.endpoint = endpoint
self.body = body
// ✅ 요청 객체 하나 = 논리적 연산 하나 = 키 하나
self.idempotencyKey = endpoint.requiresIdempotencyKey ? UUID().uuidString : ""
}
}
// 재시도는 같은 APIRequest 객체를 반복 사용한다
final class APIClient {
func send(_ request: APIRequest, maxAttempts: Int = 3) async throws -> Data {
for attempt in 1...maxAttempts {
var urlRequest = URLRequest(url: baseURL.appending(path: request.endpoint.path))
urlRequest.httpMethod = request.endpoint.method
urlRequest.httpBody = request.body
if !request.idempotencyKey.isEmpty {
urlRequest.setValue(request.idempotencyKey,
forHTTPHeaderField: "Idempotency-Key") // ✅ 항상 같다
}
do { return try await perform(urlRequest) }
catch {
guard attempt < maxAttempts else { throw error }
try await Task.sleep(for: .seconds(pow(2, Double(attempt))))
}
}
throw ClientError.exhausted
}
private func perform(_ r: URLRequest) async throws -> Data {
try await URLSession.shared.data(for: r).0
}
private let baseURL = URL(string: "https://api.example.com")!
}
// ✅ 계층 구조로 표현하면 실수하기 어려워진다
// [요청 생성: 키 부여] → [재시도 계층] → [전송 계층]
// 한 번만 반복 반복
struct Endpoint {
let path: String; let method: String; let requiresIdempotencyKey: Bool
}
enum ClientError: Error { case exhausted }② 서버 보관 기간과 클라이언트 재시도/오프라인 큐 상한을 문서로 합의한다. 어긋나면 "만료된 키로 온 재시도"가 조용히 중복을 만든다.
import Foundation
// 서버가 키를 24시간 보관하는데 클라이언트 오프라인 큐가 3일을 버티면,
// 만료된 키로 온 재시도가 '새 연산'으로 처리되어 조용히 중복이 된다.
enum IdempotencyContract {
/// 서버 보관 기간 (백엔드 팀과 합의한 값)
static let serverRetention: TimeInterval = 24 * 60 * 60
/// 클라이언트는 그보다 짧게 잡는다 — 안전 여유를 둔다
static let clientRetryWindow: TimeInterval = 20 * 60 * 60
static func isStillValid(_ createdAt: Date) -> Bool {
Date().timeIntervalSince(createdAt) < clientRetryWindow
}
}
// ✅ 오프라인 큐에서 만료된 요청은 재전송하지 않는다
struct QueuedRequest: Codable {
let key: String
let endpoint: String
let body: Data
let createdAt: Date
}
actor OfflineQueue {
private var pending: [QueuedRequest] = []
func flush() async {
var remaining: [QueuedRequest] = []
for request in pending {
guard IdempotencyContract.isStillValid(request.createdAt) else {
// ⚠️ 만료됨 — 그냥 보내면 중복 위험이 있다
await handleExpired(request)
continue
}
if await send(request) { continue }
remaining.append(request)
}
pending = remaining
}
/// 만료된 요청은 '상태 조회'로 결과를 확인한 뒤 판단한다
private func handleExpired(_ request: QueuedRequest) async {
if let outcome = await queryOutcome(key: request.key) {
record(outcome) // 이미 처리됐다
} else {
await notifyUser(request) // 사용자에게 확인을 요청한다
}
}
private func send(_ r: QueuedRequest) async -> Bool { true }
private func queryOutcome(key: String) async -> String? { nil }
private func record(_ outcome: String) {}
private func notifyUser(_ r: QueuedRequest) async {}
}
// ⚠️ 이 값은 코드 주석이 아니라 API 문서에 명시하고 양쪽이 참조해야 한다.
// 서버가 보관 기간을 줄이면 클라도 함께 줄여야 한다.③ 키 + 요청 본문 지문을 함께 저장해, 같은 키로 다른 내용이 오면 명확히 거부(422)한다.
import Foundation
import CryptoKit
// 같은 키로 다른 내용이 오는 상황은 반드시 명시적으로 거부해야 한다.
// 조용히 처리하면 첫 요청의 결과가 두 번째 요청의 응답으로 돌아간다.
struct IdempotentEnvelope: Codable {
let key: String
let bodyDigest: String
let createdAt: Date
init(key: String, body: Data) {
self.key = key
self.bodyDigest = Data(SHA256.hash(data: body)).base64EncodedString()
self.createdAt = Date()
}
}
// ✅ 클라이언트도 자기 쪽에서 한 번 검증한다 — 버그를 개발 중에 잡는다
actor IdempotencyLedger {
private var issued: [String: IdempotentEnvelope] = [:]
func register(key: String, body: Data) throws {
let envelope = IdempotentEnvelope(key: key, body: body)
if let existing = issued[key] {
guard existing.bodyDigest == envelope.bodyDigest else {
// 💥 같은 키인데 본문이 다르다 — 재시도 로직 버그다
throw LedgerError.digestMismatch(key: key)
}
return // 정상 재시도
}
issued[key] = envelope
}
func prune(olderThan interval: TimeInterval) {
let cutoff = Date().addingTimeInterval(-interval)
issued = issued.filter { $0.value.createdAt > cutoff }
}
}
/* 서버 측 규약
저장: (key, request_fingerprint, status_code, response_body)
재요청 시:
지문 일치 → 저장된 응답을 그대로 반환 (200/201)
지문 불일치 → 422 Unprocessable Entity
{ "error": "idempotency_key_reused_with_different_body" }
⚠️ 지문 계산 방식을 양쪽이 동일하게 맞춰야 한다.
JSON 키 순서가 다르면 같은 내용이어도 지문이 달라진다.
→ 정렬된 인코딩을 쓴다 */
func canonicalEncode<T: Encodable>(_ value: T) throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
return try encoder.encode(value)
}
enum LedgerError: Error { case digestMismatch(key: String) }④ 하지 말 것: 서버 auto-increment ID를 멱등 키로 재활용. 하지 말 것: 키를 로그·URL 경로에 넣어 노출하기(민감 연산의 재현 위험).
import Foundation
import OSLog
// ❌ ① 서버 auto-increment ID를 멱등 키로 재활용
// · 그 ID는 서버가 요청을 '받은 뒤에' 생기므로, 첫 요청이 타임아웃되면
// 클라이언트는 키를 알 수 없다 (재시도에 쓸 값이 없다)
// · 예측 가능해서 다른 사용자의 연산과 충돌하거나 추측될 수 있다
func badKeyFromServerID(_ orderID: Int) -> String {
String(orderID) // 💥 순서가 거꾸로다
}
// ✅ 클라이언트가 요청 시작 시점에 만든 충돌 확률이 무시할 만한 값
func goodKey() -> String {
UUID().uuidString // v4 — 예측 불가, 충돌 무시 가능
}
// ❌ ② 키를 URL 경로·쿼리에 넣는다
func badURL(_ key: String) -> URL {
URL(string: "https://api.example.com/payments/\(key)")!
// 서버 접근 로그·프록시·Referer에 평문으로 남는다.
// 민감한 연산의 키가 노출되면 재현 공격에 쓰일 수 있다.
}
// ✅ 헤더로 보낸다
func goodRequest(_ key: String, body: Data) -> URLRequest {
var r = URLRequest(url: URL(string: "https://api.example.com/payments")!)
r.httpMethod = "POST"
r.httpBody = body
r.setValue(key, forHTTPHeaderField: "Idempotency-Key")
return r
}
// ❌ ③ 로그에 그대로 찍는다
private let log = Logger(subsystem: "com.app", category: "payment")
func badLog(_ key: String) {
print("결제 요청 key=\(key)") // 시스템 로그·크래시 리포트에 남는다
}
// ✅ 마스킹하거나 private으로
func goodLog(_ key: String) {
log.debug("결제 요청 key=\(key, privacy: .private)")
}
func masked(_ key: String) -> String {
guard key.count > 8 else { return "***" }
return "\(key.prefix(4))…\(key.suffix(4))"
}
// ⚠️ 크래시 리포터의 breadcrumb에 URL이 자동 수집되는 경우도 확인해야 한다"서버가 멱등 키를 만들어 응답에 담아 주면 되지 않나?"는 순서가 틀렸다. 서버가 키를 만들려면 이미 요청을 한 번 받아 처리를 시작한 뒤인데, 그 첫 요청 자체가 타임아웃되면 클라이언트는 서버가 준 키를 받지 못한다 — 재시도에 쓸 키가 없다. 또 "멱등 키는 영원히 보관해야 안전하다"도 오해다. 재시도는 유한한 시간 안에 오고, 그 창을 덮는 유한한 TTL이면 충분하다. 무한 보관은 스토리지 낭비이자 오히려 관리 리스크다.
숙제를 낼 때 이름을 누가 쓰는지 생각해 보자. 학생이 미리 자기 이름을 써서 내면, 나중에 "제 숙제 들어갔어요?" 하고 물을 때 "제 이름으로 찾아봐 주세요"라고 말할 수 있다. 반대로 선생님이 다 걷은 다음에 번호를 매겨 알려 주는 방식이라면, 하필 알려 주려는 순간 종이 쳐서 못 듣고 나온 학생은 다음번에 가리킬 게 아무것도 없다. 서버가 키를 만들면 정확히 이 일이 벌어진다 — 키를 알려 주는 그 응답이 유실되니까. 그래서 이름표는 보내는 쪽이 붙인다.
보관 기간도 상식선이다. 선생님이 제출 명단을 졸업할 때까지 들고 있을 필요는 없고, 학생이 다시 물어보러 올 만한 기간만 덮으면 된다. 문제는 명단을 버린 뒤에 누가 뒤늦게 같은 숙제를 또 들고 오는 경우다. 선생님은 그걸 새 숙제로 받아 버린다. 그래서 규칙은 하나다 — 명단 보관 기간이 학생이 늦게 올 수 있는 기간보다 짧으면 안 된다. 다만 비유가 여기서 한 발 모자라는데, 결제에서는 명단이 사라진 뒤에 새 이름표로 다시 내면 돈이 두 번 나가므로, 그럴 땐 새로 내기 전에 "제 것 처리됐나요?"부터 물어봐야 한다.
꼬리 질문
여러 기기에서 같은 계정으로 같은 주문을 넣는 경우, 기기별로 다른 키가 생성된다. 이건 중복인가 아닌가? 어떻게 다뤄야 하는가?
키가 다르면 서버의 멱등 계층에는 서로 다른 연산으로 보인다. 그리고 이건 대체로 재시도가 아니라 진짜 두 건의 주문이다 — 멱등 키는 "같은 요청의 재전송"을 막는 장치이지 "사용자가 여러 번 의도한 주문"을 막는 장치가 아니기 때문이다.
따라서 이 문제는 멱등 계층이 아니라 비즈니스 규칙으로 다뤄야 한다. 장바구니나 주문 단위로 서버가 소유한 단일 자원 ID(예: 주문번호)를 먼저 발급받아 그걸 신원으로 삼거나, "미결제 동일 주문이 이미 있음"을 사용자에게 확인시키는 식이다. 이걸 멱등 키로 뭉개려 하면 정당한 재주문까지 차단하게 된다.
UUIDv4 대신 요청 내용 해시를 키로 쓰면 어떤 장점과 함정이 있는가?
장점은 클라이언트가 상태를 따로 저장하지 않아도 같은 내용이면 자동으로 같은 키가 나와, 우연한 동일 재전송을 자연스럽게 dedup하고 키 관리·영속화 부담이 준다는 것이다.
함정이 더 많다. (1) 사용자가 정말로 같은 금액을 두 번 결제하려는 정당한 경우를 "중복"으로 오인해 막아버린다 — 의도는 다른데 내용이 같으면 충돌한다. (2) 내용에 타임스탬프나 nonce가 섞이면 재시도인데도 해시가 달라져 dedup이 깨진다. (3) 정확한 정규화(canonicalization) 없이는 필드 순서만 달라도 다른 키가 되고, 해시가 내용을 추측 가능하게 만들 수도 있다. 그래서 순수한 멱등 신원은 의도와 1:1인 UUID가 안전하고, 내용 해시는 "같은 키에 다른 본문" 검증용 지문으로 곁들이는 편이 낫다.
서버는 키를 24시간 뒤 만료하는데 오프라인 큐에 3일간 남아 있던 결제 재시도가 뒤늦게 도착하면, 서버는 이를 새 연산으로 보고 이중 결제한다. 이 "만료-지연" 창을 클라이언트와 서버는 각각 무엇으로 막아야 하는가?
근본 원인은 서버의 키 보관 기간(TTL)이 클라이언트가 재시도를 흘려보낼 수 있는 최대 시간보다 짧다는 데 있다. 그래서 서버는 TTL을 "클라이언트 재시도 상한 + 오프라인 큐 잔존 시간 + 여유"를 덮도록 잡고, 이 두 경계(서버 보관 기간과 클라이언트 큐 상한)를 문서로 합의해 어긋나지 않게 해야 한다. 보조로, 요청에 실린 생성 시각이 TTL을 넘겼으면 새 연산으로 커밋하기 전에 거부할 수 있다. 다만 그 시각은 클라이언트가 선언한 값이라 신뢰 경계 밖이므로(Q4), 늦은 요청을 걸러 내는 보수적 방향으로만 쓰고 근본 해법은 TTL 정렬이다.
클라이언트 쪽은 오프라인 큐에 요청을 영속화할 때 생성 시각을 함께 저장해, 서버가 이미 키를 만료했을 만큼 오래된 대기 요청을 같은 키로 그대로 재전송하지 않도록 한다. 그런 요청은 그냥 폐기하거나, 사용자에게 재확인을 받아 새 키로 다시 시작하는 편이 안전하다. 단 새 키 재시작 자체도 안전하지 않다 — 원 요청이 TTL 만료 전에 이미 서버에 닿아 성공했다면 새 키는 곧바로 이중 결제다. 그래서 재시작 전에 주문·결제 상태 조회 API로 결과를 먼저 확인해야 하며, 오래 묵은 큐 항목을 확인 없이 무심코 flush하는 것이 바로 이중 결제로 가는 길이다.
Q4. 클라이언트 시간과 서버 시간이 다를 때 무엇을 신뢰해야 하는가?
보안·정합성이 걸린 결정(토큰 만료, 이벤트 순서, 정산 시각, 쿠폰 유효기간)은 서버 시간을 신뢰한다. 클라이언트 시계는 사용자가 임의로 바꿀 수 있고 표류(drift)하므로 신뢰 경계 밖이다. 다만 서버 시간을 "얼마"로 볼지 정할 때 단순히 벽시계(wall clock) 값 하나를 믿는 것도 위험하다 — 순서는 서버가 매기는 단조 증가 시퀀스나 논리적 시계로, 경과 시간을 재는 데는 되감기지 않는 단조 시계(monotonic clock)를, 절대 시각을 표시·기록하는 데는 UTC 기준 벽시계를 쓰고, 클라이언트에는 서버 시간을 내려보내거나 오프셋을 계산해 표시를 보정한다. 요약하면 "믿을 시각은 서버, 재야 할 간격은 단조 시계, 클라이언트 시계는 표시 보정용".
CS 원리
컴퓨터에는 성격이 다른 두 시계가 있다. 벽시계(wall/real-time clock)는 "지금 몇 시"라는 절대 시각을 주지만 NTP 동기화·사용자 설정·윤초로 갑자기 튀거나 뒤로 감길 수 있다. 단조 시계(monotonic clock)는 절대 시각은 모르지만 절대 뒤로 가지 않아 두 시점 사이의 경과 시간을 재는 데 정확하다. 그래서 타임아웃·재시도 간격·성능 측정은 단조 시계로, 로그 타임스탬프·만료 시각은 벽시계로 재는 것이 원칙이다.
분산 환경에선 각 노드의 벽시계가 서로 다르다(clock skew). NTP로 줄여도 수십 ms~수 초의 오차가 남고, 모바일은 더 심하다. 따라서 서로 다른 기계의 벽시계 값을 크기 비교해 순서를 정하면 안 된다 — 이벤트 순서는 시계가 아니라 서버가 매기는 단조 증가 시퀀스나 논리적 시계(Lamport clock)로 정한다. "시간의 절대 진실"을 가질 노드를 하나(서버)로 정하는 것이 실무의 타협이다.
iOS에서는
iOS는 두 시계를 명확히 구분해 제공한다. 절대 시각은 Date()(벽시계, 사용자가 설정에서 바꾸면 그대로 바뀜), 경과 시간은 단조 시계인데 여기서도 둘로 갈린다 — 기기가 잠든 시간까지 포함해 재려면 ContinuousClock(mach_continuous_time), 잠든 구간을 빼고 활동 시간만 재려면 SuspendingClock·DispatchTime·mach_absolute_time이다. 특히 타임아웃·쿨다운·재시도 간격을 Date() 차이로 계산하면, 사용자가 시계를 앞당기거나 뒤로 돌리는 순간 로직이 깨진다. 반드시 단조 시계를 써야 한다.
토큰 만료 같은 보안 판단은 클라이언트 시계로 하지 않는다. 서버가 만료를 판정하고, 클라이언트는 401을 받으면 갱신하는 흐름이 정석이다. 클라이언트에서 "곧 만료"를 미리 감지하고 싶다면, 서버 응답의 Date 헤더로 서버-클라이언트 시간 오프셋을 구해 보정한 값을 쓰되, 이는 어디까지나 UX 최적화이고 최종 판정은 서버다.
// 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 헤더를 강제로 틀리게 바꿔 보정 로직이 어떻게 반응하는지 본다.
프로젝트 적용
① 보안·정합성 판단(만료, 순서, 유효기간, 정산)은 서버 시간으로. 클라이언트 시계는 표시 보정에만.
import Foundation
// 클라이언트 시계는 사용자가 임의로 바꿀 수 있다. 신뢰 경계 밖이다.
// ❌ 클라 시계로 만료를 판정 — 시계를 되돌리면 만료가 풀린다
func isExpiredBad(_ coupon: Coupon) -> Bool {
coupon.expiresAt < Date() // 💥 설정에서 날짜를 바꾸면 우회된다
}
// ✅ 서버 시간을 기준으로 판정한다
actor ServerClock {
private var offset: TimeInterval = 0 // 서버 시각 - 기기 시각
private var lastSync: Date?
/// 모든 응답의 Date 헤더로 오프셋을 보정한다
func sync(from response: HTTPURLResponse) {
guard let header = response.value(forHTTPHeaderField: "Date"),
let serverDate = DateFormatter.rfc1123.date(from: header) else { return }
offset = serverDate.timeIntervalSince(Date())
lastSync = Date()
}
/// 보정된 '서버 기준 현재 시각'
var now: Date { Date().addingTimeInterval(offset) }
/// 동기화가 오래됐으면 신뢰하지 않는다
var isReliable: Bool {
guard let lastSync else { return false }
return Date().timeIntervalSince(lastSync) < 3600 // ⚠️ 바로 아래 ②가 금지한 'Date 차이로 경과 측정'이다.
// 시계를 뒤로 돌리면 음수가 되어 항상 true가 된다 — ContinuousClock 기준으로 재야 한다
}
}
// ✅ 진짜 중요한 판정은 아예 서버가 한다
func redeemCoupon(_ code: String) async throws -> Redemption {
// 클라는 "이 쿠폰 쓸게요"만 보내고, 유효성은 서버가 판정한다
try await api.redeem(code)
}
// ✅ 클라 시계는 '표시'에만 쓴다
@MainActor
func displayExpiry(_ coupon: Coupon, clock: ServerClock) async -> String {
let now = await clock.now
let remaining = coupon.expiresAt.timeIntervalSince(now)
return remaining > 0 ? "\(Int(remaining / 3600))시간 남음" : "만료됨"
// ⚠️ 이 표시가 틀려도 실제 사용은 서버가 막는다
}
struct Coupon { let expiresAt: Date }
struct Redemption: Decodable {}
enum api { static func redeem(_ c: String) async throws -> Redemption { Redemption() } }
extension DateFormatter {
static let rfc1123: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "GMT")
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
return f
}()
}② 경과 시간·타임아웃·쿨다운·애니메이션 스케줄은 단조 시계(ContinuousClock/DispatchTime)로. Date() 차이 금지.
import Foundation
// 벽시계(Date)는 사용자 변경·NTP 보정으로 점프하거나 되감긴다.
// 경과 시간·타임아웃·쿨다운은 되감기지 않는 시계로 재야 한다.
// ❌ Date 차이로 경과 시간 측정
func measureBad(_ work: () -> Void) -> TimeInterval {
let start = Date()
work()
return Date().timeIntervalSince(start) // 💥 중간에 시계가 바뀌면 음수도 나온다
}
// ✅ 단조 시계 — 시스템 부팅 이후 단조 증가한다
func measure(_ work: () -> Void) -> Duration {
let start = ContinuousClock.now
work()
return ContinuousClock.now - start
}
// ✅ 쿨다운·레이트리밋
actor RateLimiter {
private var lastCall: ContinuousClock.Instant?
private let interval: Duration
init(interval: Duration) { self.interval = interval }
func allow() -> Bool {
let now = ContinuousClock.now
if let last, now - last < interval { return false }
lastCall = now
return true
}
private var last: ContinuousClock.Instant? { lastCall }
}
// ✅ 타임아웃
func withTimeout<T>(_ duration: Duration,
_ work: () async throws -> T) async throws -> T {
let deadline = ContinuousClock.now + duration
let result = try await work()
// ⚠️ 이건 타임아웃이 아니다 — work()가 끝날 때까지 무한정 기다린 뒤 사후 검사할 뿐이라
// 작업이 걸리면 영원히 반환하지 않고, 정상 완료된 결과도 '늦었다'며 버린다.
// 실제 타임아웃은 withThrowingTaskGroup으로 작업과 타이머를 경쟁시키고 진 쪽을 cancelAll()한다.
guard ContinuousClock.now < deadline else { throw TimeoutError() }
return result
}
/* ⚠️ 두 시계의 차이
ContinuousClock : 기기가 잠들어 있어도 계속 흐른다 (경과 시간에 적합)
SuspendingClock : 기기가 잠들면 멈춘다 (활성 작업 시간에 적합)
Date : 벽시계. 점프·되감기 가능. 절대 시각 표현용.
⚠️ 단조 시계는 재부팅하면 리셋된다 — 앱을 넘어 지속되는 값에는 못 쓴다.
"다음 알림까지 3일"은 Date로, "이 요청 타임아웃 30초"는 단조 시계로. */
struct TimeoutError: Error {}③ 서버-클라이언트 간 데이터에는 시각을 UTC(ISO 8601)로 저장·전송하고, 표시할 때만 로컬 타임존으로 변환. 타임존을 저장 값에 섞지 않는다.
import Foundation
// 타임존을 저장 값에 섞으면 나중에 되돌릴 수 없다.
// 항상 UTC로 저장·전송하고, 사용자에게 보여 줄 때만 변환한다.
// ✅ 전송·저장 — ISO 8601 UTC
enum WireFormat {
static let iso8601: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
f.timeZone = TimeZone(identifier: "UTC")
return f
}()
static func encode(_ date: Date) -> String { iso8601.string(from: date) }
static func decode(_ string: String) -> Date? { iso8601.date(from: string) }
}
// ✅ JSONDecoder에 일괄 적용
extension JSONDecoder {
static let api: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .custom { decoder in
let s = try decoder.singleValueContainer().decode(String.self)
guard let date = WireFormat.decode(s) else {
throw DecodingError.dataCorrupted(
.init(codingPath: decoder.codingPath,
debugDescription: "잘못된 날짜: \(s)"))
}
return date
}
return d
}()
}
// ✅ 표시 — 그때만 사용자 타임존으로
@MainActor
enum DisplayFormat {
static func dateTime(_ date: Date) -> String {
date.formatted(.dateTime.year().month().day().hour().minute())
// Locale.current·TimeZone.current가 자동 적용된다
}
static func relative(_ date: Date) -> String {
date.formatted(.relative(presentation: .named)) // "3시간 전"
}
}
// ❌ 하지 말 것
// · 로컬 시각 문자열을 저장 ("2026-07-27 14:00") → 어느 타임존인지 모른다
// · 타임존을 별도 컬럼으로만 저장 → 조인·정렬이 어긋난다
// · 날짜만 저장하고 시각을 버림 → 경계 계산이 틀어진다
// ⚠️ '날짜'가 의미 단위인 경우(생일, 기념일)와 미래의 약속(회의·알람·예약)은 예외다. 후자는 UTC 순간만 저장하면 그 사이 타임존 규칙이 바뀔 때 현지 시각이 어긋나므로 현지 시각 + IANA 타임존 ID를 함께 저장한다.
// 이건 시각이 아니라 달력상의 날이므로 타임존과 무관하게 저장한다.
struct Birthday: Codable {
let year: Int, month: Int, day: Int // Date가 아니라 구성요소로
}④ 하지 말 것: 여러 기기의 createdAt 벽시계를 크기 비교해 이벤트 순서 정하기. 하지 말 것: 클라이언트 시계로 쿠폰 만료 판정.
import Foundation
// 여러 기기의 createdAt을 크기 비교해 순서를 정하면
// 기기 간 시계 차이(수 초~수 분)로 순서가 뒤집힌다.
struct Message {
let id: UUID
let deviceCreatedAt: Date // 기기가 찍은 시각 — 신뢰할 수 없다
let serverSequence: Int64? // 서버가 부여한 순번 — 이걸 써야 한다
let text: String
}
// ❌ 기기 시각으로 정렬
func sortBad(_ messages: [Message]) -> [Message] {
messages.sorted { $0.deviceCreatedAt < $1.deviceCreatedAt }
// 💥 A 기기 시계가 30초 빠르면 나중에 보낸 메시지가 앞에 온다
}
// ✅ 서버가 부여한 단조 증가 순번으로 정렬
func sort(_ messages: [Message]) -> [Message] {
messages.sorted {
($0.serverSequence ?? .max, $0.id.uuidString)
< ($1.serverSequence ?? .max, $1.id.uuidString)
}
// 아직 서버에 도달하지 않은 것(전송 중)은 맨 뒤로
}
// ✅ 서버가 순번을 못 주면 논리 시계를 쓴다
struct LamportClock {
private var counter: Int64 = 0
mutating func tick() -> Int64 {
counter += 1
return counter
}
/// 다른 기기의 이벤트를 받으면 그 값보다 크게 맞춘다
mutating func observe(_ remote: Int64) {
counter = max(counter, remote) + 1
}
}
// ⚠️ 쿠폰 만료도 마찬가지다 — 클라 시계로 판정하면 우회된다
func isCouponValidBad(_ coupon: Coupon) -> Bool {
coupon.expiresAt > Date() // 💥 설정에서 날짜를 되돌리면 통과
}
// ✅ 사용 시도 자체를 서버가 판정하게 한다
func redeemCoupon(_ code: String) async throws -> Bool {
try await api.redeem(code) // 유효성은 서버 시간 기준
}
struct Coupon { let expiresAt: Date }
enum api { static func redeem(_ c: String) async throws -> Bool { true } }"NTP로 동기화하면 모든 기기 시간이 같아지니 클라이언트 시계를 믿어도 된다"는 과신이다. NTP는 오차를 줄일 뿐 없애지 못하고, 사용자는 언제든 자동 설정을 끄고 시계를 바꿀 수 있으며, 배터리 방전으로 초기화되기도 한다. 반대 극단인 "그럼 단조 시계만 쓰면 되지"도 틀렸다 — 단조 시계는 절대 시각을 모르고 기기 재부팅 시 리셋되므로 "몇 시에 일어난 일"이나 "만료 시각"을 표현할 수 없다. 두 시계는 용도가 다르고, 절대 신뢰가 필요한 순간에는 둘 다 아닌 서버가 답이다.
교실 벽시계와 손에 든 스톱워치를 떠올려 보자. 벽시계는 "지금 몇 시"를 알려 주지만, 의자를 딛고 올라가 바늘을 아무렇게나 돌려 버릴 수 있다. 스톱워치는 몇 시인지는 모르지만 한 번 누르면 흐른 시간만 정직하게 센다. 그래서 "50m 달리는 데 몇 초 걸렸나"는 스톱워치(monotonic clock, 뒤로 절대 안 가는 시계)로 재야 한다. 벽시계로 재다가 누가 시계를 5분 뒤로 돌리면 기록이 마이너스가 되거나, 쿨다운이 영영 안 끝나는 버그가 된다. 거꾸로 "이 쿠폰이 몇 시에 끝나나" 같은 절대 시각은 스톱워치로는 아예 표현할 수가 없다.
여기에 문제가 하나 더 붙는다. 반마다 벽시계가 조금씩 다르다는 것. 1반 시계로 적은 시각과 2반 시계로 적은 시각을 나란히 놓고 "누가 먼저 손 들었나"를 비교하면 순서가 뒤집힌다. 그래서 정말 중요한 판정은 시계를 여러 개 두지 않고 심판 한 명(서버)의 시계로만 한다. 심판 시계가 세상에서 제일 정확해서가 아니라, 모두가 같은 시계를 보게 만들어 다툼이 없어지기 때문이다. 내 폰의 시계는 화면에 예쁘게 보여 주는 용도지 판정용이 아니다.
꼬리 질문
앱이 오랫동안 백그라운드에 있다 깨어났다. SuspendingClock과 ContinuousClock 중 무엇으로 "백그라운드에 있던 시간"을 재야 하는가? 이유는?
실제로 흐른 벽시계 경과를 재려면 ContinuousClock이다. 이 시계는 기기가 sleep/suspend된 동안에도 계속 흐르기 때문에 백그라운드 체류 시간을 포함한다. SuspendingClock이 멈추는 건 앱이 백그라운드로 내려갈 때가 아니라 기기(시스템)가 잠들 때다 — 화면이 꺼져 기기가 잠든 구간을 빼먹어 실제 경과보다 짧게 재고, 기기가 깨어 있는 채 앱만 백그라운드였다면 두 시계는 같은 값을 준다.
그래서 "마지막 갱신 후 N분 지나면 새로고침" 같은 판단은 ContinuousClock으로 해야 한다. 다만 둘 다 절대 시각이 아닌 경과일 뿐이고, 토큰 만료 같은 보안 판정은 여전히 서버 시간이 답이다.
서버조차 여러 대이고 각자 시계가 다르다면, 여러 서버에 걸친 이벤트의 전역 순서를 어떻게 정하는가(logical clock, hybrid logical clock)?
물리 시계 값을 크기 비교하면 skew 때문에 순서가 뒤집힐 수 있으니, 인과관계를 담는 논리 시계를 쓴다. Lamport clock은 보내는 쪽이 카운터를 올려 메시지에 싣고 받는 쪽이 max(내 값, 받은 값) + 1로 갱신해, 인과적으로 앞선 이벤트가 항상 더 작은 값을 갖게 한다(단 절대 시각과는 무관).
실무에서 인기 있는 건 Hybrid Logical Clock(HLC)로, 물리 시계(대략의 실제 시각)와 논리 카운터를 합쳐 "사람이 읽을 만한 근사 시각 + 인과적 단조성"을 동시에 만족시킨다(CockroachDB 등이 사용). 진짜 전역 실시간 순서가 꼭 필요하면 Google TrueTime처럼 시계 불확실성 구간을 하드웨어로 좁히고 commit-wait으로 대기하는 방식이 있지만 특수 인프라가 필요하다.
오프라인에서 만든 로컬 데이터의 createdAt을, 온라인 복귀 후 서버 순서와 어떻게 일관되게 병합하는가?
클라이언트의 createdAt은 신뢰 경계 밖이므로 그 값만으로 전역 순서를 정하면 안 된다. 실무 패턴은 로컬 시각을 "클라이언트 표기"로만 보관하고, 서버 도달 시 서버가 수신 시각과 단조 시퀀스를 별도로 부여해 그걸 정렬 기준으로 삼는 것이다.
동시 편집이 얽히면 단순 last-write-wins는 데이터 손실을 낳으므로 벡터 시계나 CRDT로 인과 병합을 한다. 사용자에게 보이는 타임라인은 클라이언트가 주장한 createdAt으로 표시하되, 정합성·충돌 판정은 서버 순서로 — 즉 표시용 시각과 순서 판정용 시각을 분리한다. 요지는 오프라인 시각을 힌트로만 쓰고, 진실의 순서는 서버(또는 논리 시계)가 확정한다는 것이다.