[Swift] async / await & concurrency

Swift 5.5 에서 등장한 비동기와 동시성을 위한 방법을 알아봅시다

naljin
23 min readAug 6, 2021

동기와 비동기

우선 동기와 비동기의 개념부터 살짝 짚고 가봅시다

동기 (sync)

작업을 동기적(synchronous)으로 실행한다는 것은 무엇을 의미할까요?

만약 어떤 작업을 동기적으로 실행한다면, 해당 작업이 끝날 때까지 다른 작업들은 기다립니다.

비동기 (async)

반면 비동기(asynchronous) 로 작업을 실행한다면요?

해당 작업이 끝나든 말든 신경 쓰지 않고 나머지 작업을 바로 실행합니다. 그래서 이미지 다운로드나 네트워킹 등 작업 시간이 오래걸리는 것들은 비동기로 처리하는게 일반적인 방식이었습니다. 메인 스레드 막혀서 사용자 이벤트 등을 못받으면 안되니까요!

이때 “일을 비동기로 보내놓고 신경을 안쓰고 있는데.. 그럼 실제 해당 작업이 언제 끝나는지는 어떻게 알지?” 라는 의문점이 들 수 있는데요, Swift에서는 클로저를 통해 해당 시점을 알려줍니다. 이게 바로 보통 우리가 completionHandler 로 부르는 것입니다.

비동기 작업의 completionHandler 사용 문제점

보통 아래같은 코드를 자주 봤을거예요! 네트워크 요청하고 데이터 받아왔을때 completion 호출하는거!

하지만 방금 코드에는 문제가 있습니다. 바로 에러 상황에서 completion 을 호출하지 않고 바로 return 한다는 것인데요, 눈치 채셨나요?

completionHandler는 작업 종료시 항상 호출 되어야하는데, 이 작업은 온전히 개발자에게 달렸습니다. 뭐 컴파일러가 “여기서 completion 호출해라!”라고 알려주지 않는다는 거죠..

따라서 5개의 잠재적 버그(정상적으로 반환하지 않아 UI를 갱신하지 못하는)발생 기회를 가진 코드가 만들어집니다

Swift 5.0부터 도입된 ResultType 을 통해 성공/에러 상황에서 보내는 모양?이 좀 더 나아지긴 했지만.. 그래도 completion 미호출에 대한 위험 있는건 마찬가지입니다.

심지어 이런 비동기 작업이 중첩 되면?! 웰컴 투 콜백 지옥 껄껄

이미지 출처: https://medium.com/dsc-srm/javascript-callback-hell-or-pyramid-of-doom-4f786d14b997

이제 completionHandler 콜백 형식의 문제점이 대충 감이 오나여?

  • completionHandler 호출하는거 잊을 수도 있고
  • 오류 처리를 어렵고 장황하게 만들고
  • 비동기 호출간의 제어 흐름이 복잡할 때도 문제가 되고
  • 심지어 매개변수 구문 @escaping (String) -> Void은 읽기 어려움!

async & await 도입

이러한 노답 상황을 해결하기 위해 Swift 5.5 부터 async & await 가 야심차게 도입되었습니다.

비동기 코드를 마치 동기 코드인것처럼 작성할 수 있기 때문에, 동기 코드에서 사용할 수 있는 동일한 언어 구조를 최대한 활용할 수 있습니다.

기존의 코드를 다시 가져와봤어요

이제 async await를 사용하면?!

모든 클로저와 들여쓰기가 사라진 straight-line code 를 볼 수 있습니다. await 키워드를 제외하고는 마치 동기 코드처럼 보이죠! 심지어 엄청 짧음!!!

Swift는 작업이 종료될 때 completion handler 없이도 이를 호출한 곳에 알려주는 것을 보장하고, 개발자는 async/await를 이용해서 안전하게, 짧게, 의도를 더 잘 반영하여 코드를 작성할 수 있습니다.

