클라우드 Mac에서 검증하는 iOS 낙관적 업데이트와 이전 응답 격리
즐겨찾기 버튼을 누르면 곧바로 색이 바뀌지만, 네트워크 요청이 실패한 뒤에는 잘못된 상태로 되돌아간다. 로그아웃 후 다시 로그인했는데 이전 요청의 응답이 새 사용자의 화면을 바꾸기도 한다. 이런 오류를 재현하려고 느린 네트워크에서 우연히 문제가 생기기를 기다릴 필요는 없다. 클라우드 Mac의 빌드 작업에서 낙관적 업데이트를 독립적인 상태 머신으로 분리하고, 성공·실패·세션 전환 이벤트를 정해진 순서로 입력하면 안정적으로 재현할 수 있다.
먼저 레코드당 하나의 쓰기만 진행하도록 규칙 정하기
이 글에서는 검증하기 가장 쉬운 전략을 사용한다. 하나의 레코드에는 진행 중인 쓰기 요청을 최대 하나만 허용한다. 요청 중에 다시 눌러도 새 요청은 보내지 않고, 화면에는 현재 임시 값을 유지하면서 저장 중임을 표시한다. 빠르게 연속으로 상태를 전환할 수는 없지만, 그 대신 되돌아갈 기준이 명확해진다.
상태는 확정된 값, 현재 표시 값, 진행 중인 요청의 티켓으로 나눈다. 작업을 시작할 때는 표시 값만 바꾼다. 성공하면 서버가 반환한 값으로 확정된 값과 표시 값을 모두 갱신하고, 명확한 실패라면 확정된 값으로 되돌린다. 목록의 각 레코드는 자기 상태를 따로 보유하며, 전역 불리언 플래그는 사용하지 않는다.
낙관적 업데이트는 사용자의 의도를 미리 보여 주는 방식이지, 저장 성공을 미리 보장하는 방식이 아니다. 클라이언트의 티켓 역시 서버의 쓰기 순서 제어를 대신할 수 없다.
결과를 알 수 없는 경우의 경계 명확히 하기
시간 초과나 연결 끊김이 서버의 거부를 뜻하지는 않는다. 이때 임시 피드백을 철회할 수는 있지만, 동기화 대기 상태로 전환한 뒤 서버 값을 다시 읽고 나서 조작을 허용해야 한다. 아래의 실패 분기는 커밋되지 않았음이 확인된 비즈니스 규칙상의 거부만 나타내며, 모든 네트워크 오류를 직접 처리하는 용도로 사용해서는 안 된다.
UI에 의존하지 않는 상태 머신 구현하기
다음 내용을 optimistic-check.swift로 저장한다. 티켓은 각각 독립적인 식별자를 사용하며, 세션을 전환할 때 재사용하지 않는다. 모델 자체는 네트워크 요청을 시작하지 않고 UI 프레임워크도 포함하지 않으므로, 명령줄 도구가 설치된 노드에서 바로 실행할 수 있다.
import Foundation
struct FavoriteState {
private(set) var confirmed: Bool
private(set) var visible: Bool
private(set) var pending: UUID?
init(_ value: Bool) {
confirmed = value
visible = value
}
mutating func begin(_ desired: Bool) -> UUID? {
guard pending == nil else { return nil }
let ticket = UUID()
pending = ticket
visible = desired
return ticket
}
mutating func finish(_ ticket: UUID, saved: Bool?) {
guard pending == ticket else { return }
if let saved {
confirmed = saved
}
visible = confirmed
pending = nil
}
mutating func replaceSession(with value: Bool) {
pending = nil
confirmed = value
visible = value
}
}
finish의 옵셔널 값에는 명확한 규칙이 있다. 값이 있으면 서버가 확정한 최종 상태를 뜻하고, nil이면 커밋되지 않았음이 확인되었다는 뜻이다. 서버는 비즈니스 규칙에 따라 요청한 값을 다른 값으로 바꿀 수 있으므로, 성공 경로에서 사용자가 클릭할 때 지정한 목표 값을 그대로 복사해서는 안 된다.
프로덕션 코드에서는 상태 머신을 메인 실행자에 두고, 네트워크 작업이 끝난 뒤에도 같은 실행자로 돌아와 상태를 수정한다. 두 콜백 큐에서 이 메서드들을 동시에 호출하지 않아야 한다. 값 타입이라고 해서 데이터 경쟁이 자동으로 사라지는 것은 아니다.
실제 네트워크 대기 대신 이벤트 순서로 검증하기
다음 검증 코드를 같은 파일의 끝에 추가한다. 대기 시간을 넣거나 실제 요청을 보낼 필요는 없다. 티켓 값은 무작위이지만 검증은 동등성 비교에만 의존하므로 결과는 여전히 결정적이다.
var state = FavoriteState(false)
let first = state.begin(true)!
precondition(state.visible && !state.confirmed)
precondition(state.begin(false) == nil)
state.finish(first, saved: nil)
precondition(!state.visible && state.pending == nil)
let second = state.begin(true)!
state.finish(second, saved: true)
precondition(state.visible && state.confirmed)
state.finish(second, saved: false)
precondition(state.visible)
let oldSession = state.begin(false)!
state.replaceSession(with: false)
let current = state.begin(true)!
state.finish(oldSession, saved: false)
precondition(state.pending == current && state.visible)
state.finish(current, saved: false)
precondition(!state.visible && !state.confirmed)
실행 방법은 다음과 같다. 이번 컴파일에는 사전 조건 검사를 비활성화하는 최적화 옵션을 추가하지 않는다.
set -eu
xcrun swiftc optimistic-check.swift -o optimistic-check
./optimistic-check
종료 코드가 0이 아니면 작업을 중단한다. 첫 번째 묶음은 실패 시 롤백과 중복 클릭을, 두 번째 묶음은 중복 콜백을 확인한다. 세 번째 묶음은 이전 세션의 응답과 서버 반환 값이 사용자 의도와 다른 경우를 확인한다. 계정 식별자나 응답 본문 전체를 출력할 필요는 없다.
네트워크 연동 시 레코드 식별 정보 유지하기
실제 요청에서는 레코드 식별자, 현재 세션 식별 정보, 티켓을 캡처해야 한다. 응답이 돌아오면 먼저 여전히 같은 레코드에 속하는지 확인한 다음, 해당 상태 머신에 결과를 전달한다. 목록 셀이 재사용될 때 행 번호만으로 대상을 찾아서는 안 된다. 정렬이나 필터링으로 행 번호가 바뀔 수 있기 때문이다.
계정을 전환할 때는 모든 레코드의 상태를 교체하고, 서버의 확정 데이터를 다시 불러와야 한다. 예제의 replaceSession은 새 세션의 초기 값을 얻은 뒤 수행하는 초기화를 보여 준다. 로그아웃 후에도 이전 값을 계속 사용해도 된다는 뜻은 아니다.
요청 취소는 불필요한 작업을 줄일 뿐, 티켓 검사를 대신하지 못한다. 취소 시점에 서버가 이미 커밋했을 수도 있고, 콜백이 큐에서 실행을 기다리고 있을 수도 있다. 여러 기기가 같은 레코드를 동시에 수정하는 경우에는 서버가 버전 조건이나 충돌 결과도 제공해야 한다. 클라이언트에서 하나의 쓰기만 진행하도록 하는 전략으로는 기기 간 경쟁까지 처리할 수 없다.
상태 검증을 릴리스 전 필수 관문으로 만들기
코드를 병합하기 전에 세 가지 검증을 갖춘다. 비즈니스 규칙에 따른 거부 후 버튼을 다시 조작할 수 있어야 하고, 결과가 불명확할 때는 추가 쓰기를 막고 동기화를 시작해야 하며, 계정 전환 후 이전 콜백이 새 상태를 바꾸지 않아야 한다. 상태 머신 검증에 더해 UI 테스트로 저장 중 표시, 접근성 상태, 클릭 차단이 실제로 pending에 연결되어 있는지도 확인한다.
롤백 오류를 발견하면 먼저 이벤트 순서, 비식별화한 레코드 식별자, 결과 유형을 보존한다. 그런 다음 이전 행 번호를 잘못 사용했거나 서로 다른 세션의 모델을 섞어 썼는지 확인한다. 대기 시간을 늘려 문제를 덮어서는 안 된다. 최종 통과 기준은 버튼이 우연히 올바른 상태로 끝나는 것이 아니라, 모든 상태 변화에 근거가 되는 이벤트가 있는 것이다.
자주 묻는 질문
시간 초과가 발생하면 서버에 저장되지 않은 것인가요?
아닙니다. 결과를 모르는 상태입니다. 임시 화면은 되돌릴 수 있지만, 연결 복구 후 서버 상태를 다시 읽고 다음 쓰기를 허용해야 합니다.
로컬 요청 식별자로 서버의 쓰기 경쟁도 막을 수 있나요?
아닙니다. 이전 응답이 현재 화면을 수정하는 것만 막습니다. 서버에는 쓰기 직렬화나 버전 조건 같은 별도 제어가 필요합니다.
빌드 파이프라인에 전용 Mac을 추가하세요
Apple Silicon M4 또는 M4 Pro를 선택할 수 있습니다. 전체 시스템을 한 고객만 사용하며, 가상 머신이 아닙니다. 빌드 작업량에 맞는 모델과 팀에 적합한 결제 주기를 선택하세요.
전용 물리 Mac 대여하기