Develup

[Concurrency] Task 취소 완벽 이해하기: 코드 예제와 모범 사례 본문

Swift/Concurrency

[Concurrency] Task 취소 완벽 이해하기: 코드 예제와 모범 사례

Develup 2025. 3. 15. 23:28
반응형

Swift의 Concurrency 모델에서 작업 취소(Cancellation)는 비동기 프로그래밍의 중요한 요소입니다. 효율적인 앱 개발을 위해서는 더 이상 필요하지 않은 작업을 적절히 취소하는 방법을 이해하는 것이 필수적입니다. 이 글에서는 Swift의 비동기 작업 취소 메커니즘을 깊이 있게 살펴보고, Task와 async/await 환경에서 작업을 취소하는 모범 사례를 알아보겠습니다.

작업 취소는 단순히 리소스를 절약하는 것 이상의 의미가 있습니다. 사용자 경험을 향상시키고, 불필요한 네트워크 요청을 방지하며, 배터리 수명을 연장하는 데 중요한 역할을 합니다. 이러한 이유로 모든 Swift 개발자는 작업 취소를 효과적으로 구현하는 방법을 알아야 합니다.

Swift Concurrency의 취소 모델 이해하기

Swift의 작업 취소 모델은 '협력적(cooperative)' 특성을 가지고 있습니다. 이는 작업이 자동으로 중단되지 않고, 취소 요청을 확인하고 적절히 반응해야 함을 의미합니다. 이 협력적 특성은 Swift의 취소 모델의 기본 개념입니다.

우선, 취소가 어떻게 작동하는지 기본 원리를 이해하는 것이 중요합니다. Swift에서 작업을 취소할 때, 시스템은 해당 작업의 '취소 플래그'를 설정합니다. 그러나 이 플래그는 단지 '취소해주세요'라는 신호일 뿐, 작업을 강제로 중단시키지는 않습니다. 작업 자체가 이 신호를 확인하고 그에 따라 행동해야 합니다.

이러한 협력적 취소 모델은 개발자에게 더 많은 유연성을 제공합니다. 작업이 취소되었을 때 리소스를 정리하거나, 임시 결과를 저장하거나, 다른 정리 작업을 수행할 수 있는 기회가 주어집니다. 이는 작업이 갑자기 중단되어 리소스 누수나 데이터 손실이 발생할 수 있는 강제 취소 모델과는 대조적입니다.

취소 확인 방법

Swift에서는 작업이 취소되었는지 확인할 수 있는 여러 방법을 제공합니다:

// 방법 1: Task.checkCancellation() 사용
func processData() async throws {
    try Task.checkCancellation() // 취소되었으면 CancellationError를 throw
    // 데이터 처리 코드...
}

// 방법 2: Task.isCancelled 속성 사용
func processDataWithFlag() async -> Data? {
    if Task.isCancelled {
        return nil // 취소되었을 때 nil 반환
    }
    // 데이터 처리 코드...
    return someData
}

위 코드에서 볼 수 있듯이, Task.checkCancellation()은 작업이 취소되었을 때 CancellationError를 발생시킵니다. 이 방법은 취소 처리를 위한 별도의 코드 경로가 필요하지 않을 때 유용합니다. 반면, Task.isCancelled 속성은 작업의 취소 상태를 불리언 값으로 반환하므로, 취소 시 특별한 정리 작업이 필요한 경우에 더 적합합니다.

취소 확인은 작업의 주요 중단점(suspension points)이나 계산 비용이 큰 작업 전에 수행하는 것이 좋습니다. 이렇게 하면 불필요한 작업을 시작하거나 계속하는 것을 방지할 수 있습니다.

작업 취소 전파 이해하기

Swift Concurrency에서 또 하나의 중요한 개념은 취소의 전파입니다. 부모 작업이 취소되면, 그 취소 상태는 모든 자식 작업으로 자동 전파됩니다.

