클라우드 Mac에서 Swift 컬렉션 분할과 슬라이스 경계 검증
레코드 일괄 업로드, 데이터베이스 조회 조건 분할, 목록의 단계별 갱신 등을 구현하다 보면 Swift 프로젝트에서 '일정 개수의 요소를 가져오는' 보조 함수를 자주 사용하게 됩니다. 전체 배열에서는 정상적으로 동작하던 함수도 dropFirst가 반환한 슬라이스를 전달하면 범위를 벗어나거나 마지막 요소를 빠뜨릴 수 있습니다. 이런 순수 로직을 클라우드 Mac의 빌드 전 검사에 넣으면 시뮬레이터를 실행하지 않고도 배치 처리의 회귀 오류를 미리 잡을 수 있습니다.
먼저 분할 규약을 확정하기
이번에 검증할 대상은 연속된 요소를 묶음으로 나누는 동작뿐입니다. 각 묶음은 지정한 개수 이하의 요소를 포함하고 입력 순서를 유지해야 합니다. 마지막 묶음은 지정한 개수보다 작아도 되며, 요소를 채워 넣거나 버리지 않습니다. 빈 컬렉션에는 빈 결과를 반환하고, 묶음 크기가 0 이하이면 오류를 던집니다.
예를 들어 요소 5개를 2개씩 나누면 크기가 2, 2, 1인 묶음 3개가 나와야 합니다. 크기를 모두 채운 묶음 2개만 반환해서는 안 됩니다. 묶음 크기가 입력 길이보다 크면 비어 있지 않은 입력 전체가 유일한 묶음이 됩니다.
분할 함수는 요소를 나눌 경계만 결정합니다. 업로드 동시 실행 수를 제한하지 않으며, 원격 API의 수신 성공을 의미하지도 않습니다. 배치별 완료 상태는 호출 측에서 별도로 기록해야 합니다.
테스트에서는 분할 결과를 다시 펼쳤을 때 입력과 완전히 일치하는지 확인해야 합니다. 묶음 개수만 검사하면 중간의 요소 하나가 중복되고 다른 요소 하나가 빠진 문제는 발견할 수 없습니다.
컬렉션 인덱스로 경계 이동하기
ArraySlice는 원본 배열의 인덱스를 유지합니다. 요소 10개에 dropFirst(5)를 적용하면 반환된 슬라이스의 시작 인덱스는 0이 아닙니다. 제네릭 Collection의 인덱스는 정수가 아닐 수도 있으므로 0..<count를 그대로 첨자 접근에 사용할 수 없습니다.
아래 구현은 인덱스를 하나씩 이동합니다. '시작 위치 + 묶음 크기'를 계산하지 않으며 임의 접근 기능에도 의존하지 않습니다. ChunkChecks.swift로 저장합니다.
enum ChunkError: Error {
case invalidSize
}
func chunks<C: Collection>(
_ input: C,
size: Int
) throws -> [C.SubSequence] {
guard size > 0 else {
throw ChunkError.invalidSize
}
var result: [C.SubSequence] = []
var start = input.startIndex
while start != input.endIndex {
var end = start
var taken = 0
while end != input.endIndex && taken < size {
input.formIndex(after: &end)
taken += 1
}
result.append(input[start..<end])
start = end
}
return result
}
인덱스를 이동하기 전에 매번 endIndex에 도달하지 않았는지 확인하고, 마지막에는 반개구간으로 슬라이스를 만듭니다. 묶음 크기가 Int.max여도 실제로 존재하는 요소만 순회하며, 컬렉션 경계를 넘어가는 정수 인덱스를 생성하려 하지 않습니다.
이 구현은 남은 컬렉션에 대해 distance를 반복 호출하지 않습니다. 임의 접근을 지원하지 않는 컬렉션에서는 남은 길이를 계속 측정하면 불필요한 반복 순회가 발생할 수 있습니다. 현재 구현에서 인덱스를 이동하는 횟수는 입력 요소 수와 같습니다.
슬라이스와 마지막 묶음의 조합 검사하기
같은 파일 끝에 다음 테스트를 추가합니다. 먼저 눈으로 확인할 수 있는 예제를 고정한 뒤, 여러 입력 길이와 묶음 크기를 순회합니다. 특정 사례 하나에서만 동작함을 확인하고 끝내지 않기 위해서입니다.
let base = Array(0..<23)
let slice = base.dropFirst(5).prefix(5)
precondition(slice.startIndex == 5)
let example = try chunks(slice, size: 2)
precondition(example.map { Array($0) } == [
[5, 6], [7, 8], [9]
])
for length in 0...17 {
let input = base.dropFirst(5).prefix(length)
for size in 1...8 {
let output = try chunks(input, size: size)
let expectedCount =
length / size + (length % size == 0 ? 0 : 1)
precondition(output.count == expectedCount)
precondition(output.flatMap { Array($0) } == Array(input))
for (position, block) in output.enumerated() {
precondition(!block.isEmpty)
precondition(block.count <= size)
if position < output.count - 1 {
precondition(block.count == size)
}
}
}
}
let huge = try chunks(slice, size: Int.max)
precondition(huge.map { Array($0) } == [Array(slice)])
for size in [0, -1, Int.min] {
do {
_ = try chunks(slice, size: size)
preconditionFailure("Expected invalidSize")
} catch ChunkError.invalidSize {
}
}
예상 묶음 개수는 몫과 나머지로 계산합니다. 흔히 쓰는 '길이에 묶음 크기를 더하고 1을 뺀 뒤 묶음 크기로 나누는' 공식은 인수가 매우 크면 나눗셈 전에 정수 오버플로가 발생할 수 있습니다.
테스트는 마지막을 제외한 모든 묶음이 지정한 크기를 채우는지, 마지막 묶음이 비어 있지 않은지, 결과를 펼쳤을 때 입력과 동일한지를 함께 확인합니다. 잘못된 인수에 대해서는 지정된 오류만 허용합니다. 그 밖의 예상치 못한 오류는 스크립트를 곧바로 실패하게 해야 하며, 광범위한 오류 처리로 문제를 삼켜서는 안 됩니다.
클라우드 Mac의 빌드 전 검사에 연결하기
Swift 툴체인이 구성된 클라우드 Mac에서 저장소 루트를 기준으로 실행합니다.
set -eu
xcrun swift --version
xcrun swift Scripts/ChunkChecks.swift
파일을 저장소의 Scripts 디렉터리에 두고, 시간이 오래 걸리는 앱 빌드보다 이 단계를 먼저 실행합니다. 기본 실행 모드에서는 사전 조건이 하나라도 충족되지 않으면 0이 아닌 종료 상태를 반환합니다. 스크립트에 출력이 없다고 실행되지 않은 것은 아니므로, 작업 상태와 종료 코드로 성공 여부를 판단해야 합니다.
안전성 검사를 제거하는 -Ounchecked 옵션으로 이 단언들을 실행해서는 안 됩니다. 팀에서 로직을 테스트 타깃으로 옮긴다면 사전 조건 검사를 테스트 단언으로 바꾸고, 실패 메시지에 입력 길이, 묶음 크기, 배치 위치가 드러나도록 해야 합니다.
독립 실행형 스크립트는 최소 재현 사례를 만드는 데 적합합니다. 정식으로 연동한 뒤에는 프로젝트에서 실제로 사용하는 구현을 호출해야 합니다. 테스트 파일에 복사한 올바른 코드만 검증하고 실제 서비스 코드에는 이전 알고리즘이 남아 있는 상황을 피하기 위해서입니다.
저장 공간과 비즈니스 로직의 책임 구분하기
슬라이스를 반환하면 즉시 발생하는 복사를 줄일 수 있지만, 원본 입력의 저장 공간이 해제된다는 뜻은 아닙니다. 마지막의 작은 묶음만 남겨도 슬라이스가 배열 전체의 기반 저장 공간을 계속 유지할 수 있습니다. 소수의 배치를 오래 보관한다면 blocks.map { Array($0) }로 독립된 배열로 변환할 수 있습니다. 다만 모든 배치를 보관한다면 변환하더라도 전체 요소 수가 줄어들지는 않습니다.
입력이 매우 크다면 모든 슬라이스를 한 번에 모으는 방식도 적절하지 않습니다. 같은 경계 이동 로직을 배치별 콜백이나 지연 이터레이션으로 바꾸면 호출 측에서 한 묶음을 처리한 뒤 다음 묶음을 요청할 수 있습니다. 이때 백프레셔와 실패 시 재시도 정책도 명확히 정해야 합니다.
마지막으로 세 가지를 확인합니다. 시작 인덱스가 0이 아닌 입력에서도 동작하는지, 마지막 묶음이 빠지지 않는지, 배치를 펼쳐도 순서가 유지되는지입니다. 분할 검증에서는 먼저 '어떤 요소가 어느 배치에 속하는가'를 보장하고, 네트워크 재시도와 동시 실행 스케줄링은 각각 따로 테스트해야 합니다. 그래야 문제가 발생한 계층을 명확하게 찾아낼 수 있습니다.
자주 묻는 질문
ArraySlice의 첫 요소를 인덱스 0으로 읽어도 되나요?
항상 가능한 것은 아닙니다. 원본 컬렉션의 인덱스가 유지되므로 startIndex와 컬렉션 인덱스 연산을 사용하세요. 0부터 시작하는 인덱스가 필요하면 Array로 변환합니다.
작은 슬라이스만 보관하면 원본 배열의 저장 공간이 해제되나요?
보장되지 않습니다. 슬라이스가 원본 저장 공간을 유지할 수 있습니다. 작은 결과를 오래 보관한다면 독립 배열로 복사하고 전체 결과 크기도 제한하세요.
팀 전용 빌드를 실행하는 Mac
Apple Silicon M4 전용 물리 Mac mini를 선택하고 팀의 툴체인과 runner 설정을 그대로 유지하세요. 일·주·월·분기 단위로 이용할 수 있으며, 이용 가능 여부는 콘솔에서 실시간으로 확인할 수 있습니다.
빌드 노드 대여하기