
💬 본 전사문은 ChatGPT 4o에 의해 번역되었습니다.
♪ 베이스 음악 재생 중 ♪
케이본 파르바르딘: 안녕하세요, 저는 케이본입니다. Swift 5.5에서 구조적 동시성(structured concurrency)이라는 개념을 사용하여 동시성 프로그래밍을 작성하는 새로운 방법을 도입했습니다. 구조적 동시성의 개념은 구조적 프로그래밍(structured programming)을 기반으로 하며, 이는 너무 직관적이어서 평소에는 거의 의식하지 않지만, 이를 잘 생각해 보면 구조적 동시성을 이해하는 데 도움이 될 것입니다. 그럼 시작해 보겠습니다.

컴퓨터가 처음 개발되고 사용되던 시기에는 프로그램이 명령어의 연속으로 작성되었기 때문에 읽기 어려웠습니다. 제어 흐름이 여기저기로 점프할 수 있었기 때문입니다. 하지만 오늘날에는 이런 방식을 거의 볼 수 없습니다. 프로그래밍 언어가 구조적 프로그래밍을 사용하여 제어 흐름을 더 일관되게 만들었기 때문입니다. 예를 들어, if-then 문은 구조적 제어 흐름을 사용합니다. 이 구문은 코드 블록이 위에서 아래로 실행되는 동안 특정 조건에서만 실행되도록 지정합니다. Swift도 마찬가지로 코드 블록이 정적 스코핑(static scoping)을 따르므로, 변수는 해당 코드 블록을 감싸는 범위 내에서 정의된 경우에만 접근할 수 있습니다. 또한, 이는 코드 블록 내에서 정의된 변수의 수명이 코드 블록을 벗어날 때 사라짐을 의미합니다. 즉, 정적 스코프를 가진 구조적 프로그래밍은 제어 흐름과 변수의 수명을 쉽게 이해할 수 있도록 해 줍니다.
좀 더 일반적으로 말하면, 구조적 제어 흐름은 자연스럽게 순차적으로 실행되거나 중첩될 수 있습니다. 이를 통해 프로그램 전체를 위에서 아래로 읽을 수 있게 됩니다. 이것이 구조적 프로그래밍의 기본 원칙입니다. 이 개념은 오늘날 우리에게 너무 직관적이어서 당연하게 여겨질 수 있습니다. 하지만 현대의 프로그램은 비동기(asynchronous)와 동시성(concurrent) 코드를 포함하고 있으며, 이러한 코드에서는 구조적 프로그래밍을 활용하여 작성하기가 어려웠습니다.

먼저, 구조적 프로그래밍이 비동기 코드를 어떻게 더 간단하게 만드는지 생각해 보겠습니다. 예를 들어, 인터넷에서 여러 이미지를 가져와 순차적으로 썸네일 크기로 조정해야 한다고 가정해 보겠습니다. 위 코드에서는 이러한 작업을 비동기적으로 수행하며, 이미지 식별자를 나타내는 문자열 배열을 인자로 받습니다. 이 함수가 호출될 때 값을 반환하지 않는다는 점에 주목할 수 있습니다. 이는 함수가 결과나 오류 발생 시 제공된 완료 핸들러로 전달하기 때문입니다. 이러한 패턴은 호출자가 나중에 응답을 받을 수 있도록 합니다. 하지만 이 패턴으로 인해 해당 함수는 오류 처리를 위해 구조적 제어 흐름을 사용할 수 없습니다. 왜냐하면 오류는 함수 내부로 던지는 것이 아니라, 함수 밖으로 던져야 하기 때문입니다. 또한, 이 패턴은 for 문을 사용하여 각 썸네일을 처리하는 것을 어렵게 만듭니다. 함수가 완료된 후 실행될 코드가 핸들러 내부에 중첩되어야 하기 때문에 반복문 대신 재귀를 사용해야 합니다.

