Swift: Structed Concurrency란?

Heechan
HcleeDev
Published in
13 min readJun 26, 2021
Photo by Arisa Chattasa on Unsplash

지난 주에 포스팅한 Async/Await와 함께 Swift 5.5부터 등장한 Structed Concurrency 기능에 대해서 소개한다. 이 친구 또한 Swift가 주목하고 있는 비동기 처리와 관련한 내용이다.

Structed Concurrency는 구조적 프로그래밍에서 착안한 것으로 비동기 처리 코드를 좀 더 보기 좋게, 좀 더 좋은 성능으로 구조를 다질 수 있도록 도와준다. 만들어진 이유부터 관련된 몇가지 구문들까지 알아보도록 하자.

왜 만든걸까

저번에 소개한 Async/Await가 굉장히 코드를 깔끔하게 표현할 수 있도록 도와주는 기능임은 분명하지만, 무턱대고 사용하면 기능 상의 문제가 생길 수 있다.

Swift-evolution에서는 이를 저녁 준비로 비유해두었다.

저녁 준비를 할 때, 채소도 썰어야 하고, 고기도 재워두어야 하고, 오븐도 예열해야 한다. 근데 사람의 한계가 있기 때문에, 각각 업무는 한 번에 하나 밖에 할 수 없다. chopVegetables 가 끝나야 다음 일인 marinateMeat 를 할 수 있고, 그것도 끝나야 preheatOven 을 할 수 있다.

위 사진의 코드는 await 를 이용해 그를 표현한 것이다. makeDinner 라는 작업은 chopVegetable 이 완료될 때까지 잠시 대기할 수 밖에 없고, 그 작업이 완료되어야 다음 작업인 marinateMeat 로 넘어갈 수 있다. 비동기적으로 실행한다고 해도, 결국 ‘sequential’하게 작업을 실행해야 해서 시간이 걸릴 수 밖에 없다.

이 저녁 작업을 효율적으로 하기 위해선, 순차적으로 하는게 아니라 동시에 해야 할 것이다.

Structed Concurrency는 이런 상황에서 여러 가지 비동기 작업들을 concurrent하게 실행할 수 있도록 만들어졌다.

Task

동시성을 관리할 수 있도록 만들어진 시스템에서는 스레드를 추가로 사용할 수 있도록 하는 기능이 대부분 있다. 한 스레드만 가지고 순서를 나중에 하니 마니 하는 것만으로는 분명히 한계가 있다. 만약 계산량이 상당히 많은 작업 하나가 스레드를 계속 잡고 있다면, 다른 작업들이 마냥 기다리고 있기엔 성능 문제가 있을 수 있다.

Swift의 Structed Concurrency는 Task라는 새로운 단위를 제시함으로써 이 문제를 관리하고자 했다.

모든 asynchronous한 작업, 비동기 작업들은 Task라는 단위 안에서 실행되도록 한다. 우리는 Task를 명시적으로 만들어 비동기적 작업을 실행할 수 있도록 시스템에게 제공한다.

시스템은 이렇게 만들어진 Task를 받아가 알아서 스케쥴링하고 실행하도록 되어있다. 따라서 우리가 굳이 개발하면서 어떤 스레드에서 어떻게 실행될지 직접 정해줄 필요는 없도록 설계되어있다.

또한 Asynchronous function은 Child Task를 만들 수 있다. Child Task가 만들어지고, 실행되기 시작하면 그 Child Task가 끝날 때까지 Parent Task는 기다려야 한다. await 를 붙여서 실행하는 작업이 끝날 때까지 원래 Parent Task는 잠시 suspend되어있는 상황과 같다.

Async-let Task

WWDC21 영상에서 소개되는 몇가지 Task 종류들을 소개하고자 한다. 첫 번째는 비교적 단순한 Async-let Task다.

비동기적으로 작업을 실행해야 할 때, 바로 Child Task가 생성된다. 그러면서 작업의 결과물이 저장될 공간을 할당하고(위 그림에서는 result ), 비동기 작업을 시작한다. 그러고 나서 다른 작업들을 하고 나면 차후 await result 에 도달했을 때 비동기 작업의 실행이 완료된 후에 result에 저장된 값을 이용할 수 있다.

이를 코드로 보면 아래와 같다.

async let 을 사용할 때 얻을 수 있는 장점을 여기서 볼 수 있다.

3, 4번째 라인에서 async let (~~~,_) = 비동기 작업 을 통해 비동기 작업을 일단 시작 시켜놓고, 나중에 실제 data와 metadata 변수가 필요할 때나 작업이 완료되었는지 확인하면 된다. async letawait 사이의 코드들을 기다릴 필요없이 실행할 수 있다는 장점이 있다.

만약 코드가 아래와 같았다면,

let (data, _) = await URLSession.shared.data(for: imageReq)
let (metadata, _) = await URLSession.shared.data(for: metadataReq)

