iOS Development Guide

GCD vs Swift Concurrency

LINE Engineering의 Swift Concurrency에 대해서 글을 기준으로, GCD와 Swift Concurrency의 실행 방식 차이를 개발자 관점에서 재정리한 문서다. 특히 queue의 FIFO 특성, thread explosion, await 시점의 스레드 반납, UI 업데이트 시 메인 스레드 전환을 중심으로 정리한다.

Confirmed from source Inference clearly labeled Knowledge items #61, #62, #67, #68, #69, #70
학습 날짜

2026-04-09 2026-04-14

Why This Work Exists

GCD와 Swift Concurrency는 둘 다 iOS에서 비동기 작업을 처리하지만, 태스크가 큐에 들어가고 실행되며 중단되고 재개되는 방식이 다르다. 이 차이를 헷갈리면 FIFO, 우선순위, 스레드 수, 컨텍스트 스위칭을 잘못 이해하기 쉽다.

Scope / Non-scope

  • DispatchQueue의 종류와 concurrent queue의 FIFO 의미를 설명한다.
  • GCD의 블로킹 모델과 Swift Concurrency의 suspension 모델 차이를 설명한다.
  • URLSession을 이용한 단순 스레드 수 비교가 왜 부정확한지 정리한다.
  • 이 문서는 Actor, Sendable, structured concurrency 전체 문법을 깊게 다루지 않는다.

As-is

흔한 오해는 다음과 같다. concurrent queue면 작업이 완전히 무작위로 시작된다고 생각하거나, GCD는 항상 작업당 스레드 하나를 만든다고 생각하거나, Swift Concurrency는 절대 컨텍스트 스위칭이 없다고 생각하는 경우다.

To-be

더 정확한 표현은 이렇다. GCD의 concurrent queue도 큐 진입 순서는 FIFO지만, 여러 작업이 겹쳐 실행될 수 있다. Swift Concurrency는 await에서 작업을 중단하고 스레드를 붙잡지 않기 때문에 블로킹으로 인한 유휴 스레드를 줄인다.

What The Developer Must Understand Next

Concurrent DispatchQueue 말고 무엇이 있나

기본적으로는 Serial DispatchQueueConcurrent DispatchQueue로 이해하면 된다. serial queue는 한 번에 하나의 태스크만 실행하고, concurrent queue는 동시에 여러 태스크를 진행할 수 있다.

동시에 실행된다는 의미

concurrent queue는 큐에서 태스크를 FIFO 순서로 꺼내더라도, 이미 실행 중인 앞선 태스크가 끝나기를 기다리지 않고 뒤 태스크를 다른 스레드에서 시작시킬 수 있다. 즉 시작 순서는 FIFO일 수 있어도 완료 순서는 달라질 수 있다.

API / Data Contract

핵심 표현 정리
  • GCD concurrent queue: 작업 시작 순서는 FIFO, 실행은 겹칠 수 있음
  • Swift Concurrency: 작업 실행 순서가 FIFO로 고정되지 않음
  • URLSession: 시스템이 관리하는 스레드가 개입하므로 순수 비교 실험으로 부적절할 수 있음
  • UI 업데이트: 메인 스레드 전환 필요, GCD와 Swift Concurrency 모두 동일
  • async URLSession: 네트워크 응답을 기다리는 동안 스레드를 꼭 붙잡고 기다리는 모델이 아님

Risks And Decisions Needed

  • “GCD는 작업당 스레드를 하나씩 만든다”라고 단정하면 부정확하다. 작업 수가 많아질수록 스레드 생성량이 커질 수 있다는 수준으로 써야 한다.
  • “Swift Concurrency는 노는 스레드가 없다”는 표현은 과하다. 정확히는 블로킹으로 묶인 유휴 스레드를 줄인다고 쓰는 편이 맞다.
  • “await 이후 반드시 같은 물리 스레드로 돌아온다”라고 쓰면 안 된다. 중요한 것은 같은 논리적 작업이 이어진다는 점이지, 동일한 스레드 복귀 보장이 아니다.

Class Diagram

classDiagram class DispatchQueue { +enqueue(task) +dequeueFIFO() } class SerialQueue { +runOneTaskAtATime() } class ConcurrentQueue { +runMultipleTasks() } class GCDTask { +blockClosure +mayBlockThread() } class SwiftTask { +suspendAtAwait() +resumeLater() } class RuntimeThreadPool { +reuseThreads() } DispatchQueue <|-- SerialQueue DispatchQueue <|-- ConcurrentQueue ConcurrentQueue --> GCDTask : schedules SwiftTask --> RuntimeThreadPool : uses

Sequence Diagram

