Swift Concurrency에 대해서

안녕하세요. iOS Platform Dev 팀의 김윤재입니다. 1편에서 설명드렸던 것과 같이, 온보딩 스터디에서 공부한 내용 중 저에게는 동시성 프로그래밍(concurrency)에 관한 내용이 가장 도움이 되었습니다.

앱의 성능 향상을 위해서는 알맞은 동시성 코드를 작성해야 합니다. 온보딩 스터디에서는 기존에 Swift에서 동시성 코드를 작성할 때 주로 사용하는 GCD(Grand Central Dispatch)를 어떻게 사용하는지, 내부 구현은 어떻게 되어있는지 자세히 공부해 볼 수 있었습니다. 또한 온보딩 스터디와 별개로 LINE 내 iOS 개발자들끼리 진행한 WWDC 2021 스터디에서는 새로 소개된 Swift Concurrency에 대해서 발표를 했는데요. 이 두 스터디를 통해 기존의 GCD와 새로 도입될 Swift Concurrency에 대해 비교하면서 공부해 볼 수 있었고, 둘의 차이점을 알 수 있었습니다. 이번 포스팅에서는 제가 스터디를 통해 알게 된 GCD와 Swift Concurrency를 비교해 보고자 합니다.

현재 제가 속해 있는 iOS Platform Dev 팀에서 담당하고 있는 업무 중 하나는 네트워크 모듈을 리팩토링하는 것입니다. 네트워크 모듈을 원활하게 개발하기 위해서는 네트워킹 API를 테스트해 볼 샘플 앱이 필요합니다. 제가 팀원으로서 처음 맡게 될 업무는 이 샘플 앱의 UI 및 기능을 개선하는 것인데요. 여러 요청이 동시에 올 수 있고 그러한 요청들을 동시에 수행해야 하기 때문에 동시성 프로그래밍이 필요합니다. 따라서 이번에 학습한 GCD와 Swift Concurrency를 테스트해 볼 좋은 기회가 되었습니다. 저희 팀에서 현재 개발하고 있는 네트워크 모듈은 이미지와 비디오 다운로드 및 업로드가 주요한 기능입니다. 이번 포스팅을 위해서는 많은 수의 이미지를 동시(concurrent)에 다운로드하는 예시로 테스트를 진행했습니다. 

 

동시성 프로그래밍

동시성 프로그래밍은 많은 수의 코어를 효율적으로 활용하는 데 도움을 줍니다. 해당 시점에서 사용 가능한 코어를 하나 이상 사용해서 처리 속도를 높이거나, 중요하지 않은 작업을 중요도가 낮은 스레드에서 실행시키는 이점을 얻습니다. iPhone 12의 경우 6개의 코어를 가지고 있는데요. 이와 같이 코어 수가 늘어남에 따라 동시성 프로그래밍을 잘 이해하고 그 이점을 활용하는 것이 점점 더 중요해지고 있습니다.

그럼 먼저 이 글에서 비교하고자 하는 GCD와 Swift Concurrency에 대해 간략하게 소개하고 본론으로 넘어가겠습니다.

 

GCD

현재 iOS 개발에서 주로 사용되고 있는 동시성 프로그래밍 API입니다. Swift가 나오기 전인 Objective-C부터 존재하던 개념으로, 동시성 프로그래밍에 대한 높은 수준의 추상화를 제공합니다. Serial DispatchQueue는 한 번에 하나의 태스크를 순차적으로 실행하고, concurrent DispatchQueue는 많은 작업을 동시에 실행합니다. 두 경우 모두 작업이 실행되는 순서는 FIFO입니다.

GCD를 사용하면 async로 작업을 수행하고 나서 보통 탈출 클로저를 이용한 completion handler를 통해 해당 작업이 끝났을 때의 처리를 해주게 됩니다. 

 

 Swift Concurrency