그럼 async랑 await 를 하나씩 살펴보져!

async

함수 이름 뒤async 가 붙으면 이 함수는 비동기라는 것 나타냅니다.

한가지 재밌는건 (?) 에러를 반환할 수 있을때의 키워드 순서가 async thorws 라는 점입니다. 하지만 이걸 호출할때는? try await 로 호출하게 되죠ㅎ.. 이게 영어 문법 상 읽기에 더 자연스럽다고 하네요

async코드는 동시(concurrent) 컨텍스트 에서만 실행 가능합니다. 즉, 다른 async 함수 내 에서, 또는 Task {}를 통해 수동으로 concurrent context를 제공할 때 그 안에서 가능합니다. 요건 좀 이따 더 살펴보기로 합시다

await

async 함수를 호출하기 위해서는 await 키워드가 필요합니다.

따라서 예시에서 호출하고 있는 URLSession 함수도 async 임을 알 수 있습니다. (iOS 15 부터 async 를 지원하는 여러 API를 선보였는데, 그 중 URL 세션이 포함됩니다)

public func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse)

중요한건 await 로 마킹된 곳은 potential suspension point(잠재적인 일시 중단 지점)로 지정된다는 것입니다.해당 함수가 대기 상태가 되면, caller 역시 대기 상태가 될 수 있습니다.

흠……….suspension point??? suspend???? 대기 상태????

ㅎㅎ… 설명 드루갑니다

Suspend

위에서 await 를 설명할 때, 해당 키워드를 만나면 그곳에서 suspend 될 수 있다는 것을 의미한다고 했어요! (await 키워드가 있다고 해서 해당 함수에서 무조건 suspend 된다는건 아님)

여기서 일시 중단 (suspend)해당 스레드가 다른 동작을 수행할 수 있게 제어권을 놓아주겠다는 의미입니다. 스레드를 block 하는게 아니라, 스레드에 대한 제어권을 넘긴다구요!!

흠……… 스레드 제어권???

ㅎㅎ.. sync / async 함수를 호출할 때 스레드 제어권이 어떻게 이동하는지 살펴보도록 합시다

sync에서의 thread 제어권

A 함수에서 B 라는 동기 함수(sync)를 호출하면, A 함수가 실행되던 스레드의 컨트롤을 B 함수에게 전달합니다.

그래서 B 함수가 끝날때까지 해당 스레드는 완전히 점유되어서 다른 일을 수행하지 못하게 됩니다.

B 함수가 끝나면 다시 A 함수에게 스레드에 대한 컨트롤 돌려줍니다.

async에서의 thread 제어권

A 함수에서 B 라는 비동기 함수(async)를 호출하면, 위에서 살펴본 sync 함수의 호출 상황과 동일하게 A 함수가 실행되던 스레드의 컨트롤을 B 함수에게 전달합니다. 그리고 B 함수가 완료되면 원래 A 함수에게 컨트롤을 돌려주죠.

하지만 B 함수는 async 함수이기 때문에 중간에 suspend 될 수 있습니다. (그러면 원래 A 함수도 suspend 됩니다.)

위에서 잠깐 언급했듯, Suspend 된다는 것은 해당 thread 에 대한 control 을 포기한다는 것입니다.

그러면 스레드에 대한 제어권system 에게 가고, 시스템은 스레드를 사용하여 다른 작업을 수행할 수 있게 됩니다.

그렇게 시스템이 열심히 우선 순위 등을 판단해가면서 여러 작업들을 실행하다가, 어느 시점에 이르면 일시 중단된 비동기 함수를 계속 실행하는 작업이 가장 중요하다고 판단하는 순간이 옵니다. 그때 해당 함수를 재개(resume) 하고, 비동기 함수는 할당 받은 스레드를 제어하고 작업을 계속할 수 있습니다.

