[Swift Concurrency] Structured Concurrency

이정훈·2025년 4월 27일
0

Swift Concurrency

목록 보기
4/5
post-thumbnail

이번 포스트는 Swift Concurrency의 디자인 철학 중 하나인 Structured Concurrency에 대한 내용을 알아보려고 한다.

Swift Concurrency의 등장으로 기존에 사용하던 Completion Handlerasync/await문법으로 대체하면서 동시성 코드를 마치 Structured Programming 방식처럼 작성할 수 있게 되었다. 이러한 새로운 동시성 프로그래밍 방식을 Structured Concurrency라고 한다.

Structured Programming

여기서 잠시 Structured Programming(구조적 프로그래밍)에 대해 짚고 넘어가자면, 가령 C언어를 사용하다보면 goto라는 키워드를 본 적이 있을 것이다.goto는 코드의 다른 부분으로 실행의 흐름을 바꿀 때 사용하는 명령어로 개발자가 코드를 위에서 아래로 읽으면서 goto를 만나면 다른 위치로 이동 하기 때문에 코드의 흐름이 아예 다르게 흘러가 가독성이 떨어지고 일명 스파게티 코드를 만들어 내어 코드를 이해하기 어렵게 하는 원인이 된다.

현대의 고수준 프로그래밍 언어들은 이러한 goto문을 없애거나 최소화하고, 코드가 위에서 아래로 흐르는 순차적 구조를 기반으로 for(반복), if(분기), 함수 등을 활용하여 시작과 끝이 명확한 순차적이고 구조적인 프로그래밍 방식을 지향하고 있다.

위의 사진과 같이 조건문이 되었든, 반복문이 되었든, 함수가 되었든 코드가 위에서 아래로 순차적으로 흐르는 구조만 지켜진다면 코드를 이해하기 훨씬 수월해진다. 개발자는 해당 코드 블록 내부에 대해 정확히 알 필요 없이, 코드 블록을 실행 후 다시 원래 흐름으로 돌아와 아래로 이어지기 때문이다. 한마디로 정리하면, 구조적인 코드 블록을 통해 흐름을 추상화할 수 있다는 것.

하지만 동시성 코드를 작성하기 위해서는 순차적인 구조를 해칠 수 밖에 없었으며, 갈수록 복잡해지는 프로그램에서 이러한 동시성 코드는 수많은 중첩된 형태의 Completion Handler 일명 콜백 지옥을 가져왔고, 또한 에러 처리의 흐름이 함수의 리턴과 연결되지 않으면서 프로그램의 실행 흐름이 흩어지는 등의 문제가 발생한다. 이러한 문제는 Swift에서 Completion Handler를 사용하면서 동일하게 발생한다.

Structured Concurrency

그렇다면 동시성 코드에서는 구조적 프로그래밍처럼 순차적인 흐름으로 작성할 수 없는가?

Swift에서는 Swift Concurrency의 등장으로 동시성 코드를 구조적 프로그래밍처럼 위에서 아래로 순차적으로 작성할 수 있게 되었으며, 이를 Structured Concurrency라고 부른다.

func fetchData(for id: String) async throws -> ([User], [Photo]) {
    ...
    let (userData, _) = try await URLSession.shared.data(from: userDataURL)
    let (photoData, _) = try await URLSession.shared.data(from: photoDataURL)
    let user: User = try JSONDecoder().decode(User.self, from: userData)
    let photo: Photo = try JSONDecoder().decode(Photo.self, from: photoData)
    
    return (users, photos)
}

위의 함수는 서버 API를 통해 데이터를 가져오는 간단한 비동기 코드로 Swift Concurrencyasync/await 키워드를 적용하여 동시성 코드를 마치 동기 코드처럼 위에서 아래로 순차적으로 읽을 수 있게 되었다. 이는 비동기를 활용한 동시성 코드에서 구조적 프로그래밍이 현실화 된 것이다.

하지만 위의 코드에서도 한 가지 비효율적인 코드가 존재하는데, photoData를 위한 서버 API 요청을 위해서는 userData를 위한 서버 API 요청이 끝나기를 기다려야 한다는 것이다. 자세한 이유는 아래와 같다.

