타이머 대기자 하나를 독립적으로 취소하려면 각 소비자에게 자체 AsyncStream과 작업 핸들을 제공하세요. Foundation 타이머를 공유한 다음, 그 값을 별도의 스트림으로 배포합니다. 그러면 소비자 A를 취소해도 A의 대기만 끝나고 소비자 B는 계속 실행될 수 있습니다.
이 튜토리얼에서는 가상의 숫자를 표시하는 작은 Mac 데스크톱 앱과 Cancel A 버튼을 만듭니다. 두 디스플레이는 하나의 타이머에서 값을 받습니다. 구체적인 목표는 공유 타이머를 중단하거나 무효화하지 않고 A의 비동기 소비를 멈추는 것입니다.
대기자 하나를 취소하면 다른 소비자에게 영향을 줄 수 있는 이유는 무엇인가요?
AsyncSequence는 비동기 반복을 설명하며, 취소 동작은 구체적인 시퀀스 구현에 따라 달라집니다. AsyncStream의 경우, 현재 표준 라이브러리 소스에는 next()에서 대기 중인 작업을 취소하면 스트림이 종료된다고 설명되어 있습니다. 호출은 즉시 또는 이후 호출에서 nil을 반환할 수 있습니다. 같은 스트림에서 새 반복자를 만든다고 해서 독립적인 구독이 설정되는 것은 아닙니다. Swift 표준 라이브러리 소스: AsyncStream.swift.
따라서 소유권이 중요합니다. 두 작업이 같은 스트림을 소비한다면, 취소가 한 반복자에만 적용되는 개별 작업이라고 간주하는 것은 이 작업의 계약에 맞지 않습니다. 대신 별도의 continuation을 가진 스트림 두 개를 만들고 생성된 각 값을 양쪽에 명시적으로 보냅니다. Apple은 AsyncStream을 콜백을 비동기 반복으로 연결하는 방법으로 설명하며, yield(_:)으로 요소를 제공하고 finish()로 스트림을 끝냅니다. AsyncStream.
작업 취소도 협력적으로 이루어집니다. cancel()을 호출하면 작업에 취소 표시가 설정되고 취소 핸들러가 실행되지만, 임의의 코드 실행을 중단하지는 않습니다. 소비자는 받은 값을 적용하기 전에 취소 여부를 확인해야 합니다. 취소는 자식 작업에도 전파되므로, 공통 부모를 취소하면 의도한 소비자 핸들만 취소할 때보다 영향이 커질 수 있습니다. Task.cancel().
이 앱에는 다음 소유권 규칙을 적용합니다.
| 작업 | 의도한 효과 |
|---|---|
| A의 작업 취소 | A의 소비를 끝내고 A의 구독을 제거합니다 |
| A의 continuation 종료 | A의 스트림을 정상적으로 끝냅니다 |
| B의 작업 취소 | B의 소비를 독립적으로 끝냅니다 |
| 타이머 허브 중지 | 타이머를 무효화하고 남은 모든 스트림을 종료합니다 |
타이머 소비자는 어떤 값을 받아야 하나요?
이 예제는 의도적으로 간단한 값 계약을 사용합니다. 처리된 타이머 콜백마다 정수가 1부터 1,000까지 증가하고, 다시 1로 돌아갑니다. 각 디스플레이에는 해당 정수와 그 제곱을 표시합니다. 예를 들어 가상의 값 7은 n=7, square=49을 생성합니다. 상한을 두어 반복 실행 중에도 계산량을 작게 유지합니다.
각 구독은 대기 중인 최신 값 하나만 버퍼링합니다. 느린 디스플레이가 중간 값을 건너뛸 수 있지만, 최신 가상 상태를 표시하는 것이 목적인 경우에는 적절합니다. 생성된 모든 이벤트를 처리해야 하는 경우에는 적합하지 않습니다. Apple은 AsyncStream의 제한된 버퍼링과 기본 무제한 버퍼를 모두 설명합니다. AsyncStream 버퍼링.
카운터는 경과 시간이 아니라 처리된 콜백 수를 셉니다. Foundation 타이머는 런 루프에 의존하며, 늦게 실행될 수 있고 놓친 실행을 모두 재생하지는 않습니다. 아래의 2초 간격은 취소 상호작용을 쉽게 관찰하기 위한 것이며, 정확한 시계를 제공하지는 않습니다. Timer.
독립적인 구독은 어떻게 만드나요?
Xcode에서 Swift 6 툴체인과 macOS 14 이상 배포 대상을 사용하는 macOS SwiftUI 앱 타깃을 만드세요. 다음 세 파일을 추가하고, 템플릿 앱 진입점과 뷰를 교체하세요. 세 파일은 함께 완전한 예제를 구성하며 외부 패키지, 네트워크 접근 또는 저장된 데이터를 필요로 하지 않습니다. 코드를 검토했지만 이 연구 환경에서는 컴파일하지 않았습니다.
공유 타이머 허브 만들기
허브는 타이머와 구독 continuation 딕셔너리를 소유합니다. subscribe()을 호출할 때마다 새 스트림을 만듭니다. 종료 콜백은 해당 식별자에 해당하는 항목만 제거합니다. Apple은 활성 스트림 반복을 취소하면 onTermination이 호출되고 반복이 nil로 끝난다고 설명합니다. AsyncStream.Continuation.onTermination.
이 코드를 TickHub.swift에 넣으세요.
import Foundation
@MainActor
final class TickHub {
private var timer: Timer?
private var number = 0
private var continuations:
[UUID: AsyncStream<Int>.Continuation] = [:]
func subscribe() -> (id: UUID, stream: AsyncStream<Int>) {
let id = UUID()
let pair = AsyncStream<Int>.makeStream(
bufferingPolicy: .bufferingNewest(1)
)
pair.continuation.onTermination = { [weak self] _ in
Task { @MainActor [weak self] in
self?.unsubscribe(id)
}
}
continuations[id] = pair.continuation
return (id, pair.stream)
}
func start() {
guard timer == nil else { return }
let newTimer = Timer(
timeInterval: 2,
repeats: true
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.emit()
}
}
newTimer.tolerance = 0.2
timer = newTimer
RunLoop.main.add(newTimer, forMode: .common)
}
private func emit() {
guard timer != nil else { return }
number = number == 1_000 ? 1 : number + 1
for continuation in continuations.values {
_ = continuation.yield(number)
}
}
func unsubscribe(_ id: UUID) {
let continuation = continuations.removeValue(forKey: id)
continuation?.finish()
}
func stop() {
timer?.invalidate()
timer = nil
let remaining = Array(continuations.values)
continuations.removeAll()
for continuation in remaining {
continuation.finish()
}
}
}
타이머 이니셜라이저는 예약되지 않은 타이머를 만들기 때문에 허브가 이를 명시적으로 메인 런 루프에 추가합니다. 콜백은 허브 상태에 접근하기 전에 메인 액터 작업으로 진입합니다. timer != nil 검사는 이미 대기 중인 콜백이 종료 후 값을 내보내지 않도록 합니다. Timer.init(timeInterval:repeats:block:) 및 RunLoop.add(_:forMode:).
허브의 모든 상태는 MainActor로 격리됩니다. 해당 실행자는 메인 디스패치 큐와 동등합니다. 종료 콜백은 콜백이 실행되는 컨텍스트에서 딕셔너리에 직접 접근하지 않고, 그곳에서 정리 작업을 예약합니다. MainActor.
소비자마다 별도의 작업 핸들 제공하기
다음 코드를 TimerDemoModel.swift에 넣으세요. subscribe()을 두 번 호출하는 것이 핵심입니다. 각각 독립적인 스트림 수명을 만듭니다. defer 블록도 소비자가 종료될 때 구독을 해제하므로, 취소뿐 아니라 명시적인 루프 종료도 처리합니다.
import Combine
@MainActor
final class TimerDemoModel: ObservableObject {
@Published private(set) var aText = "Waiting"
@Published private(set) var bText = "Waiting"
@Published private(set) var aCancelled = false
@Published private(set) var stopped = false
private let hub = TickHub()
private var aTask: Task<Void, Never>?
private var bTask: Task<Void, Never>?
private var started = false
func start() {
guard !started else { return }
started = true
let a = hub.subscribe()
let b = hub.subscribe()
let source = hub
aTask = Task { @MainActor [weak self] in
defer { source.unsubscribe(a.id) }
for await number in a.stream {
guard !Task.isCancelled else { break }
self?.aText = "n=\(number), square=\(number * number)"
}
}
bTask = Task { @MainActor [weak self] in
defer { source.unsubscribe(b.id) }
for await number in b.stream {
guard !Task.isCancelled else { break }
self?.bText = "n=\(number), square=\(number * number)"
}
}
hub.start()
}
func cancelA() {
guard !aCancelled, !stopped else { return }
aCancelled = true
aTask?.cancel()
}
func stopAll() {
guard !stopped else { return }
stopped = true
aTask?.cancel()
bTask?.cancel()
hub.stop()
aTask = nil
bTask = nil
}
}
cancelA()은 취소 작업을 정확히 하나만 수행합니다. hub.stop()을 호출하거나 B의 작업에 접근하지 않습니다. 루프는 숫자를 받은 뒤 디스플레이를 변경하기 전에 Task.isCancelled를 확인합니다. 이는 Swift에 문서화된 협력적 취소 모델을 따릅니다. The Swift Programming Language — 동시성.
두 정리 경로, 즉 종료 콜백과 작업의 defer이 모두 A를 제거하려고 할 수 있습니다. 여기서는 의도된 동작입니다. unsubscribe(_:)은 먼저 항목을 제거한 뒤 반환된 continuation을 종료합니다. 나중에 다시 제거를 시도해도 아무 항목도 찾지 못하므로 다른 구독은 그대로 유지됩니다.
데스크톱 컨트롤 추가하기
이 코드를 TimerWaiterDemoApp.swift에 넣으세요. 타깃에는 @main 앱 선언을 하나만 두세요.
import SwiftUI
@main
@MainActor
struct TimerWaiterDemoApp: App {
var body: some Scene {
WindowGroup {
TimerDemoView()
}
}
}
@MainActor
struct TimerDemoView: View {
@StateObject private var model = TimerDemoModel()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Independent timer consumers").font(.headline)
Text("A: \(model.aText)")
Text(model.aCancelled ? "A cancelled" : "A active")
Text("B: \(model.bText)")
HStack {
Button("Cancel A") { model.cancelA() }
.disabled(model.aCancelled || model.stopped)
Button("Stop all") { model.stopAll() }
.disabled(model.stopped)
}
}
.padding(24)
.frame(minWidth: 420)
.onAppear { model.start() }
.onDisappear { model.stopAll() }
}
}
@StateObject는 뷰 컨테이너의 수명 동안 모델 저장 공간을 안정적으로 유지하며, 게시된 변경 사항은 종속 텍스트 뷰를 업데이트합니다. 뷰가 사라질 때 실행되는 동작은 뷰 수준의 정리를 제공하지만, 정확한 실행 시점은 뷰에 따라 달라집니다. Stop all 버튼도 직접 종료 작업을 제공합니다. StateObject 및 onDisappear(perform:).
취소가 해당 소비자에만 적용되는지 어떻게 확인하나요?
앱을 실행하고 다음 승인 절차를 진행하세요. 아래 숫자는 확인할 가상의 관찰 예시이며, 기록된 테스트 결과가 아닙니다.
- 두 디스플레이의 값이 바뀔 때까지 기다립니다. 일반적으로 둘 다 같은 최신 숫자와 제곱을 표시해야 합니다.
- 타이머가 다음 값을 내기 전에
Cancel A를 클릭합니다. A의 상태가A cancelled로 바뀌고, 표시 값은 고정됩니다. - 타이머가 두 번 이상 더 실행될 때까지 기다립니다. A가 고정된 상태로 B의 값은 계속 바뀌어야 합니다.
Stop all을 클릭합니다. 이제 B의 값도 더 이상 바뀌지 않아야 합니다.- 앱을 다시 실행하고 A가 첫 값을 받기 전에 즉시 취소합니다. B가 숫자를 받기 시작하는 동안 A는
Waiting상태를 유지해야 합니다.
이 절차는 소비가 시작된 뒤와 첫 유효 값이 도착하기 전 모두에서 취소를 확인합니다. 이 설계에서는 메인 액터의 취소 동작과 디스플레이 업데이트가 순차적으로 처리됩니다. 버튼을 누르기 전에 완료된 업데이트는 표시된 채로 남으며, 취소 검사는 이후 재개된 A의 반복에서 다른 값을 적용하지 못하게 합니다.
동작이 다르다면 타이머 간격을 바꾸기 전에 소유권 경계를 확인하세요.
| 증상 | 먼저 확인할 사항 |
|---|---|
| A를 취소했을 때 B도 멈춤 | 공유 스트림을 사용하거나 hub.stop()을 호출하는 정리 코드가 있는지 확인합니다 |
| 디스플레이가 서로 다른 값들을 받음 | 각 소비자가 별도의 subscribe() 결과를 받는지 확인합니다 |
| 값이 표시되지 않음 | 타이머가 시작되었고 앱의 실행 중인 메인 런 루프에 추가되었는지 확인합니다 |
| 간혹 값이 건너뜀 | 최신 값 버퍼링 정책이 디스플레이 요구 사항에 맞는지 확인합니다 |
| 뷰가 사라진 뒤에도 작업이 계속됨 | 종료 작업이 두 작업 핸들과 허브에 모두 도달하는지 확인합니다 |
일회성 대기자나 재시작에는 어떤 변경이 필요한가요?
일회성 대기자의 경우 구독을 내부적으로 유지하고, 첫 번째로 허용 가능한 값을 처리한 뒤 루프를 종료하세요. 루프가 break을 통해 끝나는 경우에도 defer 구독 해제 작업을 유지하세요. 이렇게 하면 의도한 구독 수명이 명확해집니다. 정상적인 일회성 완료는 해당 구독을 제거하면서 B가 공유 생산자를 계속 사용할 수 있도록 해야 합니다.
A를 재시작하려면 이전 작업이 완료된 후 새 구독과 새 작업을 만드세요. 취소된 작업을 재설정하거나 종료된 스트림을 재사용하지 마세요. 재시작 가능한 인터페이스에서는 새 항목을 설치하기 전에 이전 작업이 완료될 때까지 기다리거나, 이전 정리가 새 상태를 제거하지 않도록 연속되는 구독 수명을 구분해야 합니다.
이 샘플은 뷰 인스턴스마다 한 번만 실행되도록 설계되었습니다. 전체 종료는 반복 타이머가 설치된 메인 액터에서 타이머를 무효화합니다. Apple은 무효화된 타이머를 재사용할 수 없다고 설명합니다. 재시작하려면 다른 타이머를 만들어야 합니다. Timer 수명 주기.
독립적인 취소는 독립적인 구독 소유권에서 비롯됩니다. 타이머는 허브에 두고, 소비자마다 별도의 스트림과 작업 핸들을 제공하며, 작업이 끝날 때 해당 소비자의 구독만 제거하세요. 그러면 데스크톱 컨트롤로 대기 중인 디스플레이 하나를 멈추면서 나머지 디스플레이는 같은 가상 계산 결과를 계속 받을 수 있습니다.
출처
- AsyncStream.swift — Swift Project.
- AsyncStream — Apple.
- onTermination — Apple.
- cancel() — Apple.
- Concurrency — Swift Project.
- Timer — Apple.
- init(timeInterval:repeats:block:) — Apple.
- add(_:forMode:) — Apple.
- MainActor — Apple.
- StateObject — Apple.
- onDisappear(perform:) — Apple.