먹고 기도하고 코딩하라

Swift Concurrency 속 Continuation의 쓰임 (feat. 컴플리션 핸들러 → async-await) 본문

앱/Swift

Swift Concurrency 속 Continuation의 쓰임 (feat. 컴플리션 핸들러 → async-await)

사과먹는사람 2024. 7. 22. 20:42
728x90
728x90

 

원래 비동기 작업 처리에 RxSwift를 주로 이용해서 앱을 만들었는데, 올해부터는 Swift Concurrency를 주로 사용해서 비동기 작업을 하고 있다. 개인적으로 Combine은 기존에 RxCocoa를 사용하던 UI 작업이나 비동기 작업 결과를 가공하는 데에 쓰기 좋고, async-await은 통신 작업과 같이 비동기 작업에서의 RxSwift를 대체하는 느낌이 강하다고 생각한다.

하반기 시작하면서 회사 앱에도 Swift Concurrency를 도입해서 기존 컴플리션 핸들러(!)를 사용해서 깊은 depth를 가진 코드를 변경하고자 통신부를 async-await 사용할 수 있도록 감싸는 작업을 했다.

 

기존 통신 로직은 이랬다.

  1. Requester를 만든다. 필요한 헤더, 베이스 URL, 쿼리, 바디 등은 미리 만들어두거나 받는다.
  2. 메소드에 따라 Alamofire request를 생성한다.
  3. request 요청 후 실행할 responseData도 같이 구성한다. 요청 성공, 실패에 따라 분기한다.
  4. 이 때 통신 완료/실패 처리에는 컴플리션 핸들러를 넘겨서 이용하기 때문에, 분기가 있거나 추가적인 작업이 있을 경우 depth가 깊어지기도 한다.

작년에 이미 한 번 RxSwift 방식의 request 처리를 다뤄서 일부 사용하고 있기 때문에, 요청을 바로 하지 않고 Requester만 만드는 작업은 이미 해놨다. 그래서 범용적인 complete, failed 메소드만 처리하면 됐다.

이번 작업은 네트워크 통신에서의 작업을 분리하기 이전에 먼저 가독성을 위해, 또 async-await 방식의 코드에 문제가 있지 않은지 베타 테스트를 할 겸 기존에 쓰던 통신 객체를 async-await하게 요청하도록 감싸도록 진행했다.

 

 

막간을 이용한 async-await의 원리

보통 async-await을 이용한 비동기 코드는 다음과 같다.

func getFollowerIdList(id: Int) async -> [Int]? {
		do {
				let profile = try await ProfileApi.asyncRequest()
				let user = try await UserApi.asyncRequest(profile)
				let followers = try await UserApi.followers.asyncRequest(user)
				return followers.compactMap { $0.id }		
		} catch let error {
				// handling error
		}
}

이걸 컴플리션 핸들러를 사용하는 방식으로 처리해야 한다면 요런 코드가 될 것이다.

