Async/Await
대부분 iOS 개발자는 비동기 프로그래밍 경험이 있을 것입니다. async/await는 비동기 프로그래밍을 더 쉽고 안전하게 해줍니다. 이 글은 애플의 Meet async/await in Swift 영상에 기반합니다.
Async/await은 Swift 5.5부터 지원합니다. 이 키워드를 사용하면 동기 코드를 작성하듯이 간단하게 비동기 코드를 작성할 수 있습니다. iOS SDK에는 await로 표현할 수 있는 수많은 비동기 함수가 존재합니다.
iOS SDK의 비동기 함수는 다양한 방법으로 호출자에게 자신의 작업이 끝났음을 알립니다.
- 컴플리션 핸들러 (completion handler)
- delegate 콜백
- async 키워드 사용
어떤 방법을 사용하든, 비동기 함수는 자신이 호출된 스레드를 블락하지 않습니다.
Completion Handler vs. Async/Await
아래 예시와 함께 completion handler와 async/await의 차이점에 대해서 알아봅시다.
여러분이 개발하고 있는 앱에서 썸네일을 보여줘야한다고 가정합시다. String을 썸네일로 변환하기 위해 위의 fetchThumbnail이라는 함수는 아래의 과정을 수행합니다.
- thumbnailURLRequest를 통한 URLRequest를 생성
- URLSession의 dataTask(with:completion:)를 통한 Data 받음
- UIImage의 initWithData를 통한 UIImage를 생성
- UIImage의 prepareThumbnail(of:completionHandler:)을 통한 이미지 썸네일로 렌더링
위 과정은 모두 이전 과정의 결과에 의존하니 순서대로 실행되어야합니다. 위 과정 중 어떤 과정은 금방 값을 리턴하지만, 어떤 과정은 리턴하기까지 비교적 오래 걸립니다. 예를 들어, 이미지를 구성하는 데이터를 다운로드하는 과정과 이미지를 썸네일로 렌더링하는 과정은 조금 더 비싼 작업입니다. 그렇기 때문에 SDK는 2번과 4번 과정에 대한 비동기 함수를 지원합니다.
Completion Handler 사용 예시
위의 코드는 completionHandler를 사용할 때 빈번히 발생하는 실수입니다. fetchThumbnail 함수의 호출자는 함수의 작업이 끝나거나 작업이 실패했을 때 그 결과에 대해 보고받아야합니다. 하지만 위 코드 중 데이터를 이미지로 변환하는 부분이나 썸네일을 준비하는 부분 중 문제가 발생하면, 컴플리션 핸들러를 호출하지 않고 리턴합니다. 위처럼 코드를 작성하게 되면 fetchThumbnail 함수의 호출자는 결과를 절대 전달받지 못하고, 앱에서 표출해야할 썸네일은 절대 업데이트되지 않습니다.
위의 실수를 만회하려면 누락됐었던 두 부분도 completion handler를 호출해서 에러를 넘겨줘야합니다. 보통의 함수라면 호출자에게 에러를 throw하겠지만, 이 경우엔 적용되지 않습니다. Swift에서 위 예시의 completion handler는 그냥 closure입니다. 안타깝게도, closure는 에러를 throw할 수 없기 때문에 Swift가 우리의 작업을 확인해줄 수 없습니다. 이 예시의 경우에도 completion handler 호출을 누락했을 때 컴파일 에러가 나지 않았습니다. 결국, 개발자가 직접 확인해야합니다.
우리는 위의 예시를 보다 안전하게 구현하기 위해 Result type 혹은 Futures를 사용할 수도 있지만 이 방법들도 읽기 쉽고 안전한 코드를 보장해주지 않습니다. 하지만 async/await를 사용한다면 달라집니다.
Async/Await 사용 예시
이전 예시와 같은 역할의 함수지만, completion handler 대신 async/await를 사용했습니다. 이 함수는 썸네일을 무사히 가져온다면 썸네일을 리턴하고, 그 과정에 에러가 생긴다면 에러를 throw합니다. 함수 두번째 줄에 이전 방식과의 차이가 드러납니다. dataTask와 data 함수 모두 비동기지만, data 함수는 awaitable합니다. 그 뜻은, 호출된 즉시 자기 자신을 중지시켜 스레드가 블락되는 것을 방지합니다. awaitable 함수를 사용해 try 키워드와 await 키워드를 혼합할 수 있었고, 그로 인해 에러 핸들링 코드를 이전 completion handler를 활용했을 때보다 더 간결하게 나타낼 수 있습니다. await 키워드는 async라고 마크된 함수를 부를 때 항상 붙습니다. data 함수가 재개되어 동작이 완료되면 리턴 값에 따라 에러를 throw하거나, data와 response 변수의 값이 정의될 것입니다. 이 동작은 completion handler를 사용한 예시의 것과 동일하지만, 훨씬 간단합니다. 그 이후엔 data를 이미지로 변환하고, 이미지의 thumbnail 프로퍼티에 접근해 이미지를 썸네일로 렌더링합니다. 썸네일이 만들어지는 동안 스레드는 다른 작업을 수행할 수 있습니다.
completion handler를 사용했을 때와 비교해서, 20줄이었던 코드가 단 6줄로 줄었습니다. 코드의 깊이도 감소했습니다. 또한, async/await를 사용하면 Swift가 작업이 완료되었을 때 항상 호출자에게 알립니다. Async/await를 사용함으로써 코드가 더 안전해지고, 짧아지고, 가독성이 좋아졌습니다.
이번엔 fetchThumbnail async 함수의 마지막에서 두번째 줄에 주목합시다. 함수 호출부가 없음에도 await 키워드가 있습니다. 그 이유는 thumbnail 프로퍼티가 async하기 때문입니다. 꼭 함수만 async일 수 있는아니라 프로퍼티, 그리고 생성자도 async일 수 있습니다. 이 예시의 경우, thumbnail 프로퍼티는 SDK의 한 부분이 아니라 UIImage의 extension으로 직접 정의한 부분입니다.
async 프로퍼티의 특성을 찾아봅시다. 첫번째로, 명시적으로 getter가 정의되어 있습니다. 이 부분은 async 프로퍼티의 필수 조건입니다. 두번째로, setter가 없습니다. read-only 프로퍼티만 async할 수 있습니다. async 키워드는 함수, 프로퍼티, 그리고 생성자에 쓰일 수 있습니다. 또한, async sequences를 도는 for loops에도 사용될 수 있습니다. async sequence는 일반 sequence와 동일하지만, 요소에 비동기로 접근한다는 차이가 있습니다.
비동기 함수 실행 중지(suspend)의 의미
어떤 함수에 async 키워드가 붙는다는 것은, 그 함수가 실행을 중지(suspend)할 수 있다는 뜻입니다. 그렇다면 비동기 함수가 실행을 중지한다는 게무슨 뜻일까요? 먼저 일반 함수를 호출하면 어떤 일이 일어나는지 알아봅시다.
우리가 실행 중인 어떤 함수가 다른 함수를 호출하면 스레드의 컨트롤을 호출된 함수에게 넘기게 됩니다. 만약 그 함수가 일반 함수라면 스레드는 그 함수가 실행 완료될 때까지 그 함수 하나만의 작업을 수행합니다. 함수의 작업을 완료하면, 해당 함수는 스레드의 컨트롤을 호출부로 다시 넘깁니다. 이처럼 일반 함수가 스레드의 컨트롤을 넘기는 경우는 함수의 작업이 완료됐을 때 뿐입니다.
비동기 함수도 함수의 작업 완료 시점에 스레드의 컨트롤을 호출부로 다시 넘긴다는 점은 똑같습니다. 하지만 일반 함수와 다르게 비동기 함수는 정지하며 스레드의 컨트롤을 넘길 수도 있습니다. 비동기 함수가 실행 중 정지를 하면 컨트롤이 호출한 함수로 넘어가는 것이 아니라 시스템으로 넘어갑니다. 이 경우 비동기 함수를 실행한 함수도 정지됩니다. 시스템은 실행해야하는 많은 작업 중 어느 작업을 먼저 실행할지 판단합니다. 언젠가는 시스템이 정지된 비동기 함수를 재실행하는 것이 가장 중요하다고 판단할 것이고, 스레드의 컨트롤을 넘길 것입니다. 그렇게 결국엔 비동기 함수의 작업이 완료됩니다. 이 과정 중 비동기 함수는 여러번 정지할 수 있고, 때로는 아예 정지하지 않을 수도 있습니다.
비동기 함수의 실행이 중지된 중 다른 작업이 진행될 수 있기 때문에 Swift 문법은 비동기 호출부에 await 키워드를 넣도록 하고 있습니다. 함수가 정지된 와중에 앱의 상태가 크게 변할 수 있다는 점을 인지해야합니다. 이 부분은 completion handler를 사용했을 때도 동일합니다. 하지만 async/await를 사용하는 것이 함수의 흐름을 보는 데 더 용이합니다. 함수가 정지된 중 다른 작업들이 실행될 수 있고, 심지어 원래 함수가 실행되던 스레드와 다른 스레드에서 함수가 재실행될 수도 있습니다.
Async/Await를 사용하면서 기억할 점
- 함수를 async라고 마크하는 것은 해당 함수가 실행 중 중지되는 것을 허가하는 것입니다. 함수가 중지되면, 호출한 함수도 중지됩니다. 그렇기 때문에 호출한 함수들도 비동기여야합니다.
- await 키워드를 사용해서 비동기 함수 중 어느 부분에서 실행이 중지될 수 있는지 표시합니다.
- 비동기 함수가 중지된 도중 스레드는 블락되지 않습니다. 시스템이 스레드를 위한 다른 작업을 스케줄링합니다.
- 비동기 함수가 재개되면 그 함수의 결과가 호출한 함수로 전달되고, 호출한 함수는 다음 코드를 이어 실행합니다.