func parentTask() async throws {
    try await withThrowingTaskGroup(of: Void.self) { group in
        group.addTask {
            // 이 작업은 parentTask가 취소되면 자동으로 취소 상태가 됨
            try await someAsyncWork()
        }
        
        // 다른 작업 추가...
        
        // 모든 작업이 완료될 때까지 대기
        try await group.waitForAll()
    }
}

이 코드에서 parentTask가 취소되면, 작업 그룹 내의 모든 자식 작업도 취소됩니다. 이는 복잡한 비동기 작업 계층에서 취소를 효율적으로 관리할 수 있게 해줍니다. 작업 그룹의 모든 작업이 취소 상태를 상속받기 때문에, 각 자식 작업에서 취소를 개별적으로 처리할 수 있습니다.

취소 전파의 이점은 명확합니다. 상위 수준에서 한 번의 취소 작업으로 전체 작업 트리를 취소할 수 있습니다. 이는 복잡한 비동기 작업 흐름을 관리할 때 특히 유용합니다.

반응형

다양한 상황에서의 Task 취소 구현

이제 실제 개발 시나리오에서 Task 취소를 어떻게 구현하는지 살펴보겠습니다. 여러 상황에 맞는 취소 패턴과 그 장단점을 이해하는 것이 중요합니다.

수동 취소 구현

가장 기본적인 형태의 취소는 Task에 대해 직접 cancel() 메서드를 호출하는 것입니다:

let task = Task {
    // 어떤 비동기 작업...
    while !Task.isCancelled {
        // 취소될 때까지 계속 작업...
    }
    // 취소 후 정리 작업...
}

// 나중에 작업을 취소
task.cancel()

위 코드에서는 작업이 Task.isCancelled를 주기적으로 확인하며, 취소 요청이 있을 때 적절히 반응합니다. task.cancel()을 호출하면 작업의 취소 플래그가 설정되고, 작업은 다음 체크포인트에서 이를 감지할 수 있습니다.

이 패턴은 간단하고 직접적이지만, 취소 처리 로직을 각 작업에 명시적으로 구현해야 한다는 점에 유의해야 합니다. 또한, 작업이 체크포인트에 도달하기 전에는 취소 요청이 처리되지 않습니다.

withTaskCancellationHandler 활용

더 세밀한 취소 제어가 필요한 경우, withTaskCancellationHandler를 사용할 수 있습니다:

let task = await Task.withTaskCancellationHandler {
    // 주요 작업 코드
    for i in 1...100 {
        if Task.isCancelled { break }
        try? await Task.sleep(for: .seconds(0.1))
        print("Processing item \(i)")
    }
} onCancel: {
    // 취소 시 실행될 코드
    print("Task was cancelled, performing cleanup...")
    // 리소스 정리, 임시 파일 삭제 등의 작업
}

// 나중에 작업 취소
task.cancel()

위 코드에서 withTaskCancellationHandler는 두 개의 클로저를 받습니다. 첫 번째는 주요 작업을 수행하는 클로저이고, 두 번째 onCancel 클로저는 작업이 취소되었을 때 실행됩니다. 이 접근 방식은 취소 시 특별한 정리 작업이 필요한 경우에 유용합니다.

onCancel 클로저는 작업이 실제로 취소 신호를 확인하기 전에도 실행될 수 있다는 점이 중요합니다. 따라서 취소 핸들러와 주 작업 사이에 상태를 공유할 때는 경쟁 상태(race condition)를 피하기 위해 주의해야 합니다.

시간 제한이 있는 작업 구현

때로는 작업에 시간 제한을 설정하여 지정된 시간이 지나면 자동으로 취소하고 싶을 수 있습니다:

func performTaskWithTimeout<T>(seconds: Double, operation: @escaping () async throws -> T) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        // 주 작업 추가
        group.addTask {
            try await operation()
        }
        
        // 타임아웃 작업 추가
        group.addTask {
            try await Task.sleep(for: .seconds(seconds))
            // 타임아웃 발생 시 그룹의 모든 작업 취소
            group.cancelAll()
            throw TimeoutError()
        }
        
        // 첫 번째로 완료되는 작업의 결과 반환 (또는 에러 throw)
        let result = try await group.next()!
        // 남은 작업 취소
        group.cancelAll()
        return result
    }
}