WWDC 2021에서 새로 소개된 동시성 프로그래밍 API입니다. Swift Concurrency는 동시성 프로그래밍을 가독성이 좋은 깔끔한 코드로 작성하고자 도입된 개념입니다. async와 await 키워드를 이용해 비동기 태스크 종료 후 코드를 작성할 수 있습니다. await 키워드로 인해 중지되면 이후에 사용해야 하는 데이터를 힙(heap) 영역에 저장해 두고, 이후에 다시 힙 영역에서 해당 데이터를 가져와 사용합니다. Swift Concurrency에 대한 자세한 내용은 Swift documentation에서 확인할 수 있습니다.

 

GCD와 Swift Concurrency 비교

GCD와 Swift Concurrency를 문법과 성능 측면에서 비교해 보겠습니다.

 

문법 

문법 측면에서는 가독성과 에러 핸들링 안정성, 동기화 처리에 대해 살펴보겠습니다. 

 

 가독성

기존의 탈출 클로저와 completion handler를 이용한 코드 작성 방식은 가독성을 저하시킬 수 있습니다. 이는 SE-0296에서 제기된 Problem 1: Pyramid of doom에 해당합니다. 기존에는 동시성 프로그래밍을 할 때 completion handler를 많이 사용했고, 그로 인해 너무 많은 콜백이 발생하면서 점점 들여쓰기가 많아져 가독성이 저하됐습니다. 아래는 completion handler 때문에 콜백이 너무 많아진 예시입니다. 

반면 같은 코드를 Swift Concurrency로 작성하면 아래와 같이 작성할 수 있습니다. 들여쓰기가 대폭 줄어들면서 가독성이 좋은 코드가 되었습니다.

 

에러 핸들링 안정성

다음으로 살펴볼 차이점은 에러 핸들링 방식입니다. 아래 코드는 제가 네트워킹 샘플 앱에서 URLSession을 이용해 이미지 하나를 다운로드하는 기능을 구현한 함수입니다. Swift Concurrency가 아닌 기존의 방식으로 작성한 경우입니다. 

기존 방식의 코드에서는 completion handler에 데이터와 에러 정보를 같이 보내주는 방법을 많이 선택합니다. 위 코드에서 붉은색으로 표시된 completion handler의 경우 에러 처리를 위해 필요한 부분이지만, 작성하지 않아도 컴파일 에러가 발생하지는 않습니다. 따라서 코드를 작성할 때 에러 핸들링을 빼먹지 않았는지 주의할 필요가 있습니다. 이는 코드를 작성하는 과정에서 꼼꼼하게 확인한다면 예방할 수 있는 문제점이긴 하지만, 수동으로 일일이 확인해야 한다는 단점이 있습니다. 또한, 발생할 수 있는 에러 혹은 올바른 결과마다 핸들러를 작성해야 하는 번거로움이 있습니다. 

위 함수를 Swift Concurrency로 재작성하면 아래와 같이 작성할 수 있습니다. 

Swift Concurrency로 작성하면 에러 핸들링은 throw로, 데이터 전달은 return으로 분리할 수 있습니다. 이런 식으로 작성하면 guard letelse 구문에서 completion handler를 누락하는 실수를 방지할 수 있습니다. 함수에서 에러를 throw하거나 이미지를 return해야 하기 때문입니다. 또한 completion handler를 사용하지 않아 콜백이 없어 가독성이 좋아집니다.

 

 동기화 처리

동시성 프로그래밍을 할 때 중요하게 생각해야 하는 점 중 하나는 동기화(synchronization) 문제입니다. 동시에 실행되는 여러 코드에서 하나의 변수에 접근할 수 있기 때문에 이를 올바르게 처리해야 합니다.

GCD에서 동기화를 안전하게 처리하는 방법은 DispatchQueue.sync를 통해 순서대로 접근하는 것을 보장하거나 뮤텍스(mutex)나 세마포어(semaphore)를 이용하는 방법 등이 있습니다. 하지만 동기화를 올바르게 처리했는지를 컴파일러가 확인해 주지는 않기 때문에 동기화 관련 버그가 발생할 수 있고, 이러한 버그들은 개발자가 코드를 작성할 때 유의해서 작성하거나 테스팅 및 디버깅을 통해 확인해야 합니다. 