이제 이전 코드를 구조적 프로그래밍을 기반으로 하는 새로운 async/await 문법을 사용하여 다시 작성해 보겠습니다. 먼저, 함수에서 완료 핸들러를 제거합니다. 대신, 함수 선언부에 async와 throws를 추가했습니다. 또한, 이제 함수가 값을 반환하도록 변경되었습니다. 함수 구현부에서 await 키워드를 사용하여 비동기 작업이 수행된다는 것을 나타냈으며, 그 이후 실행될 코드가 중첩될 필요가 없습니다. 따라서 이제 for 문을 사용하여 썸네일을 순차적으로 처리할 수 있습니다. 또한, 오류를 던지도록 할 수 있으며, 컴파일러가 오류 처리를 빠뜨리지 않았는지 확인해 줍니다. async/await에 대한 자세한 내용은 “Meet async/await in Swift” 세션을 참고하세요.
이 코드는 훌륭하지만, 수천 개의 이미지에 대한 썸네일을 생성해야 한다면 어떻게 될까요? 썸네일을 하나씩 처리하는 것은 더 이상 효율적이지 않습니다. 게다가, 각 썸네일의 크기를 다른 URL에서 다운로드해야 한다면 어떻게 될까요? 이제 여러 다운로드를 병렬로 실행할 수 있도록 동시성을 추가할 기회입니다. 프로그램에 동시성을 추가하려면 추가적인 작업(task)을 생성하면 됩니다.

작업은 Swift의 새로운 기능으로 async 함수와 함께 동작합니다. 작업은 비동기 코드를 실행할 수 있는 새로운 실행 컨텍스트를 제공합니다. 각 작업은 다른 실행 컨텍스트와 동시에 실행되며, 안전하고 효율적인 경우 자동으로 병렬 실행되도록 스케줄링됩니다. 또한, 작업은 Swift에 깊이 통합되어 있기 때문에 컴파일러가 일부 동시성 관련 버그를 방지하는 데 도움을 줄 수 있습니다. 한 가지 중요한 점은 async 함수를 호출하는 것만으로 새로운 작업이 생성되지 않는다는 것입니다. 작업을 생성하려면 명시적으로 생성해야 합니다.
Swift에는 여러 가지 형태의 작업이 있습니다. 이는 구조적 동시성이 유연성과 단순성 사이의 균형을 맞추는 것과 관련이 있기 때문입니다. 그래서 이번 세션의 남은 시간 동안 저와 조는 각각의 작업 유형을 소개하고 그 장단점에 대해 설명할 것입니다. 먼저, 가장 간단한 작업부터 시작해 보겠습니다. 이것은 async-let 바인딩이라는 새로운 문법을 사용하여 생성됩니다.

새로운 문법을 이해하는 데 도움이 되도록 먼저 일반적인 let 바인딩의 평가 과정을 분석해 보겠습니다. let 바인딩에는 두 가지 요소가 있습니다. 하나는 등호 오른쪽에 있는 초기화 표현식과 다른 하나는 등호 왼쪽에 있는 변수 이름입니다. 또한, let 구문 앞이나 뒤에 다른 문장이 올 수도 있으므로 이를 함께 고려하겠습니다. Swift가 let 바인딩에 도달하면 초기화 표현식이 평가되어 값을 생성합니다. 이 예제에서는 URL에서 데이터를 다운로드하는데 이는 시간이 걸릴 수 있습니다. 데이터 다운로드가 완료되면 Swift는 해당 값을 변수 이름에 바인딩한 후 다음 구문으로 진행합니다. 여기서 중요한 점은 실행 흐름이 단 하나뿐이라는 것입니다. 각 단계는 화살표로 추적할 수 있으며 순차적으로 실행됩니다.

다운로드에 시간이 걸릴 수 있기 때문에 데이터를 요청한 후, 프로그램이 다른 작업을 계속 수행하다가 실제로 데이터가 필요할 때 사용할 수 있도록 하고 싶습니다. 이를 위해 기존 let 바인딩 앞에 async 키워드를 추가하면 됩니다. 이렇게 하면 동시적 바인딩(concurrent binding)인 async-let이 생성됩니다. 그러나 동시적 바인딩의 평가 방식은 순차적 바인딩과 다릅니다. 이제 async-let이 어떻게 동작하는지 살펴보겠습니다.
바인딩을 만나기 직전부터 시작하겠습니다. 동시성 바인딩을 평가하기 위해 Swift는 먼저 기존의 작업에서 새로운 하위 작업을 만듭니다. 모든 작업은 프로그램의 실행 컨텍스트를 나타내므로 이 단계에서 두 개의 화살표가 동시에 나옵니다. 첫 번째 화살표는 하위 작업을 나타내며, 즉시 데이터 다운로드를 시작합니다. 두 번째 화살표는 상위 작업을 나타내며, 변수 result를 플레이스홀더 값에 바인딩합니다. 이 상위 작업은 이전 구문을 실행하던 곳과 동일한 작업입니다. 하위 작업이 데이터를 다운로드하는 동안에도 상위 작업은 동시성 바인딩 이후의 문장을 계속 실행합니다. 그러나 result의 실제 값이 필요한 표현식에 도달하면, 상위 작업은 하위 작업이 완료되어 result의 플레이스홀더 값을 채울 때까지 일시 중단됩니다.
이 예제에서 URLSession 호출은 오류를 발생시킬 수도 있습니다. 이는 result 값을 얻기 위해 잠시 기다릴 때 오류가 발생할 가능성이 있다는 뜻입니다. 따라서 이를 처리하기 위해 try를 작성해야 합니다. 그리고 걱정하지 마세요. result 값을 다시 읽어도 그 값이 다시 계산되지 않습니다.