다시 한번 정리해볼까요?

  1. await로 async 함수를 호출하는 순간, 즉 Suspension point 만나는 순간 해당 스레드 제어권 포기
  2. 따라서 async 작업같은 블록에 있는 아래 코드들을 스레드 잡아서 바로 실행 못함
  3. 스레드 제어권시스템에게 넘기면서, 시스템에게 원래 async 작업도 스케쥴 하라고 함
  4. 이제 시스템은 “ㅎㅎ 다른게 더 중요해보이는디?” 하고 해당 스레드에서 다른 작업 먼저 실행할 수 있음
  5. 그러다가 시스템이 원래 async 함수가 중요해지는 순간이 왔다고 판단하면 “ㅇㅋ 너 이제 resume 해라” 하고, 걔한테 특정한 스레드 제어권 줘서 마저 실행 됨 (이때 resume 되는 스레드는 다른 스레드일 수 있음)

이렇게 비동기 함수는 정지된 상태에서 다른 작업이 수행될 수 있기 때문에, “기다린다”라는 뜻을 가진 await 키워드로 비동기 호출을 표시하는게 적절해보이져? await로 표시된 코드의 일시 중단 지점은 같은 이유로 스레드 양보(yielding)라고도 불립니다.

await 키워드를 통해 code block 이 하나의 transaction 으로 처리되지 않을 수 있다는 것을 인식할 수 있습니다. function은 suspend 되고, 다른 것들이 먼저 실행 될 수 있기 때문에 일시 중단 되는 동안 앱의 상태가 크게 변할 수 있음을 알아야 합니다.

함수가 suspend 되고, 다른 작업을 실행하기 위해 스레드를 해제하는 것을 이해하는건 중요하니까 그림과 함께 한번 더 살펴볼게요!! 어떻게 이런 일이 일어날까요? 어떻게 스레드를 포기할 수 있을까요?

프로그램의 모든 스레드는 함수 호출(function call)을 위한 state를 저장하는 데 사용되는 하나의 스택을 갖습니다.

스레드가 함수 호출을 실행 하면 새 프레임스택에 푸시되며, 이 새로운 stack frame 은 함수에서 로컬 변수, return address 및 기타 필요한 정보를 저장하는 데 사용할 수 있습니다.

그리고 함수 실행이 끝나면 해당 stack frame 은 다시 pop 됩니다.

이제 async function 을 살펴보겠습니다.

방금 스레드의 함수 호출에서 살펴본 바와 같이, feed 의 add(:) 메서드가 호출 될 때, 가장 최근의 스택 프레임은 add 입니다.

이 스택 프레임은 idarticle 같이 suspension point 를 사이를 걸쳐가면서 사용할 필요가 없는 local 변수를 저장합니다. (반대로 suspension point 를 사이를 걸쳐가면서 사용해야하는 변수가 뭔지는 조금 이따가 설명할게요!)

add 의 body 에는 await 로 마킹된 하나의 suspension point 가 있는데요, 여기서 잠깐!!! suspension point 에서는 함수가 멈췄다가 나중에 다시 재개될 수 있다고 했잖아요?

그렇기 때문에 await 전 / 후 모두 사용되는 정보를 저장하기 위한 공간이 필요합니다 (아래 코드에서는 newArticles 파라미터가 이에 해당합니다). suspension point 전체에서 사용할 수 있는 정보를 저장하기 위해 add 를 위한 비동기 프레임heap 에 저장합니다.

이어서 add 함수 내의 await database.save 함수가 실행되면 add 를 위한 stack frame 은 save 를 위한 스택 프레임으로 대체됩니다. 어차피 미래에 필요한 변수는 async frame 목록에 저장되어있기 때문에, add 위에 save 라는 새로운 스택 프레임을 추가하는 대신 그냥 최상위 스택 프레임을 대체시킵니다.

