클라우드 Mac에서 iOS 링 버퍼의 순환과 가득 참·비어 있음 검증

클라우드 Mac에서 iOS 링 버퍼의 순환과 가득 참·비어 있음 검증

Windows에서 데이터 수신 로직을 작성한 뒤 클라우드 Mac으로 옮겨 iOS 테스트를 실행하면, 링 버퍼를 가득 채우고 일부를 읽어 낸 다음 다시 쓰는 상황에서만 데이터가 유실될 수 있습니다. 한 번 넣고 한 번 꺼내는 테스트로는 이런 오류를 잡아내기 어렵습니다. 여기서는 버퍼를 독립적인 순수 Swift 타입으로 분리해 OSXMesh 클라우드 Mac의 터미널에서 검증한 뒤, 같은 검증문을 앱의 테스트 타깃으로 옮깁니다.

가득 참·비어 있음과 실패 시 동작부터 정하기

이 글에서는 고정 용량의 선입선출 정수 버퍼를 사용합니다. 용량은 0보다 커야 하며, 버퍼가 가득 차면 기존 데이터를 덮어쓰지 않고 새 요소를 거부합니다. 빈 버퍼에서 읽으면 nil을 반환합니다. 일괄 쓰기는 일부만 성공할 수 있으며, 실제로 받아들인 요소 수를 반환하고 나머지 입력은 호출자가 처리하도록 남겨 둡니다.

가능한 설계가 이것뿐인 것은 아니지만, 먼저 하나를 명확히 정해야 합니다. 제품 요구사항상 가장 오래된 데이터를 덮어써야 한다면 별도의 타입과 검증문을 작성해야 합니다. 같은 append가 호출 위치에 따라 동작 규칙을 암묵적으로 바꾸게 해서는 안 됩니다.

링 버퍼의 정확성은 단순히 “범위를 벗어나지 않는 것”에 그치지 않습니다. 실패한 연산은 상태를 바꾸면 안 되며, 읽기에 성공한 요소의 순서는 받아들인 순서와 같아야 합니다.

head는 다음에 읽을 위치를 나타내고, count는 가득 찬 상태와 빈 상태를 구분합니다. 논리적인 끝 위치는 이 두 값으로 계산하므로 모든 저장 슬롯을 사용할 수 있습니다. 예제에서는 인덱스를 더할 때 오버플로가 발생하지 않도록 용량 상한을 두며, 테스트에서 실수로 지나치게 큰 용량을 전달하는 것도 방지합니다.

독립 실행 가능한 최소 구현 작성하기

RingCheck.swift를 새로 만들고 아래 코드를 넣습니다. 표준 라이브러리에만 의존하므로 시뮬레이터도, 프로젝트 코드 서명도 필요하지 않습니다. 먼저 swift --version을 실행해 툴체인 정보를 기록합니다. 명령을 사용할 수 없다면 그래픽 인터페이스에서 개발 도구 설치와 초기 설정을 먼저 마칩니다.

struct IntRing {
    let capacity: Int
    private var storage: [Int?]
    private var head = 0
    private(set) var count = 0

    init(capacity: Int) {
        precondition((1...1_000_000).contains(capacity))
        self.capacity = capacity
        storage = Array(repeating: nil, count: capacity)
    }

    mutating func append(_ value: Int) -> Bool {
        guard count < capacity else { return false }
        storage[(head + count) % capacity] = value
        count += 1
        return true
    }

    mutating func pop() -> Int? {
        guard count > 0 else { return nil }
        let value = storage[head]!
        storage[head] = nil
        head = (head + 1) % capacity
        count -= 1
        return value
    }

    mutating func appendBatch(_ values: [Int]) -> Int {
        var accepted = 0
        for value in values {
            guard append(value) else { break }
            accepted += 1
        }
        return accepted
    }

    func snapshot() -> [Int] {
        (0..<count).map {
            storage[(head + $0) % capacity]!
        }
    }
}