이제 async-let이 어떻게 작동하는지 알았으니, 이를 사용하여 썸네일을 가져오는 코드에 동시성을 추가할 수 있습니다. 이전 코드에서 단일 이미지를 가져오는 부분을 별도의 함수로 분리하였습니다. 이 새로운 함수는 전체 크기의 이미지와 최적의 썸네일 크기가 포함된 메타데이터라는 두 개의 서로 다른 URL에서 데이터를 다운로드합니다. 순차적인 바인딩을 사용할 때는 let의 오른쪽에서 일시 중단이 되거나 오류가 발생할 수 있으므로 try await을 작성합니다. 하지만 두 개의 다운로드를 동시에 수행하려면 각 let 앞에 async를 추가해야 합니다. 이제 다운로드가 하위 작업에서 수행되므로 동시 바인딩의 오른쪽에서는 더 이상 try await을 작성하지 않습니다. 동시적으로 바인딩된 변수를 사용할 때, 즉, 상위 작업이 해당 변수의 값을 읽으려고 할 때, 그 값이 아직 준비되지 않았을 경우 작업이 끝날 때까지 기다려야 합니다. 따라서 메타데이터와 이미지 데이터를 읽는 표현식 앞에 try await을 작성합니다. 또한, 동시적으로 바인딩된 변수를 사용하는 데 있어서 별도의 메서드 호출이나 추가적인 변경이 필요하지 않다는 점을 유의하세요. 이러한 변수들은 순차 바인딩에서와 동일한 타입을 유지합니다.

지금까지 말씀드린 이러한 하위 작업들은 실제로 작업 트리(task tree)라는 계층 구조의 일부입니다. 이 트리는 단순한 구현 상의 세부 사항이 아니라 구조적 동시성의 중요한 요소입니다. 이 트리는 작업의 취소(cancellation), 우선순위(priority), 태스크-로컬 변수(task-local variables)와 같은 속성에 영향을 끼칩니다. 또한, 어느 한 비동기 함수에서 다른 비동기 함수를 호출할 때, 동일한 작업이 그 호출을 실행하는 데 사용됩니다. 즉, fetchOneThumbnail 함수는 해당 작업의 모든 속성을 상속받습니다. async let을 사용할 때와 같이 새로운 구조적 작업을 생성하면 해당 작업은 현재 함수가 실행 중인 작업의 하위 작업이 됩니다. 작업은 해당 함수의 범위 내에서 생명 주기가 결정되며, 특정 함수의 하위 작업이 되는 것이 아닙니다. 작업 트리는 상위 작업과 하위 작업 간의 연결로 구성됩니다. 이 연결은 상위 작업과 그에 속한 모든 하위 작업이 완료될 때까지 전체 작업이 끝날 수 없도록 강제합니다. 이는 하위 작업을 await하지 않는 비정상적인 제어 흐름이 발생하더라도 유지됩니다.

예를 들어, 이 코드에서는 먼저 메타데이터 작업이 끝날 때까지 기다린 후 이미지 데이터 작업을 수행합니다. 첫 번째로 대기한 작업이 오류를 발생시키며 종료되면, fetchOneThumbnail 함수는 즉시 해당 오류를 발생시키며 종료해야 합니다. 하지만 두 번째 다운로드를 수행하는 작업은 어떻게 될까요? 비정상적인 종료를 하더라도 Swift는 대기 중이지 않는 작업을 자동으로 취소된 상태로 표시한 다음, 함수가 종료되기 전에 해당 작업이 완료될 때까지 기다립니다. 작업을 취소된 상태로 표시한다고 해서 작업이 즉시 중단되는 것은 아닙니다. 이는 단순히 해당 작업의 결과가 더 이상 필요하지 않음을 알리는 것입니다. 실제로 특정 작업이 취소되면 해당 작업의 모든 하위 작업도 자동으로 취소됩니다.

