Swift Concurrency 다시 파고든 여정

피터·2025년 9월 23일

Concurrency

목록 보기
9/10
post-thumbnail

https://developer.apple.com/videos/play/wwdc2021/10254

얼마 전 이 WWDC 영상을 보고 블로그에 정리했었습니다.

GCD와 Concurrency 뭐가 다른건가요? - 2탄

"이해했다"고 생각했죠.

그런데 최근 유튜브 알고리즘에 이 영상이 다시 떠서 복습 삼아 봤는데...
"엥? 내가 뭘 이해했다는 거지?"

특히 이 부분들이 명확하지 않았습니다:

  • URLSession의 delegateQueue가 정확히 뭐하는 건지
  • GCD의 Thread Explosion이 왜 일어나는지
  • Swift Concurrency가 이걸 어떻게 해결하는지
  • Actor Reentrancy가 대체 무슨 의미인지

그래서 오늘, 모든 의문이 해소될 때까지 파고들었습니다.

1. URLSession과 delegateQueue: 첫 번째 관문

영상 처음 이 코드를 봤을 때 이해가 안 됐습니다.

let concurrentQueue = OperationQueue.current 
let urlSession = URLSession(configuration: .default, delegate: self, delegateQueue: concurrentQueue)

"delegateQueue가 뭐지? 왜 필요하지?"

사실 순간 좀 부끄러운 생각도 들었습니다. URLSession을 그렇게 많이 사용했는데 정확히 이해하지 못하고 있었다니... 하지만 모르는 것보다 알려고 하지 않는 게 더 부끄러운 거니까, 잠시 접어두고 학습을 시작했습니다.

네트워크와 스레드의 관계

핵심은 이것이었습니다. 네트워크 I/O는 스레드를 블로킹하지 않습니다. 기본적으로 그렇게 설계되어 있습니다.

그럼 어떻게 응답이 돌아왔을 때 알 수 있을까요? 네트워크 요청의 전체 흐름을 살펴보겠습니다:

네트워크 요청의 흐름:

  1. 요청 전송 → OS 커널에 위임
  2. 응답 대기 → 스레드는 다른 일을 함
  3. 네트워크 응답이 도착하면 OS 커널이 URLSession에게 알림
  4. URLSession은 응답을 처리한 뒤, 그 결과(데이터, 에러 등)와 함께 completion handler 또는 delegate 메서드 호출을 작업(Operation) 형태로 delegateQueue에 추가
  5. delegateQueue에 있는 스레드가 이 작업을 순서대로 가져가서 실행

여기서 중요한 점은 delegateQueue가 "어느 스레드에서 실행할지"가 아니라 "어느 큐에 작업을 넣을지"를 결정한다는 것입니다.

OS 커널이 뭔가요?

OS 커널은 운영체제의 핵심 부분으로, 앱과 하드웨어(CPU, 메모리, 네트워크 카드 등) 사이의 다리 역할을 합니다. 네트워크 요청이 오면 이 커널이 직접 하드웨어를 제어하며 통신을 처리합니다. 복잡한 하드웨어 작동을 앱이 쉽게 이용할 수 있도록 해주는 총괄 관리자라고 생각하면 됩니다.

2번의 "스레드는 다른 일을 함"은 어떤 스레드를 말하는 건가요?

URLSessionTask의 resume() 메서드를 호출해서 네트워크 요청을 보낸 바로 그 스레드를 말합니다.

문제의 시작: Concurrent Queue + Sync

이 영상에서 문제의 시작은 다음과 같은 코드였습니다:

// Concurrent queue에서 100개 completion handler 실행
URLSession(..., delegateQueue: concurrentQueue)

for feed in feedsToUpdate {
    urlSession.dataTask(with: feed.url) { data, response, error in
        // ...
        dbQueue.sync {  // 💥 여기가 문제!
            updateDatabase(...)
        }
    }
}

왜 이게 문제일까요?

2. Thread Explosion의 메커니즘

GCD의 동작 원리를 이해해야 합니다:

  1. Concurrent queue에 작업 추가 → 코어 수만큼 스레드 생성
  2. 스레드가 블로킹되면? → 새 스레드 생성
  3. 또 블로킹? → 또 생성
  4. 반복... → Thread Explosion 💥