sequenceDiagram participant Q as Concurrent Queue participant T2 as Thread 2 participant Other as External Work participant RT as Swift Runtime rect rgb(245,248,255) Note over Q,T2: GCD Q->>T2: Run Task A T2->>Other: sync wait / blocking call Other-->>T2: complete T2->>Q: Continue Task A end rect rgb(240,253,244) Note over RT,T2: Swift Concurrency RT->>T2: Run Task B T2->>RT: hit await, suspend Task B RT->>T2: reuse thread for Task C RT-->>T2: resume Task B later end

Flowchart

flowchart LR A["Task starts"] --> B{"Execution model"} B -->|"GCD blocking wait"| C["Current thread stays blocked"] C --> D["Other work finishes"] D --> E["Resume on an available thread"] B -->|"Swift await"| F["Task state stored"] F --> G["Thread returned to runtime"] G --> H["Thread runs another task"] H --> I["Suspended task resumes later"]

Performance Reading: Priority And Suspension Points

LINE Engineering의 Swift Concurrency 성능 관련 글 문맥에서 중요한 포인트는, Swift Concurrency가 높은 우선순위 task를 더 빨리 실행할 수 있도록 스케줄링하려 한다는 점이다.

  • 낮은 우선순위 task를 먼저 넣었더라도, 잠시 후 높은 우선순위 task가 들어오면 suspension point 이후에는 높은 우선순위 task가 먼저 스레드를 배정받을 수 있다.
  • 높은 우선순위 task가 이미 런타임이 사용하는 스레드를 채우고 있으면, 낮은 우선순위 task가 완전히 굶지 않도록 스레드가 추가 생성될 수 있다.
  • 즉 Swift Concurrency는 우선순위를 더 잘 반영하려고 하지만, 스레드 수를 절대 고정 불변으로 유지하는 모델은 아니다.
정리 문장

Swift Concurrency는 작업이 들어온 순서보다 우선순위를 더 적극적으로 반영해 실행하려 하고, 필요한 경우 starvation을 막기 위해 스레드를 추가할 수 있다.

Why Suspension Points Matter

이 장점은 현재 실행 중인 task가 Swift Concurrency 런타임의 스레드를 너무 오래 붙잡지 않을 때만 잘 드러난다. suspension point가 없으면 task는 중간에 스레드를 반납하지 못하고 계속 점유할 수 있다.

  • 커스텀 async 함수 안에 실제 suspension point가 있는지 확인해야 한다.
  • 겉으로는 async여도 내부에 실제 대기/양보 지점이 없으면 Swift Concurrency의 장점을 살리지 못할 수 있다.
  • 필요하다면 Task.yield()를 넣어 명시적으로 다른 task에 실행 기회를 줄 수 있다.

다만 suspension point가 너무 많아도 스케줄링 오버헤드가 커질 수 있으므로, 오래 스레드를 독점하는 구간에만 신중하게 넣는 것이 좋다.

withCheckedContinuation And Thread Usage

withCheckedContinuation 블록의 시작 부분은 기본적으로 Swift Concurrency가 실행 중인 문맥에서 시작된다. 따라서 그 안에서 바로 수행하는 일반 동기 코드가 길어지면, Swift Concurrency 런타임이 사용하는 스레드를 오래 점유할 수 있다.

이때 핵심은 높은 우선순위 task를 GCD로 보내는 것이 아니다. 오히려 suspension point가 없는 무거운 동기 작업을 GCD 같은 별도 실행 문맥으로 분리해서, Swift Concurrency 쪽 실행 자원을 높은 우선순위 task를 위해 남겨두는 것이 포인트다.

Practical Rule

  • 높은 우선순위 task가 늦어지지 않게 하려면, 높은 우선순위 task를 GCD로 보내는 것이 아니라 무거운 동기 작업을 분리해야 한다.
  • Swift Concurrency 런타임이 사용하는 제한된 실행 스레드는 cooperative scheduling에 중요하므로, suspension point 없는 작업으로 오래 붙잡지 않는 편이 좋다.
  • GCD로 넘긴 작업도 결국 그쪽 스레드를 점유할 수는 있지만, 적어도 Swift Concurrency 런타임의 핵심 실행 자원을 직접 막지는 않는다.
func someNormalFunction(..., completionHandler: @escaping () -> Void) {
    someQueue.async {
        heavyWork()
        completionHandler()
    }
}

위 구조는 heavyWork()가 길다면 someQueue의 스레드를 점유할 수 있다. 그래도 Swift Concurrency 실행 문맥에서 그대로 돌리는 것보다는, 런타임의 제한된 실행 스레드를 비워 두는 데 의미가 있다.

