Apple Inc. Swift 동시성 시각화 및 최적화 - WWDC22 - 비디오 - Apple Developer
Swift 동시성 시각화 및 최적화 - WWDC22 - 비디오 - Apple Developer
Instruments에서 Swift 동시성 템플릿을 통해 앱을 최적화하는 방법을 알아보세요. 흔히 발생하는 성능 문제를 논의하고 Instruments를 사용하여 이러한 문제를 찾고 해결하는 방법을 보여드리겠습니다
developer.apple.com
실제 적용 -> Swift Concurrency Instrument 실제 프로젝트에 적용해보기
정리: Visualize and optimize Swift Concurrency (WWDC 2022)
Instruments에서 Swift Concurrency 템플릿을 통해 앱을 최적화하는 방법을 알아보자
- 흔히 발생하는 성능 문제를 논의하고 Instruments를 사용하여 이러한 문제를 찾고 해결하는 방법 소개
- UI 응답성을 유지하고 병렬 성능 최대화
- 앱 내에서 Swift Concurrency 활동을 분석하는 방법 소개
Intro (0:09)
- Swift 동시성 코드를 더 잘 이해하고 보다 빠르게 만드는 방법 소개
- Instruments 14의 새로운 시각화 도구 소개하고자 한다
목차
- Swift Concurrency Recap
- Concurrency Optimization
- Thread Pool Exhaustion
- Continuation Misuse
1. Swift Concurrency Recap

→ 동시성 코드가 제공하는 새로운 연동 기술들은 동시성 프로그래밍을 보다 쉽고 안전하게 만들어줌
Async/await
- Async/await는 동시성 코드의 기본 구문 빌딩 블록
- 실행 스레드 블로킹 없이도 작업을 실행 도중에 일시 중단했다가 나중에 재개할 수 있는 함수를 생성하고 호출할 수 있음

Task
- 동시성 코드의 기본 작업 단위
- 동시성 코드를 실행하고 코드 상태 및 관련 데이터를 관리
- Task에는 지역 변수와 취소 처리 비동기 코드의 실행 및 중단 등이 포함

Structured concurrency
- 병렬로 실행하고 완료될 때까지 기다릴 하위 작업을 만듦
- Swift 언어는 작업을 그룹화하여 사용하지 않을 경우 자동으로 대기 또는 취소하도록 하는 구문을 제공

Actor
- actor는 공유 데이터에 접근해야 하는 여러 작업을 조정
- 외부로부터 데이터를 격리하고 한 번에 하나의 작업만 내부 상태를 조작할 수 있도록 하여 동시성 변이로 인한 데이터 레이스를 방지

Instruments 14
- 앱의 모든 활동을 캡처하고 시각화함 → 앱이 수행하는 Task를 이해하고 문제를 찾고 성능을 개선하는 데 도움을 줌
2. Concurrency Optimization (2:34)
Swift 동시성 코드로 앱을 최적화하는 방법을 살펴보자
Swift Concurrency로 올바른 동시성 및 병렬 코드를 쉽게 작성할 수 있지만
동시성 구조를 잘못 사용할 가능성도 있고, 목표했던 성능상의 이점을 얻을 수 없는 방식일 수도 있음

→ Swift Concurrency를 사용하여 코드 작성 시 흔히 발생하는 몇몇 문제들은 성능 저하나 버그를 유발
- MainActor 블로킹 → 앱을 정지시킬 수 있음
- Actor contention (actor 경합) / Thread Pool 고갈(Exhaustion) → 병렬 실행을 줄여 성능 저하됨
- Continuation misuse (Continuation 코드 오용) → 누수나 충돌 유발
→ 새로운 Swift Concurrency 도구가 해결 가능함
Main actor 블로킹부터 살펴보자
Main actor 블로킹은 Main actor에서 장시간 동안 작업 실행 시 발생함

