변경 가능한 반복자 하나를 만들고, 연속된 각 값에 대해 await iterator.next()을 호출하세요. 간단한 Mac 명령줄 계산에서는 스트림과 반복자를 모두 반복 읽기 작업 밖에 두고, nil를 완료로 처리하세요.

이 안내에서는 메모리에서 생성한 합성 정수 4, 7, 10를 소비하고, 각 값을 제곱한 뒤 합계가 165인지 확인합니다. 이 계산은 메모리에서 생성된 값만 사용합니다. 반복자의 수명, 버퍼링, 완료를 하나의 작은 프로그램에서 확인할 수 있도록 하는 것이 목적입니다.

값이 반복될 때 실제로 무엇이 다시 시작되나요?

Swift는 비동기 시퀀스와 그 반복자를 구분합니다. 시퀀스는 makeAsyncIterator()을 통해 반복자를 제공하고, 반복자는 next() 호출을 통해 진행됩니다. 호출할 때마다 요소 하나를 반환하거나 반복이 끝났으면 nil을 반환합니다. Apple은 AsyncIteratorProtocol에서 이 계약을 설명합니다.

애플리케이션이 첫 번째 값을 반복해서 받는 경우, 살펴봐야 할 작업은 두 가지입니다. 다른 반복자를 만드는 것과 다른 스트림을 만드는 것입니다. 이 둘은 서로 바꿔 쓸 수 없습니다. 현재 표준 라이브러리 구현에서 AsyncStream 반복자는 스트림의 공유 컨텍스트를 보유하며, makeAsyncIterator()은 이미 존재하는 이 컨텍스트를 반복자에 전달합니다. 따라서 같은 스트림에서 반복자를 하나 더 만들어도 처음부터 독립적으로 다시 재생되지 않습니다. 공식 AsyncStream 구현을 참고하세요.

반면 4, 7, 10를 내보내는 새 스트림을 만드는 팩토리를 호출하면 해당 값이 들어 있는 또 다른 소스가 생성됩니다. 매번 읽을 때마다 이 팩토리를 호출하고 첫 요소만 소비한다면 모든 읽기에서 4을 받을 수 있습니다. getter에서 스트림을 생성하는 계산 속성도 같은 결과를 낼 수 있습니다. 이 작업에서는 소스를 한 번만 만들고 전체 계산 동안 반복자 하나를 유지하세요.

코드를 어디로 옮길지 판단할 때 이 차이를 활용하세요. 소스를 유지하려면 스트림 생성을 반복 작업 밖으로 옮기고, 명확한 단일 소비 경로를 유지하려면 반복자 생성을 반복 작업 밖으로 옮기세요. 반복자를 반복해서 생성하는 것을 처음부터 다시 시작하는 방법이나 진행 상태를 유지하는 방법으로 사용하지 마세요.

Mac에서 완전한 로컬 계산 실행하기

Swift 6 이상 툴체인이 설치된 Mac을 사용하세요. Terminal에서 설치된 컴파일러를 확인한 다음 새 디렉터리에 작은 실행 파일 패키지를 만드세요. Swift.org의 명령줄 도구 빌드하기 가이드에서 버전 확인, 패키지 생성, swift run 작업 흐름을 설명합니다.

swift --version
mkdir IteratorDemo
cd IteratorDemo
swift package init --name IteratorDemo --type executable

Sources 아래에서 생성된 실행 파일의 소스 코드를 찾으세요. 아래의 완전한 프로그램으로 내용을 바꾸세요. 소스 파일 이름을 IteratorDemo.swift로 지정하고, 템플릿이 main.swift를 만들었다면 이름을 바꾸고 기존 내용을 삭제하세요. 실행 진입점은 하나만 남겨 두세요. Swift.org에서는 main.swift 진입점과 @main 진입점을 같은 실행 파일에서 함께 사용할 수 없다고 설명합니다.

이 예제에는 import가 필요하지 않습니다. AsyncStream은 Swift에 포함되어 있고 Foundation은 별도 프레임워크입니다. 이 계산에는 Foundation API가 필요하지 않습니다. 이러한 경계는 Apple의 AsyncStream 문서와 Foundation 개요에 나와 있습니다.