func getFollowerIdList(id: Int, completion: ([Int]? -> Void) -> Void {
		ProfileApi.request() { profile in
				guard let profile else { return }
				UserApi.request(profile) { user in 
						guard let user else { return } 
						UserApi.followers.request(user) { followers in. 
								guard let followers else { return }
								completion(followers.compactMap { $0.id })
						} 
				}		
		}
}

이 정도 depth의 코드를 async-await으로 간결하게 만드는 것 자체가 기적이다.

 

이론적으로 봤을 때 메소드 시그니처에 async를 붙이고, await을 호출해 비동기 코드를 핸들링하는 것이 async-await를 쓰는 방법이다. 이 때, await 키워드는 중지점(suspension point)으로, 코드 수행 중 await 키워드를 만날 때 시스템에게 메소드를 잠시 중지하고(스레드의 제어권을 양보(yield)해서 시스템이 제어권을 가져온다) 다른 스레드에서 작업(다운로드라든지, 통신이라든지)을 할 것을 통지한다.

스위프트 시스템은 힙에 현재 함수의 상태(중지점에서 이어서 실행하는 데 필요한 컨텍스트)를 저장하고, continuation(연속)을 만든다. 돌아와서 계속 실행할 연속성을 만드는 것이다. 그래서 await로 호출한 작업이 종료되면 continuation이 resume되고, 중지돼있던 곳부터 다시 작업 수행이 시작되는 것이다. (단, suspend 과정에서 추가적인 스레드 생성 없이 작업할 수 있도록 적절한 스레드로 배정하는 과정이 포함되어 있기에, resume 시 배정받는 스레드는 이전과 다를 수도 있음)

힙에 저장되는 이유는, 스레드마다 생성되는 스택 영역과 달리 힙은 모든 스레드가 공유할 수 있기 때문에 다른 스레드에서 resume되더라도 일시정지된 함수 상태를 알 수 있기 때문이다.

async-await을 사용할 때 시스템은 자동으로 continuation을 관리하게 된다.

 

 

Continuation

그런데 위 코드에 continuation은 끼어들 자리가 없다. 이미 async-await 자체로 Swift Concurrency를 이용한 비동기 작업을 완벽하게 수행하고 있다.

그럼 continuation이란 게 왜 필요한가? 기존에 컴플리션 핸들러로 작성된 코드를 완벽하게 async-await으로 변경할 수 없을 때, 기존의 컴플리션 핸들러를 감싸서 사용하거나 해야 할 때 사용하는 것이다.

 

Continuation은 프로그램의 상태를 표현한다. 특히 UIKit에서 많이 사용되고, 이미 존재하는 델리게이트와 컴플리션 핸들러 코드를 async 방식으로 쉽게 옮길 수 있도록 도와주는 것이 Continuation API다.

Continuation은 Unsafe, Checked 크게 2가지로 나뉘는데 resume을 딱 1번 호출하는지를 체크하느냐 그렇지 않느냐의 차이다. 비동기 작업을 실행하려면 continuation.resume을 호출해야 하는데, resume을 아예 호출하지 않으면 태스크를 계속 await 상태로 두기 때문에 관련 리소스의 누수가 일어날 수 있고, 2번 이상 호출하는 건 정의되지 않은 행위로 직접 수행해봤을 때는 앱이 죽는 결과를 가져왔다. 그러므로 정말 딱 1번만 호출할 수 있게끔 해야 하는데 개발자는 실수할 수 있으므로 적어도 개발하는 단계에서는 Checked를 사용하면 좋다. Checked는 규칙이 위반됐을 때 로그를 남겨준다.

Checked의 경우 resume 동작이 없거나 여러 번 있는지를 런타임에서 체크하지만, Unsafe의 경우 오버헤드를 적게 하는 것이 목표이기 때문에 가능한 런타임 체크를 하지 않는다.

여기에 Throwing까지 추가하면, 이 continuation은 이제 에러가 있을 때 resume(throwing:)으로 에러를 던질 수도 있게 된다.

    func asyncRequest() async throws -> ResponseType? {
        try await withCheckedThrowingContinuation { [weak self] continuation in
            guard let self else { 
                continuation.resume(throwing: NSError.init(domain: "Requester has disappeared.", code: -1))
                return
            }
            self.success = { data in
                guard let data else {
                    continuation.resume(throwing: NSError.init(domain: "no data", code: 0))
                    return
                }
                continuation.resume(returning: data)
            }
            
            self.fail = { [weak self] error, data in
                if self?.failCount == 0 {
                    continuation.resume(throwing: error ?? NSError.init(domain: "network error", code: 0))
                }
                return
            }
            
            self.request()
        }
    }

 

사용하는 곳에선 이렇게 사용할 수 있다.

Task는 비동기 작업의 단위이다. 때문에 Task를 사용하는 건.. 알아서 정해서 사용하면 된다.

Task {
		await withTaskCancellationHandler {
				do {
						let profile = try await ProfileApi.asyncRequest()
						// code
				}	catch let error {
						// handling error
				}
		} onCancel: {
				// cancel the task
		}
}

 

Continuation과 컴플리션 핸들러의 차이점

얼핏 봐서는 continuation이 컴플리션 핸들러와 비슷해 보인다. Continuation을 쓰는 이점이 있을까?

GCD 방식에서, 우리는 주로 컴플리션 핸들러를 보내서 작업의 성공/실패에 따른 후속 작업을 하게 된다. 이 경우 스레드 블락 시 태스크를 다른 스레드로 보내는 데 Full thread context switching 비용이 발생한다.

하지만 continuation을 사용할 때는 function call 정도 비용으로 메소드 호출, 실행이 가능해진다. 왜냐하면 Swift concurrency에서는 core 개수에 맞게 스레드를 사용하기 때문에 메모리와 스케줄링에 있어 부담을 덜 수 있고, 코어에서 실행하는 스레드를 갈아끼우는 컨텍스트 스위칭이 일어나지 않기 때문이다. 스레드를 사용하는 대신 Continuation을 사용해 작업이 다시 시작하는지 추적한다. 스위칭은 continuation 간에 일어난다.

 

 

 

 

References

  

 

728x90
반응형
Comments