iOS 비동기 프로그래밍(Callback, RxSwift, Combine, Concurrency)

이경은·2024년 1월 25일
0
  • 동기와 비동기 개념을 이해합니다.
  • Callback, RxSwift, Combine, Concurrency에 대한 이해를 바탕으로 비동기 프로그래밍의 개념에 대해 이해합니다.

동기, 비동기 개념 이해하기

동기(Synchronous)와 비동기(Asynchronous)의 개념에 대해 알아봅니다.

쓰레드(Thread)

컴퓨터 프로그램을 실행하는데 사용되는 가장 작은 실행 단위를 의미합니다.

쓰레드는 프로세스 내에서 실행되는 작은 단위의 실행 흐름입니다.
각각의 쓰레드는 독립적으로 실행될 수 있으며, 여러 쓰레드가 동시에 작업을 수행할 수 있습니다.
이러한 다중 쓰레드 작업은 시스템 자원을 효율적으로 활용하고, 병렬적으로 작업을 처리할 수 있도록 도와줍니다.

동기(Synchronous)

순차적으로 진행되는 것을 의미합니다.

한 작업이 시작되면 그 작업이 완료될 때까지 다음 작업은 대기합니다.
즉, 작업이 차례대로 실행되며, 한 작업이 끝날 때까지 다음작업이 기다리게 됩니다.
이렇게 되면 작업들이 순차적으로 실행되므로 순서가 중요한 경우에 사용됩니다.

비동기(Asynchronous)

순차적으로 기다리지 않고, 여러 작업이 동시에 진행되는 것을 의미합니다.

한 작업이 시작되더라도 결과를 기다리지 않고 다음 작업이 시작될 수 있습니다.
비동기 작업은 대표적으로 네트워크 요청, 파일 입출력, 사용자 입력 대기 등과 관련이 있습니다.
비동기적인 작업을 사용하면 여러 작업이 동시에 처리되기에 시스템 자원을 효율적으로 사용할 수 있습니다.

DispatchQueue

DispatchQueue는 iOS에서 Grand Central Dispatch(GCD)를 사용하여 비동기적으로 작업을 관리하는데 사용되는 클래스입니다.

다양한 작업을 백그라운드 스레드에서 실행하고, 메인 스레드와 같은 특정 스레드에서 실행되도록 예약할 수 있습니다.
이를 통해 앱의 성능을 향상시키고 사용자 인터페이스의 반응성을 유지할 수 있습니다.
일반적으로 다음과 같은 두 가지 유형의 DispatchQueue가 사용됩니다.


Serial Queue (직렬 큐)
작업을 순차적으로 실행하는 Queue입니다.
한 번에 하나의 작업만 실행되며, 이전 작업이 완료된 후에 다음 작업이 실행됩니다.

Concurrent Queue (병렬 큐)
여러 작업을 동시에 실행할 수 있는 Queue입니다.
병렬 큐는 여러 작업을 동시에 시작하고, 시스템 자원과 상황에 따라 동시에 실행됩니다.
아래는 DispatchQueue를 사용하여 작업을 예약하고 실행하는 간단한 예제입니다.

// Serial Queue 생성
let serialQueue = DispatchQueue(label: "com.example.serialQueue")

// ConcurrentQueue 생성
let concurrentQueue = DispatchQueue(label: "com.example.concurrentQueue", attributes: .concurrent)

// 작업 예약
serialQueue.async {
		// 직렬 큐에서 실행될 작업
		print("Serial Queue 작업 1")
}

concurrentQueue.async {
		// 병렬 큐에서 실행될 작업
		print("Concurrent Queue 작업 1")
}

// 메인 스레드에서 실행될 작업
DispatchQueue.main.async {
		// UI 업데이트 등을 포함한 메인 스레드 작업
		print("메인 스레드 작업")
}

위 코드에서 async 메서드를 사용하여 작업을 각 큐에 예약했습니다.

async 메서드를 호출함으로써 코드 블록이 해당 큐에 추가되고, 해당 큐에서 비동기적으로 실행됩니다.

Tread 사용에서 주의할 점은 UI 업데이트와 관련된 작업은 반드시 메인 스레드에서 실행되어야하므로, UI 업데이트와 관련된 코드는 DispatchQueue.main.async를 사용하여 메인스레드에서 실행되어야 합니다.

이어지는 아래 예제에서는 Serial Queue를 사용하여, 동기와 비동기에 대해 설명합니다.


동기 예제
아래의 동기 예제 코드는 “시작”, “동기 작업1”, “동기 작업2”, “동기 작업3”, “끝” 순서로 메세지를 출력하며, DispatchQueue의 sync 메서드를 통해 클로저 내의 작업이 순차적으로 실행되는 것을 보여줍니다.