따라서, URLSession의 구현이 자체적으로 구조화된 작업을 생성하여 이미지를 다운로드했다면 해당 작업들은 취소되었다고 표시됩니다. fetchOneThumbnail 함수는 직접적으로든 간접적으로든 생성한 모든 구조화된 작업이 완료되면 최종적으로 오류를 던지며 종료됩니다.
이는 구조화된 동시성의 근본적인 개념으로 작업의 생명 주기를 효과적으로 관리하여 작업이 의도치 않게 누출되는 것을 방지합니다. 이는 마치 ARC(자동 참조 카운트)가 메모리의 생명 주기를 자동으로 관리하는 것과 유사합니다. 지금까지 취소가 어떻게 전파되는지에 대한 개요를 설명했습니다. 하지만 작업이 최종적으로 언제 중지될까요?

작업이 중요한 트랜잭션을 수행 중이거나 네트워크 연결을 열어둔 상태라면 단순히 작업을 중단하는 것은 올바르지 않습니다. 그렇기 때문에 Swift에서는 작업 취소가 협력적인 방식으로 이루어집니다. 즉, 코드에서 명시적으로 취소 여부를 확인하고 적절한 방식으로 실행을 마무리해야 합니다. 현재 실행 중인 작업의 취소 상태는 비동기 함수뿐만 아니라 일반 함수에서도 확인할 수 있습니다. 이러한 특성은 특히 오래 실행되는 연산을 포함하는 API를 구현할 때 중요합니다. 사용자는 언제든지 취소 가능한 작업에서 해당 API를 호출할 수 있으며, 실행 중인 연산이 가능한 한 빨리 중지되기를 기대할 것입니다. 협력적 취소(cooperative cancellation)를 얼마나 간단하게 활용할 수 있는지 확인하기 위해서 다시 썸네일 가져오기 예제로 돌아가 보겠습니다.


여기에서 저는 모든 썸네일을 한번에 가져오는 대신 fetchOneThumbnail 함수를 호출하도록 수정했습니다. 만약 이 함수가 취소된 작업 내에서 호출된다면 불필요한 썸네일을 생성하느라 애플리케이션이 지연될 수 있습니다. 이를 막으려면 각 반복의 시작 부분에서 checkCancellation을 호출하면 됩니다. 이 호출은 현재 작업이 취소된 경우에만 오류를 발생시킵니다. 또한, 코드에서 현재 작업의 취소 상태를 Boolean 값으로 가져올 수도 있습니다.
이 버전의 함수에서는 요청된 썸네일 중 일부만 포함된 부분 결과를 딕셔너리 형태로 반환하고 있습니다. 이렇게 할 경우, API 문서에서 부분 결과가 반환될 수 있음을 명확히 명시해야 합니다. 그렇지 않으면 작업이 취소될 때, 사용자 코드가 전체 결과를 필요로 하는 경우 치명적인 오류가 발생할 수 있습니다.
지금까지 async-let이 구조적 프로그래밍의 핵심을 유지하면서도 프로그램에 가벼운 동시성을 추가하는 방법을 제공한다는 것을 살펴보았습니다. 이어서 소개할 다음 유형의 작업은 작업 그룹(group task)입니다. 작업 그룹은 async-let보다 높은 유연성을 제공하면서도 구조적 동시성의 장점을 유지할 수 있습니다. 앞서 살펴본 것처럼, async-let은 사용할 수 있는 동시 작업의 개수가 고정되어 있을 때 효과적으로 작동합니다.

앞서 논의한 두 가지 함수를 다시 살펴보겠습니다. for 문에서 각 썸네일 ID에 대해 fetchOneThumbnail을 호출하면 정확히 두 개의 하위 작업이 생성됩니다. 만약 이 함수의 구현부를 for 문 내부에 직접 작성하더라도 동시성의 총량은 변하지 않습니다. async-let은 변수 바인딩과 유사한 범위를 갖습니다. 즉, 두 개의 하위 작업이 완료되어야만 다음 반복이 시작됩니다. 하지만 for 문에서 모든 썸네일을 동시에 가져오는 작업을 시작하려면 어떻게 해야 할까요? 이 경우, 동시성의 총량은 정적으로 결정되지 않으며 배열 내 ID 개수에 따라 달라집니다. 이러한 상황에서 적절한 도구가 바로 작업 그룹(task group)입니다.