100개 피드 × 각각 sync로 블로킹 = 100개 가까운 스레드가 생성됩니다.

위에서 OS 커널이 URLSession에게 네트워크 응답을 알려주고 completion handler를 큐에 넣는다고 했죠. 그런데 이 코드를 보면 각 completion handler 안에서 dbQueue.sync로 동작하고 있습니다. sync이기 때문에 스레드가 블로킹될 수밖에 없습니다.

Thread Explosion은 왜 나쁠까?

  1. 메모리 낭비

    • 각 스레드에는 스레드를 추적하기 위한 스택과 관련 커널 데이터 구조가 있습니다
    • 각 스택은 512KB~1MB 크기입니다
    • 100개 스레드면 50~100MB 메모리를 차지합니다
    • 블로킹된 채로 아무 일도 하지 않으면서 메모리만 차지합니다
  2. 스케쥴링 오버헤드

    • 6개 코어가 100개 스레드를 돌아가며 실행해야 합니다
    • 과도한 컨텍스트 스위칭이 일어나 CPU 실행 효율성도 떨어집니다
    • 실제 일하는 시간보다 "누구 차례인지 결정하는 시간"이 더 오래 걸립니다

문제는 GCD가 "왜" 스레드가 막혔는지 모른다는 것입니다. 그저 "막혔으니 새로 만들자"만 알 뿐입니다.

3. Swift Concurrency의 해결책

await와 Continuation


Swift Concurrency에서는 컨텍스트 스위칭이 일어나지 않습니다. 코어 수만큼만 스레드가 생성되고, blocked threads 대신 blocked Continuations만 존재합니다. 이 Continuation은 가벼운 객체입니다.

이것이 가능하려면 다음과 같은 언어적 특징이 필요합니다:

  • await와 중단되지 않는 스레드
  • Swift task model의 종속성 추적

운영체제에는 스레드가 차단되지 않는 런타임 계약이 필요합니다.

await를 만나면:

  1. 함수가 suspension point에서 중단
  2. 상태를 Continuation(힙)에 저장
  3. 스레드는 다른 Task 실행
  4. 완료되면 재개(같은 스레드일 수도, 다른 스레드일 수도)

핵심은 스레드가 블로킹되지 않으니 새로 만들 필요가 없다는 것입니다.

종속성 추적: GCD와의 진짜 차이

GCD와 Swift Concurrency의 진짜 차이는 tracking dependencies, 즉 종속성 추적입니다. 종속성 추적이 무슨 뜻일까요?

"semaphores hide dependency information from the Swift runtime, but introduce a dependency in execution in your code"

발표 문장을 봅시다:

"semaphores hide dependency information from the Swift runtime, but introduce a dependency in execution in your code"

1. "hide dependency information from runtime"

  • Runtime은 종속성을 모릅니다
  • semaphore.wait()가 무엇을 기다리는지 알 수 없습니다

2. "but introduce a dependency in execution"

  • 하지만 실제로는 종속성이 존재합니다
  • 코드 실행 순서상 의존 관계가 있습니다

다시 말하면:

Task {
    let result = heavyWork()
    semaphore.signal()  // 신호 보냄
}

Task {
    semaphore.wait()  // 신호 기다림 - 첫 번째 Task에 종속!
    useResult()
}

실제로는 두 번째 Task가 첫 번째 Task에 종속되어 있지만(dependency exists), Runtime은 이 관계를 전혀 모릅니다(dependency hidden).

종속성을 모르기 때문에 Runtime은 무엇을 기다리는지 알 수 없습니다. 그렇기 때문에 다른 작업으로 전환할 수 없어서 스레드를 그냥 블로킹시킬 수밖에 없습니다.

반면 Swift Concurrency는 종속성을 알고 있습니다. Runtime이 종속성을 알기 때문에 continuation으로 상태를 저장하고 스레드를 다른 작업에 할당할 수 있습니다. 그리고 나중에 돌아와서 재개가 가능합니다.

await는 단순히 기다리는 게 아닙니다. "무엇을" 기다리는지 Runtime에게 알려주는 것입니다.

종속성 체인이란 정확히 무엇인가?

종속성은 "이 작업이 완료되려면 무엇이 먼저 끝나야 하는지"를 나타냅니다. Swift Concurrency에서 종속성 체인을 추적한다는 것은 Runtime이 각 작업이 무엇을 기다리고 있는지 정확히 알고 있다는 의미입니다.