여기서 강제 언래핑은 논리적으로 유효한 구간의 슬롯에는 반드시 값이 있다는 내부 불변 조건에 의존합니다. 구현이 이 조건을 깨뜨렸다면 기본값으로 오류를 가리지 말고 테스트가 즉시 실패하도록 해야 합니다. 읽은 뒤 슬롯을 비우는 방식은 나중에 요소를 참조 타입으로 바꿨을 때 객체를 제때 해제하는 데도 도움이 됩니다.

작은 용량으로 순환 오류 드러내기

다음 코드를 파일 끝에 추가합니다. 용량이 3인 시나리오에서는 버퍼를 먼저 채우고 요소 두 개를 읽은 뒤, 배열 끝을 넘어 처음으로 돌아가며 데이터를 씁니다. 용량이 1인 시나리오는 “빈 슬롯 하나를 남겨 두는” 설계를 현재의 용량 계약과 혼용한 문제를 드러낼 수 있습니다.

var ring = IntRing(capacity: 3)
precondition(ring.appendBatch([10, 20, 30]) == 3)
precondition(!ring.append(99))
precondition(ring.snapshot() == [10, 20, 30])
precondition(ring.pop() == 10)
precondition(ring.pop() == 20)
precondition(ring.appendBatch([40, 50, 60]) == 2)
precondition(ring.snapshot() == [30, 40, 50])
precondition(ring.pop() == 30)
precondition(ring.pop() == 40)
precondition(ring.pop() == 50)
precondition(ring.pop() == nil)
precondition(ring.count == 0)

var single = IntRing(capacity: 1)
precondition(single.pop() == nil)
precondition(single.append(7))
precondition(!single.append(8))
precondition(single.pop() == 7)
precondition(single.appendBatch([]) == 0)

특히 쓰기가 거부된 뒤의 스냅샷을 확인해야 합니다. 반환값이 false인지만 검사해서는 부족합니다. 잘못된 구현은 인덱스를 먼저 이동시킨 뒤에야 버퍼가 가득 찼다는 사실을 확인할 수 있습니다. 빈 버퍼에서 읽을 때도 마찬가지로, 다음 유효한 읽기 위치를 몰래 바꾸면 안 됩니다.

고정 시드로 참조 큐와 매 단계 비교하기

경계 사례를 통과했다면 모델 기반 테스트를 추가합니다. 참조 모델에는 일반 배열을 사용합니다. 배열 맨 앞의 요소를 제거하는 작업은 상대적으로 느리지만, 여기서는 용량이 매우 작으므로 검토하기 쉬운 구현을 우선합니다. 최종 결과만 확인하는 것보다 매 단계에서 내용과 개수를 함께 비교해야 최초로 차이가 발생한 지점을 찾기 쉽습니다.

var seed: UInt64 = 0x5EED
func next() -> UInt64 {
    seed = seed &* 6364136223846793005 &+ 1
    return seed
}

for capacity in [1, 2, 3, 7] {
    var tested = IntRing(capacity: capacity)
    var model: [Int] = []

    for step in 0..<20_000 {
        if (next() >> 32) % 2 == 0 {
            let value = Int(next() % 10_000)
            let accepted = model.count < capacity
            precondition(tested.append(value) == accepted)
            if accepted { model.append(value) }
        } else {
            let expected = model.isEmpty ? nil : model.removeFirst()
            precondition(tested.pop() == expected)
        }

        precondition(
            tested.snapshot() == model,
            "capacity=\(capacity), step=\(step)"
        )
        precondition(tested.count == model.count)
    }
}

swift RingCheck.swift를 실행합니다. 검증 실패 없이 정상 종료되어야 통과입니다. 이 결과로 성능을 판단할 수는 없으며, 반복 횟수는 테스트 규모를 나타낼 뿐입니다. 일괄 쓰기 경로에는 남은 공간이 없는 경우, 정확히 충분한 경우, 슬롯 하나가 부족한 경우의 세 가지 명시적 테스트도 추가하고, 반환된 개수와 입력에서 받아들이지 못한 뒷부분을 확인해야 합니다.

