본문 바로가기
Dev/WWDC 정리

[WWDC21] Use async/await with URLSession + 적용

by Mintta 2023. 3. 23.

시작

swift concurrency관련된 wwdc를 이어서 보는중입니다. 앞선 async/await 개념에 관한 글은 조금 정리가 덜 된 것 같아서 그 다음편으로 이어지는 해당 세션에 대한 글부터 먼저 올리고자합니다. 정리 마지막에는 실제 코드를 한번 리팩토링하는 경험까지 가져보았습니다.

예제 코드 (completion handler)

얼핏봤을 때 문제가 없어보이지만 이제부터 이 코드의 문제점 3가지에 대해서 앞으로 살펴볼 것입니다.

Control Flow

control flow를 먼저 살펴보자

  1. 우리는 data task를 만들고 task를 resume시킨다
  2. task가 끝나면 completion handler로 들어와서, response를 확인하고 이미지를 만든다.

control flow가 이렇게 앞뒤로 왔다갔다 정신이 없네요

+3개의 다른 실행 컨텍스트 (execution context)

그리고 이 짧은 코드에 3개의 각기 다른 실행 컨텍스트가 있습니다.

  1. outmost layer(가장 바깥쪽 계층)는 호출자의 스레드 또는 queue에서 실행되고,
  2. URLSessionTask completionHandler는 session의 delegate queue에서 실행되며
  3. 마지막 completion handler는 main queue에서 실행된다.

그렇기 때문에 우리는 경쟁 조건 등과 같은 스레드 이슈를 피하기 위해서 정말 많은 주의를 기울여야합니다!

이제 어떤 문제점들이 있는지 봅시다

문제점 찾기

각각의 completion handler 호출이 main queue로 일관되게 dispatch되지 않습니다

early return이 없다

  • error가 나면 거기서 completion handler를 호출하고 return해야합니다.
  • 지금 이 상태라면 completion handler가 두번 불릴 수 있고 이건 caller의 의도와는 전혀 다르게 움직일 것입니다.

UIImage 생성이 실패할 수도 있다

  • 생성 실패가 되면 nil을 보내주게 될 것이다.

이제 async/await를 이용해서 개선시켜보자 !

async/await 사용

  • 이 코드도 control flow부터 살펴보면 위에서 아래까지 linear한 control flow를 가지고 있습니다.
  • 모든 것이 같은 동시성 문맥(concurrency context)에서 돌아간다는 것을 알 수 있습니다.
  • 이제 더이상 스레딩 이슈에 대해서 걱정할 필요가 없습니다

  • URLSession.shared.data(from:) 은 async 메서드입니다.
  • 하이라이트된 저 줄에서 execution context은 blocking없이 지연됩니다.
  • throw키워드를 통해서 에러를 던집니다. 이걸로 Swift의 native error handling을 통해서 에러를 잡을 수 있게 됩니다.
  • 컴파일러단에서 nil이 안넘어가게끔 막을 수 있다는 것도 장점입니다.

URLSession.data

URLSession.upload

URLSession.download

  • download 메서드는 response body를 메모리 안에보다는 파일로 저장합니다.
  • 해당 메서드는 기존의 download 메서드와 다르게 파일을 자동으로 삭제해주지 않기 때문에, 직접 지워주는 것을 잊지 말아야합니다.

마지막 try FileManager.default.moveItem(at:to:) 코드에서, 파일을 다른 위치로 옮기고 있습니다. (FileHandle에도 AsynceSequence를 사용할 수 있다)

작업 취소

  • Swift 동시성 취소는 URLSession async 메서드와 함께 작동합니다.
  • 취소하는 한 가지 방법은 concurrency Task.Handle을 사용하는 것입니다.
  • 여기서는 async를 호출하여 두 개의 리소스를 하나씩 로드하는 concurrency Task를 생성합니다.
  • 나중에 Task.Handle을 사용하여 현재 실행 중인 작업을 취소할 수 있습니다.

💡 동시성(Concurrency) Task는 "Task"라는 이름을 공유하지만 URLSessionTask와는 관련이 없다는 점에 유의해야 합니다 !

 

앞서 이야기했던 data, upload, download는 전체 response body가 도착할 때까지 기다렸다가 return합니다. 그렇다면 점진적으로 response body를 받고 싶을 때는 어떻게 하면 될까??

Response body 점진적으로 받기

  • response header를 받으면 return되며
  • response body를 bytes의 AsyncSequence로 전달합니다.

  • 반환값은 (URLSession.AsyncBytes, URLResponse)의 튜플입니다.

이걸 적용하는 예제를 살펴봅시다

좋아요수가 리얼타임으로 업데이트되게끔 하기

  • bytes의 반환값은 위에서 살펴봤듯이 URLSession.AsyncBytes. 이것이 점진적으로 response body를 받을 수 있게끔 도와줍니다.

  • 응답의 각 줄을 JSON 데이터로 파싱하기 위해 AsyncBytes에서 lines 메서드를 사용할 수 있습니다.
  • 이것으로 한줄 씩 데이터를 받을 때마다 response를 얻을 수 있습니다.
  • UI Update는 main actor에서 이루어져야하니 async 함수인updateFavoriteCount(with:) 앞에 await 키워드를 붙여주었다.(?)
    • Note that the UI updates need to happen on the main actor, and that's why I'm using await syntax to call updateFavoriteCount, which is an async function.
    • updateFavoriteCount(with:) 함수는 async함수이면서 위에 @MainActor 마크가 되어있는 함수인 것 같습니다 !

URLSession은 인증 문제, metrics 등과 같은 이벤트에 대한 콜백을 제공

