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

1. 비동기 처리 방법/ Completion/ Combine / async/await

by Toughie 2023. 7. 10.

Completion/ Combine / async/await 

Swift에서 비동기 처리 방법은 다양하다. 하지만 각각의 방식들이 등장한 순서가 있어서 점차 발전/개선되는 느낌이 있다.

 

일반적으로 프로그램은 작업을 순차적으로 실행하는 동기적인 방식으로 동작한다. 앞에 일이 끝나야 다음 일을 한다는 것!

하지만 비동기 처리는 작업을 병렬적으로 실행하고, 작업의 완료를 기다리지 않고 다음 작업을 실행하는 것을 말한다.

작업들 중 일부가 동시에 실행되는 것을 의미! 이전 작업의 완료를 기다리지 않고!

 

여기서는 컴플리션 핸들러, 컴바인 그리고 async,await에 대해 알아보려 한다.


1. 컴플리션 핸들러 (Completion Handler)

컴플리션 핸들러는 이름 그대로 비동기 작업이 완료된 후 (completion) 완료된 작업을 처리(핸들)해주는 방식이다.

즉 비동기 작업이 완료된 후 호출되는 콜백함수를 말한다. 작업이 완료되면 컴플리션 핸들러가 호출되고, 결과나 에러를 전달할 수 있다.

import SwiftUI
import Combine

final class DownloadImageAsyncImageLoader {
    
    var url: URL?
    
    init() {
        self.url = URL(string: "https://picsum.photos/200")
    }
    
    ...
    func downloadWithEscaping(completion: @escaping (_ image: UIImage?, _ error: Error?) -> Void) {
        guard let url = self.url else { return }
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            let image = self?.handleResponse(data: data, response: response)
            completion(image, error)
        }
        .resume()
    }
    
        func handleResponse(data: Data?, response: URLResponse?) -> UIImage? {
        guard
            let data = data,
            let image = UIImage(data: data),
            let response = response as? HTTPURLResponse,
            response.statusCode >= 200 && response.statusCode < 300 else {
            return nil
        }
        return image
    }

호출

    func fetchImage() {
        //self를 사용하면 현재 객체에 대한 강한 참조가 만들어 질 수 있음.
        // 클로저가 완료될 때까지 self에 대한 강한 참조가 유지돼서 뷰모델 인스턴스가 메모리에서 해제되지 않음
        // 이는 메모리 누수를 일으킬 수 있고, 불필요한 리소스 낭비가 발생할 수 있음
        // [weak self]를 사용해서 클로저가 self(뷰모델 객체)를 약한 참조로 캡쳐
        // 클로저가 더이상 필요하지 않을 때, self에 대한 강한 참조가 자동으로 해제됨.
        // 클로저의 실행이 끝나면 약한 참조로 캡쳐된 self는 nil로 설정됨
        // 즉 self에 대한 강한 참조가 해제되고 rc도 감소되어 메모리 관리에 도움이 된다는 뜻
        loader.downloadWithEscaping { [weak self] image, error in
            DispatchQueue.main.async {
                self?.image = image
            }
            
            //Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
            
            //            if let image = image {
            //                self?.image = image
            //            }
        }
    }

@escaping 클로저도 사용하고, 메서드 내부/호출 시에 Reference Counting을 신경 써줘야 한다. (weak self 캡쳐리스트 사용)

이렇게 컴플리션 핸들러를 사용하는 방식은 위와 같이 신경써야 하는 부분도 많고, 개발자가 실수할 여지도 있다.

completion을 호출하지 않는다든가.. URLSession의 경우 .resume()을 까먹는다든가..

 

또한 비동기 작업이 여러 단계로 중첩되는 경우에 콜백 함수들이 중첩돼서 코드의 가독성이 안좋아지는 콜백지옥이 발생할 수 있다.

작업의 성공/실패 여부에 따라 각각 다른 콜백함수로 처리하면서 에러 처리가 복잡해질 수도 있다.

그리고 비동기 작업의 흐름이 콜백함수에 의해 이루어지기 때문에 코드 자체가 실행 흐름을 파악하기에 비직관적인 단점이 있다.

 

* 기존의 콜백 패턴을 활용해서 비동기 작업의 결과를 처리하는 방식이기 때문에 Swift의 초기 버전부터 사용가능하다는 장점은 있음.


2. 컴바인(Combine) 

iOS 13/ macOS 10.15, watchOS6부터 사용 가능

Swift5에서 추가된 라이브러리로, 비동기 작업의 시퀀스를 처리하기 위한 기능을 제공함.

비동기 작업의 결과를 스트림으로 처리하고, 다양한 Operator를 사용해서 데이터 조작 및 변환이 용이함.

Publisher & Subscriber를 기반으로 동작하기 때문에 데이터의 흐름을 '선언적인 방식으로 조작'할 수 있음.

