Starbucks Caramel Frappuccino
본문 바로가기
  • 그래 그렇게 조금씩
iOS Developer/Concurrency

3. Task/ .task / cancel() / priority / detached

by Toughie 2023. 7. 12.

Task/ .task / cancel() / priority / detached 

Task에 대해 알아보고, 관련 개념 및 사용법을 살펴보자.

https://developer.apple.com/documentation/swift/task

 

Task | Apple Developer Documentation

A unit of asynchronous work.

developer.apple.com

Task는 비동기 작업의 실행 단위이다.

Task 인스턴스를 초기화 할 때 클로저를 통해 Task가 수행할 작업을 명시한다.

Task는 초기화 즉시 동작을 시작하기 때문에 명시적으로 작업시작!을 해줄 필요가 없다.

또한 Task 인스턴스를 초기화 하고 나서 Task가 완료되는 것을 기다리거나 취소할 수 있다.

Task가 작업을 완료할 때까지 기다리거나 취소하지 않고 Task에 대한 참조를 해제하는 것은 프로그래밍 에러가 아니다!

Task는 참조를 유지하는 것과 상관없이 실행되기 때문이다. 

하지만 만약 Task에 대한 참조를 해제하면, Task의 작업 수행 결과를 기다리거나, Task를 취소하지 못한다.

 

그럼 먼저 비동기 작업 예시를 만들어 본다.

URL을 통해 이미지를 받아오는 비동기 작업이 포함되어 있다.

import SwiftUI

class TaskPracViewModel: ObservableObject {
    @Published var image: UIImage?
    @Published var image2: UIImage?
    
    func fetchImage() async {
        //5초 이상 걸리는 작업을 가정, 중간에 task를 취소하는 것을 명확히 확인해보기 위함
        try? await Task.sleep(nanoseconds: 5_000_000_000)
        do {
            guard let url = URL(string: "https://picsum.photos/200") else { return }
            //response는 와일드카드 처리
            let (data, _) = try await URLSession.shared.data(from: url, delegate: nil)
            //메인 엑터에서 할당
            await MainActor.run(body: {
                self.image = UIImage(data: data)
                print("image returned successfully")
            })
        } catch {
            print(error.localizedDescription)
        }
    }
    
    func fetchImage2() async {
        do {
            guard let url = URL(string: "https://picsum.photos/200") else { return }
            let (data, _) = try await URLSession.shared.data(from: url, delegate: nil)
            await MainActor.run(body: {
                self.image2 = UIImage(data: data)
            })

        } catch {
            print(error.localizedDescription)
        }
    }
}

네비게이션 링크에 진입했을 때, Task가 수행되는 것을 test하기 위해 
메인 뷰를 간단하게 만들어 준다.

struct TaskPracHomeView: View {
    var body: some View {
        NavigationView {
            ZStack {
                NavigationLink {
                    //비동기 작업들을 수행할 뷰
                    TaskPrac()
                } label: {
                    Text("Navigate")
                }
            }
        }
    }
}

이제 비동기 작업들을 수행할 뷰인 TaskPrac에서는 어떤 것들을 해야 할까?

먼저 뷰모델에 URL을 통해 이미지를 받아오는 비동기 메서드를 만들어 뒀으니

이걸 통해서 이미지를 화면에 보여주면 될 것이다.

struct TaskPrac: View {
    
    @StateObject private var vm = TaskPracViewModel()
    