save 함수 내부에서는 컨텐츠가 데이터베이스에 저장되는 동안 스레드를 차단하는 대신, 다른 유용한 작업을 수행할 수 있도록 await 를 호출하는 코드가 있다고 가정해봅시다. 그렇게 자신의 필요에 의해 save async frame 얻은 후에, save function 내부에서 await 를 호출함으로써 save function 이 suspend 되었다고 해볼게여 (여기서부터 정신 단디 잡으십셔!).

그럼 스레드 제어권을 포기하고 시스템에게 넘기기 때문에, 해당 스레드에서 다른 일(otherWork2)을 수행할 수 있습니다.

참고로 suspension point 에서 유지되는 모든 정보힙에 저장되므로 나중에 실행을 계속하는 데 사용할 수 있습니다. 이 async 프레임 목록은 continuation에 대한 런타임에서의 표현입니다.

흠……….continuation???

Hㅏ……. 이건 좀 이따 보도록 할게요..◠‿◠ 킵고잉 ㄱㄱ

잠시 후 일부 스레드가 해제되었다고 가정합시다. 이때 해제된 스레드가 이전과 동일한 스레드일 수도 있고 다른 스레드일 수도 있는데, 중지되었던 나머지 부분들은 이곳에서 마저 실행될 수 있습니다.

따라서 await 호출 전에 코드를 실행한 스레드가, (continuation을 pick up 해서) 멈췄던 작업을 재개하는 동일 스레드라는 보장은 없습니다.

save 함수가 스레드를 재할당 받아서 resume 될 때 stack frame 은 요렇게 표현됩니다.

save 의 동작을 마치고 [ID] 를 반환하면, save 를 위한 stack frame 은 add 를 위한 stack frame 으로 대체됩니다.

이후 add 내부에서 zip 함수를 만나면서 새로운 stack frame 이 쌓이고

zip function 이 끝나면 다시 stack frame 은 pop 되고 add 의 이후 동작이 이어서 실행됩니다.

Continuation

후 자꾸 위에서 Continuation 이라는 단어가 나왔죠? 여기서 잠깐 짚고 넘어가보자구요

Continuation은 단순히 비동기 호출 후에 일어나는 일입니다. await 호출 아래의 모든 것은 Continuation 입니다.

그럼 Continuation이 등장하게 된 배경부터 살펴봅시다.

Motivation

Swift API는 종종 콜백을 통해 비동기 코드 실행을 제공합니다. 이것은 async/await가 도입되기 전에 코드 자체가 작성되었거나, 주로 이벤트 중심인 다른 시스템과 연결되어 있기 때문에 발생할 수 있습니다. 이러한 경우 내부적으로 콜백을 사용하는 동안 클라이언트에 비동기 인터페이스를 제공할 수 있습니다. 이러한 경우 호출하는 비동기 작업은 자체적으로 일시 중단될 수 있어야 하는 동시에 이벤트 기반 동기 시스템이 이벤트에 대한 응답으로 작업을 재개할 수 있는 메커니즘을 제공해야 합니다.

Proposed solution

라이브러리는 현재 비동기 작업(asynchronous task)에 대한 continuation 을 얻기 위한 API를 제공합니다 . 작업(task) 의 continuation을 가져오면 작업이 일시 중단되고 동기 코드에서 작업을 재개할 수 있는 값이 생성됩니다.

이것은 Swift에서 async function이 작동하는 방식입니다. async function 을 통해 고수준에서 안전하게 continuation을 생성, 관리, resume 할 수 있습니다.

Use In ViewModel

아 이제 async, await 개념은 볼만큼 본거 같으니까 이걸 ViewModel 같은데서는 어떻게 호출해서 사용하는지 봅시다

우선 앞에서 살펴본 fetchThumnail()함수가 async 함수기 때문에 호출을 위해 await 를 사용했습니다.

또한 async코드를 시행할 수 있는 동시(concurrent) 컨텍스트를 제공하기 위해 뷰모델의 fetchThumbnail 함수도 async 로 만들었습니다.

Use In View