첫 번째 줄의 URLSession.shared.data(for: imageReq) 작업이 모두 완료되고 난 후에야 다음 줄의 URLSession.shared.data(for: metadataReq) 가 시작한다.

하지만 위에서 보았듯 async let 을 이용하면 두 가지 작업이 백그라운드에서 동시에 굴러갈 수 있도록 시작시킬 수 있다. 나중에 await 를 이용해 작업이 완료되었는지 확인 및 대기도 할 수 있다. 이가 async let 을 이용한 Structed Concurrency다.

Group Task

async let 은 비동기 작업의 갯수가 정해져있을 때는 사용하기 좋지만, 작업의 갯수가 유동적일 때는 사용하기 불편하다. 예를 들어 서버로부터 리스트를 받아와 썸네일 사진을 가져와야 하는데, 리스트에 들어있는 아이템이 한 3개 정도로 정해져있다면 async let 3번 쓰고 말 수도 있겠지만 몇개가 올 줄 모른다면 아래처럼 해야 한다.

이렇게 만들면 생기는 단점이 썸네일 하나를 가져오고 나서야 for 루프가 다시 돌아간다. 만약 들어오는 id의 갯수가 100개라면 100개의 비동기 작업을 순차적으로 진행하느라 fetchThumbnails 함수는 꽤 오랜 시간 suspend되어있을 수 밖에 없다.

그래서 Swift는 Task Group을 제공한다. 위에서 보이는 것처럼 withThrowingTaskGroup 을 이용해 에러를 내뱉을 수 있는 가능성이 있는 명령을 Group으로 묶어 병렬적으로 굴러갈 수 있도록 할 수 있다.

group.async 를 통해 withThrowingTaskGroup 으로 만들어진 그룹에 비동기 작업을 추가하고, 멈춰있는 것이 아니라 for문을 계속 돌린다. 그렇게 되면 하나가 다 되길 기다리고, 또 다시 돌리고 기다릴 필요가 없어진다.

하지만 위 코드를 무턱대고 돌리려고 하면 컴파일러가 거부한다. 이유는 Data Race가 일어날 수 있기 때문이다.

그룹 안에 들어가있는 각 비동기 작업들은 thumbnails 라는 딕셔너리에 값을 적어넣으려고 접근하고 있다. 하지만 동시성을 따지는 상황에서 이런 상황이 벌어지면 서로 충돌이 날 가능성이 아주아주 높다. 원래는 크래시 나면 개발자가 이거 뭐지… 하고 울먹이며 어디서 문제 터지는지 찾아야 하는데 Swift는 컴파일러에서부터 예방해준다.

이때 Sendable 프로토콜을 이용하면 동시성을 고려해야 하는 상황에서 mutable 변수를 캡처하는 것을 금지시켜 이상한 Data Race 문제가 생기지 않도록 방지할 수 있다. 사실 우리가 오픈 API를 만들게 아니면 직접 정의할 일은 별로 없는 것 같긴 하다.

우리가 동시성을 따져야 하는 상황에서 Task 단위로 관리하고자 하는 작업, 명령들은 Sendable로 관리되게 설계되어있으며, 데이터를 안전하게 전달할 수 있도록 한다.

그래서 이런 기능도 함께 제공한다. group.async 가 return한 값이 group 내에 저장되어 차후에 group으로부터 꺼내올 수 있다. for 문에서 값을 꺼내와 thumbnails 에 저장하는 방식으로 위 문제를 해결할 수 있다.

애플 문서를 확인해보면 TaskGroup이 Generic으로 ChildTaskResult 타입을 따로 받고 있고, 그의 async 메서드는 Sendable 클로저를 받아와 ChildTaskResult를 반환한다. 반환된 값은 TaskGroup에 저장된다. 문서를 확인해보면 TaskGroup에는 Collections가 제공하는 메서드(filter, map같은 메서드)를 다수 지원하고 있음을 확인할 수 있다.

작업 Cancel

여러 Task 중 하나가 에러가 뜨거나 모종의 이유로 작업을 취소해야 하는 경우, 비동기 작업의 결과 값을 받아오기를 취소해야 하는 경우가 있다.

Async-let Task 상황부터 생각해보자. 기본적으로 Parent Task는 Child Task가 종료되어 반환될 때까지 대기하고 있어야 한다.

만약 fetchOne 실행 중에, 뭔가 네트워크 이슈로 data 쪽 Task가 에러를 던졌을 경우, 우선 data Task는 즉시 취소된다. 이 에러는 fetchOne으로 전달이 된다. fetchOne은 이 작업을 취소하기 위해 다른 Child Task도 확인한다. 살펴보니 metadata가 이미 작업이 완료되었다고 한다. 이럴 경우 따로 metadata를 취소시키지 않고 fetchOne 메서드를 취소, 종료한다.

하지만 만약 metadata가 아직 값을 받아오는 도중이었고, 이를 취소하지 않았다면 어떨까?