Main actor
- 메인 스레드에서 모든 작업을 실행하는 특수한 actor
- UI 작업은 메인 스레드에서 수행되어야 하는데 Main actor는 UI 코드를 Swift 동시성 코드에 통합해줌
→ 메인 스레드는 UI를 업데이트하려면 가용성을 유지해야 하는데 장기 실행 작업이 메인 스레드를 장기간 점유하면 앱이 멈춘 것처럼 보이고, 응답하지 않음
따라서, Main actor에서 실행되는 코드는 신속하게 작업을 완료하거나, 작업을 백그라운드로 이동시켜야 함

→ 메인 액터에서는 작은 단위 작업으로 UI 업데이트
이제 실제 앱의 예시를 보자 (File Squeezer 앱 - 폴더 내 파일 압축 앱) (04:17)
File Squeezer 앱
- 폴더 내의 모든 파일을 빠르게 압축하기 위해 만든 것
- 작은 파일은 문제 없이 압축됨
- 대용량 파일은 오래 걸려서 UI가 완전히 정지되어 어떠한 동작도 하지 않음
→ Instruments의 새로운 Swift Concurrency 도구로 성능 문제 조사 가능

성능을 조사할 때, 가장 먼저 할 일은 Swift Task 도구에서 제공하는 최상위 통계를 찾는 것


- Running Tasks: 동시에 실행중인 Task의 수
- Alive Tasks: 특정 시점에 얼마나 많은 Task가 있는지를 나타냄
- Total Tasks: 해당 시점까지 생성된 Task의 총 개수를 그래프로 표시한 것
앱의 메모리 사용량을 줄이려면 Alive Tasks, Total Tasks의 통계를 자세히 살펴야 함
이러한 모든 통계를 조합하면 코드가 얼마나 잘 병렬화되어 있고 얼마나 많은 리소스를 소비하는 지 알 수 있음
Instruments의 세부 항목을 살펴보자 (05:55)
Task Forest
- 구조화된 동시성 코드 작업 간의 상하위 관계를 그래픽으로 표현


Summary Task State
- 다양한 상태에서 각 Task에 소요된 시간을 보여줌
- 마우스 오른쪽 버튼을 클릭하면 선택한 작업에 대한 모든 정보가 포함된 트랙을 타임라인에 고정하여 자세히 볼 수 있음
- 이를 통해, 장기간 실행 중이거나 actor에 대한 접근 권한을 얻기 위해 대기 중인 관심 작업을 빠르게 찾고 확인 가능


Task를 타임라인에 고정하면 4가지 주요 기능을 제공함

- Task가 어떤 상태인지 보여주는 트랙 제공
- 세부 사항 보기에서 작업 생성을 역추적할 수 있음
- Narrative 보기를 통해 현재 Swift 태스크가 대기 중 상태인 경우, 어떤 작업을 기다리고 있는지와 같은 더 많은 맥락 정보를 제공
- Narrative 보기에서도 요약 보기에서와 동일한 핀 작업에 접근할 수 있으므로 하위 작업이나 스레드 또는 Swift actor를 타임라인에 고정할 수 있음

→ 이러한 Narrative 보기는 Swift 태스크와 다른 동시성 기본 요소 및 CPU의 관련성을 찾을 때도 도움이 됨
새로운 Instruments의 일부 기능을 살펴봤으니 앱을 프로파일링하고 코드를 최적화 해보자 (07:40)
앱으로 대용량 파일을 끌어와보자

→ 앱 실행이 멈추고 UI가 응답하지 않음
프로세스 트랙을 보면 Instruments가 UI가 정지된 정확한 위치를 표시해줌

→ 이 기능은 정지가 언제 얼마동안 발생했는지 명확하지 않은 경우 유용함
앞서 말했듯이 최상위 Swift Task 통계부터 보는 것이 좋음
가장 먼저 눈에 띄는 것은 Running Tasks (실행 중인 Task의 수)

