모바일 앱은 신뢰할 수 없는 네트워크 위에서 도는 분산 시스템의 한 노드다. 이 챕터는 "요청은 갔는데 응답이 안 온다", "결제가 두 번 될 수도 있다", "기기 시계를 못 믿는다" 같은, 서버-클라이언트 사이에서 반드시 마주치는 네 가지 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은 GET·HEAD·PUT·DELETE를 멱등으로, POST를 비멱등으로 정의한다. 여기서 멱등이란 "같은 요청을 N번 보내도 서버 상태에 미치는 효과가 1번과 같다"는 의미다(응답 바디까지 같아야 한다는 뜻은 아니다).
iOS에서는
URLSession이 스스로 재시도하는 경우는 좁다. 재사용하려던 keep-alive(persistent) 연결이 요청 바이트가 나가기 전에 이미 닫혀 있었던 경우처럼, 서버에 아무것도 도달하지 않은 것이 확실한 상황에 한해 새 연결로 다시 시도할 수 있다. 반면 요청을 이미 전송한 뒤 응답 대기 중 타임아웃(NSURLErrorTimedOut)이나 네트워크 끊김(NSURLErrorNetworkConnectionLost)은 자동으로 재시도하지 않는다. 처리됐을 수도 있기 때문이다. 이 판단을 프레임워크에 떠넘기지 말고 앱 계층에서 명시적으로 해야 한다.
구조적 동시성에서는 취소와 재시도를 분리해서 다뤄야 한다. 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) // 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를 지원하면 재시도 허용으로 표시.
- 지수 백오프에 반드시 jitter를 섞는다. 없으면 서버 장애 복구 순간 모든 클라이언트가 동시에 재시도해 다시 무너뜨린다(thundering herd).
- 재시도 상한(횟수·총 시간)을 둔다. 무한 재시도는 사용자 배터리·데이터를 태우고 서버 부하를 키운다.
- 하지 말 것: 결제·주문 같은 비멱등 POST를 idempotency key 없이 타임아웃만 보고 재시도. 이중 결제의 직행 티켓이다.
"타임아웃 = 실패니까 안전하게 재시도하면 된다"는 틀렸다. 타임아웃은 실패가 아니라 결과를 모름(unknown)이다. 서버는 이미 정상 처리하고 커밋까지 마쳤을 수 있다. 마찬가지로 "GET은 안전하고 POST는 위험하다"도 정확하지 않다 — 위험한 건 메서드가 아니라 부수효과의 유무와 중복 제거 장치의 유무다. 멱등 키를 갖춘 POST는 재시도해도 안전하고, 서버가 조회 때마다 로그를 남긴다면 그 GET조차 부수효과가 있다.
꼬리 질문
- 서버가 응답을 보내기 직전에 DB 커밋은 성공했지만 프로세스가 죽었다. 클라이언트가 재시도하면 어떤 순서로 안전을 보장해야 하는가?
- 재시도 대상이 5xx가 아니라 408 Request Timeout과 425 Too Early라면 각각 어떻게 다르게 처리해야 하는가?
- gRPC/HTTP/2 스트리밍처럼 연결 하나에 여러 요청이 실려 있을 때, 연결 끊김에서 어떤 요청이 서버에 도달했는지 어떻게 판별하는가?
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 등)에 키만 먼저 기록하면 "키는 있는데 결제는 롤백됨" 틈이 생긴다.
- 서버는 같은 키의 재요청에 대해 처음의 상태 코드·바디를 그대로 재현한다. 두 번째에 409를 던지면 클라이언트가 "실패"로 오인해 사용자에게 잘못 표시할 수 있다.
- 키에는 요청 지문(fingerprint)을 함께 검증한다. 같은 키인데 금액이 다르면 그건 재시도가 아니라 버그/공격이므로 422로 거부.
- 하지 말 것: 클라이언트 버튼 비활성화만 믿기. 하지 말 것: 재시도 때 키 새로 만들기.
"프론트에서 더블 탭만 막으면 중복은 안 난다"는 착각이다. 사용자 더블 탭은 중복의 한 원인일 뿐이고, 네트워크 타임아웃 후 자동 재시도·앱 재시작 후 큐 재실행은 UI를 우회한다. 또 하나: "서버에서 요청이 올 때마다 최근 N초 내 같은 금액 결제가 있으면 막자"는 시간 윈도우 방식은 위험하다 — 사용자가 진짜로 같은 금액을 두 번 결제하려는 정당한 경우를 잘못 차단한다. 신원은 시간이 아니라 명시적 키로 판별해야 한다.
꼬리 질문
- DB가 유니크 제약을 지원하지 않는 NoSQL/이벤트 스트림 환경이라면 중복 제거를 어디서 어떻게 구현하는가?
- 결제 서비스와 재고 차감 서비스가 분리된 마이크로서비스일 때, 한쪽만 성공하는 부분 실패를 어떻게 멱등하게 만드는가(saga/outbox)?
- 같은 키의 두 요청이 거의 동시에 서버 두 인스턴스에 도착하는 경쟁 상태에서, 유니크 제약 외에 무엇이 더 필요한가?
Q3. Idempotency key는 무엇이며 누가 생성하고 얼마나 유지해야 하는가?
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초) 낮춘 스테이징에서, 만료 후 같은 키로 보내면 새 레코드가 생기는지 본다.
프로젝트 적용
- 키는 "요청 시작" 지점에서 단 한 번 만든다. 인터셉터가 모든 요청에 자동으로 새 키를 다는 구현은 재시도마다 키가 바뀌어 위험 — 키 부여는 재시도 계층 바깥이어야 한다.
- 서버 보관 기간과 클라이언트 재시도/오프라인 큐 상한을 문서로 합의한다. 어긋나면 "만료된 키로 온 재시도"가 조용히 중복을 만든다.
- 키 + 요청 본문 지문을 함께 저장해, 같은 키로 다른 내용이 오면 명확히 거부(422)한다.
- 하지 말 것: 서버 auto-increment ID를 멱등 키로 재활용. 하지 말 것: 키를 로그·URL 경로에 넣어 노출하기(민감 연산의 재현 위험).
"서버가 멱등 키를 만들어 응답에 담아 주면 되지 않나?"는 순서가 틀렸다. 서버가 키를 만들려면 이미 요청을 한 번 받아 처리를 시작한 뒤인데, 그 첫 요청 자체가 타임아웃되면 클라이언트는 서버가 준 키를 받지 못한다 — 재시도에 쓸 키가 없다. 또 "멱등 키는 영원히 보관해야 안전하다"도 오해다. 재시도는 유한한 시간 안에 오고, 그 창을 덮는 유한한 TTL이면 충분하다. 무한 보관은 스토리지 낭비이자 오히려 관리 리스크다.
꼬리 질문
- 여러 기기에서 같은 계정으로 같은 주문을 넣는 경우, 기기별로 다른 키가 생성된다. 이건 중복인가 아닌가? 어떻게 다뤄야 하는가?
- UUIDv4 대신 요청 내용 해시를 키로 쓰면 어떤 장점과 함정이 있는가?
- 키 저장소가 결제 DB와 물리적으로 분리돼 있을 때 원자성을 어떻게 확보하는가?
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/SuspendingClock(Swift Concurrency)이나 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 헤더를 강제로 틀리게 바꿔 보정 로직이 어떻게 반응하는지 본다.
프로젝트 적용
- 보안·정합성 판단(만료, 순서, 유효기간, 정산)은 서버 시간으로. 클라이언트 시계는 표시 보정에만.
- 경과 시간·타임아웃·쿨다운·애니메이션 스케줄은 단조 시계(
ContinuousClock/DispatchTime)로.Date()차이 금지. - 서버-클라이언트 간 데이터에는 시각을 UTC(ISO 8601)로 저장·전송하고, 표시할 때만 로컬 타임존으로 변환. 타임존을 저장 값에 섞지 않는다.
- 하지 말 것: 여러 기기의
createdAt벽시계를 크기 비교해 이벤트 순서 정하기. 하지 말 것: 클라이언트 시계로 쿠폰 만료 판정.
"NTP로 동기화하면 모든 기기 시간이 같아지니 클라이언트 시계를 믿어도 된다"는 과신이다. NTP는 오차를 줄일 뿐 없애지 못하고, 사용자는 언제든 자동 설정을 끄고 시계를 바꿀 수 있으며, 배터리 방전으로 초기화되기도 한다. 반대 극단인 "그럼 단조 시계만 쓰면 되지"도 틀렸다 — 단조 시계는 절대 시각을 모르고 기기 재부팅 시 리셋되므로 "몇 시에 일어난 일"이나 "만료 시각"을 표현할 수 없다. 두 시계는 용도가 다르고, 절대 신뢰가 필요한 순간에는 둘 다 아닌 서버가 답이다.
꼬리 질문
- 앱이 오랫동안 백그라운드에 있다 깨어났다.
SuspendingClock과ContinuousClock중 무엇으로 "백그라운드에 있던 시간"을 재야 하는가? 이유는? - 서버조차 여러 대이고 각자 시계가 다르다면, 여러 서버에 걸친 이벤트의 전역 순서를 어떻게 정하는가(logical clock, hybrid logical clock)?
- 오프라인에서 만든 로컬 데이터의
createdAt을, 온라인 복귀 후 서버 순서와 어떻게 일관되게 병합하는가?