작업 그룹은 구조적 동시성의 한 형태로 동시 작업의 개수를 동적으로 조절할 수 있도록 설계되었습니다. 작업 그룹을 도입하려면 withThrowingTaskGroup 함수를 호출하면 됩니다. 이 함수는 오류를 발생시킬 수 있는 하위 작업을 생성할 수 있도록 범위가 지정된 group 객체를 제공합니다. 작업 그룹에 추가된 작업들은 해당 그룹의 범위를 벗어나서 실행될 수 없습니다. 따라서 for 문 전체를 이 그룹 안에 배치하면 group 객체를 활용하여 동적으로 원하는 개수의 작업을 생성할 수 있습니다.
그룹에서 하위 작업을 생성하려면 group 객체의 async 메서드를 호출하면 됩니다. 그룹에 추가된 하위 작업은 즉시 실행되며, 실행 순서는 정해져 있지 않습니다. 그룹의 범위를 벗어나면 그룹 내 모든 작업이 완료될 때까지 암묵적으로 대기하게 됩니다. 이것은 앞서 설명한 작업 트리 규칙의 결과이며, 작업 그룹도 구조적 동시성을 따르기 때문입니다. 이제 우리가 원했던 동시성을 달성했습니다. 즉, fetchOneThumbnail을 호출할 때마다 하나의 하위 작업이 생성되며, 이 함수 내부에서 async-let을 사용하여 두 개의 추가 작업이 생성됩니다. 이러한 구조적 동시성의 또 다른 장점은 트리 구조에서 자연스럽게 동시성 수준이 결합된다는 점입니다. 즉, 작업 그룹 내에서 async-let을 사용할 수도 있고, 반대로 async-let 작업 내에서 작업 그룹을 생성할 수도 있습니다.

지금 이 코드는 아직 실행할 준비가 완전히 되지 않았습니다. 만약 그대로 실행하려고 하면 컴파일러가 데이터 경합(data race)이 발생한다고 경고할 것입니다. 문제의 원인은 각 하위 작업이 단일 딕셔너리에 썸네일을 삽입하려고 한다는 점입니다. 이는 프로그램에서 동시성을 증가시킬 때 흔히 발생하는 실수로 의도치 않게 데이터 경합을 초래할 수 있습니다. 이 딕셔너리는 한 번에 하나의 접근만 허용해야 하지만, 두 개의 하위 작업이 동시에 썸네일을 삽입하려고 하면 충돌이 발생할 수 있습니다. 그 결과로 프로그램이 충돌하거나 데이터가 손상될 위험이 있습니다.

예전에는 이 버그를 직접 찾아야 했지만, 지금은 Swift가 정적으로 검사를 하며 이러한 버그가 발생하는 것을 원천적으로 방지합니다. 새로운 작업을 생성할 때마다 해당 작업은 @Sendable 클로저라는 새로운 클로저 유형 내에서 이루어집니다. @Sendable 클로저의 구현부는 렉시컬 컨텍스트(lexixal context)에서 변경 가능한 변수를 캡처하는 것이 제한됩니다. 이는 작업이 시작된 후 해당 변수들이 수정될 가능성이 있기 때문입니다. 따라서 작업 내에서 캡처하는 값은 안전하게 공유할 수 있어야 합니다. 예를 들어, Int 및 String과 같은 값 타입이거나, 액터(actor)나 자체 동기화를 구현한 클래스처럼 여러 스레드에서 접근해도 안전한 객체여야 합니다. 이 주제와 관련하여 “Protect mutable state with Swift actors”라는 세션이 마련되어 있으니, 꼭 확인해 보시길 바랍니다.

우리 예제에서 데이터 경합을 피하려면 각 하위 작업이 값을 반환하도록 하면 됩니다. 이 설계 방식에서는 결과를 처리하는 책임을 상위 작업에게만 부여합니다. 이 경우, 각 하위 작업이 ID와 썸네일 이미지를 포함하는 튜플을 반환하도록 지정했습니다. 그런 다음, 각 하위 작업 내에서 딕셔너리에 직접 값을 쓰는 대신 상위 작업이 처리할 키-값 튜플을 반환하도록 했습니다. 상위 작업은 새로운 for-await 문을 사용하여 각 하위 작업의 결과를 순회할 수 있습니다. for-await 문은 하위 작업이 완료된 순서대로 결과를 가져오며, 이 반복문은 순차적으로 실행되기 때문에 상위 작업이 안전하게 각 키-값 쌍을 딕셔너리에 추가할 수 있습니다. 이것은 for-await 반복문을 사용하여 비동기 시퀀스를 처리하는 한 가지 예시일 뿐입니다. 만약 여러분이 직접 정의한 타입이 AsyncSequence 프로토콜을 준수하게 한다면, for-await을 사용하여 해당 타입의 요소를 순회할 수도 있습니다. 더 자세한 내용은 “Meet AsyncSequence” 세션에서 확인할 수 있습니다.