→ 대부분의 경우 하나의 Task만 실행되지만 현재, 모든 Task가 강제 직렬화되어 있어 서 이 부분이 문제의 일부임을 알 수 있음
Summary: Task State를 통해 가장 오래 실행 중인 작업을 찾고, 해당 작업을 타임라인에 고정할 수 있음

해당 트랙의 Narrative 보기를 보면 백그라운드에서 짧게 실행되고 메인 스레드에서 오래 실행된 것을 알 수 있음

메인 스레드를 타임라인에 고정해보자


→ 메인 스레드가 여러 긴 작업에 의해 블로킹 되어 있음
여기에서 스스로에게 이 Task는 어떤 작업을 수행하며, 어디에서 왔는지를 질문해야 됨
Narrative 보기로 돌아가면 두 질문에 대한 답을 구할 수 있음

- 오른쪽 Creation Backtrace를 보면, compressAllFiles 함수에서 Task가 생성됐음을 알 수 있음
- Narrative 보기는 Task가 compressAllFiles의 클로저 1을 실행함을 알 수 있음
- 우클릭 하면 소스 뷰어를 열 수 있음

클로저 1은 압출 파일을 호출하고 있음

이제 이 Task가 생성된 위치와 수행 중인 작업을 알았으므로 코드를 조정함으로써 메인 스레드에서 복잡한 계산을 수행하는 것을 피할 수 있음

- compressAllFiles 함수는 CompressionState 클래스 내에 있고 CompressionState 클래스는 @MainActor에서 실행되므로 해당 Task도 메인 스레드에서도 실행된 것
- @Published 속성은 메인 스레드에서만 업데이트되어야 하므로 이 클래스는 MainActor에 있어야 하며 그렇지 않으면 런타임 에러 발생
- 따라서 이 클래스를 자체 actor로 변환
그러나 컴파일 에러 발생함

- 변수 files, logs는 2개의 다른 actor로 보호되어야 하기 때문
- 기본적으로 가변 상태는 2개의 다른 actor로 보호되어야 한다고 했기 때문 ??
💡Actor 'CompressionState' cannot have a global actor
actor는 이미 자기만의 isolation을 갖고 있기 때문에, 여기에 또 global actor(@MainActor)를 직접 붙이는 건 금지됨
class는 @MainActor로 main-thread에 고정시킬 수 있음actor는 자체 isolation을 갖기 때문에 @MainActor랑 동시에 못 씀

- 이 클래스에는 2개의 서로 다른 변경 가능한 상태가 있는데
- 상태 중 하나인 'files' 속성은 SwiftUI에 의해 관찰되므로 Main actor로 분리해야 함
- ‘logs’는 특정 시점에 어떤 스레드가 로그에 접근하는지는 중요하지 않아, Main actor에 있을 필요는 없음
- 따라서, 따로 actor를 두어 동시 접근으로부터 보호해보자

→ 이제 필요 시 Task가 둘 사이를 이동할 방법만 추가하면 됨
- logs 상태를 ParallelCompressor Actor로 옮기고,
- actor들 간에 정보 전달이 이루어지도록 CompressionState 클래스에서 logs 변수를 참조하는 코드를 제거하고 ParallelCompressor actor에 추가
- 마지막으로 CompressionState를 업데이트하여 ParallelCompressor에서 compressFile을 호출하도록 함