이 때, sync 메서드를 사용하면 해당 작업이 완료될 때까지 현재 스레드가 차단되므로 주의해야합니다.

import Foundation

func syncOperation() {
		print("시작")
		let queue = DispatchQueue(label: "com.example.queue")

		queue.sync {
				for i in 1...3 {
						print("동기 작업\(i)")
				}
		}
		print("끝")
}

syncOperation()

비동기 예제(Serial Queue를 사용)
아래의 비동기 예제 코드는 “시작”, “비동기 작업1”, “비동기 작업2”, “비동기 작업3”, “끝” 순서로 메세지를 출력하며, DispatchQueue의 async 메서드를 통해 클로저 내의 작업이 비동기적으로 실행되고, 작업이 완료된 후에 콜백 함수가 호출되는 것을 보여줍니다.

다만, 동기 작업과 다른 것 중 하나는 비동기 작업은 큐(Queue)에서 관리되기에 각 비동기 작업의 실행순서는 보장되지 않습니다. 즉, “비동기 작업1”, “비동기 작업2”, “비동기 작업3”의 순서는 실행할 때마다 변경될 수 있습니다.

import Foundation

func asyncOperation(completion: @escaping () -> Void) {
		print("시작")
		let queue = DispatchQueue(label: "com.example.queue")

		queue.async {
				for i in 1...3 {
						print("비동기 작업\(i)")
				}
				completion() // 작업이 완료된 후에 콜백 함수 호출
		}
}

asyncOperation {
	print("끝")
}

이제 이렇게 동기, 비동기 그리고 Dispatch Queue까지 알아봤으니 실제 비동기 프로그래밍을 구현하는 여러 방법에 대해서 알아보겠습니다.

우선 Swift로 비동기 프로그래밍을 구현하는 CallbackConcurrency에 대해 알아보겠습니다.


방법1. Swift로 비동기 프로그래밍 구현하기

Callback을 활용한 비동기 프로그래밍

fetchDataUsingCallback 함수는 주어진 URL에서 데이터를 다운로드하고, 다운로드가 완료되면 클로저를 호출하여 결과를 전달합니다.

  • 순수한 Swift 만으로 구현할 수 있습니다.
  • completion 파라미터 인자가 클로저를 받습니다. 결국 통신을 하고 completion 인자를 실행하는 Callback방식입니다.
  • 여러개의 연쇄 비동기 작업을 처리해야하는 경우에는 Callback Hell(콜백 지옥) 혹은 pyramid of doom(파멸의 피라미드) 라고도 불리는, callback의 깊이가 깊어지는 단점이 있습니다.
import Foundation

func fetchDataUsingCallback(from url: URL, completion: @escaping (Result<Data, Error>) -> Void) {
		let task = URLSession.shared.dataTask(with: url) { data, responce, error in
				if let error = errror {
						completion(.failure(error))
						return
				}
				
				guard let data = data else {
						let error = NSError(domain: "InvalidData", code: 0, userInfo: nil)
						completion(.failure(error))
						return
				}
				completion(.success(data))
		}
		task.resume()
}

// 단일 사용 예제
guard let url = URL(string: "http://www.example.com/data") else { return }

fetchDataUsingCallback(from: url) { result in
		switch result {
				case .sucess(let data):
						print("Received data: \(data)")
				case .failure(let error):
						print("Error: \(error)")
		}
}

// 연속된 비동기 처리 예제
guard
		let url1 = URL(string: "http://www.example.com/data/1"),
		let url2 = URL(string: "http://www.example.com/data/2")
else {
		return
}

fetchDataUsingCallback(from: url) { result in
		switch result {
				case .success(let data1):
						print("Received data 1: \(data1)")
						fetchDataUsingCallback(from: url2) { result in
								switch result {
										case .success(let data2):
												print("Received data 2: \(data2)")
										case .failure(let error2):
												print("Error 2: \(error2)")
								}
						}
				case .failure(let error1):
						print("Error 1: \(error1)")
		}
}

Concurrency를 활용한 비동기 프로그래밍

fetchDataUsingConcurrency 함수가 async로 선언되어 있습니다.

  • await 키워드를 사용하여 URLSessiondata(from: ) 메서드를 호출하고, 데이터 다운로드가 완료될 때까지 일시 중단됩니다.
  • Callback과 달리, 비동기 코드를 동기적으로 작성하는 것처럼 보이게 하면서도 백그라운드 스레드에서 비동기적으로 실행시킬 수 있습니다.
  • 순수한 Swift만으로 구현할 수 있습니다. (단, Swift 5.5 이상 버전에서만 가능)