    var body: some View {
        VStack(spacing: 40) {
            if let image = vm.image {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
                    .frame(width: 200, height: 200)
            }
            
            if let image = vm.image2 {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
                    .frame(width: 200, height: 200)
            }
        }
        ...

이렇게만 하면 지금 뷰모델의 @Published에 값이 없기 때문에 이미지가 보이지 않을 것이다.

그래서 이미지를 로드하는 메서드를 실행시켜야 한다.

VStack이 그려질 때 메서드들을 실행시켜 준다면?

VStack {
...
}
.onAppear {
    Task {
        await vm.fetchImage()
        await vm.fetchImage2()
    }
}

이렇게 하면, 초기 화면에서 네비게이션 링크를 타고 들어가서 뷰가 그려질 때 (body가 호출되며 VStack이 그려지기 전에 
비동기 메서드를이 실행될 것이다. 물론 이 구조에서는 Task 내부 코드들이 동기적으로 실행되겠지만..!
(vm.fetchImage()가 끝나고 나서 fetchImage2()가 실행될 것. 

이를 병렬적으로 처리하는 방법은 async let이라는 개념이 필요해서 다음 포스팅에서 다룰 예정.

 

편의를 위해서 아래 예시코드부터는 fetchImage() 하나만 사용함!

 

지금은 fetchImage가 5초 이상 걸리는 긴 작업인데, 만약 뷰에 진입했다가 유저가 뒤로 돌아가거나, 다른 뷰로 넘어간다면?
작업을 취소하는것이 합리적이다. (이미지가 필요 없어진 경우니까)

흠.. 그럼 onDisappear에서 처리하면 되나?

다행히 task가 작업을 멈추도록 하는 취소 메서드가 존재한다.

하지만 위의 onAppear 내부에서 그냥 Task 인스턴스를 생성하면 이후에 인터렉트가 불가능하기 때문에,

따로 프로퍼티를 만들어서 관리해보자.

@State private var fetchImageTask: Task<(), Never>?

요렇게 할건데.. 제네릭 부분을 좀 살펴보면

Task의 정의는 아래와 같다.

지금 다루고 있는 에시에서는 Task 내부에서 작업이 성공해도 반환하는 것이 없기에 Void로,

또한,  작업이 실패해도 반환되는 오류가 없음을 나타내기 위해 Never를 사용했다.

값이 반환되지 않거나, 실행이 완료되지 않는 메서드에서 Never 반환타입을 사용 (ex. 무한 루프)

즉 값이나 제어권이 반환되지 않음을 나타내기 위해 사용하는 타입 정도로 이해하고 넘어감!

 

그리고 이제 인터렉트(task 취소)를 위해서 아래와 같이 할 수 있다.

VStack {
...
}
.onAppear {
    fetchImageTask = Task {
        await vm.fetchImage()
    }
}
.onDisappear {
    fetchImageTask?.cancel()
}

근데 이렇게 onAppear, onDisappear에서 따로따로 할 필요 없이 깔끔하게 할 수 있는 방법이 있다.

바로 .task !

 

task

뷰가 나타나기 전에 수행할 비동기 테스크를 추가하는 메서드!

여기서 Task 우선도(priority)와 action이 있는데 먼저 action 부분을 살펴보자.

만약 작업이 완료되기 전에 뷰가 disappear되고 나서 특정 시점에 스유가 알아서 task를 취소해줌!

위에서 onDisappear에서 했던 것을 그냥 자동으로 해준다. 호오 편하군

 

checkCancellation

하 지 만

task를 cancel 한다고 바로 빡! 멈추는 것은 아니다.

만약 오래 걸리는 task라면 캔슬을 했음에도, 여전히 진행되고 있을 수 있다.

ex. 도는데 오래 걸리는 루프가 있다면 task는 취소됐지만 루프는 여전히 돌고 있는 경우..

그래서 중간 중간에 과속방지턱 같은 것을 설치해 줄 수 있다.

https://developer.apple.com/documentation/swift/task/checkcancellation()

 

checkCancellation() | Apple Developer Documentation

Throws an error if the task was canceled.

developer.apple.com

    func fetchImage() async {
    
        try? await Task.sleep(nanoseconds: 5_000_000_000)
        
        do {
            guard let url = URL(string: "https://picsum.photos/200") else { return }
            let (data, _) = try await URLSession.shared.data(from: url, delegate: nil)
            
            //체크포인트, 과속방지턱 느낌(비유임)
            //만약 task가 취소됐다면, 그 시점에서 Task.CancellationError
            //그리고 catch 블럭에서 에러 처리
            //이를 활용하면 적절한 시점에 작업을 즉시 종료할 수 있음.
            try Task.checkCancellation()
            
            await MainActor.run(body: {
                self.image = UIImage(data: data)
                print("image returned successfully")
            })
        } catch {
            print(error.localizedDescription)
        }
    }

 

 

Priority

Task에서 비동기 작업을 할 때 각 작업에 우선순위를 정해줄 수 있다.

(DispatchQueue.global(qos:)를 떠올려 보면 아? 할 것이다.)

            Task(priority: .high) {
                print("HIGH : \(Thread()) : \(Task.currentPriority)")
            }
            
            Task(priority: .userInitiated) {
                print("USREINITIATED : \(Thread()) : \(Task.currentPriority)")
            }
            Task(priority: .medium) {
                print("MEDIUM : \(Thread()) : \(Task.currentPriority)")
            }
            Task(priority: .low) {
                print("LOW : \(Thread()) : \(Task.currentPriority)")
            }
            Task(priority: .utility) {
                print("UTILITY : \(Thread()) : \(Task.currentPriority)")
            }
            Task(priority: .background) {
                print("BACKGROUND : \(Thread()) : \(Task.currentPriority)")
            }

위에서부터 아래로 갈수록 우선순위가 낮아진다.

디폴트는 userInitiated이다.

다만 우선순위가 높다고 무조건 먼저 끝난다는 말은 아니다!

만약 우선순위가 high인 task가 아래에 있다면, 아래와 같이 순서가 바뀔 수 있다.

USREINITIATED : <NSThread: 0x600000cd3d00>{number = 10, name = main} : TaskPriority(rawValue: 25)
HIGH : <NSThread: 0x600000cd3d00>{number = 11, name = main} : TaskPriority(rawValue: 25)
MEDIUM : <NSThread: 0x600000cd3740>{number = 12, name = main} : TaskPriority(rawValue: 21)
<_NSMainThread: 0x600000cb80c0>{number = 1, name = main}
TaskPriority(rawValue: 25)
LOW : <NSThread: 0x600000cd3e40>{number = 13, name = main} : TaskPriority(rawValue: 17)
UTILITY : <NSThread: 0x600000cd3e80>{number = 14, name = main} : TaskPriority(rawValue: 17)
BACKGROUND : <NSThread: 0x600000ceeec0>{number = 15, name = main} : TaskPriority(rawValue: 9)

즉 우선순위가 high라고 무조건 제일 먼저 끝나는 것이 아니지만, 우선순위가 높기 때문에 

다른 것들보다 빨리 출력될 수는 있다는 것이다.

 

yield()

이와 관련해서 yield()라는 메서드도 있다.

진짜 이름 그대로 양보하는 것이다 ㅋㅋ

현재 실행 중인 작업을 일시 중단하고, 다른 작업에게 실행을 양도하는 메서드이다.

다른 작업이 실행되고 나서 현재 작업이 재개될 수 있다.

왜 필요한가? 다른 작업들에게 실행 기회를 먼저 주기 위해서이다.

            Task(priority: .high) {
                await Task.yield() //다른 태스크 먼저
                print("HIGH : \(Thread()) : \(Task.currentPriority)")
            }

이런 경우에 만약 yield가 없다면 high 우선순위를 가진 작업이 먼저 바로 실행되기 때문에,

다른 우선순위의 작업들이 먼저 실행될 수 있도록 잠시 양보하는 것이다.

우선 순위 자체를 조정할 수도 있을 것 같은데..🤔 우선 순위는 그대로 두되,

작업의 내용이나 순서에 따라 조정해야 하는 경우 yield를 사용할 수 있을 것 같다.

 

우선순위의 상속

            Task(priority: .userInitiated) {
                print("USREINITIATED : \(Thread()) : \(Task.currentPriority)")

                Task {
                    print("USREINITIATED2 : \(Thread()) : \(Task.currentPriority)")
                }
            }

여기서 두 번쨰 Task 블럭의 우선순위는 뭘까? .userInitiated이다.

첫 번째 Task(부모 테스크)의 우선순위가 userInitiated이고, 두 번째 Task는 부모 내부에서 생성되었기 때문에

부모의 우선순위를 상속하기 때문이다. 

 

부모 Task 내부에서 자식 Task를 만들었기 때문에 내부의 자식 Task가 부모 Task와 동일한 실행 흐름에서 순차적으로

실행된다. 자식 Task는 부모 Task와 같은 스레드에서 실행됨. (아래 출력 결과에서 둘 다 main)

USREINITIATED : <NSThread: 0x600003e147c0>{number = 10, name = main} : TaskPriority(rawValue: 25)
USREINITIATED2 : <NSThread: 0x600003e14440>{number = 11, name = main} : TaskPriority(rawValue: 25)

 

detached

            Task(priority: .userInitiated) {
                print("USREINITIATED : \(Thread()) : \(Task.currentPriority)")

                Task.detached {
                    print("USREINITIATED2 : \(Thread()) : \(Task.currentPriority)")
                }
            }

detached를 사용하면 내부 작업이 현재 작업(상위 Task)과 독립적으로 실행될 수 있음을 의미한다.

즉 별도의 작업 스레드에서 실행될 수 있고, 현재 작업의 실행 흐름과 독립적으로 진행된다는 말.

USREINITIATED 🌱: <NSThread: 0x60000042d7c0>{number = 10, name = main} : TaskPriority(rawValue: 25)
USREINITIATED2 🌱: <NSThread: 0x600000428380>{number = 11, name = (null)} : TaskPriority(rawValue: 21)

출력 결과를 보면 스레드도 다르고, 우선도도 다름을 알 수 있다.

말 그대로 떨어져 나가는 것.

 

하지만 공식문서의 Discussion 부분을 살펴보면 재밌는 것이 있는데

이렇게 부모-자식 Task를 사용할 때 가능하면 detached를 사용하지 말라고 한다.

 

자식 task는 부모 task의 우선도와 task-local 저장소를 상속받고, 부모 task가 취소되면 자식 task들은 자동으로 다 취소가 되는데,

detached를 사용하면 이런 것들을 하나하나 개발자가 신경써야 하기 때문인 것으로 보인다. (집 나가면 🐶고생이라는 말..)