actor ParallelCompressor {
var logs: [String] = []
unowned let status: CompressionState
init(status: CompressionState) {
self.status = status
}
func compressFile(url: URL) -> Data {
log(update: "Starting for \\(url)")
let compressedData = CompressionUtils.compressDataInFile(at: url) { uncompressedSize in
Task { @MainActor in
status.update(url: url, uncompressedSize: uncompressedSize)
}
} progressNotification: { progress in
Task { @MainActor in
status.update(url: url, progress: progress)
await log(update: "Progress for \\(url): \\(progress)")
}
} finalNotificaton: { compressedSize in
Task { @MainActor in
status.update(url: url, compressedSize: compressedSize)
}
}
log(update: "Ending for \\(url)")
return compressedData
}
func log(update: String) {
logs.append(update)
}
}
@MainActor
class CompressionState: ObservableObject {
@Published var files: [FileStatus] = []
var compressor: ParallelCompressor!
init() {
self.compressor = ParallelCompressor(status: self)
}
func update(url: URL, progress: Double) {
if let loc = files.firstIndex(where: {$0.url == url}) {
files[loc].progress = progress
}
}
func update(url: URL, uncompressedSize: Int) {
if let loc = files.firstIndex(where: {$0.url == url}) {
files[loc].uncompressedSize = uncompressedSize
}
}
func update(url: URL, compressedSize: Int) {
if let loc = files.firstIndex(where: {$0.url == url}) {
files[loc].compressedSize = compressedSize
}
}
func compressAllFiles() {
for file in files {
Task {
let compressedData = await compressor.compressFile(url: file.url)
await save(compressedData, to: file.url)
}
}
}
}
변경된 코드로 다시 대용량 파일을 업로드해보자 (12:43)

- UI가 정지되지는 않았지만, 아직 속도가 느림
- 작업 시간을 최소화하려면 머신의 모든 코어를 최대한 활용해야 함
→ Main actor로부터 작업을 이동시켜 문제는 해결했지만 여전히 원하는 성능을 얻지 못하고 있음
그 이유를 알려면 actor를 자세히 살펴봐야 함
actor는 여러 작업의 공유 상태를 안전하게 조작하게 해주지만 공유 상태에 대한 액세스를 직렬화하는 방식이기 때문에 한 번에 하나의 작업만 actor를 점유할 수 있으며 그 동안 해당 actor를 사용해야 하는 다른 작업은 대기해야 함

→ Detached Task, Task Group, Async let을 사용하여 병렬 계산 필요함
또한, 이런 코드의 actor를 사용할 경우 작업 간에 공유되는 actor에서 많은 작업을 수행하지 않도록 주의해야 함
여러 작업이 동일한 actor를 동시에 사용려고 하면 actor가 해당 작업의 실행을 직렬화되므로 병렬 컴퓨팅이 주는 성능상의 장점을 누릴 수 없음
actor 데이터에 대한 독점 접근 권한이 꼭 필요한 경우에만 actor에서 작업을 실행하고 그 외엔 모두 actor 밖에서 실행되어야 함 (14:11)
- 작업을 chunk(덩어리)로 나누어 일부 chunk만 actor에서 실행되도록 해야 함


다시 File Squeezer 앱의 트레이스를 봐보자

- Task Summary를 보면 동시성 코드가 큐에 작업을 넣은 상태(enqueue)에서 엄청난 시간을 소요하고 있는데 actor에 대한 독점적 액세스를 기다리는 작업이 많다는 뜻
Task 하나를 고정해서 이유를 찾아보자

- 이 작업은 압축 실행 전 ParallelCompressor actor에 연결되기를 기다리는 과정에 상당한 시간을 소비함
actor를 타임라인에 고정해보자

- 이 actor 대기열은 장기 실행 작업에 의해 블로킹된 듯 함
- 하지만, Task는 실제 필요한 시간 동안만 actor에 남아 있어야 해야 됨
Narrative로 돌아가보자
ParallelCompressor의 대기열에 입력된 후 작업은 compressAllFiles에 있는 클로저 1에서 실행됨