@main
struct IteratorDemo {
    static func makeValues() -> AsyncStream<Int> {
        AsyncStream<Int>(bufferingPolicy: .bufferingOldest(3)) {
            continuation in
            for value in [4, 7, 10] {
                let result = continuation.yield(value)
                guard case .enqueued = result else {
                    preconditionFailure("A sample value was not enqueued")
                }
            }
            continuation.finish()
        }
    }

    static func main() async {
        let stream = makeValues()
        var iterator = stream.makeAsyncIterator()
        var received: [Int] = []
        var squareTotal = 0

        for readNumber in 1...3 {
            guard let value = await iterator.next() else {
                preconditionFailure("Stream ended before read \(readNumber)")
            }

            received.append(value)
            squareTotal += value * value
            print("Read \(readNumber): \(value)")
        }

        let end = await iterator.next()
        let stillEnded = await iterator.next()

        precondition(received == [4, 7, 10])
        precondition(squareTotal == 165)
        precondition(end == nil)
        precondition(stillEnded == nil)

        print("Square total: \(squareTotal)")
        print("Finished: \(end == nil)")
    }
}

패키지 디렉터리에서 실행 파일을 실행하세요.

swift run IteratorDemo

빌드 메시지가 표시될 수 있으며, 애플리케이션의 예상 출력은 다음과 같습니다.

Read 1: 4
Read 2: 7
Read 3: 10
Square total: 165
Finished: true

산술은 명시적으로 설명을 위한 것입니다. 4 × 4 + 7 × 7 + 10 × 10 = 165. 어설션은 정확한 값과 순서, 계산 결과, 종료 동작을 확인합니다. 조사 환경에서 Swift 컴파일러를 사용할 수 없어 이 프로그램은 검토만 했으며 여기서 실행하지는 않았습니다.

이 반복자는 왜 올바르게 계속 진행되나요?

프로그램은 makeValues()을 한 번, makeAsyncIterator()을 한 번 호출합니다. 세 번의 읽기 모두 같은 변경 가능한 변수를 사용합니다. Apple은 반복자의 next() 메서드를 변경 가능하고 비동기적인 메서드로 선언합니다. 따라서 var iterator와 await iterator.next()이 모두 필요한 이유를 알 수 있습니다.

생산자는 소비가 시작되기 전에 스트림을 생성하면서 세 값을 내보냅니다. 따라서 버퍼 용량은 이 예제 계약의 일부입니다. .bufferingOldest(3)은 정수 세 개를 모두 담을 공간이 있습니다. 모든 yield의 결과를 확인하면 예상치 못한 큐 삽입 실패가 드러나므로, 값이 누락된 채 계산이 진행되지 않습니다. Apple은 읽기가 대기 중이지 않을 때 yield(_:)가 값을 버퍼링하려 시도한다고 설명합니다.

이 값들을 내보낸 뒤 finish()를 호출하면 생산이 끝나지만 버퍼에 있는 값은 계속 소비할 수 있습니다. 채택된 Swift 제안서에는 버퍼에 있는 요소가 종료 상태인 nil보다 먼저 소비자에게 전달된다고 명시되어 있습니다. 따라서 생산자가 이미 끝난 뒤에도 처음 세 번의 읽기는 성공합니다. AsyncStream 및 AsyncThrowingStream, SE-0314를 참고하세요.

네 번째 읽기는 종료되었으며 소진된 스트림을 만나 nil을 반환합니다. 다섯 번째 읽기는 종료 계약을 확인합니다. 반복자가 nil을 반환한 뒤에는 이후 읽기도 nil을 반환해야 합니다. nil을 나중에 다시 시도하거나 반복자를 새로 만들라는 뜻으로 해석하지 마세요. AsyncIteratorProtocol에서는 이를 반복의 끝으로 정의합니다.

명시적 읽기와 연속 루프 중 선택하기

번호가 매겨진 계산처럼 개별 읽기 사이에 주변 작업을 제어해야 한다면 명시적인 next() 호출을 사용하세요. 전체 소비 세션을 관리하는 범위에 반복자를 두세요. 호출할 때마다 자체 반복자를 만드는 헬퍼는 그 소유 관계를 모호하게 합니다. 스트림까지 다시 만드는 헬퍼는 소스 자체를 바꿉니다.