Detailed Explanation For Question 2

“GCD는 다른 스레드 작업이 끝날 때까지 thread2가 기다린다”는 말은, 현재 실행 중인 작업이 어떤 결과를 동기적으로 기다리거나 블로킹되는 API를 만나면, 그 작업을 실행하던 스레드가 그대로 점유된 채 대기한다는 뜻이다.

예를 들어 thread2에서 실행 중이던 작업 A가 중간에 세마포어 대기, 동기 I/O, 동기 디스패치, 잠금 획득 대기 같은 블로킹 지점을 만나면, 작업 A는 끝나지 않았으므로 thread2도 반환되지 않는다. 그 스레드는 OS 관점에서 잠들어 있거나 대기 중일 수 있지만, 런타임이 자유롭게 다른 GCD 작업을 올려 쓸 수 있는 상태는 아니다.

반면 Swift Concurrency의 await는 블로킹이 아니라 suspension이다. 작업 B가 await를 만나면 필요한 상태를 저장해 두고, 해당 스레드는 즉시 런타임에 반환되어 다른 태스크 실행에 재사용될 수 있다. 그래서 차이의 핵심은 GCD는 종종 스레드가 기다리고, Swift Concurrency는 작업이 기다린다는 점이다.

Concrete Example

// GCD
queue.async {
  let data = blockingFetch()
  DispatchQueue.main.async {
    imageView.image = UIImage(data: data)
  }
}

// Swift Concurrency
Task {
  let data = try await asyncFetch()
  imageView.image = UIImage(data: data)
}

위 예시에서 핵심 차이는 blockingFetch()가 thread를 붙잡을 수 있다는 점과, await asyncFetch()는 작업만 중단시키고 thread를 반납할 수 있다는 점이다. 다만 마지막 UI 반영은 둘 다 메인 스레드에서 일어나야 한다.

Question 1: API 통신 중 Thread 2가 기다린다고 말해도 되나

무조건 그렇다고 쓰면 부정확하다. 무엇으로 네트워크를 기다리느냐에 따라 다르다.

  • 블로킹 방식: 동기 네트워크 호출처럼 결과가 올 때까지 현재 함수를 멈춰 세우는 방식이면, 그 작업을 수행하던 thread2가 실제로 기다린다고 표현할 수 있다.
  • completion handler 기반 URLSession: 요청을 시작한 뒤 함수는 빠져나오고, 응답이 오면 나중에 콜백이 호출된다. 이 경우 요청을 건 thread2가 계속 붙잡혀 기다린다고 보는 것은 적절하지 않다.
  • async/await 기반 URLSession: await 동안 기다리는 것은 thread가 아니라 task다. 작업 상태는 저장되고, thread는 다른 일을 할 수 있다.

따라서 API 통신 전체를 뭉뚱그려 “thread2가 기다린다”라고 말하면 틀릴 수 있다. 더 정확한 표현은 블로킹 API면 thread가 기다리고, 비동기 API면 작업만 나중에 이어진다다.

Question 2: lock 대기란 무엇인가

lock은 공유 자원에 동시에 접근하지 못하게 막는 장치다. 한 스레드가 이미 lock을 잡고 있으면, 다른 스레드는 그 lock이 풀릴 때까지 접근하지 못한다. 이때 발생하는 대기가 lock wait다.

lock.lock()
sharedArray.append(value)
lock.unlock()

위 상황에서 thread1이 lock.lock() 후 아직 unlock하지 않았는데 thread2도 같은 lock을 잡으려 하면, thread2는 바로 다음 코드를 실행하지 못하고 멈춘다. 이것이 lock 대기다. lock을 오래 쥐고 있으면 다른 스레드들이 연쇄적으로 기다리게 되어 성능이 나빠질 수 있다.

Question 3: await 시 작업 상태는 어디에 저장되나

원문 기준으로, await로 인해 중지되면 이후에 다시 실행하는 데 필요한 데이터는 힙(heap) 영역에 저장된다. 그래서 현재 함수 호출 스택을 계속 붙잡고 있을 필요가 없다.

이 저장된 상태에는 재개 위치, 지역 변수 중 이후에도 필요한 값, 이어서 수행할 논리적 task 정보가 포함된다고 이해하면 된다. 중요한 점은 상태가 스레드 자체에 묶여 있는 것이 아니라, 재개 가능한 task 상태로 관리된다는 것이다.

Practical Reading

  • GCD: 스레드가 블로킹될 수 있음
  • Swift Concurrency: task 상태를 힙에 저장하고 스레드를 반납할 수 있음
  • 그래서 차이는 “stack에 매달린 실행”과 “heap에 저장된 재개 가능 상태”로 보면 이해가 쉬움