작업 그룹은 구조화된 동시성의 한 형태이지만, 그룹 작업과 async-let 작업에서 작업 트리 규칙이 적용되는 방식에는 약간의 차이가 있습니다. 예를 들어, 그룹의 결과를 반복하는 동안 오류가 발생한 하위 작업을 만나게 되었다고 가정해 봅시다. 해당 오류가 그룹에서 던져지면 그룹 내의 모든 하위 작업이 암묵적으로 취소되고 대기하게 됩니다. 이는 async-let과 동일한 방식으로 동작합니다.
차이점은 그룹이 정상적으로 종료될 때 발생합니다. (async-let 바인딩과 달리) 그룹이 블록에서 빠져나갈 때 (await을 하지 않더라도) 취소가 암묵적으로 발생하지 않습니다. 이러한 동작 방식 덕분에 작업 그룹을 사용하여 포크-조인(fork-join) 패턴을 더 쉽게 표현할 수 있습니다. 즉, 작업들이 단순히 대기될 뿐 취소되지 않습니다. 또한, 그룹의 cancelAll 메서드를 사용하여 그룹을 빠져나가기 전에 모든 작업을 수동으로 취소할 수도 있습니다.
작업을 취소하는 방법과 상관없이 취소는 작업 트리 아래로 자동 전파된다는 점을 기억하세요. Swift에서 async-let과 작업 그룹은 구조화된 범위 내에서 제공하는 두 가지 유형의 작업입니다. 이제 조가 구조화되지 않은 작업(unstructured tasks)에 대해 설명해 줄 것입니다.
조 그로프: 고마워요, 케이본. 안녕하세요, 저는 조입니다. 케이본이 구조화된 동시성이 오류 전파, 취소 및 기타 부가 작업을 단순화하는 방법을 보여주었죠. 이는 명확한 계층 구조를 가진 작업에 동시성을 추가할 때 유용합니다. 하지만 프로그램에 작업을 추가할 때 항상 계층 구조가 있는 것은 아닙니다. Swift는 또한 구조화되지 않은 작업 API를 제공하는데, 이는 훨씬 더 많은 유연성을 제공하는 대신 수동으로 관리해야 할 요소가 많아지는 단점이 있습니다.

작업이 명확한 계층 구조에 속하지 않는 경우가 많이 있습니다. 가장 명확한 예로, 비동기 코드가 아닌 코드에서 비동기 작업을 실행하려고 할 때는 상위 작업이 전혀 없을 수도 있습니다. 또한, 작업의 생명 주기가 단일 범위나 단일 함수의 범위를 벗어날 수도 있습니다. 예를 들어, 어떤 메서드 호출을 통해 객체를 활성 상태로 만들면서 작업을 시작하고, 다른 메서드 호출을 통해 객체를 비활성 상태로 만들면서 해당 작업을 취소해야 할 수도 있습니다.

이러한 상황은 AppKit과 UIKit에서 델리게이트 객체를 구현할 때 자주 발생합니다. UI 관련 작업은 반드시 메인 스레드에서 실행되어야 하며, Swift의 액터 관련 세션에서 논의된 바와 같이, 메인 액터(main actor)로 선언된 UI 클래스들이 이를 보장합니다.

예를 들어, 컬렉션 뷰(Collection View)가 있다고 가정해 보겠습니다. 아직 컬렉션 뷰의 데이터 소스 API를 사용할 수 없고, 대신 방금 작성한 fetchThumbnails 함수를 사용하여 컬렉션 뷰의 항목이 표시될 때 네트워크에서 썸네일을 가져오려 합니다. 하지만, 델리게이트 메서드는 async가 아니므로 비동기 함수를 호출할 때 await을 직접 사용할 수 없습니다. 이를 위해 새로운 작업을 시작해야 하지만, 이 작업은 사실상 델리게이트 액션에서 시작된 작업의 연장선에 있습니다. 우리는 이 새로운 작업이 UI 우선순위를 가진 상태에서 계속 메인 액터에서 실행되기를 원합니다. 하지만, 이 작업의 생명 주기를 단순히 현재 델리게이트 메서드의 범위에만 제한하고 싶지는 않습니다. 이와 같은 상황을 처리할 수 있도록, Swift에서는 구조화되지 않은 작업을 만들 수 있도록 지원합니다.