Swift Concurrency에서는 이를 컴파일 단계에서 확인해 동기화를 제대로 처리하지 않은 코드가 있다면 컴파일 에러를 발생시킵니다. 개발자의 실수를 미연에 방지해 주는 안전장치 역할을 하는 것이죠. 이에 대한 예시 또한 네트워킹 샘플 앱에서 다운로드 관련 함수를 작성하던 중 발견했는데요. 기존 방법에 따라 코드를 작성하다 잘못하면 아래와 같은 실수를 할 수 있습니다.

위 코드는 ViewController 내부 함수로 이미지 다운로드가 완료될 때마다 self의 int형 변수를 1씩 늘려주는데요. 이때 데이터 레이스(data race) 문제가 발생할 수 있습니다. downloadImageWithURL 함수의 completion handler에서 변할 수 있는 상태에 대한 독립적인 접근을 보장하지 못하기 때문입니다. 하지만 컴파일러는 이에 대한 에러를 발생시키지 않습니다. 따라서 위와 같이 코드를 잘못 작성하면 버그가 발생할 수 있습니다. 이런 문제를 방지하기 위해서는 DispatchQueue.sync를 이용해 코드를 작성하거나 뮤텍스나 세마포어를 이용해 독립적인 접근을 보장해 주어야 합니다.

반면 Swift Concurrency를 사용하면 이러한 데이터 레이스를 미연에 방지할 수 있습니다. 같은 역할을 하는 코드를 Swift Concurrency를 이용해 작성하면 아래와 같습니다.

기존 코드의 경우 컴파일 에러가 발생하지 않았지만, Swift Concurrency를 사용하면 비 독립적(non-isolated) 구문이 변할 수 있는 프로퍼티에 접근하는 것을 금지한다는 메시지와 함께 컴파일 에러가 발생합니다. 이를 통해 개발자의 실수로 데이터 레이스 문제가 발생하는 것을 방지할 수 있습니다.

정리해 보면 Swift Concurrency는 코드의 가독성을 높이고 개발자의 실수를 방지하는 안전장치 역할을 합니다. WWDC 2021에서는 문법 이외에도 성능 관점에서의 장점을 소개하고 있는데요. 성능은 얼마나 차이가 있을지 살펴보겠습니다.

 

성능

성능 측면에서는 테스트를 통해 스레드 생성량과 콘텍스트 스위칭 수를 비교해 보고, Swift Concurrency에서 우선순위 역전을 어떻게 방지하고 있는지 살펴보겠습니다. 

 

스레드

GCD를 이용해서 코드를 작성할 때 조심해야 할 점 중 하나는 thread explosion입니다. thread explosion이 발생하면 컨텍스트 스위칭(context switching)이 많아지고 성능이 저하될 수 있습니다. 또한, 블록된 스레드가 어떤 자원을 잠그고(lock) 있을 때 데드락을 발생시킬 수도 있습니다.

즉, thread explosion이 발생하면 너무 많은 스레드 블록에서 유발되는 메모리 오버헤드, 너무 많은 컨텍스트 스위칭에서 유발되는 스케줄링 오버헤드가 발생할 수 있습니다. 이에 따라 thread explosion을 막기 위해 하나의 서브시스템에 하나의 Dispatch Queue 혹은 Dispatch Queue 위계(hierarchy)를 할당하는 것이 권장되고 있습니다. 이는 서로 연관된 작업이 하나의 스레드에서 실행될 수 있게 하는 의도입니다. 이렇게 현재 개발에서 사용되고 있는 GCD의 경우 thread explosion을 예방하는 안전한 코드 작성이 필요합니다.

