클라우드 Mac에서 iOS 저수지 샘플링 검증하기
Windows에서 로그 필터링 기능을 구현한 뒤, 전체 개수를 알 수 없는 이벤트 스트림에서 일부 레코드를 무작위로 남겨야 하는 상황을 생각해 보겠습니다. 모든 레코드를 배열에 담아 섞으면 입력이 늘어날수록 메모리 사용량도 커집니다. 클라우드 Mac에서는 Swift로 저수지 샘플링을 먼저 검증한 다음 같은 구현을 iOS 프로젝트에 통합할 수 있습니다. 핵심은 결과가 ‘무작위처럼 보이는지’ 살펴보는 것이 아니라, 선택 규칙을 반복해서 확인할 수 있는 계약으로 만드는 데 있습니다.
샘플링 단위와 용량 계약 정하기
이 글에서는 Algorithm R을 사용합니다. 처음 k개 항목을 보관하고, t번째 항목을 읽을 때 0부터 t−1까지의 정수 중 j를 균등하게 뽑습니다. j가 k보다 작을 때만 해당 슬롯을 교체합니다. 순회가 끝나면 각 입력 위치가 표본에 포함될 확률은 k/n이며, 추가 저장 공간은 O(k), 순회 시간은 O(n)입니다.
이 결론이 성립하려면 이전까지의 이력이 주어진 상태에서도 매번 추첨이 균등해야 합니다. 난수가 올바른 범위 안에 들어간다는 사실만으로 공정성이 보장되지는 않습니다.
먼저 네 가지 경계 조건을 정합니다. 용량은 음수일 수 없습니다. 용량이 0이면 입력을 소비하지 않고 빈 표본을 반환합니다. 입력 개수가 용량보다 적으면 모두 보관합니다. 값이 같더라도 위치가 다르면 별개의 항목으로 참여합니다. 출력 슬롯의 순서는 무작위 순열이 아니며, 원래 입력 순서를 유지한다는 보장도 없습니다.
공정한 샘플링이라고 해서 매번 다른 결과가 나와야 하는 것은 아닙니다. ‘연속된 두 결과는 같으면 안 된다’를 테스트 조건으로 넣으면 정상적인 무작위 결과가 간헐적 실패로 바뀝니다.
난수 공급원과 선택 로직 분리하기
OSXMesh 클라우드 Mac에 로그인한 뒤 터미널에서 xcrun swift --version을 실행해 현재 도구 체인에서 Swift를 실행할 수 있는지 확인합니다. 다음 코드를 ReservoirCheck.swift로 저장합니다. 클로저는 상한값 자체를 포함하지 않는 상한을 인자로 받으므로, 실제 서비스의 난수 공급원과 테스트 스크립트가 동일한 선택 로직을 사용할 수 있습니다.
func reservoir<S: Sequence>(
_ input: S,
capacity: Int,
draw: (Int) -> Int
) -> [S.Element] {
precondition(capacity >= 0)
guard capacity > 0 else { return [] }
var sample: [S.Element] = []
var seen = 0
for value in input {
seen += 1
if sample.count < capacity {
sample.append(value)
} else {
let slot = draw(seen)
precondition(slot >= 0 && slot < seen)
if slot < capacity {
sample[slot] = value
}
}
}
return sample
}
let empty = reservoir([Int](), capacity: 2) { _ in
preconditionFailure("Unexpected draw")
}
precondition(empty.isEmpty)
let zero = reservoir([1, 2], capacity: 0) { _ in
preconditionFailure("Unexpected draw")
}
precondition(zero.isEmpty)
let short = reservoir([1, 2], capacity: 3) { _ in
preconditionFailure("Unexpected draw")
}
precondition(short == [1, 2])
var counts: [String: Int] = [:]
for first in 0..<3 {
for second in 0..<4 {
var script = [first, second].makeIterator()
var bounds: [Int] = []
let sample = reservoir(0..<4, capacity: 2) { upper in
bounds.append(upper)
guard let next = script.next() else {
preconditionFailure("Draw script exhausted")
}
return next
}
precondition(bounds == [3, 4])
precondition(script.next() == nil)
precondition(sample.count == 2)
precondition(Set(sample).count == 2)
let key = sample.sorted().map { String($0) }
.joined(separator: ",")
counts[key, default: 0] += 1
}
}
let expected = ["0,1", "0,2", "0,3", "1,2", "1,3", "2,3"]
precondition(Set(counts.keys) == Set(expected))
precondition(counts.values.allSatisfy { $0 == 2 })
xcrun swift ReservoirCheck.swift를 실행합니다. 통과하면 아무것도 출력하지 않고, 실패하면 0이 아닌 종료 상태로 끝납니다. 이 스크립트는 사전 조건 검사에 의존하므로 안전 검사를 비활성화하는 최적화 옵션으로 실행하면 안 됩니다. 실제 애플리케이션에서 외부로부터 용량 값을 받는 경우에는 알고리즘을 호출하기 전에 값을 검증해야 합니다. 잘못된 입력 때문에 프로세스가 종료되도록 두어서는 안 됩니다.
열두 경로를 모두 열거해 선택 규칙 검증하기
후보 위치 네 개에서 두 개를 선택할 때, 세 번째 항목에서는 세 가지 추첨 결과가, 네 번째 항목에서는 네 가지 추첨 결과가 가능합니다. 따라서 전체 경로는 열두 개입니다. 앞서 정한 난수 공급원 가정에 따르면 각 경로의 확률은 십이분의 일이며, 순서를 구분하지 않는 위치 조합 여섯 개가 각각 두 번씩 나와야 합니다.
여기서 확인하는 것은 각 위치의 등장 횟수만이 아니라 조합의 분포입니다. 잘못된 구현 중에는 각 항목의 주변 빈도는 거의 같으면서도 특정 조합을 항상 누락하는 경우가 있습니다.
스크립트는 난수 공급원에 전달된 상한이 각각 3과 4인지, 준비한 추첨 시퀀스를 정확히 모두 소비했는지도 확인합니다. 호출이 한 번 부족하거나 한 번 더 발생해도, 상한을 현재 슬롯 개수로 잘못 지정해도 실패합니다. 정렬은 비교용 키를 만드는 데만 사용합니다. 이를 이유로 실제 함수의 반환 계약을 몰래 바꾸면 안 됩니다.
이 정도 규모로도 흔한 교체 조건 오류를 잡을 수 있지만, 임의의 입력 크기에 대한 수학적 증명은 아닙니다. 병합 전에는 귀납 논증에 필요한 조건을 검토하고, 다른 작은 입력 크기와 용량에 대해서도 전수 열거 테스트를 추가해야 합니다.
중복 값과 스트리밍 입력의 함정 다루기
중복 값 테스트에는 고유한 일련번호가 있는 레코드를 사용해야 합니다. 예를 들어 내용이 같은 이벤트 두 개를 각각 위치 0과 위치 1로 표시합니다. 표본에 포함된 업무 데이터 값이 모두 고유한지 단순히 확인하는 대신, 선택된 위치가 중복되지 않는지 검증해야 합니다. 샘플링 전에 중복을 제거하면 이벤트 유형별 가중치가 달라지고 추가 저장 공간도 필요해집니다.
용량이 1일 때는 반드시 교체하는 경우와 반드시 건너뛰는 경우를 각각 테스트합니다. 용량이 입력 길이와 같으면 난수 공급원을 호출하면 안 됩니다. 용량이 입력 길이보다 크면 출력 길이는 입력 길이와 같아야 합니다.
한 번만 소비할 수 있는 시퀀스에는 읽기 횟수를 세는 반복자를 추가합니다. 용량이 0이면 읽기 횟수도 0이어야 하고, 양수이면 입력을 한 번만 순회해야 합니다. 길이를 미리 구하려고 Array(input)을 호출하지 마세요. 선택 결과가 올바르더라도 스트리밍 방식의 메모리 목표는 이미 깨진 상태입니다.
카운터를 사용하려면 입력 항목 수도 Int로 표현할 수 있어야 합니다. 끝이 없는 이벤트 스트림은 윈도우가 끝나는 시점에 샘플링을 마쳐야 합니다. 이 동기 함수가 저절로 반환할 것이라고 기대해서는 안 됩니다. 취소와 윈도우 종료는 상위 계층에서 제어해야 합니다.
프로젝트 통합 시 재현 가능한 증거 남기기
실제 서비스에서는 Int.random(in: 0..<upper)로 정수를 공급할 수 있습니다. 무작위 정수에 나머지 연산을 적용해 범위를 억지로 맞추거나, 고정된 테스트 스크립트를 실제 기능에 넣지 마세요. 여기서 다루는 샘플링은 일반적인 데이터 처리용이며, 보안이 중요한 추첨에 그대로 사용해서는 안 됩니다.
스크립트를 저장소에 함께 커밋하고, 클라우드 Mac의 빌드 단계에서 프로젝트 테스트보다 먼저 실행합니다. 실패가 발생하면 최소한 입력 위치, 용량, 추첨 시퀀스, 도구 체인 버전을 남겨야 합니다. 원인 분석을 위해 실제 사용자 이벤트 본문을 기록해서는 안 됩니다.
마지막으로 검증을 세 계층으로 나눕니다. 결정적인 경계 조건 테스트, 작은 규모의 경로 전수 열거, 그리고 독립적인 통계 관찰입니다. 통계 관찰은 난수 공급원 연결의 이상을 찾는 데 유용하지만, 지나치게 좁은 빈도 기준으로 매 커밋을 차단하는 데는 적합하지 않습니다. 회귀 판단의 근거는 우연히 직관에 맞지 않았던 표본이 아니라, 명확하게 재생할 수 있는 실패 경로여야 합니다.
자주 묻는 질문
실행할 때마다 다른 표본이 나와야 하나요?
아닙니다. 같은 표본이 반복될 수 있습니다. 알고리즘은 전제가 충족될 때의 선택 확률을 보장하며, 연속 실행 결과가 반드시 다르다는 뜻은 아닙니다.
추출 전에 중복 값을 제거해야 하나요?
고유한 값만 추출한다는 요구가 있을 때만 제거합니다. 일반적인 저수지 샘플링은 입력 위치를 대상으로 하므로 값이 같아도 서로 다른 후보입니다.
다음 빌드는 클라우드 Mac에서
내 Mac이 없어도 브라우저에서 macOS와 Xcode를 사용할 수 있습니다. 독립형 물리 머신을 선택하고 일간, 주간, 월간 또는 분기 단위로 이용 기간을 설정하세요.
클라우드 Mac 대여