// 사용 예시
do {
    let result = try await performTaskWithTimeout(seconds: 5.0) {
        // 5초 내에 완료해야 하는 작업
        return try await someNetworkRequest()
    }
    print("Success: \(result)")
} catch is TimeoutError {
    print("Operation timed out")
} catch {
    print("Error: \(error)")
}

이 코드는 작업 그룹을 사용하여 두 개의 경쟁 작업을 생성합니다. 하나는 실제 작업을 수행하고, 다른 하나는 타이머 역할을 합니다. 첫 번째로 완료되는 작업(성공적인 작업 완료 또는 타임아웃)의 결과가 반환되고, 나머지 작업은 취소됩니다.

이 패턴은 네트워크 요청이나 시간이 오래 걸릴 수 있는 작업에 대해 데드라인을 설정할 때 특히 유용합니다. 작업이 지정된 시간 내에 완료되지 않으면 자동으로 취소되므로, 리소스가 무한정 소모되는 것을 방지할 수 있습니다.

취소에 대한 우아한 대응 전략

작업 취소를 감지하는 것도 중요하지만, 취소에 어떻게 대응할지 결정하는 것도 마찬가지로 중요합니다. 여기서는 취소에 대응하는 여러 전략을 살펴보겠습니다.

부분 결과 반환

일부 시나리오에서는 작업이 취소되더라도 이미 계산된 부분 결과를 반환하는 것이 유용할 수 있습니다:

func processItems(_ items: [Item]) async -> [ProcessedItem] {
    var results: [ProcessedItem] = []
    
    for item in items {
        // 취소 확인
        if Task.isCancelled {
            // 작업이 취소되었지만, 이미 처리된 결과 반환
            return results
        }
        
        // 각 항목 처리
        let processed = await processItem(item)
        results.append(processed)
    }
    
    return results
}

위 코드에서는 작업이 취소되면, 그 시점까지 성공적으로 처리된 항목들을 반환합니다. 이 접근 방식은 부분적인 데이터라도 유용할 수 있는 경우에 적합합니다. 예를 들어, 사용자가 검색 결과 페이지를 스크롤하다가 새로운 검색을 시작할 때, 이미 로드된 결과는 표시하면서 추가 로딩을 중단할 수 있습니다.

부분 결과를 반환할 때는 API 사용자에게 결과가 완전하지 않을 수 있음을 명확히 알리는 것이 중요합니다. 이를 위해 반환 타입에 완료 상태를 나타내는 플래그를 포함하거나, 문서에 이 동작을 명확히 설명할 수 있습니다.

리소스 정리를 위한 defer 활용

취소되었든 정상적으로 완료되었든 관계없이 리소스를 정리해야 하는 경우, defer 블록을 사용하는 것이 좋습니다:

func processFileData() async throws {
    // 파일 열기
    let fileHandle = try FileHandle(forReadingFrom: fileURL)
    
    // 함수가 종료될 때 항상 파일을 닫음
    defer {
        try? fileHandle.close()
        print("File closed")
    }
    
    // 읽기 작업 수행
    while !Task.isCancelled {
        let data = try fileHandle.read(upToCount: 1024)
        if data.isEmpty { break }
        try await processChunk(data)
    }
    
    // 작업이 취소된 경우
    if Task.isCancelled {
        print("Reading was cancelled")
        throw CancellationError()
    }
}

이 코드에서 defer 블록은 함수가 어떤 경로로 종료되든 항상 실행됩니다. 작업이 성공적으로 완료되었든, 취소되었든, 또는 다른 오류가 발생했든 상관없이 파일 핸들이 항상 닫히게 됩니다. 이는 리소스 누수를 방지하는 좋은 방법입니다.

defer는 특히 데이터베이스 연결 닫기, 파일 핸들 정리, 임시 파일 삭제 등의 정리 작업에 유용합니다. 취소된 작업도 적절히 정리되도록 보장하여 앱의 안정성을 향상시킵니다.