fetchOne이 data Task로부터 에러를 받고 metadata를 체크하지 않고 멋대로 본인만 취소하고 끝냈다면, metadata는 괜히 필요도 없는데 CPU 계산만 차지하고 그 결과 값은 갈 곳이 없을 것이다. 쓸데 없는 메모리 누수가 생기는 것이다.

그래서 Async-let Task에서는 이 tree를 따라서 취소가 전파된다. 상위 Task가 취소되면 하위 Task도 모두 취소가 된다고 생각할 수 있다. 이 예시에서는 data가 취소되어 fetchOne에 전달되면, fetchOne은 다른 Child Task도 체크한다. metadata에게도 취소되었음을 전달하고, metadata까지 취소가 완료되면 그제서야 fetchOne도 종료된다.

이번엔 Group Task에서의 취소를 생각해보자.

위 사진을 다시 가지고 왔는데, 코드보다는 밑의 그래프를 보자. 그림에는 일단 한 Task 하위만 그려져 있지만 Group에 속한 각 Task는 Child Async-let Task를 가지고 있을 것이다.

Group의 첫 번째 Task에 달려 있는 Child Task가 하나 잘못되었다고 쳤을 때, 일단 그 첫 번째 Task는 취소될 것이다. 같이 달려있는 Child Task도 다 취소가 될 것이다.

하지만 같은 Group에 있는 다른 Task는 사실 같은 Tree에 속한게 아니라서 그대로 대기 중에 있다. 문제가 생긴 Task야 바로 Group block 밖으로 튀어나오겠지만, 나머지 Group 내 Task들은 에러가 발생했다는 사실을 전달받을 수 없으므로 그대로 각자가 가지고 있는 Async-let 비동기 작업이 종료되길 기다리고 있다.

그래서 이럴 경우 TaskGroup의 cancelAll 메서드를 개발자가 직접 달아줘야 한다. Group Task의 경우 자동으로 관리해주는 Async-let Task과 달리 이런 점에서 개발자가 직접 신경써줘야 하는 점이 있다.

Unstructed Task

위에 있는 Structed Concurrency를 따르는 Task들은 다 Parent Task, Child Task, Group 등을 계층적으로 표현할 수 있었다. 하지만 우리가 언제나 그 계층 구조에 맞는 것만 필요한 것은 아닐 것이다. 어떤 작업이 지금 호출하는 함수의 범위에서 커버되지 않는 경우도 있을 것이다. Swift는 그런 경우도 처리할 수 있도록 Unstructed Task를 지원한다.

async 를 표시하지 않은 함수 안에서 await 를 작성하니 당연히 컴파일 에러가 나지만, @MainActor 등의 Actor를 명시, 상속해준 후 async {} 를 이용해 작업을 Main Thread에 비동기적으로 실행되도록 올릴 수 있다.

위를 보면 collectionView()async {} 내부 작업이 끝날 때까지 대기하는 것이 아니라, 스레드에 작업을 올려두기만 한다.

WWDC 영상에서는 예시로 Child Task를 실행했지만 특정 조건에선 Parent Task가 아닌 다른 메서드가 해당 Task를 취소할 수 있어야 하는 경우를 제시했다.

이 코드에선 MyDelegate 클래스에 thumbnailTasks 딕셔너리를 새롭게 만들어 async {} 작업을 저장하도록 한다. 타입을 잘 보면 딕셔너리 값의 타입이 Task.Handle임을 확인할 수 있다.

이렇게 딕셔너리에 저장해두면 차후 해당 작업을 다른 메서드에서도 취소할 수 있다. 여기서는 CollectionView의 인덱스를 저장했다고 볼 수 있다.

CollectionView에서 해당 아이템이 썸네일을 다 로드하기 전에 유저가 위로 올려버려 보이지 않는다면, 굳이 썸네일을 계속 다운로드할 필요가 없다. 그럴 경우를 다운로드 작업을 딕셔너리에서 찾아내 취소할 필요가 있다.

이렇게 다른 메서드에서도 async 로 올려진 Task에 접근할 수 있다. 이렇게 어디서든 비동기 작업을 올릴 수 있고, 접근할 수 있도록 만들어진 경우 structed가 아니라 Unstructed Task라고 한다.

이런 Unstructed Task를 사용할 때는 에러 핸들링이나 cancel 관련 처리도 직접 해줘야 하는 특징이 있다.

결론

처음엔 왜 이름이 Struct지, Unstruct지 싶었는데 계층 관계가 확실히 그려지는 경우와 아닌 경우, Top-down으로 읽어가면서 코드를 쉽게 파악할 수 있는지 여부에 따라 갈리는 것 같다.

Swift가 정말 잘 만든 언어지만 부족한 점이 조금씩 있었는데, 그런 점도 조금씩 채워가는걸 보면 앞으로도 기대된다.

참고한 것

swift-evolution/0304-structured-concurrency.md at main · apple/swift-evolution (github.com)

--

--

Heechan
HcleeDev

Junior iOS Developer / Front Web Developer, major in Computer Science