반면 Swift Concurrency에서는 보다 편하게 스레드를 관리할 수 있습니다. Swift Concurrency에서 await으로 중단됐을 때, CPU가 컨텍스트 스위칭을 해서 다른 스레드를 불러오는 것이 아니라 같은 스레드에서 다음 함수를 실행시킵니다. 즉, 하나의 코어가 하나의 스레드를 실행하도록 유지하는 것을 보장합니다. 기존에 스레드의 컨텍스트 스위칭으로 진행되던 것이 같은 스레드 내의 함수 호출로 대체되는 것입니다. 

이에 대한 테스트 역시 진행했습니다. URL을 이용해 이미지를 다운받아 해당 이미지로 화면을 업데이트하는 작업은 앱에서 쉽게 찾을 수 있는 예시인데요. 이와 같은 예시에 Swift Concurrency를 적용해 보고자 앞서 코드 예시로 들었던 많은 수(200개)의 이미지를 동시에 다운로드해서 다운로드가 끝날 때마다 UIImageView의 이미지를 업데이트하는 코드로 실험을 진행했습니다. 실행 환경은 iOS 15.0의 테스트용 iPhone입니다.

프로파일을 통해서 중점적으로 보고자 한 것은 스레드 생성량과 이에 따른 컨텍스트 스위칭의 차이입니다.

위 사진은 GCD 코드로 실행했을 때의 프로파일 결과입니다.

위 사진은 Swift Concurrency를 사용했을 때의 프로파일 결과입니다. 결과를 수치로 비교하면 아래와 같습니다.

GCDSwift Concurrency
활성화되는 스레드 수1510
컨텍스트 스위칭60225889

Swift Concurrency를 사용하면 일반적으로 스레드 생성량 감소와 이에 따른 컨텍스트 스위칭 감소를 기대합니다. 하지만 결과를 살펴보면 활성화되는 스레드 수는 비슷했고, 컨텍스트 스위칭은 Swift Concurrency가 조금 적었지만 큰 차이는 없었습니다. 이런 결과가 나타난 이유를 살펴보겠습니다. 

우선 UI 업데이트는 메인 스레드에서 일어나기 때문에, UI 업데이트를 빈번하게 하면 컨텍스트 스위칭이 많아지며, 따라서 Swift Concurrency를 이용한 코드에서도 컨텍스트 스위칭이 많이 발생했을 것입니다. 

또한 이 코드에서는 concurrent DispatchQueue를 사용하는 것이 아니라 URLSessionConfiguration의 httpMaximumConnectionsPerHost를 늘려서 동시에 URL 다운로드를 진행했습니다. 따라서 URLSession에서 스레드를 어떻게 관리하는지를 이해해야 비교가 가능한데 이를 확인하는 것이 어렵습니다. 

추가로, URLSession에서의 다운로드 관련 스레드와 다운로드 이후 작업 관련 스레드를 구분하는 것이 어려웠습니다. 따라서 URLSession과 같이 자체적으로 스레드 관리를 해주는 부분을 제외하고 사용자가 정의한 동시성 코드만 확인하는 과정이 필요했습니다. 

위와 같은 문제를 해결하기 위해 간단한 예시를 추가로 만들어서 실제로 Swift Concurrency를 사용자 정의 함수를 만들어 실제로 사용할 때 어떤 차이점이 있을지 확인해 봤습니다. 같은 기능을 하는 함수를 GCD와 Swift Concurrency, 각각 구현해서 둘의 차이점을 확인했습니다. 

먼저 아래는 GCD 코드입니다. 

하나의 DispatchWorkItem은 100만 개의 역순으로 만들어진 int형 배열을 오름차순으로 정렬합니다. 이 작업을 동시에 여러 개 진행하는데요. 테스트는 50개(코드에서 numberOfConcurrency)의 DispatchWorkItem을 Concurrent DispatchQueue에서 동시에 수행되게 만들었습니다. 앞서 말씀드린 테스트 조건(iOS 15.0 테스트용 iPhone)에서 테스트하면 아래와 같은 결과가 나옵니다. 