Question 4: Task는 객체인가

Apple 문서 기준으로 Task비동기 작업의 단위(a unit of asynchronous work)이며, Swift 타입으로는 struct Task<Success, Failure>다. 즉 일반적인 의미의 reference type 객체라고 부르기보다는, 비동기 실행 단위를 다루기 위한 Swift 값 타입 핸들로 이해하는 편이 정확하다.

다만 실무에서는 “task 하나가 실행 중이다”처럼 개념적으로 객체처럼 말하는 경우가 많다. 중요한 것은 Task 값 자체실제로 런타임에서 수행 중인 비동기 작업을 완전히 같은 것으로 단순화하지 않는 것이다.

Question 5: Task와 Continuation의 관계

continuation은 현재 task를 나중에 다시 이어서 실행할 수 있게 해 주는 연결점이다. Apple 문서 표현 그대로, withCheckedContinuation 계열 함수는 현재 task에 대한 continuation을 closure에 전달하고, closure가 끝나면 calling task는 suspended 된다.

이후 외부 콜백이나 이벤트가 도착했을 때 resume(...)을 호출하면, 그 continuation이 가리키던 suspended task가 다시 이어진다. 즉 관계를 짧게 쓰면 continuation은 task를 재개하기 위한 핸들이다.

Question 6: Continuation과 Resume 차이

  • Continuation: 중단된 task를 나중에 다시 시작할 수 있도록 들고 있는 재개용 수단
  • Resume: 그 continuation에 대해 실제로 재개를 수행하는 동작

따라서 둘은 같은 레벨의 개념이 아니다. continuation은 “무엇으로 재개할지”이고, resume은 “그것으로 실제 재개하는 행위”다.

try await withCheckedThrowingContinuation { continuation in
    legacyAPI.fetch { result in
        switch result {
        case .success(let value):
            continuation.resume(returning: value)
        case .failure(let error):
            continuation.resume(throwing: error)
        }
    }
}

위 코드에서 continuation이 재개 핸들이고, resume(returning:) 또는 resume(throwing:)이 실제 재개 호출이다.

Question 7: Suspend 사용 코드 예시

Swift에서 보통 suspend를 직접 호출하지는 않는다. awaitTask.sleep, Task.yield(), continuation 기반 API에서 현재 task가 suspend 된다.

func example() async throws {
    print("1. before await")
    try await Task.sleep(for: .seconds(1))
    print("2. after await")
}

위 코드에서 Task.sleep을 await 하는 동안 현재 task는 suspend 된다. 이때 스레드는 그 task를 붙잡고 계속 기다릴 필요가 없고, 다른 작업에 사용될 수 있다.

func loadName() async -> String {
    await withCheckedContinuation { continuation in
        legacyLoadName { name in
            continuation.resume(returning: name)
        }
    }
}

이 예시에서는 closure가 끝난 뒤 현재 task가 suspend 되고, 나중에 콜백에서 resume이 호출되면 중단됐던 task가 다시 이어진다.

Serial queue가 completion을 기다리는 것은 아니다

serial queue가 보장하는 것은 이전 block이 끝난 뒤 다음 block을 실행한다는 점이다. 여기서 중요한 것은 "비동기 작업의 실제 완료"가 아니라 "현재 block의 종료"다.

let serialQueue = DispatchQueue(label: "serial")

serialQueue.async {
    apiCall { result in
        print("task1 completion")
    }
}

serialQueue.async {
    print("task2 start")
}

위 코드에서 첫 번째 block은 apiCall(...)을 시작하고 바로 리턴할 수 있다. 그러면 serial queue 입장에서는 첫 block이 이미 끝난 것이므로, completion이 오기 전에도 두 번째 block을 시작할 수 있다.

Concurrent queue와의 차이

두 queue 모두 비동기 API의 completion 자체를 자동으로 기다려 주지는 않는다. 차이는 다음이다.

  • serial queue: 이전 block이 끝나야 다음 block 시작
  • concurrent queue: 이전 block이 아직 실행 중이어도 다음 block 시작 가능
  • FIFO 의미: 보통 task1이 먼저 큐에서 빠지지만, completion 순서까지 보장하는 것은 아니다
안전한 표현: concurrent queue는 "task2가 task1보다 먼저 큐에서 빠진다"기보다, task1이 끝나기 전에 task2도 실행을 시작할 수 있다고 쓰는 편이 정확하다.

completion 순서까지 보장하고 싶다면