하는 delegate 모델을 중심으로 설계되었습니다. 새로운 async 메서드는 더 이상 기본 작업을 노출하지 않는데, 작업과 관련된 인증 문제를 어떻게 처리할까요?

  • task delegate추가 인자를 받는다.

유저 인증 예제

사용자 로그인이 되어야지 할 수 있는 작업들 (로그인 이후 유저 정보 필요로 하는 작업들)

1. URLSessionTaskDelegate 만들기

  • 이름은 AuthenticationDelegate로 해준 모습.
  • NSObject와 URLSessionTaskDelegate 프로토콜을 채택하고 있다.
  • signInController를 의존성 주입받고 있다.
class AuthenticationDelegate: NSObject, URLSessionTaskDelegate {
    private let signInController: SignInController
    
    init(signInController: SignInController) {
        self.signInController = signInController
    }
    
    func urlSession(_ session: URLSession,
                    task: URLSessionTask,
                    didReceive challenge: URLAuthenticationChallenge) async
    -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodHTTPBasic {
            do {
                let (username, password) = try await signInController.promptForCredential()
                return (.useCredential,
                        URLCredential(user: username, password: password, persistence: .forSession))
            } catch {
                return (.cancelAuthenticationChallenge, nil)
            }
        } else {
            return (.performDefaultHandling, nil)
        }
    }
}

  • delegate 객체는 인스턴스 변수가 아니며, Task가 완료되거나 실패할 때까지 Task에 의해 강력하게 유지됩니다.
  • 여기서 새로운 점은 delegate를 사용하여 URLSession task의 인스턴스에 특정한 이벤트를 처리할 수 있다는 것인데, 이는 delegate 메서드 내부의 로직이 특정 URLSession task에만 적용되고 다른 task에는 적용되지 않을 때 유용합니다.

delegate 객체는 비회원 상태로도 앱을 사용할 수 있고 그 안에서 로그인을 한 유저만 사용가능한 기능등이 있을 때 해당 상황에서 유용하게 쓰일 수 있겠다고 생각했습니다. 하지만 서버와의 토큰값 등을 통해서 유저가 로그인을 하고 있는지를 확인하는데, 이런 토큰값을 통해 이런건 해결할 수 있지 않나 하는 생각이 들어서 명확히 이거다! 싶은 상황은 또 안떠오르네요.

Wrap up

  • completion handler → async function
  • Events handler → AsyncSequence

이렇게 바꿔서 적용함으로써 코드의 품질을 높여보자..!

직접 적용해보기

제가 학교에서 졸업프로젝트로 커스텀 카메라 화면을 만들어야하는 부분이 있는데 해당 부분을 구현하는 중에 completion handler를 사용하는 코드가 있어 이를 직접 async/await로 바꿔 적용해보겠습니다

Completion handler ver.

private func checkCameraPermission() {
    switch AVCaptureDevice.authorizationStatus(for: .video) {
    case .notDetermined:
        // Permission 요청
        AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
            guard granted else { return }
            DispatchQueue.main.async {
                self?.setUpCamera()
            }
        }
    case .restricted:
        break
    case .denied:
        break
    case .authorized:
        setUpCamera()
    @unknown default:
        break
    }
}

카메라 권한을 확인하는 함수입니다.

저희가 여기서 눈여겨볼 부분은 notDetermined, 권한에 대한 어떠한 설정도 안된 상태일 때 허가를 요청하는 부분입니다.

control flow 확인

앞서 했듯이 control flow부터 확인해보면 위에서 살펴봤듯이 앞 뒤가 섞여있는 것을 볼 수 있습니다.

Execution context

caller는 호출자의 스레드에서, requestAccess의 completion handler는 또 다른 스레드, 그리고 UI관련 업데이트는 메인 스레드에서 해주고 있습니다. 맨 처음 completion handler예제의 상황과 똑같은 것 같죠?

 

이제 async/await을 적용시켜보겠습니다.

Refactoring with async/await

private func checkCameraPermission() {
    Task {
        switch AVCaptureDevice.authorizationStatus(for: .video) {
        case .notDetermined:
            // Permission 요청
            let granted = await AVCaptureDevice.requestAccess(for: .video)
            if granted {
                setUpCamera()
            }
        case .restricted:
            break
        case .denied:
            break
        case .authorized:
            setUpCamera()
        @unknown default:
            break
        }
    }
}

애플에서는 저같은 사람들을 위해 requestAccess에 대한 async함수 또한 준비해두었습니다! 해당 async함수를 동기적인 코드에서는 바로 호출할 수 없고 비동기 세상에서만 호출할 수 있습니다.

그렇기 때문에 애플의 표현을 빌려, 동기 세상(synchronous world)과 비동기 세상(asynchronous world)을 이어주는 Task함수를 통해서 두 세상을 연결시켜주었습니다 !

async 함수인 requestAccess메서드를 호출하기 위해 앞에 await를 붙여줍니다! requestAccess메서드는 에러를 던지지 않기 때문에 try같은 문은 필요없습니다. 만일 에러를 던지는 함수라면 try await문을 사용하면 됩니다.

setUpCamera메서드는 메인 스레드에서 돌아가야만 합니다. 따라서 저는 이곳에서 메인큐에 넣는 것이 아닌,

위의 예제에서 했듯이 MainActor를 사용해 해당 함수는 메인스레드에서 돌아갈 것임을 보장해주었습니다.

바로 이렇게요 !

 

@MainActor
private func setUpCamera() { ... }

 

이제 control flow도 Linear해졌고 같은 execution context를 가질 수 있게 되었을 뿐만 아니라 훨씬 의도가 잘보이고 깔끔한 코드가 되었네요. 간단한 애플의 API를 completion handler에서 async/await, task를 활용해서 리팩토링까지 해보았는데 여러므로 장점이 훨씬 많아보이네요 !

댓글