iOS 페이지네이션 경합과 중복 검증
사용자가 iOS 목록에서 필터 조건을 빠르게 바꾼다. 이전 조건의 두 번째 페이지는 아직 응답을 기다리는 중인데, 새 조건의 첫 번째 페이지는 이미 화면에 표시됐다. 몇 초 뒤 이전 응답이 도착하면서 관련 없는 항목이 목록에 섞인다. 이런 문제는 네트워크가 안정적일 때 재현하기 어렵다. 클라우드 Mac에서 스크롤을 반복해도 두 요청의 응답 순서를 원하는 대로 보장할 수는 없다. 더 확실한 방법은 페이지 응답이 목록을 변경하는 규칙을 상태 모델로 분리하고, 입력 순서를 고정해 검증한 다음 UI에 연결하는 것이다.
목록의 상태 변경 규칙부터 정의하기
아래 예제는 1부터 시작하는 페이지 번호와 정수형 비즈니스 ID를 사용한다. 실제 API는 다른 필드를 반환해도 되지만, 최소한 현재 쿼리, 응답을 기다리는 페이지, 이미 표시한 ID, 서버에 다음 페이지가 남아 있는지는 구분해야 한다. 필터 조건이 바뀔 때마다 새 쿼리로 취급한다. 이전 조건과 새 조건이 결국 같은 항목을 반환하더라도 이전 요청이 새 목록을 변경해서는 안 된다.
각 쿼리에 증가하는 generation을 부여하고 페이지 번호와 함께 요청 식별 정보에 담는다. 응답의 식별 정보가 현재 대기 중인 요청과 정확히 일치할 때만 목록에 반영한다. 로딩 중에 목록 끝에 다시 도달했다면 해당 이벤트는 무시한다. 같은 페이지 번호로 요청 두 개를 동시에 보내지 않는다.
요청 취소는 네트워크와 컴퓨팅 자원을 아끼기 위한 조치일 뿐, 이전 콜백이 실행되지 않는다는 보장은 아니다. 목록을 변경하기 전에는 요청 식별 정보를 다시 확인해야 한다.
실행 가능한 모델로 세 가지 응답 순서 검증하기
아래 내용을 Pager.swift로 저장하고 Xcode 명령줄 도구가 설치된 Mac에서 xcrun swift Pager.swift를 실행한다. 네트워크나 타이머를 사용하지 않으므로 실행할 때마다 같은 문제 상황을 검증할 수 있다.
struct Ticket: Equatable {
let generation: Int
let page: Int
}
struct Pager {
private(set) var generation = 0
private(set) var nextPage = 1
private(set) var ids: [Int] = []
private(set) var inFlight: Ticket?
private(set) var hasMore = true
mutating func start() -> Ticket? {
guard hasMore, inFlight == nil else { return nil }
let ticket = Ticket(generation: generation, page: nextPage)
inFlight = ticket
return ticket
}
mutating func reset() {
generation += 1
nextPage = 1
ids = []
inFlight = nil
hasMore = true
}
mutating func finish(
_ ticket: Ticket,
rows: [Int],
hasMore: Bool
) {
guard inFlight == ticket else { return }
var seen = Set(ids)
for id in rows where seen.insert(id).inserted {
ids.append(id)
}
nextPage += 1
self.hasMore = hasMore
inFlight = nil
}
}
var pager = Pager()
let page1 = pager.start()!
assert(pager.start() == nil)
pager.finish(page1, rows: [1, 2], hasMore: true)
let page2 = pager.start()!
pager.finish(page2, rows: [2, 3], hasMore: true)
pager.finish(page2, rows: [2, 3], hasMore: true)
assert(pager.ids == [1, 2, 3])
let oldPage = pager.start()!
pager.reset()
let newPage = pager.start()!
pager.finish(oldPage, rows: [4], hasMore: false)
assert(pager.ids.isEmpty)
pager.finish(newPage, rows: [7], hasMore: false)
assert(pager.ids == [7])
assert(pager.start() == nil)
첫 번째 단언은 목록 끝에 연속으로 도달해도 로딩이 한 번만 시작되는지 확인한다. 두 번째 묶음은 페이지 간 ID 중복과 같은 콜백의 중복 실행을 함께 검증한다. 항목 2는 한 번만 표시되며, 이미 완료된 페이지의 응답도 다시 받아들여서는 안 된다. 마지막 묶음은 필터 조건 전환을 재현한다. 이전 세대의 페이지가 뒤늦게 도착해도 새 목록이나 새 목록의 hasMore를 변경해서는 안 된다.
실제 요청에 연결할 때도 쓰기 경계 유지하기
UI 계층은 “다음 페이지 로드” 또는 “쿼리 초기화” 이벤트만 전달한다. 요청을 시작할 때 start()가 반환한 식별 정보를 보관하고, 요청이 끝나면 응답을 finish에 전달한 뒤 모델에서 목록을 읽는다. 초기화할 때 이전 네트워크 작업을 함께 취소할 수는 있다. 다만 네트워크 완료 콜백이 모델을 거치지 않고 UI의 배열에 항목을 직접 추가하게 해서는 안 된다.
API에 맞춰 조정할 부분이 두 가지 있다. 첫째, 예제는 ID로 중복을 제거한다. API에 안정적인 ID가 없다면 배열 위치로 대신하지 말고 레코드의 식별 기준부터 정해야 한다. 둘째, 예제는 페이지 번호로 진행한다. 커서 기반 API라면 요청에 사용한 커서를 식별 정보에 담고, 응답을 받아들인 뒤 응답이 반환한 새 커서를 저장해야 한다. 새 커서가 이전 커서와 같은데도 API가 다음 페이지가 있다고 응답한다면, 프로토콜 오류를 기록하고 자동 로딩을 중단해 같은 페이지를 반복 요청하지 않도록 한다.
오류와 재시도는 어떻게 처리할까
예제는 성공 응답만 다룬다. 실제 구현에서는 실패를 처리할 때도 요청 식별 정보를 확인해야 한다. 이전 요청의 오류가 새 쿼리의 로딩 상태를 덮어써서는 안 된다. 현재 페이지의 요청이 실패하면 진행 중 표시를 해제하되 페이지 번호는 올리지 않는다. 사용자가 재시도하면 같은 페이지를 요청해야 한다. UI에는 로딩, 실패, 재시도 가능 상태를 각각 표시하고, 한 번의 시간 초과로 이미 표시된 항목을 지우지 않는다.
안정적인 실행 환경에서 테스트하기
먼저 개발용 Mac에서 위 모델을 실행해 상태 규칙을 확인하고, 같은 응답 순서를 프로젝트 테스트로 옮긴다. 테스트 더블은 테스트 코드가 각 페이지 응답을 전달할 시점을 결정하도록 만들어야 한다. 짧은 지연 시간을 넣어 요청 순서를 추측해서는 안 된다. 이후 iOS 시뮬레이터에서 UI를 확인한다. 목록 끝에 연속으로 도달해도 로딩 표시가 한 번만 나타나는지, 필터를 바꾼 뒤 이전 항목이 다시 나타나지 않는지, 마지막 페이지 이후 요청이 더 발생하지 않는지 살핀다.
원격 데스크톱의 네트워크 지연은 문제를 관찰하는 데 도움이 될 수 있지만, 테스트가 성립하기 위한 조건이 되어서는 안 된다. 클라우드 Mac의 가치는 팀이 동일한 도구 체인과 시뮬레이터 환경에서 이러한 단언을 반복 실행할 수 있다는 데 있다. 요청 순서는 여전히 테스트가 제어한다. 목록 구현을 수정할 때마다 “연속으로 목록 끝에 도달하기, 페이지 간 항목 중복, 이전 응답의 지연 도착, 실패 후 재시도” 네 가지 테스트 사례를 유지한다.
병합 전 목록 변경 경로 점검하기
마지막으로 코드의 호출 경로를 따라가며 배열을 변경하는 모든 지점을 찾는다. 완료 콜백, 당겨서 새로고침, 필터 변경, 화면 복원 경로가 모두 동일한 요청 식별 정보 검증을 거치는지 확인한다. 그렇지 않으면 모델 테스트가 통과해도 우회하는 콜백이 목록을 오염시킬 수 있다. 중복 제거가 서버에서 제공한 정렬 순서를 조용히 바꾸지 않는지도 확인한다. 마지막 페이지 여부는 가장 먼저 도착한 아무 요청이 아니라 받아들인 응답을 기준으로 정해야 한다.
페이지네이션 경합을 재현하려고 네트워크가 나빠지기를 기다릴 필요는 없다. “어떤 응답이 목록을 변경할 수 있는가”를 실행 가능한 규칙으로 만들면, 응답 순서가 뒤바뀌는 상황도 운영 환경의 스크린샷에서나 보이는 간헐적 문제가 아니라 일반적인 회귀 테스트가 된다.
자주 묻는 질문
이전 요청을 취소하면 늦은 응답 검사가 필요 없나요?
필요합니다. 목록을 변경하기 전에 응답의 검색 세대와 현재 대기 중인 페이지를 모두 확인해야 합니다.
서로 다른 페이지에 같은 항목이 있으면 어떻게 하나요?
안정적인 비즈니스 ID로 중복을 제거하되, 페이지 번호나 커서가 실제로 진행하는지도 별도로 확인합니다.
다음 원격 개발을 위한 Mac 준비하기
용도에 맞는 모델과 리전을 선택하고 화면 공유, VNC 또는 SSH로 연결하세요. 가격과 실제 이용 가능 여부는 콘솔에서 실시간으로 확인할 수 있습니다.