completion 순서 보장은 queue 종류만으로 해결되는 문제가 아니다. 비동기 작업의 "실제 완료 시점"까지 순서를 묶고 싶다면, 흐름 제어 수단을 별도로 써야 한다.

1. completion 안에서 다음 작업 시작

가장 단순한 방식이다. task1의 completion에서 task2를 호출하면 completion 순서가 강제된다. 다만 callback pyramid가 되기 쉽다.

2. async/await로 직렬 표현

실무에서 가장 읽기 좋다. let a = try await fetchA() 다음 줄에 try await fetchB()를 쓰면 논리적 완료 순서가 코드 순서와 맞아 떨어진다.

3. OperationQueue dependency

오래된 코드나 취소, 의존성, 상태 추적이 많은 경우 유용하다. operation 간 선행 관계를 명시적으로 걸 수 있다.

4. DispatchGroup / semaphore

GCD 레벨에서 묶을 수는 있다. 하지만 semaphore로 스레드를 블로킹하는 방식은 현대 iOS 코드에서 기본 선택으로 추천하지 않는다.

실무에서는 어떻게 하나

  • 새 코드면 보통 async/await로 순서를 표현한다.
  • 여러 요청을 동시에 던지고 모두 끝난 뒤 모으려면 TaskGroup / async let을 쓴다.
  • completion handler 기반 레거시 API면 우선 continuation으로 async 함수로 감싸는 것을 고려한다.
  • 정말 GCD만 써야 하는 레거시 구간이면 completion 체이닝이나 DispatchGroup을 쓴다.
  • semaphore로 serial queue를 억지로 막아 completion 순서를 맞추는 방식은 마지막 수단에 가깝다.

한 줄 요약

실무에서는 "queue를 무엇으로 만들까"보다 순서를 보장해야 하는 단위가 block 실행 순서인지, 실제 비동기 완료 순서인지를 먼저 구분한다. 대부분의 혼란은 이 둘을 섞어서 생각할 때 생긴다.

요약: block 순서 제어가 필요하면 queue 특성을 보고, completion 순서 제어가 필요하면 async/await, dependency, callback chaining 같은 상위 흐름 제어를 본다.

Then 8

serial queue에 넣었는데 왜 순서가 깨진 것처럼 보이나

흔한 이유는 block 순서와 completion 순서를 혼동했기 때문이다. serial queue는 block 실행 시작 순서는 직렬화하지만, block 안에서 시작한 비동기 작업의 completion 시점까지 묶어 주지는 않는다.

Then 9

completion 순서를 맞추려고 serial queue + semaphore를 쓰면 안 되나

가능은 하지만 기본 선택으로는 좋지 않다. 스레드를 블로킹하고, 잘못 쓰면 deadlock과 성능 저하를 만들기 쉽다. 새 코드라면 async/await로 순서를 표현하는 편이 더 명확하고 유지보수성이 높다.

Then 10

concurrent queue에서도 task1이 먼저 큐에서 빠지면 결국 시작 순서도 보장되는 것 아닌가

큐에서 빠지는 순서와 실제 CPU에서 실행이 전개되는 순서는 완전히 같은 개념이 아니다. concurrent queue는 task1이 먼저 dequeued되더라도, task2가 곧바로 다른 워커에서 실행을 시작해 체감상 거의 동시에 시작될 수 있다. 그래서 설계할 때는 dequeue 순서보다 overlap 가능성을 더 중요하게 봐야 한다.

Then 11

순서를 보장해야 하는데도 일부는 병렬화하고 싶으면 어떻게 하나

단계별로 나누면 된다. 예를 들어 1단계에서 여러 작업을 병렬로 수행하고, 그 결과를 모두 모은 뒤 2단계를 직렬로 이어 가는 식이다. Swift Concurrency에서는 async let, TaskGroup, 그 다음 순차 await 조합이 가장 읽기 좋다.

QA Checklist

  • concurrent queue를 “완전 랜덤 실행”으로 설명하지 않았는지 확인한다.
  • FIFO를 “완료 순서”가 아니라 “큐에서 시작되는 순서”로 설명했는지 확인한다.
  • await를 스레드 블로킹이 아니라 task suspension으로 설명했는지 확인한다.
  • URLSession 비교의 한계를 명확히 적었는지 확인한다.

Operations / Rollout Checklist

  • 팀 내 공유 시 “GCD는 스레드가 기다리고, Swift Concurrency는 task가 기다린다” 문장을 핵심 요약으로 사용한다.
  • 성능 비교 실험을 추가한다면 URLSession 대신 사용자 정의 async 함수와 CPU 작업으로 분리해서 측정한다.
  • 후속 문서에서는 Actor, MainActor, Task priority 상속을 별도 주제로 분리한다.