모든 요소를 완료될 때까지 처리하는 것이 작업의 전부라면 for await 루프 하나를 사용하세요. Apple의 AsyncStream 개요에서는 이 지원되는 소비 방식을 보여 줍니다. 예제 프로그램에서는 아래 코드 조각으로 기존 main() 메서드 전체를 대체하고, 기존 makeValues() 메서드와 이를 둘러싼 형식은 그대로 유지하세요.

static func main() async {
    let stream = makeValues()
    var received: [Int] = []
    var squareTotal = 0

    for await value in stream {
        received.append(value)
        squareTotal += value * value
    }

    precondition(received == [4, 7, 10])
    precondition(squareTotal == 165)
    print("Square total: \(squareTotal)")
}

이 버전은 하나의 연속적인 소비 세션을 나타냅니다. 개별 읽기 명령이 필요하지 않을 때 선택하세요. 개별 읽기가 작업 구조의 일부라면 명시적 반복자를 선택하세요. 두 방식 모두 올바르게 구성된 소스와 정의된 끝이 필요합니다.

반복되거나 누락되거나 끝나지 않는 읽기 진단하기

다음 순서대로 살펴보면 소스 수명 문제를 버퍼링 및 완료 문제와 구분할 수 있습니다. 동작을 더 추가하기 전에 고정된 예제에 적용하세요.

  1. 4가 반복됨: 소스 생성을 살펴보세요. 각 작업이 makeValues()를 다시 호출하는지, 또는 새 스트림을 만드는 getter에 접근하는지 확인하세요. 팩토리 호출을 반복 읽기 바깥에 두세요.
  2. 소비자 간 값이 예상과 다름: 소유 관계를 살펴보세요. 이 계산에는 소비자 하나와 반복자 하나를 사용하세요. 같은 스트림의 반복자를 여러 개 만들어도 값의 독립적인 복사본이 생기지 않습니다.
  3. 값이 누락됨: 버퍼 용량을 살펴보세요. 예제는 소비 전에 정수 세 개를 모두 생성합니다. 더 작은 버퍼를 사용하면 사용 가능한 값이 달라집니다.
  4. 마지막 읽기가 끝나지 않음: 완료 처리를 살펴보세요. continuation 기반 스트림에서는 버퍼가 비어 있는 것만으로 생산이 끝나지 않습니다. 유한 생산자가 finish()를 호출하는지 확인하세요.
  5. 읽기가 겹침: 소비를 순차적으로 처리하세요. 반복자에서 읽기를 하나 시작한 뒤 완료될 때까지 기다리고 나서 다음 읽기를 시작하세요.

이 예제의 조건에서는 버퍼 변경에 따른 결과를 예측할 수 있습니다. .bufferingOldest(2)를 사용하면 처음 두 값이 남고 새 값 10은 버려집니다. .bufferingNewest(2)을 사용하면 버퍼에서 가장 오래된 값인 4이 버려져 7과 10이 남습니다. 이는 Apple이 문서화한 bufferingOldest(_:) 및 bufferingNewest(_:) 정책을 바탕으로, 명시된 생산 순서에 대해 도출한 결과입니다.

어느 정책도 생산자가 소비를 기다리게 하지는 않습니다. Apple은 yield(_:)이 즉시 반환한다고 설명하므로 반복자 하나를 유지해도 버퍼 용량 부족을 보완할 수 없습니다. 이 유한한 정수 세 개 계산에는 용량 3이면 충분합니다. 생산 패턴이 다르면 용량도 별도로 결정해야 합니다.

반복자 접근은 순차적으로 유지하세요. Apple은 AsyncStream.Iterator가 Sendable을 준수하지 않으며 next()를 동시에 호출하려고 경합하는 것은 프로그래머의 오류라고 설명합니다. 취소도 continuation에 영향을 줍니다. 값을 기다리는 동안 소비 작업이 취소되면 Apple의 next() 문서에 따라 스트림이 종료됩니다. 반복자를 새로 만드는 것은 취소된 스트림을 재개하는 문서화된 방법이 아닙니다.

이 로컬 계산에서 전체 소비 계약은 간단합니다. 소스 하나를 만들고, 변경 가능한 반복자 하나를 유지하며, 읽기를 순차적으로 기다리고, 값 세 개를 모두 버퍼에 보존한 뒤 nil에서 멈춥니다. 이렇게 하면 진행 상태가 명확해지고 최종 합계를 확인할 수 있습니다.

출처