생성기는 상위 비트로 연산을 선택합니다. 단순한 생성기의 하위 비트에 나타나는 규칙 때문에 연산이 기계적으로 번갈아 실행되는 것을 피하기 위해서입니다. 실패를 재현하려면 실패 순간의 시드만 기록하지 말고 초기 시드, 용량, 실패가 발생한 단계를 함께 보관해야 합니다.

프로젝트에 통합하고 적용 범위 지키기

독립 스크립트가 통과하면 타입을 애플리케이션 모듈로 옮기고, 검증문을 테스트 프레임워크의 동등성 검증문으로 바꿉니다. 최소한 다음 세 가지 검사는 유지해야 합니다.

  • 상태 제약: 요소 수는 항상 0 이상 용량 이하여야 하며, 실패한 연산 전후의 스냅샷은 완전히 같아야 합니다.
  • 순서 제약: 중복 값과 음수도 받아들인 순서대로 반환해야 합니다. 배열 비교를 집합 비교로 대체하면 안 됩니다.
  • 일괄 쓰기 제약: 반환값은 실제로 쓴 요소 수와 같아야 하며, 호출자는 받아들여지지 않은 입력 뒷부분을 이미 소비된 것으로 처리하면 안 됩니다.

애플리케이션에 비우기 인터페이스가 있다면 빈 상태, 가득 찬 상태, 이미 순환한 상태에서 각각 비운 뒤 다시 가득 채워 이전 데이터가 다시 나타나지 않는지 확인합니다. 요소 타입을 제네릭으로 바꾼다면 참조 해제도 별도로 검사해야 합니다. 정수 테스트를 통과했다고 객체 수명 관리까지 올바르다고 볼 수는 없습니다.

이 구현은 순차 접근에만 적합합니다. 락 프리 큐가 아니며, 실시간 제약이 있는 오디오 콜백에 그대로 넣기에도 적합하지 않습니다. 여러 태스크가 공유한다면 먼저 동기화 경계를 명확히 정한 다음 동시성 동작을 테스트해야 합니다. 구현, 경계 테스트, 고정 시드 모델 테스트를 함께 커밋해 두면 다음에 인덱스 알고리즘을 수정할 때도 같은 통과 기준으로 검증할 수 있습니다.

자주 묻는 질문

두 인덱스 대신 head와 count를 저장하는 이유는 무엇인가요?

두 인덱스가 같아도 비어 있거나 가득 찬 상태일 수 있습니다. count가 0이면 비어 있고 용량과 같으면 가득 찬 것으로 구분하면 저장 슬롯을 하나 비워 둘 필요가 없습니다.

고정 시드 테스트가 경계값 테스트를 대신할 수 있나요?

아닙니다. 용량 하나, 빈 읽기, 가득 찬 버퍼에 쓰기, 끝에서 처음으로 넘어가는 동작을 먼저 개별 검증해야 합니다. 이후 긴 작업열을 기준 큐와 비교하고 실패 시 시드와 작업 번호를 남깁니다.

여러 태스크에서 동시에 수정해도 되나요?

안 됩니다. 예제에는 동기화가 없습니다. 공유하려면 actor 등으로 상태 접근을 격리하고 동시 접근 계약을 별도로 테스트해야 합니다.

다음 빌드는 클라우드 Mac에서

내 Mac이 없어도 브라우저에서 macOS와 Xcode를 사용할 수 있습니다. 독립형 물리 머신을 선택하고 일간, 주간, 월간 또는 분기 단위로 이용 기간을 설정하세요.

클라우드 Mac 대여

브라우저에서 클라우드 Mac 사용하기

가상 머신이 아닌 독립형 물리 머신입니다. 모델, 리전, 이용 기간을 선택한 다음 macOS 작업 환경을 시작하세요.