Swiftawait 키워드를 만나면 Susponsion Point으로 스레드 제어권을 반납하고 서버에서 응답이 온 후 스레드 제어권을 받고 다음 라인의 코드를 실행한다. 해당 API 응답을 이용하여 다음 API를 연쇄적으로 호출한다면 이번 API의 응답을 기다려야하겠지만, 그렇지 않은 경우, 즉 두 API가 별개로 동작하는 경우라면 이러한 작업을 동시에 병렬로 동작할 수 없을까?

async-let 바인딩

이런 상황에서 async-let 바인딩을 통해 두 API를 동시에 호출할 수 있다.

기존 비동기 함수의 await 키워드를 삭제하고, let 앞에 async 키워드를 사용하여 async-let 바인딩을 통해 선행되는 API 호출 작업을 기다리지 않고 바로 다음 코드를 동시에 실행할 수 있다. 그리고 해당 값을 사용하기 위해서는 사용하는 쪽에서 await 키워드를 사용하여 place holder를 충족할 때까지 기다린다.

async-let 바인딩을 통해 동시에 여러 비동기 작업을 실행하면서 구조적인 흐름을 유지하는 것이 가능해졌다.

async-let 바인딩 내부 동작 과정

코드 실행 중 async-let 키워드를 만나면 Swiftparent task에 종속된 child task를 생성한다. 이때 child task는 두 가지 작업을 수행한다. 첫째, 변수에 Place Holder를 즉시 할당하고, 둘째, 비동기적으로 데이터 다운로드를 시작한다. 그럼 child task가 데이터를 다운로드하는 동안 parent task는 이후에 따르는 코드를 계속해서 진행할 수 있다. 이후 parent taskawait 키워드를 마주하게 되면 Place Holder의 값이 충족될 때까지 대기한다.

Task Tree

async-let 바인딩의 동작 과정에 따르면 위의 코드는 아래와 같이 구조화된 형태의 Task Tree를 구성하게 된다.

Task Tree는 각 parent task와 child task 간의 링크된 계층 형태로 구성된다. 각 링크는 child task들이 정상적으로 완료되어야만 parent task가 완전히 종료될 수 있다는 규칙을 적용한다. 이 규칙은 오류 발생과 같은 비정상적인 흐름에도 동일하게 적용된다. 다시 말해, child task 작업 진행 중 하나라도 오류를 발생시키면 fetchData() 함수는 오류를 발생시키면서 함께 종료된다.

이와 같이 구조화된 형태의 Task는 해당 Task가 에러에 의해 취소되면 하위 Task 또한 자동으로 취소됨을 보장한다.

Group Task

만약 수십 또는 수백 개의 데이터를 병렬로 가져오려면 async-let 바인딩 만으로는 한계가 존재한다.

이런 상황에서 Group Task를 사용하면 좀 더 효율적인 코드를 작성할 수 있다. Group Task 또한 Structured Concurrency의 한 형태로, 동적인 개수의 비동기 작업을 병렬로 처리하는 데 적합하다.

가령, 위와 같이 N개의 id 값을 가지고 있는 ids 배열로 부터 각각의 id에 해당하는 유저 정보를 가져오는 상황을 살펴보자. 각각의 id는 for-loop를 순회하면서 Group에 Group Task를 생성한다. 생성된 Group Task는 생성 즉시 실행 된다.

앞서 언급했듯이 Group Task 역시 Structured Concurrency의 한 형태로 생성 된 Group Task 또한 Task Tree를 구성하게 된다. 이에 따라 생성된 Group Task가 모두 종료될 때까지 자동으로 대기한다. 또한 마찬가지로 하나의 Group Task가 실행 중 에러를 발생하면 나머지 Group Task들도 모두 취소된다.

만약 group.async 내부에서 users의 배열을 직접 수정하면 Data Race가 발생할 수 있다. Group Task는 비동기로 실행되며 await 이전과 이후의 스레드가 동일하다는 보장이 없기 때문이다. 따라서 작업이 완료된 Group Task의 결과는 AsyncSequence로 제공되는 for-try-await-loop를 사용하여 완료된 순서대로 데이터를 전달 받는다.

profile
새롭게 알게된 것을 기록하는 공간

0개의 댓글