Swift가 버전 5.5에서 네이티브 동시성 지원을 도입했을 때, 기존의 Sequence 프로토콜은 이미 IteratorProtocol을 통해 동기 반복 모델을 확립하고 있었습니다. Sequence 프로토콜은 중단 없이 즉시 요소를 생성하는 변형 next() 함수를 반환하는 makeIterator() 메서드를 요구합니다. 이 설계는 Swift의 async/await 패러다임 이전의 것이었으며, 동기 소비 기대와 비동기 생산 기능 간의 근본적인 장애를 초래하여 병렬 계층 구조를 필요로 하게 만들었습니다.
핵심 갈등은 Sequence의 next() 메서드 서명이 async 키워드를 포함할 수 없다는 것입니다. AsyncSequence가 Sequence를 확장하게 되면, 네트워크 I/O 또는 타이머에서 데이터가 비동기적으로 도착할 때 충족할 수 없는 동기 요소 접근 요구를 상속받게 됩니다. 또한, 동기 코드가 비동기 작업을 촉발하는 것을 허용하는 것은 Swift의 구조적 동시성 보장을 위반하게 되어, 비동기 코드가 Task 맥락 외부에서 실행되도록 허용하고 런타임 전반에 걸쳐 계층적 취소 전파를 깨트릴 수 있습니다.
Swift 설계자들은 AsyncSequence가 Sequence에서 상속되지 않는 독립적인 프로토콜 계층을 만들었습니다. AsyncIteratorProtocol은 mutating func next() async throws -> Element?를 정의하여 타입 서명에서 중단 지점을 명시적으로 표시합니다. 이 격리는 반복이 비동기 맥락 내에서만 일어날 수 있도록 보장하여, Swift 런타임이 계속성을 관리하고, 작업 취소를 처리하며, 호출 스택을 올바르게 유지하면서 동기 코드가 우연히 중단 의존 작업을 호출하는 것을 방지합니다.
// 동기 및 비동기 혼합 시도 (비유적 실패) protocol BrokenAsyncSequence: Sequence { // 동기 IteratorProtocol.next()와 비동기 요구를 모두 충족할 수 없습니다. } // 올바른 비동기 설계 struct TimedEvents: AsyncSequence { typealias Element = Date struct Iterator: AsyncIteratorProtocol { var count = 0 mutating func next() async -> Date? { guard count < 5 else { return nil } count += 1 await Task.sleep(1_000_000_000) // 중단 지점 return Date() } } func makeAsyncIterator() -> Iterator { Iterator() } }
시나리오: 헬스 모니터링 앱에서 고주파 센서 데이터 처리.
문제 설명: 개발팀은 CoreMotion을 사용하여 낙상을 감지하기 위해 60Hz에서 가속도계 데이터를 스트리밍할 필요가 있었습니다. 그들은 초기에는 하드웨어를 메인 스레드의 촘촘한 while 루프에서 폴링하여 센서 피드를 Sequence로 모델링했습니다. 이 접근 방식은 데이터 수집 중 UI를 차단했고, 앱 종료의 위험이 있었습니다. 그들은 비동기 센서 콜백과 데이터 처리 파이프라인을 통합하기 위한 세 가지 아키텍처 접근 방식을 고려했습니다.
해결책 1: 스레드 차단 브리지.
그들은 비동기 센서 API를 DispatchSemaphore로 감싸 동기 대기를 강제하는 사용자 정의 Sequence 반복자를 만드는 것을 고려했습니다.
장점: 표준 Array 초기자와 map/filter 알고리즘을 사용할 수 있습니다.
단점: 호출 스레드를 차단하여 iOS에서 감시 탐지 종료 위험이 있으며, CPU 사이클이 낭비되고, 수면 중 취소가 불가능합니다.
해결책 2: 콜백 기반 위임. 그들은 Sequence 준수를 완전히 포기하고 각 센서 업데이트에 대한 완료 핸들러로 위임 패턴을 사용하는 것을 고려했습니다. 장점: 블로킹이 없으며, 메인 스레드를 멈추지 않고 비동기 하드웨어 접근을 허용합니다. 단점: Sequence 작업의 조합 가능성을 잃고, 변환을 연쇄할 때 깊이 중첩된 "콜백 지옥"을 생성하며, 역압력 구현을 거의 불가능하게 만듭니다.
해결책 3: AsyncStream을 사용하는 네이티브 AsyncSequence.
그들은 CoreMotion 콜백을 연속을 사용하여 AsyncStream으로 감싸고, 그런 다음 for try await 및 AsyncAlgorithms 패키지로 처리하기로 했습니다.
장점: Swift 동시성과 통합되며, 작업 취소를 지원하고, throttle 및 debounce 연산자를 사용할 수 있으며, 응답성을 유지합니다.
단점: iOS 13+ 배포 대상이 필요하며, 팀이 구조적 동시성 패턴을 배워야 합니다.
선택된 해결책: 팀은 CMMotionManager 업데이트를 .bufferingNewest(1) 정책의 AsyncStream으로 감싸는 해결책 3을 채택했습니다. 이는 데이터 처리 속도가 60Hz 하드웨어 샘플링보다 느려질 경우 최신 판독값만 유지하도록 하여 메모리 부풀음을 방지했습니다.
결과: 낙상 감지 알고리즘은 프레임을 떨어뜨리지 않고 전체 샘플링 빈도를 유지했으며, CPU 사용량은 폴링 접근 방식에 비해 70% 감소하고, UI는 반응성을 유지했습니다. 사용자가 앱을 백그라운드로 전환했을 때 시스템은 자동 Task 취소가 스트림 반복기까지 전파되어 하드웨어 리소스를 적절히 해제했습니다.
질문 1: 비동기 for 루프에서 레이블과 함께 break 또는 continue를 사용할 수 있나요? 반복자는 어떻게 되나요?
답변: 네, 레이블이 있는 제어 흐름은 for try await 루프에서 작동합니다. 그러나 후보자들은 종종 수명 주기 측면을 오해합니다. 비동기 루프에서 break할 때, AsyncIterator는 즉시 범위를 벗어납니다. 반복자가 값 타입인 경우 deinit이 실행되어 파일 설명자와 같은 리소스를 해제합니다. 참조 타입인 경우 참조가 제거됩니다. 중요한 점은 AsyncSequence 자체에 cancel() 메서드가 없으며; 취소는 Task 계층을 통해 처리됩니다. 반복기의 정리는 별도의 취소 핸들러가 아니라 deinit에서 구현되어야 하며, 프로토콜이 모든 반복기가 참조 타입이라는 것을 보장할 수 없기 때문입니다.
질문 2: 왜 AsyncSequence가 일반 시퀀스처럼 Array(myAsyncSequence) 초기자를 지원하지 않나요?
답변: Array의 초기자는 인수가 Sequence를 준수해야 하므로, AsyncSequence는 Sequence를 확장하지 않기 때문에 직접 Array 생성자에 전달할 수 없습니다. 후보자들은 비동기 시퀀스에 특별히 설계된 Array 초기자를 사용해야 한다는 것을 종종 놓칩니다: try await Array(myAsyncSequence). 이것은 멤버별 초기자가 아닌 전역 비동기 함수입니다. Swift는 이 맥락에서 비동기 초기자를 지원하지 않기 때문입니다. 이 작업은 각 next() 호출을 순차적으로 기다려 모든 요소를 집계하며, 작업 취소를 존중하여 물질화 중에 부모 Task가 취소될 경우 CancellationError를 발생시킵니다.
질문 3: AsyncStream에서 역압력은 NotificationCenter의 AsyncSequence와 어떻게 다릅니까?
답변: 이는 중요한 구현 세부 사항을 드러냅니다. AsyncStream은 역압력을 지원합니다: 소비자가 느릴 경우, 생산자의 yield 호출은 소비자가 next()를 호출할 때까지 중단됩니다. 이는 연속 기반 세마포어를 통해 구현됩니다. 그러나 NotificationCenter의 시퀀스는 역압력을 구현하지 않으며, 소비자가 속도를 맞출 수 없는 경우 알림이 무한정 축적될 수 있는 무한 버퍼를 사용합니다. 후보자들은 종종 모든 AsyncSequence 구현이 역압력을 균일하게 처리한다고 가정합니다. 실제로 AsyncSequence는 풀 기반 프로토콜이지만 생산자의 동작은 구현에 따라 정의됩니다. 역압력이 있는 비동기 시퀀스에 푸시 기반 API를 연결하기 위한 주요 도구로 AsyncStream을 이해하는 것이 중요하며, 이는 고처리량 시나리오에서 메모리 고갈을 방지하는 데 필수적입니다.