많은 수의 스레드가 생성되면서 컨텍스트 스위칭이 많이 발생했습니다. 앞선 예시의 GCD 코드보다 더 많은 스레드가 생성된 이유는 Concurrent DispatchQueue에서는 하나의 DispatchWorkItem을 수행할 때 주로 스레드를 생성해서 수행하기 때문입니다. 따라서 실행해야 하는 DispatchWorkItem이 많아질수록 스레드 생성량 또한 많아집니다.

이를 Swift concurrency로 바꿔서 작성하면 아래와 같이 작성할 수 있습니다. 

주의할 점은, 미리 API에 구현된 async 함수를 사용하는 것이 아닌 새로운 async 함수를 구현할 때에는 중단 지점을 지정해 줘야 한다는 것입니다. 이는 위 코드에서와 같이 Task.yield()를 통해서 스레드를 할당받거나 양보하는 지점을 정해줄 수 있습니다. 만약 async로 함수를 작성했는데 await을 이용한 중단 지점이 함수 내에 없다면 비동기가 아닌 동기로 동작합니다. 

이에 대한 프로파일 결과는 아래와 같습니다. 

GCD 코드에 비해 스레드 생성량이 월등히 적고 컨텍스트 스위칭도 비교적 적은 것을 확인할 수 있습니다. 둘을 수치로 비교해 보면 아래와 같습니다. 

GCDSwift Concurrency
활성화되는 스레드 수474
컨텍스트 스위칭655224816

메인 스레드로의 빈번한 컨텍스트 스위칭이 필요하고 Concurrent DispatchQueue를 사용하지 않았던 앞선 이미지 다운로드 예시와는 달리, UI 업데이트가 없고 Concurrent DispatchQueue를 사용한 예시의 경우 스레드 생성량이나 컨텍스트 스위칭에서 유의미한 차이를 발견할 수 있었습니다.

동시성 프로그래밍을 할 때는 URLSession의 함수들과 같은 Swift API를 이용할 수도 있지만, 사용자 정의 함수를 이용하는 경우 또한 많습니다. 이때 동시에 여러 작업을 수행하고자 하면 Concurrent DispatchQueue를 사용하는 경우가 많고, 이때는 Swift Concurrency를 사용하면 스레드 생성량이나 컨텍스트 스위칭을 줄일 수 있을 것입니다.

정리하면, Concurrent DispatchQueue를 사용하는 GCD 코드의 경우 Swift Concurrency에 비해 스레드 생성량이나 콘텍스트 스위칭 수에서 많은 차이를 보였습니다. 반면 Concurrent DispatchQueue를 사용하지 않거나 thread explosion을 예방하는 코드를 잘 작성한 경우에는 스레드 생성량의 차이가 크게 발생하지 않았습니다. 하지만 Swift Concurrency를 사용해서 코드를 작성하면 스레드 개수가 코어 수보다 늘어나지 않기 때문에 thread explosion이 미연에 방지되는 장점이 있습니다. 

 

우선순위 역전

Dispatch Queue를 이용해 동시성 프로그래밍을 하다 보면 우선순위 역전(priority inversion)이 발생할 수 있습니다. 하나의 큐에 QoS(Quality of Service)가 각기 다른 작업이 담길 수 있기 때문입니다. 예를 들어 Background QoS인 태스크들이 큐에 추가된 후 User Initiated QoS인 태스크가 추가됐다고 가정하겠습니다. 이런 경우 GCD에서는 앞에 있던 background QoS 태스크들의 우선순위를 user initiated로 높여서 새로 추가된 태스크가 너무 오래 앞선 태스크를 기다리지 않게 해줍니다.

Swift Concurrency에서는 이에 대해 조금 더 근본적인 해결책을 제공합니다. Dispatch Queue의 경우 FIFO로 태스크를 실행시키기 때문에 앞의 태스크들의 우선순위를 높이는 방식을 선택하지만, Swift Concurrency에서는 작업이 실행되는 순서가 FIFO가 아니기 때문에 우선순위가 높은 태스크를 먼저 실행시킬 수 있습니다.