오류 던지기와 옵셔널 반환 전략 비교

취소에 대응하는 두 가지 일반적인 방법은 CancellationError를 던지거나 nil 또는 빈 컬렉션을 반환하는 것입니다. 각 접근 방식에는 장단점이 있습니다:

// 전략 1: 오류 던지기
func fetchData() async throws -> Data {
    try Task.checkCancellation() // 취소된 경우 CancellationError 던짐
    // 데이터 가져오기...
    return data
}

// 전략 2: 옵셔널 반환
func fetchDataOptional() async -> Data? {
    if Task.isCancelled { return nil }
    // 데이터 가져오기...
    return data
}

오류를 던지는 방법은 취소가 예외적인 상황이라고 간주할 때 적합합니다. 이는 호출자가 취소를 명시적으로 처리하도록 강제하며, 다른 오류 처리 메커니즘과 일관성을 유지합니다.

반면, 옵셔널을 반환하는 방법은 취소가 예상되는 일반적인 작업 흐름의 일부일 때 적합합니다. 이 접근 방식은 호출자가 nil 검사를 통해 취소를 쉽게 처리할 수 있게 해주지만, 취소와 다른 실패 케이스를 구별하기 어려울 수 있습니다.

어떤 전략을 선택할지는 API의 맥락과 예상 사용 패턴에 따라 달라집니다. 일관성을 위해 관련 API 전체에서 동일한 전략을 사용하는 것이 좋습니다.

실제 애플리케이션에서의 취소 패턴

이론을 넘어, 취소 메커니즘이 실제 앱에서 어떻게 활용되는지 살펴보겠습니다. 여기서는 일반적인 iOS 앱 시나리오에서의 취소 패턴을 다룹니다.

UI 상호작용에 따른 작업 취소

사용자 인터페이스 작업에서 취소는 흔한 요구사항입니다. 예를 들어, 사용자가 검색 화면을 떠나거나 새로운 검색을 시작할 때 진행 중인 검색을 취소하고 싶을 것입니다:

class SearchViewModel: ObservableObject {
    @Published var results: [SearchResult] = []
    @Published var isLoading = false
    
    private var searchTask: Task<Void, Never>?
    
    func search(query: String) {
        // 이전 검색 취소
        searchTask?.cancel()
        
        // 새로운 검색 시작
        isLoading = true
        searchTask = Task {
            do {
                // 검색 전에 취소 확인
                try Task.checkCancellation()
                
                let newResults = try await performSearch(query: query)
                
                // UI 업데이트 전에 다시 한번 취소 확인
                try Task.checkCancellation()
                
                // 메인 스레드에서 UI 업데이트
                await MainActor.run {
                    self.results = newResults
                    self.isLoading = false
                }
            } catch is CancellationError {
                // 검색이 취소됨 - 아무것도 하지 않음
                if !Task.isCancelled {
                    await MainActor.run {
                        self.isLoading = false
                    }
                }
            } catch {
                // 다른 오류 처리
                await MainActor.run {
                    self.isLoading = false
                    // 오류 표시
                }
            }
        }
    }
    
    func cancelSearch() {
        searchTask?.cancel()
        searchTask = nil
        
        // UI 상태 업데이트
        isLoading = false
    }
}

이 코드에서 search(query:) 메서드는 항상 새 검색을 시작하기 전에 이전 검색 작업을 취소합니다. 이는 최신 검색 결과만 표시되도록 보장하고, 불필요한 네트워크 요청을 방지합니다. 검색 작업은 주요 중단점에서 취소 상태를 확인하고, 취소된 경우 적절히 처리합니다.

또한, 사용자가 명시적으로 검색을 취소할 수 있도록 cancelSearch() 메서드도 제공합니다. 이 메서드는 현재 작업을 취소하고 로딩 상태를 즉시 업데이트합니다.

Task 생명주기 관리와 자동 취소