비동기 코드 부분을 클로저로 이동시키고, 그 클로저를 전달하여 비동기 작업을 구성해 봅시다. 이제 런타임에서 어떤 일이 일어나는지 살펴보겠습니다. 작업을 생성하는 지점에 도달하면, Swift는 이를 원래의 범위와 동일한 액터(메인 액터)에서 실행하도록 예약합니다. 한편, 스레드의 제어권은 즉시 호출자에게 반환됩니다. 썸네일 작업은 메인 스레드에서 실행됩니다. 델리게이트 메서드는 메인 스레드를 블로킹하지 않으며, 실행할 수 있는 여유가 생길 때 처리됩니다.

이러한 방식으로 작업을 구성하면 구조적 코드와 비구조적 코드의 장점을 혼합한 방식으로 작동하게 됩니다. 직접 생성한 작업은 실행된 컨텍스트의 액터를 상속하며, 작업 그룹이나 async-let과 마찬가지로 상위 작업의 우선순위 및 기타 속성을 상속합니다. 하지만 새로운 작업은 특정 범위에 묶이지 않습니다. 즉, 실행된 범위 내에서 생명 주기가 제한되지 않으며, 상위 작업이 비동기일 필요도 없습니다. 따라서 우리는 어디서든 이러한 비구조적 작업을 생성할 수 있습니다. 이러한 유연성의 대가로, 구조적 동시성이 자동으로 처리하는 요소들을 수동으로 관리해야 합니다. 예를 들어, 취소와 오류는 자동으로 전파되지 않으며, 작업의 결과 또한 명시적으로 처리하지 않는 한 자동으로 기다리지 않습니다.

우리는 컬렉션 뷰의 항목이 표시될 때 썸네일을 가져오는 작업을 시작했으며, 썸네일이 준비되기 전에 항목이 화면 밖으로 스크롤이 되면 해당 작업을 취소해야 합니다. 범위가 지정되지 않은 작업이기 때문에 작업 취소는 자동으로 이루어지지 않습니다. 이제 작업 취소를 구현해 봅시다. 작업을 생성한 후, 반환된 값을 저장하도록 합시다. 작업을 생성할 때 이 값을 행(row) 인덱스를 키로 하는 딕셔너리에 저장하면 나중에 해당 작업을 취소하는 데 사용할 수 있습니다. 또한 작업이 완료되면 딕셔너리에서 해당 항목을 제거하여 이미 끝난 작업을 취소하려고 하지 않도록 해야 합니다.
여기서 주목할 점은 비동기 작업 안팎에서 동일한 딕셔너리에 접근해도 컴파일러가 데이터 경합 오류를 띄우지 않는다는 것입니다. 델리게이트 클래스는 메인 액터에 바인딩되어 있으며, 새로운 작업도 이를 상속받기 때문에 절대로 병렬로 실행되지 않습니다. 따라서, 데이터 경합에 대해 걱정할 필요없이 이 작업 안에서 메인 액터에 바인딩된 클래스의 저장 프로퍼티에 안전하게 접근할 수 있습니다.

한편, 나중에 동일한 테이블 행이 화면에서 제거되었다는 알림을 델리게이트가 받으면, 해당 값의 cancel 메서드를 호출하여 작업을 취소할 수 있습니다.

이제 특정 범위에 종속되지 않고 독립적으로 실행되는 구조화되지 않은 작업을 생성하는 방법을 살펴보았습니다. 이러한 작업은 원래 실행된 컨텍스트의 특성을 상속할 수도 있습니다. 하지만 때로는 원래 컨텍스트로부터 아무것도 상속받지 않기를 원할 수도 있습니다. 최대한의 유연성을 제공하기 위해 Swift는 Detached Task를 제공합니다. 이름에서 알 수 있듯이, Detached Task는 실행 컨텍스트와 독립적입니다. 이들도 구조화되지 않은 작업이므로 생성된 범위에 의해 수명이 제한되지 않습니다. 하지만, Detached Task는 원래 실행된 컨텍스트로부터 아무것도 가져오지 않는다는 점이 다릅니다. 기본적으로 Detached Task는 동일한 액터에 속하지 않으며, 실행된 곳의 우선순위도 그대로 따를 필요가 없습니다. 이들은 독립적으로 실행되며, 우선순위와 같은 속성에는 일반적인 기본값이 적용됩니다. 그러나 선택적 매개변수를 사용하여 새로운 작업이 실행되는 방식과 위치를 제어할 수도 있습니다.