이에 대한 예시를 함께 살펴보겠습니다. 작업을 시작하고 print로 시작을 알려준 뒤 앞선 코드 예시대로 이미지 하나를 다운로드하고 나면 다시 한번 print로 종료를 알려주는 예시입니다. 먼저 GCD로 구현한 간단한 예시를 살펴보겠습니다.

Background QoS인 DispatchWorkItem 15개가 먼저 Concurrent DispatchQueue에 인입되고 User Initiated QoS인 DispatchWorkItem이 들어간 상황입니다. DispatchQueue는 Concurrent DispatchQueue여도 작업이 시작되는 순서는 FIFO이기 때문에 결과와 같이 User Initiated QoS인 작업이 가장 늦게 시작됩니다.

같은 상황을 Swift Concurrency로 만들면 아래와 같이 작성할 수 있습니다. 

Swift Concurrency의 경우 작업이 실행되는 순서가 FIFO가 아닙니다. 먼저 추가된 작업이 실행되고 있을 때 우선순위가 더 높은 작업이 동시성 작업에 추가되면 해당 작업을 먼저 실행하고 우선순위가 낮은 작업을 수행합니다. User Initiated 우선순위인 작업이 추가되기 전에 Background 우선순위인 작업 세 개가 먼저 실행되기 시작했지만, User Initiated 작업이 추가되면 바로 해당 작업을 먼저 수행시키는 것을 확인할 수 있습니다.

이와 같이 Swift Concurrency를 사용하면 우선순위 역전을 미연에 방지할 수 있습니다.

 

마치며

iOS 온보딩 스터디를 통해 GCD를 이용한 동시성 프로그래밍을 공부했고, WWDC 2021 세션 발표를 준비하면서 Swift Concurrency에 대해 공부했습니다. 이에 기반해 네트워킹 샘플 앱을 작성할 때 두 가지 API를 이용한 코드로 각각 작성해 봤고, Swift Concurrency의 성능을 테스트해 봤습니다. 

이번 테스트에서 사용한 코드는 비교적 간단한 코드이기 때문에 GCD에서 탈출 클로저가 한 번만 사용됐지만, 복잡한 로직이나 네트워크 요청을 여러 차례 보내는 경우에는 completion handler를 많이 사용하게 되면서 가독성이 많이 떨어지곤 합니다. 이와 같은 이유로 asyncawait을 이용해 탈출 클로저를 사용하지 않는 Swift Concurrency의 문법이 매우 매력적이라고 생각했고, 협업할 때는 코드의 가독성이 중요한 만큼 Swift Concurrency가 좋은 방향의 변화라는 생각이 들었습니다.

Swift Concurrency의 성능적인 이점도 확인해 볼 수 있었습니다. Swift Concurrency로 코드를 작성하면 스레드가 코어 개수만큼 유지되는 것이 보장되는 장점이 있습니다. 또한, 우선순위 역전에 대해서도 GCD보다 근본적인 해결책을 제공하는 것을 확인했습니다.

스터디를 진행할 때에는 iOS 15.0부터 사용할 수 있는 API로 알고 있었지만, Swift Pullrequest를 통해 최근에 iOS 13.0에도 Swift Concurrency를 적용할 수 있는 방법이 있다는 것을 알게 되었습니다. 따라서 Swift Concurrency를 이용한 코드를 곧 작성해서 적용할 수 있을 것입니다. 

이번 스터디를 통해 iOS의 동시성 프로그래밍의 현재와 미래에 대해서 이해할 수 있었고, 앞으로 실제 코드를 작성하면서 이를 사용할 기회가 많이 있을 것 같습니다. 여러모로 유익했던 이번 온보딩 스터디 과정에서 특히 많은 도움이 된 파트였습니다.