예를 들어 아침 식사를 준비하는 코드를 봅시다:

func makeBreakfast() async {
    let bread = await toast()        // toast에 종속
    let egg = await fryEgg()         // fryEgg에 종속
    let coffee = await brewCoffee()  // brewCoffee에 종속
    return combine(bread, egg, coffee)
}

Runtime은 이 코드를 보고 정확히 알 수 있습니다. "아, 이 함수는 toast가 끝나야 하고, 그 다음 fryEgg가 끝나야 하고, 그 다음 brewCoffee가 끝나야 완료되는구나."

이 정보가 있으면 Runtime은 똑똑한 결정을 내릴 수 있습니다. toast가 진행 중일 때 스레드를 블로킹하지 않고 다른 Task를 실행시킬 수 있고, toast가 완료되면 정확히 어떤 Task를 재개해야 하는지 알 수 있습니다.

반면 GCD의 DispatchGroup.wait()DispatchSemaphore.wait()는 Runtime에게 "뭔가를 기다려"라고만 말할 뿐, "무엇을" 기다리는지는 알려주지 않습니다. 그래서 Runtime은 스레드를 블로킹시킬 수밖에 없고, 새 스레드를 만들어야 합니다.

이것이 바로 await가 단순히 기다리는 것이 아니라 "무엇을 기다리는지 Runtime에게 알려주는" 이유입니다.

협력적 스레드 풀

Swift Concurrency의 약속:

  1. 스레드 수는 CPU 코어 수와 같습니다
  2. 스레드는 절대 블로킹되지 않습니다
  3. Continuation 간 전환은 함수 호출 비용과 동일합니다

결과적으로 Thread Explosion 없는 효율적인 동시성을 사용할 수 있습니다.

await와 원자성

Swift Concurrency에서 중요한 원칙이 하나 있습니다. await는 원자성을 깬다는 것입니다.
원자성이란 "나눌 수 없는 하나의 작업"을 의미합니다. 하지만 await를 만나면 Task가 일시 중단되고, 그 사이에 다른 일이 일어날 수 있습니다.

actor BankAccount {
	var balance = 1000
 
  	func transfer() async {
        let current = balance  // balance 읽기
        await someWork()       // 여기서 중단!
        balance = current - 100 // 위험: balance가 변경되었을 수도
   }
}

await 전에 읽은 값이 await 후에도 유효하다고 가정하면 안 됩니다. await는 코드에서 "여기서 다른 일이 일어날 수 있다"고 명시적으로 표시하는 지점입니다.

Runtime Contract와 안전한 사용

Swift Concurrency가 제대로 작동하려면 "스레드를 블로킹하지 말 것"이라는 런타임 계약이 필요합니다.

안전한 동기화 도구들

그렇다고 모든 lock을 쓰면 안 되는 건 아닙니다. NSLock이나 os_unfair_lock 같은 것들은 동기 코드에서 짧고 명확한 임계 영역을 보호할 때는 안전합니다. 다만 Swift Concurrency 기본 요소들처럼 컴파일러가 올바른 사용을 강제해주지 않습니다. 그래서 개발자가 직접 주의해야 합니다.

위험한 동기화 도구들

반면 DispatchSemaphore, pthread_cond, NSCondition, pthread_rwlock 같은 것들은 Swift Concurrency와 함께 쓰면 안전하지 않습니다. 이것들은 런타임이 종속성을 추적하지 못하게 숨기기 때문입니다.

특히 Task 경계를 넘어서 이런 도구들을 사용하면 큰 문제가 생길 수 있습니다. 협력적 스레드 풀의 스레드가 무한정 블로킹될 수 있습니다.

디버깅 도구

Apple은 이런 문제를 찾아내도록 환경 변수를 제공합니다:
LIBDISPATCH_COOPERATIVE_POOL_STRICT=1

이 환경 변수를 설정하고 앱을 실행하면, 디버그 런타임이 "forward progress" 불변성을 엄격하게 강제합니다. 만약 협력적 스레드 풀의 스레드가 멈춘 것처럼 보이면, 안전하지 않은 차단 기본 요소를 사용하고 있다는 신호입니다.