Combine은 함수형 프로그래밍, 반응형 프로그래밍의 개념을 활용해서 비동기 작업을 처리하는 방식인 것.

    func handleResponse(data: Data?, response: URLResponse?) -> UIImage? {
        guard
            let data = data,
            let image = UIImage(data: data),
            let response = response as? HTTPURLResponse,
            response.statusCode >= 200 && response.statusCode < 300 else {
            return nil
        }
        return image
    }

    func downloadWithCombine() -> AnyPublisher<UIImage?, Error> {
    //예시로 던지는 에러라 참고만 바람.
        guard let url = self.url else {
            return Fail<UIImage?, Error>(error: NSError(domain: "Invalid URL", code: -1))
                .eraseToAnyPublisher()
        }
        //dataTaskPublisher(for:) 활용
        return URLSession.shared.dataTaskPublisher(for: url)
            //data, response, error를 매핑 후 AnyPublisher로 Publisher 타입 변환
            .map(handleResponse)
            .mapError({ $0 })
            .eraseToAnyPublisher()
    }

호출

    func fetchImage2() {
    //비동기 작업 시작
        loader.downloadWithCombine()
        //sink 연산자를 통해 구독(subscribe)를 생성
        //completion은 여기서 사용 안 함
        //비동기 작업이 완료되면 receiveValue 클로저가 호출됨
        //@Published 프로퍼티에 이미지를 할당하기에 메인큐에서 진행해줌
            .sink { _ in
                
            } receiveValue: { [weak self] image in
                DispatchQueue.main.async {
                    self?.image = image
                }
            }
            .store(in: &cancellables)
    }
    //위 메서드는 비동기작업 완료 후 이미지 할당을 메인큐에서 비동기적으로 수행함.
    //이미지 할당이 비동기적으로 이루어지기 때문에 메인큐에서 다른 작업이 진행중이면 이미지가 표시되지 않을 수도 있음.

    func fetchImage3() {
        loader.downloadWithCombine()
            //.receive 연산자를 통해 데이터 스트림 처리를 메인 큐로 전환
            .receive(on: DispatchQueue.main)
            .sink { _ in
                
            } receiveValue: { [weak self] image in
            //이미 메인큐이기 때문에 DispatchQueue.main.async 할 필요 없음
                self?.image = image
            }
            .store(in: &cancellables)
    }
    //위 메서드는 이미지 할당을 메인 큐에서 동기적으로 처리함.
    //따라서 뷰에 이미지가 바로 표시될 수 있음.

컴바인은 굉장히 강력한 라이브러리지만, 개념과 사용법이 좀 복잡한 문제가 있다.(당연히 연습이 필요하겠지만..)

그래도 Rx와 달리 순정 라이브러리라는 장점! 선언적인 방식으로 데이터 흐름을 관리할 수 있다는게 제일 큰 장점 같다.


3. async/await

Swift 5.5부터 도입. 비동기 작업을 직관적이고 '동기적인 코드와 유사한 구조로'처리할 수 있음

async 키워드를 사용해서 비동기 함수를 선언하고, await 키워드를 사용해서 비동기 작업의 완료를 기다림.

코드의 가독성이 굉장히 좋고, 콜백 헬을 피할 수 있음. 네이티브이기 때문에 추가적인 라이브러리,프레임워크 필요 없음.

    func handleResponse(data: Data?, response: URLResponse?) -> UIImage? {
        guard
            let data = data,
            let image = UIImage(data: data),
            let response = response as? HTTPURLResponse,
            response.statusCode >= 200 && response.statusCode < 300 else {
            return nil
        }
        return image
    }

    func downloadWithAsync() async throws -> UIImage? {
        guard let url = self.url else { return UIImage(systemName: "xmark") }
        
        do {
            let (data, response) = try await URLSession.shared.data(from: url, delegate: nil)
            return handleResponse(data: data, response: response)
        } catch {
            throw error
        }
    }

호출

    func fetchImageAsync() async {
        let image = try? await loader.downloadWithAsync()
        await MainActor.run {
            self.image = image
        }
    }

뷰에 적용(Task)

struct DownloadImageAsync: View {
    
    @StateObject private var vm = DownloadImageAsyncViewModel()
    
    var body: some View {
        ZStack {
            if let image = vm.image {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
                    .frame(width: 250, height: 250)
            }
        }
        //Task를 통해서
        .onAppear {
            Task {
                await vm.fetchImageAsync()
            }
        }
    }
}

async/await을 쓰면 코드가 굉장히 간결하고 읽기 쉬워진다. 또한 컴파일러를 통해서 실수를 덜? 할 수 있다.

await 빼먹었어요..와 같은 에러 메세지를 통해서..

 

다만 Swift5.5이후부터 사용 가능하다는 점.(WWDC23에서 Swift5.9가 발표되었다.)

또한 내부 동작 과정에서 추가적인 메모리 사용이 필요하다는 점, 여러 개의 비동기 작업을 동시에 실행하는 것은 다소 까다로울 수 있다는 단점이 있다.


컴플리션 핸들러, 컴바인, async/await 중에서 뭐가 제일 좋은 것이냐? 라는 질문은 의미가 없다고 생각한다.

해당 프로젝트에 맞게 그냥 필요한 것을 쓰면 되는 것이고.. 이 포스팅 카테고리에서는 async/await에 집중해 보려고 한다.

async/await이 뭔지, Task가 뭔지, 그래서 어떻게 쓰는건데?와 같은 것들 말이다.