서버에서 썸네일을 가져온 후, 나중에 다시 가져올 때 네트워크 요청을 피할 수 있도록 로컬 디스크 캐시에 저장하고 싶다고 가정해 봅시다. 이 캐싱 작업은 메인 액터에서 실행될 필요가 없으며, 모든 썸네일 가져오기를 취소하더라도 이미 가져온 썸네일을 캐싱하는 것은 여전히 유용합니다. 따라서, Detached Task를 사용하여 캐싱을 수행하도록 설정해 봅시다. 작업을 분리하면, 새로운 작업이 실행되는 방식을 더욱 유연하게 조정할 수 있습니다. 캐싱 작업은 메인 UI와 충돌하지 않도록 백그라운드 우선순위에서 실행되어야 합니다.

조금 더 먼 미래를 계획해보겠습니다. 썸네일에서 여러 개의 백그라운드 작업을 수행해야 할 경우, Detached Task_ 내부에서 구조화된 동시성을 활용할 수도 있습니다. 각 작업의 강점을 최대한 활용하기 위해 다양한 종류의 작업을 결합할 수 있습니다. 개별 백그라운드 작업마다 독립적인 작업으로 분리하는 대신, 작업 그룹을 설정하고 각 백그라운드 작업을 해당 그룹의 하위 작업으로 생성할 수 있습니다. 이렇게 하면 여러 가지 이점이 있습니다.

향후 백그라운드 작업을 취소해야 하는 경우, 작업 그룹을 사용하면 최상위 Detached Task를 취소하는 것만으로 모든 하위 작업을 한 번에 취소할 수 있습니다. 이 취소는 자동으로 하위 작업에 전파되므로, Task를 배열로 따로 관리할 필요가 없습니다. 또한, 하위 작업은 상위 작업의 우선순위를 자동으로 상속받습니다. 이 모든 작업을 백그라운드에서 수행하려면 Detached Task를 백그라운드에서 실행하게 하는 것으로 충분하며, 해당 속성이 모든 하위 작업에 자동으로 상속되므로 백그라운드 우선순위를 설정하는 것을 잊어 UI 작업이 방해받는 상황을 걱정할 필요가 없습니다.

우리는 Swift에서 사용할 수 있는 주요 작업 형태를 모두 살펴보았습니다. async-let을 사용하면 고정된 개수의 하위 작업을 변수 바인딩으로 생성할 수 있으며, 해당 바인딩이 정해진 범위를 벗어나면 자동으로 취소 및 오류가 전파됩니다. 동적으로 생성되는 하위 작업이 필요하지만 여전히 특정 범위 내에서 관리되어야 한다면, 작업 그룹을 사용할 수 있습니다. 특정 범위에 명확히 속하지 않지만 상위 작업과 관련된 작업을 수행해야 할 경우, 구조화되지 않은 작업을 만들 수 있지만, 이를 수동으로 관리해야 합니다. 그리고 최대한의 유연성이 필요한 경우, 상위 작업으로부터 아무것도 상속받지 않는 Detached Task를 사용할 수도 있습니다.
구조적 동시성은 Swift가 지원하는 동시성 기능의 일부일 뿐입니다. 이러한 기능이 언어 전체에서 어떻게 맞물리는지 확인하려면 다른 세션들도 꼭 살펴보시기 바랍니다. “Meet async/await in Swift”에서는 비동기 함수에 대한 자세한 내용을 다루며, 이는 동시 코드를 작성하기 위한 구조적 기반을 제공합니다. 또한, 액터는 데이터 격리를 통해 데이터 경합으로부터 안전한 동시 시스템을 만들 수 있도록 합니다. 이에 대한 자세한 내용은 “Protect mutable state with Swift actors” 세션에서 확인할 수 있습니다. 우리는 작업 그룹에서 for-await 문을 사용하는 것을 보았으며, 이는 AsyncSequence의 한 예에 불과합니다. AsyncSequence는 비동기 데이터 스트림을 다룰 수 있는 표준 인터페이스를 제공합니다. 이에 대한 자세한 API는 “Meet AsyncSequence” 세션에서 더 깊이 다룹니다. 작업은 핵심 OS와 통합되어 낮은 오버헤드와 높은 확장성을 달성하며, “Swift concurrency: Behind the scenes” 세션에서는 이것이 어떻게 구현되는지에 대한 기술적인 세부 사항을 확인할 수 있습니다.
이 모든 기능이 결합되어 Swift에서 동시성을 활용한 코드를 쉽고 안전하게 작성할 수 있도록 도와줍니다. 이를 통해 동시 작업을 관리하는 메커니즘이나 멀티스레딩으로 인한 잠재적 버그에 대한 걱정을 줄이면서도, 기기의 성능을 최대한 활용하고 앱의 핵심적인 부분에 집중할 수 있습니다. 시청해 주셔서 감사합니다. 남은 컨퍼런스도 즐기시길 바랍니다.