actor ParallelCompressor {
...
func compressFile(url: URL) -> Data {
log(update: "Starting for \\(url)")
let compressedData = CompressionUtils.compressDataInFile(at: url) { uncompressedSize in
Task { @MainActor in
status.update(url: url, uncompressedSize: uncompressedSize)
}
} progressNotification: { progress in
Task { @MainActor in
status.update(url: url, progress: progress)
await log(update: "Progress for \\(url): \\(progress)")
}
} finalNotificaton: { compressedSize in
Task { @MainActor in
status.update(url: url, compressedSize: compressedSize)
}
}
log(update: "Ending for \\(url)")
return compressedData
}
...
}
@MainActor
class CompressionState: ObservableObject {
...
func compressAllFiles() {
for file in files {
Task { // 클로저 1.
let compressedData = await compressor.compressFile(url: file.url)
await save(compressedData, to: file.url)
}
}
}
}
- 소스 코드를 보면 이 클로저가 압축 작업을 실행함을 알 수 있음
- compressFile 함수는 ParallelCompressor actor의 일부이므로 함수의 모든 작업이 actor에서 실행되면서 다른 압축 작업을 모두 차단하게 됨
- 이를 해결하려면 nonisolation compressFile 함수를 Detached Task로 가져와야 함
- (클래스가 Main Actor로 되어 있으므로 메인 스레드에서 작업하지 않으려면 분리해야 함)
→ compressFile 함수를 actor 격리 하지 않고 (nonisolation)
→ Detached Task로 호출해야 함
compressFile 함수를 actor에서 격리하지 않게 변경하면 actor 데이터에 접근해야 될 때까지 다른 스레드에서 실행 가능함

files 속성에 액세스해야 하는 경우 Main actor로 이동하지만

작업이 완료되는 즉시 'Thead Pool'로 다시 이동

logs 속성에 접근해야 하면 ParallelCompressor actor로 가기도 함

다만 거기서도 작업이 끝나는 즉시 actor를 떠나 스레드 풀에서 실행됨

물론 압축 작업을 해야 하는 Task는 하나만이 아니고 여러 개일 텐데
actor에 제약받지 않음으로 모든 작업을 동시에 실행할 수 있으며 오직 스레드 수에 의해서만 제한됨

각 actor는 한 번에 하나의 Task만 실행할 수 있지만 대부분의 경우 Task가 actor에 있을 필요가 없으므로
압축 작업을 병렬로 실행하고 모든 가용 CPU 코어를 활용할 수 있음

→ 병렬적으로 실행되지만, actor에 대한 접근은 하나씩만 가능
compressFile 함수를 nonisolated로 선언하면 컴파일러 오류가 발생함 (17:47)

- nonisolated로 선언하면 actor에 간섭을 받지 않는 외부에서 호출하는 것으로 보면 되는데
- 메서드 내부에서 log 메서드에 접근하지만, 이는 actor 소유이므로 직접 접근할 수 없음
→ await 키워드를 사용하여 호출해야 함
Detached Task
- Task는 부모 actor를 상속하기 때문에, Main Actor로 선언된 부모 actor에 분리된 작업을 만들기 위해 Task.detached 사용
- 분리된 작업의 경우, 상위 클래스(compressor 변수)에 접근하려면 명시적으로 self를 캡처해야 함
다시 테스트를 해보면 동시에 압축하며 UI 응답성을 유지하는 것을 확인 가능함


→ ParallelCompressor actor를 보면 대부분의 작업은 actor에서 단시간 동안 실행되었고, 대기열 크기는 일정 수준을 넘지 않음
3. Thread Pool Exhaustion (스레드 풀 고갈) (19:45)
- 스레드 풀이 고갈되면 성능이 저하되거나 앱이 교착 상태에 빠질 수 있음
- Swift 동시성 코드는 작업이 실행 중일 때 앞으로 진행되도록 하며
- 무언가를 기다릴 때는 보통 일시 중단함으로써 대기하는데 작업 내의 코드가 일시 중단 없이 파일 또는 네트워크 IO 블로킹이나 Lock 획득과 같은 블로킹 호출을 수행할 수도 있으며 이는 작업이 앞으로 진행되어야 한다는 요건에 위배됨