4. Actor: 동시성의 새로운 패러다임

이전 글에서 Actor에 대해 다뤘었는데, Actor 이전에도 데이터 레이싱 문제는 있었고 이를 해결하기 위해 NSLock과 Serial Queue를 사용했었습니다.

Serial Queue의 한계

Serial Queue를 사용할 때 이런 상황을 생각해봅시다:

serialQueue.async { // D1
    sleep(5)  // 긴 작업
}

serialQueue.async { // D2
    print("빠른 작업")
}

결과는 항상 D1이 먼저, D2가 나중입니다. Serial Queue는 엄격한 FIFO 방식이라 D1이 완전히 끝나야만 D2가 시작할 수 있습니다.

이제 우선순위가 있는 상황을 봅시다:

serialQueue.async { print("저우선순위 1") }
serialQueue.async { print("저우선순위 2") }
serialQueue.async { print("저우선순위 3") }
serialQueue.async { print("고우선순위!!!") }  // 앞의 3개가 끝나야 실행됨

고우선순위 작업이 나중에 도착했지만, 앞의 3개가 모두 끝나야 실행될 수 있습니다. 이게 바로 Priority Inversion 문제입니다.

Actor의 재진입성이 해결하는 방법

Actor는 재진입 가능하기 때문에 이 문제를 해결할 수 있습니다:

actor Database {
    func lowPriority1() async { await work() }
    func lowPriority2() async { await work() }
    func lowPriority3() async { await work() }
    func highPriority() async { await work() }
}

실행 흐름은 이렇습니다:

  • Low1 시작 후 await에서 중단
  • Low2 시작 후 await에서 중단
  • Low3 시작 후 await에서 중단
  • High 도착 → 바로 실행 가능 (Actor는 실행 중이 아니므로)

WWDC에서 말했듯이, Runtime이 우선순위에 따라 작업 순서를 조정할 수 있습니다. 이게 바로 Reentrant 특성의 힘입니다.

MainActor와 컨텍스트 스위칭

그런데 한 가지 궁금한 점이 생깁니다. Swift Concurrency에서도 스레드 간 이동하는 컨텍스트 스위칭이 일어나지 않을까요?

MainActor를 보면 답을 알 수 있습니다. MainActor는 전역 액터로 메인 스레드에서만 동작합니다.

다음 상황을 봅시다:

@MainActor
func updateUI(for article: Article) async

@MainActor
func updateArticles(for ids: [ID]) async {
	for id in ids {
	    let article = await database.loadArticle(with: id)
	    await updateUI(for: article)
    }
}

async/await을 사용했지만, 이 코드는 협력적 스레드 풀과 메인 스레드를 계속 오가야 합니다. 각 루프마다 두 번씩 컨텍스트 스위칭이 일어납니다. 만약 ids가 100개라면 200번의 컨텍스트 스위칭이 발생합니다. 이는 비효율적입니다.

Actor Hopping이란?

Actor hopping은 한 Actor에서 다른 Actor로 실행 컨텍스트가 전환되는 것을 의미합니다. 마치 징검다리를 건너듯 스레드가 Actor 사이를 "뛰어넘는다"고 해서 이런 이름이 붙었습니다.
해결책은 작업을 묶는 것입니다:

@MainActor
func updateArticles(for ids: [ID]) async {
    let articles = await database.loadArticles(with: ids)  // 1번
    await updateUI(for: articles)
}

이렇게 하면 컨텍스트 스위칭을 최소화할 수 있습니다. 협력적 풀 안에서의 Actor 전환은 가벼운 함수 호출 수준이지만, MainActor와의 전환은 진짜 스레드 간 컨텍스트 스위칭이 필요하기 때문입니다.

주황색이 main 스레드이고 초록색이 협력적 풀입니다.

마치며

다시 공부하니 Task와 GCD의 근본적인 차이가 명확해졌습니다. 특히 "종속성 추적"이라는 개념이 얼마나 중요한지, 그리고 그것이 어떻게 전체 시스템의 효율성을 바꾸는지 깨달았습니다.

처음 볼 때는 그냥 "새로운 문법"으로 보였던 것들이, 사실은 동시성에 대한 완전히 다른 사고방식이었습니다.

profile
iOS 개발자입니다.

0개의 댓글