import Foundation

func fetchDataUsingConcurrency(from url: URL) async throws -> Data {
		let (data, _) = try await URLSession.shared.data(from: url)
		return data
}

// 단일 사용 예제
Task {
		do {
				guard let url = URL(string: "http://www.example.com/data") { return }
				let data = try await fetchDataUsingConcurrency(from: url)
				print("Received data: \(data)")
		} catch {
				print("Error: \(error)")
		}
}

// 연속된 비동기 처리 예제
// Callback과는 달리 동기적으로 작성하는 것처럼 보이게 하면서도, 백그라운드 스레드에서 비동기적으로 실행시킬 수 있습니다.
Task {
		do {
				guard
						let url1 = URL(string: "http://www.example.com/data/1"),
						let url2 = URL(string: "http://www.example.com/data/2")
				else {
						return
				}
				let data1 = try await fetchDataUsingConcurrency(from: url1)
				print("Received data 1: \(data1)")
				let data2 = try await fetchDataUsingConcurrency(from: url2)
				print("Received data 2: \(data2)")
		} catch {
				print("Error: \(error)")
		}
}

Async
async 키워드는 비동기 작업을 나타내는 함수나 블록을 표시하는데 사용됩니다.
이 키워드를 함수 앞에 붙이면 해당 함수는 비동기적으로 실행됩니다.
비동기 함수는 작업이 완료될 때까지 기다리지 않고 다른 작업을 계속할 수 있도록 합니다.
함수 내부에서 await를 사용하여 비동기 작업이 완료될 때까지 대기하고 결과를 가져올 수 있습니다.

Await
awaitasync 함수 내에서 사용되며, 비동기 작업의 완료를 기다리고 해당 작업의 결과를 반환합니다.
await는 값을 반환하는 함수나 메서드 앞에 사용되며, 비동기 작업이 완료될 때까지 실행을 일시중지하고 그 결과를 반환합니다.

Task
Task는 비동기적으로 실행되는 코드조각을 나타냅니다.
각각의 Task는 독립적으로 실행될 수 있으며, 비동기적인 작업을 수행합니다.
이는 스레드를 직접적으로 다루지 않고도 비동기 코드를 실행하고 관리할 수 있도록 돕습니다.


다음으로 이제는 RxSwift, Combine을 이용한 비동기 프로그래밍에 대해 알아보기에 앞서서 Reactive Programming이라는 것에 대해 개념을 정의해보겠습니다.


방법2. Reactive Programming으로 비동기 프로그래밍 구현하기

Reactive Programming 이해하기

Reactive Programming은 데이터 스트림과 변화에 반응하여 데이터 처리를 수행하는 프로그래밍 패러다임입니다.
이 패러다임은 데이터 스트림의 발생, 전달, 처리를 중심으로 하며, 데이터의 비동기적인 변화를 다루기 위해 설계되었습니다.

Reactive Programming 구현체 이해하기

Reactive Programming의 개념을 구현하는 Swift기반 프레임워크인 Combine과 RxSwift에 대해 알아봅니다


Combine

  • Combine은 Apple이 iOS 13부터 소개한 프레임워크로, Swift에서 Reactive Programming을 위한 공식 라이브러리입니다.
  • Publisher, Subscriber, Operator 등의 개념을 사용하여 데이터 스트림의 생성, 변형, 구독 등을 다룹니다.

RxSwift

  • Swift에서 Reactive Programming을 위한 서드파티 라이브러리입니다.
  • Observable, Observer, Operator 등의 개념을 사용하여 데이터 스트림의 생성, 변형, 구독 등을 다룹니다.

Reactive Programming 구성요소 이해하기

Observable(RxSwift) / Publisher(Combine)
데이터의 스트림을 나타내는 핵심개념으로, 데이터의 발행자입니다.

Observer(RxSwift) / Subscriber(Combine)
Observable / Publisher가 발행한 데이터를 구독하여 처리하는 역할을 합니다.

Operators
데이터 스트림을 변환하거나 조작하기 위한 함수들을 제공합니다.
map, filter, merge 등의 연산자를 이용하여 데이터를 다룰 수 있습니다.

Schedulers
비동기 작업을 수행하는 스케줄러를 제공하여 코드의 실행의 시기와 순서를 관리합니다.


Combine을 활용한 비동기 프로그래밍