이 경우 작업은 실행 중인 스레드를 계속 점유하지만 실제 CPU 코어는 사용하지 않는 상태인데 스레드 풀은 한정적이고 일부는 차단되므로 동시성 런타임이 모든 CPU 코어를 온전히 사용할 수 없게 되고 이는 결국 수행 가능한 병렬 계산의 양과 앱의 최대 성능 감소로 이어짐
→ 극단적인 경우 전체 스레드 풀이 블로킹되어 교착 상태에 빠질 수 있음
따라서, 아래 사항들을 주의해야 함

- 블로킹 호출 피하기
- 오래 걸리는 작업 (파일 및 네트워크 I/O)는 비동기로 사용
- 조건 변수나 세모포어에 대기하지 않도록 유의
- 경합이 많거나 장기간 유지되는 Lock은 피하기
- 이러한 작업을 수행하는 코드는 Swift Concurrency 외부에서 호출하고 Continuation으로 연결하기
4. Continuation Misuse (21:34)
Continuation을 사용하는 경우 제대로 사용하도록 주의해야 함
Continuation

- Swift 동시성과 다른 형태의 비동기 코드를 잇는 다리
- 현재 작업을 일시 중단하고 호출 시 재개하는 콜백 기능을 제공
- 콜백 기반 비동기 API와 함께 사용할 수 있음
- Swift Concurrency의 관점에서는 작업이 일시 중단된 다음 Continuation이 재개될 때 다시 시작됨
- 콜백 기반 비동기 API 관점에서는 작업이 시작되고 완료될 때 콜백이 호출됨
- Swift Concurrency Instruments는 Continuation에 대해 알고 그에 따라 시간 간격을 표시하여 작업이 Continuation 호출을 기다리고 있음을 보여줌
- Continuation 콜백은 정확히 한 번만 호출되어야 함
- 이는 콜백 기반 API의 일반적인 요구사항이지만, 비공식적이며, Swift 언어에서 강제하지 않기 때문에 흔히 간과함
- 콜백이 호출되지 않거나, 두 번 이상 호출되면 Task 누수 발생
이 코드는 withCheckedContinuation을 사용하여 Continuation을 얻은 다음 콜백 기반 API를 호출함

- 콜백에서는 Continuation을 재개하는데 이는 정확히 한 번만 호출해야 한다는 요건을 충족함

- 왼쪽 코드는 성공일 경우만, 재개하므로 오류남
- 실패할 경우, 재개하지 않아, 영원히 중단되기 때문
- 오른쪽 코드는 Continuation을 두 번 재개하므로 앱이 오작동하거나 충돌남
Continuation 코드는 Checked와 Unsafe 2가지 유형이 있음
- 성능이 절대적으로 중요한 경우가 아니면 withCheckedContinuation API를 권장
- Checked Continuation 코드는 오용을 자동 감지하여 오류 경고를 띄움
- Checked Continuation이 2번 호출되면 Continuation을 트랩

- Continuation이 아예 호출되지 않을 경우 Continuation 손상 시 콘솔에 메시지가 출력되어 Continuation 누출을 알려줌

Instruments의 새 Swift Concurrency 템플릿에는 이 밖에도 많은 기능이 있음

- Swift 동시성 Instruments는 Continuation 상태에서 무기한 중단된 해당 작업을 표시
- 구조화된 동시성을 그래픽으로 시각화 하는 기능
- 작업 생성 호출 트리 뷰 보기
'WWDC' 카테고리의 다른 글
| WWDC 2021 - Protect mutable state with Swift actors 정리 (0) | 2025.09.09 |
|---|---|
| WWDC 2021 - Meet AsyncSequence 정리 (0) | 2025.09.08 |
| WWDC 2021 - Use async/await with URLSession 정리 (0) | 2025.09.05 |
| WWDC 2021 - Discover concurrency in SwiftUI 정리 (0) | 2025.09.04 |
| WWDC 2021 - Explore structured concurrency in Swift (1) | 2025.09.03 |