Why This Work Exists
GCD와 Swift Concurrency는 둘 다 iOS에서 비동기 작업을 처리하지만, 태스크가 큐에 들어가고 실행되며 중단되고 재개되는 방식이 다르다. 이 차이를 헷갈리면 FIFO, 우선순위, 스레드 수, 컨텍스트 스위칭을 잘못 이해하기 쉽다.
LINE Engineering의 Swift Concurrency에 대해서 글을 기준으로, GCD와 Swift Concurrency의 실행 방식 차이를 개발자 관점에서 재정리한 문서다. 특히 queue의 FIFO 특성, thread explosion, await 시점의 스레드 반납, UI 업데이트 시 메인 스레드 전환을 중심으로 정리한다.
2026-04-09 2026-04-14
GCD와 Swift Concurrency는 둘 다 iOS에서 비동기 작업을 처리하지만, 태스크가 큐에 들어가고 실행되며 중단되고 재개되는 방식이 다르다. 이 차이를 헷갈리면 FIFO, 우선순위, 스레드 수, 컨텍스트 스위칭을 잘못 이해하기 쉽다.
흔한 오해는 다음과 같다. concurrent queue면 작업이 완전히 무작위로 시작된다고 생각하거나, GCD는 항상 작업당 스레드 하나를 만든다고 생각하거나, Swift Concurrency는 절대 컨텍스트 스위칭이 없다고 생각하는 경우다.
더 정확한 표현은 이렇다. GCD의 concurrent queue도 큐 진입 순서는 FIFO지만, 여러 작업이 겹쳐 실행될 수 있다. Swift Concurrency는 await에서 작업을 중단하고 스레드를 붙잡지 않기 때문에 블로킹으로 인한 유휴 스레드를 줄인다.
기본적으로는 Serial DispatchQueue와 Concurrent DispatchQueue로 이해하면 된다. serial queue는 한 번에 하나의 태스크만 실행하고, concurrent queue는 동시에 여러 태스크를 진행할 수 있다.
concurrent queue는 큐에서 태스크를 FIFO 순서로 꺼내더라도, 이미 실행 중인 앞선 태스크가 끝나기를 기다리지 않고 뒤 태스크를 다른 스레드에서 시작시킬 수 있다. 즉 시작 순서는 FIFO일 수 있어도 완료 순서는 달라질 수 있다.
LINE Engineering의 Swift Concurrency 성능 관련 글 문맥에서 중요한 포인트는, Swift Concurrency가 높은 우선순위 task를 더 빨리 실행할 수 있도록 스케줄링하려 한다는 점이다.
Swift Concurrency는 작업이 들어온 순서보다 우선순위를 더 적극적으로 반영해 실행하려 하고, 필요한 경우 starvation을 막기 위해 스레드를 추가할 수 있다.
이 장점은 현재 실행 중인 task가 Swift Concurrency 런타임의 스레드를 너무 오래 붙잡지 않을 때만 잘 드러난다. suspension point가 없으면 task는 중간에 스레드를 반납하지 못하고 계속 점유할 수 있다.
async여도 내부에 실제 대기/양보 지점이 없으면 Swift Concurrency의 장점을 살리지 못할 수 있다.Task.yield()를 넣어 명시적으로 다른 task에 실행 기회를 줄 수 있다.다만 suspension point가 너무 많아도 스케줄링 오버헤드가 커질 수 있으므로, 오래 스레드를 독점하는 구간에만 신중하게 넣는 것이 좋다.
withCheckedContinuation 블록의 시작 부분은 기본적으로 Swift Concurrency가 실행 중인 문맥에서 시작된다.
따라서 그 안에서 바로 수행하는 일반 동기 코드가 길어지면, Swift Concurrency 런타임이 사용하는 스레드를 오래 점유할 수 있다.
이때 핵심은 높은 우선순위 task를 GCD로 보내는 것이 아니다. 오히려 suspension point가 없는 무거운 동기 작업을 GCD 같은 별도 실행 문맥으로 분리해서, Swift Concurrency 쪽 실행 자원을 높은 우선순위 task를 위해 남겨두는 것이 포인트다.
func someNormalFunction(..., completionHandler: @escaping () -> Void) {
someQueue.async {
heavyWork()
completionHandler()
}
}
위 구조는 heavyWork()가 길다면 someQueue의 스레드를 점유할 수 있다.
그래도 Swift Concurrency 실행 문맥에서 그대로 돌리는 것보다는, 런타임의 제한된 실행 스레드를 비워 두는 데 의미가 있다.
“GCD는 다른 스레드 작업이 끝날 때까지 thread2가 기다린다”는 말은, 현재 실행 중인 작업이 어떤 결과를 동기적으로 기다리거나 블로킹되는 API를 만나면, 그 작업을 실행하던 스레드가 그대로 점유된 채 대기한다는 뜻이다.
예를 들어 thread2에서 실행 중이던 작업 A가 중간에 세마포어 대기, 동기 I/O, 동기 디스패치, 잠금 획득 대기 같은 블로킹 지점을 만나면, 작업 A는 끝나지 않았으므로 thread2도 반환되지 않는다. 그 스레드는 OS 관점에서 잠들어 있거나 대기 중일 수 있지만, 런타임이 자유롭게 다른 GCD 작업을 올려 쓸 수 있는 상태는 아니다.
반면 Swift Concurrency의 await는 블로킹이 아니라 suspension이다. 작업 B가 await를 만나면 필요한 상태를 저장해 두고, 해당 스레드는 즉시 런타임에 반환되어 다른 태스크 실행에 재사용될 수 있다. 그래서 차이의 핵심은 GCD는 종종 스레드가 기다리고, Swift Concurrency는 작업이 기다린다는 점이다.
// 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 반영은 둘 다 메인 스레드에서 일어나야 한다.
무조건 그렇다고 쓰면 부정확하다. 무엇으로 네트워크를 기다리느냐에 따라 다르다.
await 동안 기다리는 것은 thread가 아니라 task다. 작업 상태는 저장되고, thread는 다른 일을 할 수 있다.따라서 API 통신 전체를 뭉뚱그려 “thread2가 기다린다”라고 말하면 틀릴 수 있다. 더 정확한 표현은 블로킹 API면 thread가 기다리고, 비동기 API면 작업만 나중에 이어진다다.
lock은 공유 자원에 동시에 접근하지 못하게 막는 장치다. 한 스레드가 이미 lock을 잡고 있으면, 다른 스레드는 그 lock이 풀릴 때까지 접근하지 못한다. 이때 발생하는 대기가 lock wait다.
lock.lock() sharedArray.append(value) lock.unlock()
위 상황에서 thread1이 lock.lock() 후 아직 unlock하지 않았는데 thread2도 같은 lock을 잡으려 하면,
thread2는 바로 다음 코드를 실행하지 못하고 멈춘다. 이것이 lock 대기다.
lock을 오래 쥐고 있으면 다른 스레드들이 연쇄적으로 기다리게 되어 성능이 나빠질 수 있다.
원문 기준으로, await로 인해 중지되면 이후에 다시 실행하는 데 필요한 데이터는 힙(heap) 영역에 저장된다.
그래서 현재 함수 호출 스택을 계속 붙잡고 있을 필요가 없다.
이 저장된 상태에는 재개 위치, 지역 변수 중 이후에도 필요한 값, 이어서 수행할 논리적 task 정보가 포함된다고 이해하면 된다. 중요한 점은 상태가 스레드 자체에 묶여 있는 것이 아니라, 재개 가능한 task 상태로 관리된다는 것이다.
Apple 문서 기준으로 Task는 비동기 작업의 단위(a unit of asynchronous work)이며,
Swift 타입으로는 struct Task<Success, Failure>다.
즉 일반적인 의미의 reference type 객체라고 부르기보다는, 비동기 실행 단위를 다루기 위한 Swift 값 타입 핸들로 이해하는 편이 정확하다.
다만 실무에서는 “task 하나가 실행 중이다”처럼 개념적으로 객체처럼 말하는 경우가 많다. 중요한 것은 Task 값 자체와 실제로 런타임에서 수행 중인 비동기 작업을 완전히 같은 것으로 단순화하지 않는 것이다.
continuation은 현재 task를 나중에 다시 이어서 실행할 수 있게 해 주는 연결점이다.
Apple 문서 표현 그대로, withCheckedContinuation 계열 함수는
현재 task에 대한 continuation을 closure에 전달하고,
closure가 끝나면 calling task는 suspended 된다.
이후 외부 콜백이나 이벤트가 도착했을 때 resume(...)을 호출하면,
그 continuation이 가리키던 suspended task가 다시 이어진다.
즉 관계를 짧게 쓰면 continuation은 task를 재개하기 위한 핸들이다.
따라서 둘은 같은 레벨의 개념이 아니다. 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:)이 실제 재개 호출이다.
Swift에서 보통 suspend를 직접 호출하지는 않는다. await나 Task.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가 보장하는 것은 이전 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을 시작할 수 있다.
두 queue 모두 비동기 API의 completion 자체를 자동으로 기다려 주지는 않는다. 차이는 다음이다.
completion 순서 보장은 queue 종류만으로 해결되는 문제가 아니다. 비동기 작업의 "실제 완료 시점"까지 순서를 묶고 싶다면, 흐름 제어 수단을 별도로 써야 한다.
가장 단순한 방식이다. task1의 completion에서 task2를 호출하면 completion 순서가 강제된다. 다만 callback pyramid가 되기 쉽다.
실무에서 가장 읽기 좋다.
let a = try await fetchA() 다음 줄에 try await fetchB()를 쓰면
논리적 완료 순서가 코드 순서와 맞아 떨어진다.
오래된 코드나 취소, 의존성, 상태 추적이 많은 경우 유용하다. operation 간 선행 관계를 명시적으로 걸 수 있다.
GCD 레벨에서 묶을 수는 있다. 하지만 semaphore로 스레드를 블로킹하는 방식은 현대 iOS 코드에서 기본 선택으로 추천하지 않는다.
실무에서는 "queue를 무엇으로 만들까"보다 순서를 보장해야 하는 단위가 block 실행 순서인지, 실제 비동기 완료 순서인지를 먼저 구분한다. 대부분의 혼란은 이 둘을 섞어서 생각할 때 생긴다.
serial queue에 넣었는데 왜 순서가 깨진 것처럼 보이나
흔한 이유는 block 순서와 completion 순서를 혼동했기 때문이다. serial queue는 block 실행 시작 순서는 직렬화하지만, block 안에서 시작한 비동기 작업의 completion 시점까지 묶어 주지는 않는다.
completion 순서를 맞추려고 serial queue + semaphore를 쓰면 안 되나
가능은 하지만 기본 선택으로는 좋지 않다. 스레드를 블로킹하고, 잘못 쓰면 deadlock과 성능 저하를 만들기 쉽다. 새 코드라면 async/await로 순서를 표현하는 편이 더 명확하고 유지보수성이 높다.
concurrent queue에서도 task1이 먼저 큐에서 빠지면 결국 시작 순서도 보장되는 것 아닌가
큐에서 빠지는 순서와 실제 CPU에서 실행이 전개되는 순서는 완전히 같은 개념이 아니다. concurrent queue는 task1이 먼저 dequeued되더라도, task2가 곧바로 다른 워커에서 실행을 시작해 체감상 거의 동시에 시작될 수 있다. 그래서 설계할 때는 dequeue 순서보다 overlap 가능성을 더 중요하게 봐야 한다.
순서를 보장해야 하는데도 일부는 병렬화하고 싶으면 어떻게 하나
단계별로 나누면 된다.
예를 들어 1단계에서 여러 작업을 병렬로 수행하고,
그 결과를 모두 모은 뒤 2단계를 직렬로 이어 가는 식이다.
Swift Concurrency에서는 async let, TaskGroup, 그 다음 순차 await 조합이 가장 읽기 좋다.