Combine에서 Publisher ↔ Subscriber간의 기본적인 관계

  • Subscriber가 Publisher에 붙습니다.
  • Publisher가 Subscription을 보내고, Subscriber는 Subscription을 통해 Publisher에게 N개의 value를 받겠다고 요청합니다.
  • Publisher는 Subscriber에게 자유롭게 값을 보낼 수 있습니다.
  • 만약 Publisher가 제한되어 있다면, Completion이나 Error를 내려보냅니다.

  • 여기서 조금 더 자세히 들어가보면, Publisher는 시간 흐름에 따라서 값 시퀀스의 변화 흐름을 Subscriber에게 발행(전송)하는 역할을 합니다.
  • 하나 혹은 여러 개의 Subscriber에게 각 값 요소를 제공합니다.(Notification의 용어와 개념이 유사)
  • Publisher는 receive 메서드를 통해 subscriber에게 구독을 가지고 소통합니다.
  • 다음으로 Subscriber는 Publisher와 반대되는 개념으로 Publisher를 구독하는 구독자라는 의미를 가집니다.
  • 주로 라이프사이클 이벤트와 함께 Subscriber에서 stream을 수신하게 됩니다.
  • 마찬가지로 request라는 메서드를 통해 어떤 값을 얼마나 받을지 등 Publisher에게 구독권을 요청하게 됩니다.
  • receive는 subscription, input, completion의 파라미터를 통해 구분되어 세 가지로 볼 수가 있습니다.
  • subscription을 담은 receive 메서드는 subscriber가 구독을 요청했고, 그것을 처리해서 구독권을 만들어 구독할 수 있게 구독자에게 구독권을 전달하는 과정이라고 볼 수 있습니다. 즉, 값은 얼마만큼, 또 최대로 받을지 등의 조건 등을 설정합니다.
  • input을 담은 receive 메서드는 실제 subscriber에서 request를 통해 값을 요청하고 해당 receive 메서드를 통해 요청한 개수의 값을 전달해줍니다. 즉, 서비스를 제공하는 것이라고 생각하면 편합니다.
  • completion을 담은 receive 메서드는 Publisher에서 제공하는 값이 끝나거나 에러가 날 때 Subscriber에게 알려주는 용도입니다.



이제 Combine을 활용해서 비동기 프로그래밍이 어떻게 사용되는지 보겠습니다.
Combine을 사용한다면 별다른 외부 라이브러리의 설치는 필요하지 않고 내장된 Combine을 import하여 바로 사용할 수 있습니다.

fetchDataUsingCombine함수가 AnyPublisher를 반환합니다.

  • 함수는 URLSession의 dataTaskPublisher를 하용하여 데이터를 다운로드하고, Combinemapsink 연산자를 사용하여 데이터나 오류를 처리합니다.
  • 외부 라이브러리를 설치해야하는 RxSwift와는 달리, 별다른 외부 라이브러리의 설치가 필요없으며, 내장된 Combineimport하여 바로 사용할 수 있습니다.
  • 여러 개의 연쇄 비동기 작업을 처리해야 하는 경우 callback에 비해 비교적 간단한 flatMap연산자를 활용하여 처리할 수 있습니다.
import Combine

// URLSession Publisher 스트림 생성
func fetchDataUsingCombine(from url: URL) -> AnyPublisher<Data, Error> {
		return URLSession.shared.dataTaskPublisher(for: url)
				.map(\(.data))
				.mapError { $0 as Error }
				.eraseToAnyPublisher()
}

// 단일 사용 예제
guard let url = URL(string: "http://www.example.com/data") else { return }
var cancellables: Set<AnyCancellable> = []

fetchDataUsingCombine(from: url)
		.sink(receiveCompletion: { completion in
				switch completion {
				case .finished:
						break // 성공적으로 완료된 경우 아무 작업도 하지 않는다.
				case .failure(let error):
						print("Error: \(error)")
				}
		}, receiveValue: { data in
				print("Received data: \(data)")
		})
		.store(in: &cancellables)
/* Cancellable은 구독을 취소하고, 메모리 누수를 방지하기 위한 프로토콜.
비동기 작업이 진행 중일 때, 해당 작업을 중단하고 자원을 해제할 수 있게 만들어줍니다.
Combine에서 비동기 작업을 효율적으로 관리하고 취소하는데 사용되는 도구입니다.
그리고 .store 메서드는 sink에서 반환된 Cancellable을 주어진 컨테이너에 저장하는 역할을 합니다.
주로 Set<AnyCancellable>과 같은 컨테이너를 사용해 비동기 작업이 관리되는 동안 이를 저장하고 나중에 필요할 때 취소할 수 있습니다.
나중에 한 번에 이 컨테이너 Set을 취소해 모든 비동기 작업을 한 번에 취소할 수 있습니다.
이렇게하는 이유는 객체가 해제되거나 더 이상 필요하지 않을 때 해당 비동기 작업을 자동으로 취소할 수 있습니다.
결국은 메모리 누수를 방지하기 위함 */
        