View에서도 사용되는 방식은 비슷합니다.

마찬가지로 async 함수를 호출을 위해 앞에 await 를 붙였습니다.

그리고 async코드는 다른 async 함수 내 에서, 또는 Task {}를 통해 수동으로 concurrent context를 제공할 때 그 안에서 수행 가능하다고 했는데, 여기서는 뭔가 .task{} 부분이 관련있어보이져?

Tasksyncasync 세계의 가교 역할을 합니다. 명시적으로 비동기 컨텍스트를 생성하여 동기화 컨텍스트에서도 비동기를 호출할 수 있습니다.

Task 에 대해 좀 더 알아보러 갈까요?

동시성을 위한 Task

여러분 그거 아시죠?? 비동기(async)랑 동시성(concurrent)은 엄연히 다른 개념인거?

sync / async 에 대한 개념을 이해하기 위해선 아래 링크를,

Serial / Concurrent 에 대한 개념을 이해하기 위해선 아래 링크를 참고합시다

튼 우리는 지금까지 async 호출을 통해 비동기 작업을 다뤄왔는데여! 여기서 기억해야할 건 async를 사용한다고 해서 한 번에 둘 이상의 작업을 수행한다는 의미는 아니라는 것입니다. 즉 async 문법 자체가 동시성을 제공하지는 않습니다.

Task는 Swift가 코드를 병렬로 실행하는 기본 메커니즘입니다. 각 Task는 다른 Task 와 함께 동시(concurrent)에 실행할 수 있는 새로운 비동기 컨텍스트를 제공합니다.

함수를 async로 표시 한다고 해서 새 Task가 생성되는 것은 아닙니다. Task는 항상 명시적으로 생성해야합니다.

아까 View 에서 async 함수를 호출 한 부분을 다시 볼까요?

.task 안에서 호출하고 있는데 한번 정의를 살펴봅시다

해당 함수를 사용하면 Task 생성해준다고 써있네여

.taskmodifier를 사용하는 대신 async {} 로 감싸서 concurrent context 를 제공할 수도 있는데 얘는 Task 종류 중 Unstructured Task 에 해당합니다.

Task 로 생성된 작업은 (메인 액터에서 생성되지 않는 한) 백그라운드 스레드에서 즉시 실행되며, await 키워드를 사용해서 완료된 값이 돌아올 때까지 기다릴 수 있습니다.

async 함수에서 또 다른 async 함수를 호출할때면 동일한 Task에서 execute call이 수행됩니다.

Task 는 Swift 와 깊이 연관되어 있기 때문에 컴파일러 단에서 일부 동시성 버그를 방지할 수 있습니다.

Swift는 high, default, low, backgroundTask 우선 순위를 제공하기 때문에 Task(priority: .high) 같이 우선 순위를 지정할 수 있습니다. high에 해당하는 userInitiated 를 사용할 수도 있고 low 를 위해 utility를 사용할 수도 있지만, userInteractive는 사용 불가합니다. 왜냐면 이는 main thread 를 위한 것이기 때문이죠!

Task 종류에는 아래와 같은 것들이 있습니다.

더 자세하게 알아보고 싶으면 아래 세션을 참고하도록 합시다.

마무리

어우 힘들어……… 진짜 역대급으로 긴 글이었네요! ㅜ 내용을 나눌까도 싶었지만 이렇게 쭉 흐름대로 이어서 보는게 더 이해가 잘될 것 같아서 하나에 다 작성했읍니다..

아직까지 저도 async, await, task 를 본격적으로 써본건 아니고 wwdc 영상과 여러 자료들을 보면서 정리한거라 틀린 내용이 있을 수도 있습니다 ㅎㅎ 감안해주시고 훈수 환영하니 이상한 내용이 있으면 댓글 남겨주세요! 그럼 20000!

추가

Actor 에 대해서도 궁금해! 라는 분들은 이곳으로 고고

참고

--

--