SwiftUI나 UIKit과 같은 UI 프레임워크에서 작업을 뷰의 생명주기에 연결하는 것이 중요합니다. onDisappear나 viewDidDisappear와 같은 생명주기 이벤트에서 작업을 취소하면 리소스 누수를 방지할 수 있습니다:

// SwiftUI 예시
struct ContentView: View {
    @State private var task: Task<Void, Never>?
    
    var body: some View {
        List(items) { item in
            Text(item.title)
        }
        .onAppear {
            // 뷰가 나타날 때 작업 시작
            task = Task {
                await loadData()
            }
        }
        .onDisappear {
            // 뷰가 사라질 때 작업 취소
            task?.cancel()
        }
    }
    
    func loadData() async {
        // 데이터 로딩 로직...
    }
}

// UIKit 예시
class ViewController: UIViewController {
    private var dataTask: Task<Void, Never>?
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        // 뷰가 나타날 때 작업 시작
        dataTask = Task {
            await loadData()
        }
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        
        // 뷰가 사라질 때 작업 취소
        dataTask?.cancel()
    }
    
    func loadData() async {
        // 데이터 로딩 로직...
    }
}

위 코드에서는 뷰가 화면에 표시될 때 작업을 시작하고, 화면에서 사라질 때 작업을 취소합니다. 이렇게 하면 사용자가 화면을 떠났을 때 백그라운드에서 계속 실행되는 불필요한 작업을 방지할 수 있습니다.

SwiftUI에서는 task 수정자를 사용하여 더 간단하게 이 패턴을 구현할 수도 있습니다:

struct ContentView: View {
    var body: some View {
        List(items) { item in
            Text(item.title)
        }
        .task {
            // 이 작업은 뷰가 사라질 때 자동으로 취소됨
            await loadData()
        }
    }
    
    func loadData() async {
        // 데이터 로딩 로직...
    }
}

.task 수정자는 뷰가 나타날 때 작업을 시작하고, 뷰가 사라질 때 자동으로 작업을 취소하므로 수동으로 작업 생명주기를 관리할 필요가 없습니다.

결론: 효과적인 취소 전략 설계하기

Swift Concurrency에서 효과적인 취소 전략을 설계하려면 작업의 특성과 사용자 경험 요구 사항을 고려해야 합니다. 다음은 효과적인 취소 전략을 개발하기 위한 몇 가지 핵심 권장사항입니다:

  1. 적절한 체크포인트 설정: 계산 비용이 높은 작업을 시작하기 전, 새로운 비동기 호출을 하기 전, 그리고 긴 루프 내에서 정기적으로 취소 상태를 확인하세요.
  2. 부분 결과 활용: 가능한 경우, 작업이 취소되더라도 이미 계산된 결과를 반환하는 것을 고려하세요. 이는 사용자 경험을 향상시킬 수 있습니다.
  3. 적절한 리소스 정리: defer 블록이나 취소 핸들러를 사용하여 작업이 취소되더라도 모든 리소스가 적절히 정리되도록 하세요.
  4. 취소 전파 활용: 구조화된 동시성을 사용하여 취소가 자동으로 자식 작업으로 전파되도록 하세요. 이는 복잡한 비동기 작업 트리를 관리하는 데 도움이 됩니다.
  5. 일관된 패턴 사용: 앱 전체에서 취소를 처리하는 일관된 패턴을 사용하세요. 이는 코드 가독성과 유지보수성을 향상시킵니다.

Swift의 협력적 취소 모델은 개발자에게 많은 유연성을 제공하지만, 이와 함께 책임도 부여합니다. 잘 설계된 취소 전략은 앱의 성능, 리소스 사용 및 사용자 경험을 크게 향상시킬 수 있습니다.

지금까지 살펴본 패턴과 기법들을 자신의 프로젝트에 적용하면 더 효율적이고 반응성이 뛰어난 Swift 앱을 만들 수 있을 것입니다. 취소는 단순히 정리하는 과정이 아니라, 훌륭한 사용자 경험을 제공하는 데 필수적인 부분임을 기억하세요.

반응형