// 연속된 비동기 처리 예제
guard
		let url1 = URL(string: "http://www.example.com/data/1"),
		let url2 = URL(string: "http://www.example.com/data/2")
else {
		return
}

fetchDataUsingCombine(from: url1)
		.handleEvents(receiveOutput: { data in
				print("Received data 1: \(data)")
		})
		.flatMap { firstData in						//비교적 간단한 flatMap 연산자를 활용해서 연쇄작업들을 처리할 수 있습니다.
				fetchDataUsingCombine(fromL url2)
		}
		.sink(receiveCompletion: { completion in
				switch completion {
				case .finished:
						break  						// 성공적으로 완료된 경우 아무 작업도 하지 않는다.
				case .failure(let error):
						print("Error: \(error)")
				}
		}, receiveValue: { data in
				print("Received data 2: \(data)")
		})
		.store(in: &cancellables)

RxSwift를 활용한 비동기 프로그래밍

fetchDataUsingRxSwift 함수가 Observable을 반환합니다.

  • Observable은 비동기적으로 데이터를 다운로드하고, 데이터가 발생할 때마다 onNext 이벤트를 발생시킵니다.
  • 즉, 해당 메서드는 URLSessionObserveableStream을 생성해줍니다.
  • 외부 라이브러리인 RxSwift를 설치하여야 사용할 수 있습니다.
  • 다양한 연산자를 사용할 수 있는 추가적인 라이브러리 생태계가 많이 발전되어 있습니다.
  • 여러개의 연쇄 비동기 작업을 처리해야 하는 경우 callback에 비해 비교적 간단한 flatMap 연산자를 활용하여 처리할 수 있습니다.
import RxSwift

// URLSession Observable 스트림 생성
// fetchDataUsingRxSwift 함수는 주어진 URL에서 데이터를 다운로드하고 Observable을 반환합니다.
func fetchDataUsingRxSwift(from url: URL) -> Observable<Data> {
		return Observable.create { observer in      // Combine에서의 Publisher에서 Observable로 대체됩니다.
				let task = URLSession.shared.dataTask(with: url) { data, response, error in
						if let error = error {
								observer.onError(error)
						} else if let data = data {
								observer.onNext(data)
								observer.onCompleted()
						}
				}
				task.resume()
				
				return Disposables.create {
						task.cancel()
				}
		}
}

// 단일 사용 예제
guard let url = URL(string: "http://www.example.com/data") else { return }
var disposeBag = DisposeBag()
// disposeBag은 구독을 추가하고 해당구독이 더 이상 필요하지 않을 때 한 번에 모든 구독을 해제할 수 있는 컨테이너 역할을 합니다.(Combine의 Cancellable)

// fetchDataUsingRxSwift 메서드를 통해 주어진 URL에서 데이터를 가져오는 RxSwift 기반의 비동기 작업을 수행합니다.
fetchDataUsingRxSwift(from: url)
		.subscribe(onNext: { data in         // .subscribe 메서드를 통해 비동기 작업의 결과 및 에러를 처리하게 됩니다.
				print("Received data: \(data)")  //  onNext는 값이 넘어왔을 때 처리하는 로직을 담고, onError는 비동기 작업이 실패했을 때 처리하는 로직을 담습니다.
		}, onError: { error in 
				print("Error: \(error)")
		})
		.disposed(by: disposeBag)
// 마지막으로 Combine의 .store처럼 disposed라는 메서드는 subcribe 메서드를 통해 반환된 구독을 disposeBag에 추가하고
// 해당 구독이 더 이상 필요하지 않을 경우 disposeBag에 추가된 모든 구독을 한 번에 해제합니다.


// 연속된 비동기 처리 예제
guard
		let url1 = URL(string: "http://www.example.com/data/1"),
		let url2 = URL(string: "http://www.example.com/data/2")
else {
		return
}

fetchDataUsingRxSwift(from: url1)
		.do(onNext: { data in
				print("Received data 1: \(data)")
		})
		.flatMap { _ in
				fetchDataUsingRxSwift(from: url2)
		}
		.subscribe(onNext: { data in
				print("Received data 2: \(data)")
		}, onError: { error in
				print("Error: \(error)")
		})
		.disposed(by: disposeBag)

Reference

Concurrency | Apple Developer Documentation
Combine | Apple Developer Documentation
Introducing Combine - WWDC19 - Videos - Apple Developer
ReactiveX
RxSwift Reference
GitHub - ReactiveX/RxSwift

0개의 댓글