쉬운 설명
여러 사람에게 심부름을 시킬 때, "한 명 끝나면 다음 사람 시키기"도 있고, "다 같이 보낸 뒤 전부 끝나면 모으기"도 있다. 여기 문서는 그 순서를 코드로 어떻게 표현하는지 모아 둔 것이다.
completion 순서, 선행 관계, 병렬 후 수집 같은 흐름을 실제 코드로 확인하기 위한 예시 모음이다. GCD, OperationQueue, async/await, TaskGroup을 각각 어떤 상황에 쓰는지 비교할 수 있게 정리한다.
2026-04-14
여러 사람에게 심부름을 시킬 때, "한 명 끝나면 다음 사람 시키기"도 있고, "다 같이 보낸 뒤 전부 끝나면 모으기"도 있다. 여기 문서는 그 순서를 코드로 어떻게 표현하는지 모아 둔 것이다.
오래된 코드나 취소, 상태 추적, 의존성 그래프가 중요한 경우에 유용하다. operation 객체 사이에 선행 관계를 명시적으로 건다.
import Foundation
final class FetchUserOperation: Operation {
private(set) var result: String?
override func main() {
if isCancelled { return }
print("1. 유저 정보 요청 시작")
Thread.sleep(forTimeInterval: 1.0)
if isCancelled { return }
result = "user_123"
print("2. 유저 정보 요청 완료")
}
}
final class FetchPostsOperation: Operation {
private let userOperation: FetchUserOperation
init(userOperation: FetchUserOperation) {
self.userOperation = userOperation
}
override func main() {
if isCancelled { return }
guard let userID = userOperation.result else { return }
print("3. \\(userID)로 게시글 요청 시작")
Thread.sleep(forTimeInterval: 1.0)
if isCancelled { return }
print("4. 게시글 요청 완료")
}
}
let queue = OperationQueue()
let userOp = FetchUserOperation()
let postsOp = FetchPostsOperation(userOperation: userOp)
postsOp.addDependency(userOp)
queue.addOperations([userOp, postsOp], waitUntilFinished: false)postsOp.addDependency(userOp)가 순서를 만든다.
queue가 concurrent여도 dependency가 있으면 선행 관계를 지킨다.
여러 비동기 작업을 동시에 시작하고, 모두 끝난 뒤 한 번 모으는 패턴에 적합하다. fan-out 후 join이 필요할 때 단순하다.
import Foundation
let group = DispatchGroup()
let queue = DispatchQueue.global()
group.enter()
queue.asyncAfter(deadline: .now() + 1) {
print("A 완료")
group.leave()
}
group.enter()
queue.asyncAfter(deadline: .now() + 2) {
print("B 완료")
group.leave()
}
group.notify(queue: .main) {
print("A, B 모두 끝난 뒤 UI 업데이트")
}enter/leave 균형이 중요하다.
"모두 끝남"은 잘 표현하지만, 복잡한 선후관계 그래프 표현에는 약하다.
흐름을 강제로 막아 completion 순서를 맞출 수는 있다. 하지만 스레드를 블로킹하므로 기본 선택으로는 추천하지 않는다.
import Foundation
let serialQueue = DispatchQueue(label: "serial")
let semaphore = DispatchSemaphore(value: 0)
serialQueue.async {
print("1. task1 시작")
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
print("2. task1 completion")
semaphore.signal()
}
semaphore.wait()
print("3. task1 block 종료")
}
serialQueue.async {
print("4. task2 시작")
}completion 순서를 코드 순서로 가장 읽기 좋게 표현하는 방식이다. 새 코드에서 순차 흐름이 필요하면 보통 이걸 먼저 본다.
func fetchUser() async throws -> String {
try await Task.sleep(for: .seconds(1))
return "user_123"
}
func fetchPosts(for userID: String) async throws -> [String] {
try await Task.sleep(for: .seconds(1))
return ["post1", "post2"]
}
func loadScreen() async throws {
let userID = try await fetchUser()
let posts = try await fetchPosts(for: userID)
print(userID, posts)
}독립적인 몇 개 작업을 간단히 병렬화하고, 나중에 결과를 합칠 때 가장 가볍다. 정적인 개수의 병렬 작업에 적합하다.
func fetchProfile() async throws -> String {
try await Task.sleep(for: .seconds(1))
return "profile"
}
func fetchSettings() async throws -> String {
try await Task.sleep(for: .seconds(1))
return "settings"
}
func loadHome() async throws {
async let profile = fetchProfile()
async let settings = fetchSettings()
let result = try await (profile, settings)
print(result.0, result.1)
}try await (profile, settings)에서 둘 다 완료되기를 기다린다.
개수가 고정되어 있으면 TaskGroup보다 더 단순하다.
async let 작업은 취소된다.
동적으로 여러 작업을 병렬로 만들고, 끝난 결과를 모으는 데 적합하다. 개수가 런타임에 달라질 수 있을 때 유용하다.
func fetchImage(id: Int) async throws -> String {
try await Task.sleep(for: .milliseconds(300))
return "image_\\(id)"
}
func loadImages(ids: [Int]) async throws -> [String] {
try await withThrowingTaskGroup(of: String.self) { group in
for id in ids {
group.addTask {
try await fetchImage(id: id)
}
}
var results: [String] = []
for try await image in group {
results.append(image)
}
return results
}
}withThrowingTaskGroup에서 child task 하나가 실패하면 그룹은 결국 에러로 종료되고,
남은 child task들은 취소된다. for try await는 "모두 끝난 뒤 한 번에" 도는 것이 아니라,
끝난 결과부터 읽다가 실패를 만나면 throw로 빠진다.
"하나라도 실패하면 전체 실패"가 아니라, 실패한 작업은 버리고 성공한 결과만 모으고 싶다면
child task 안에서 throw를 바깥으로 그대로 올리지 말고 Result나 optional로 감싸는 방식이 낫다.
func fetchImage(id: Int) async throws -> String {
if id == 2 {
throw NSError(domain: "sample", code: 1)
}
try await Task.sleep(for: .milliseconds(300))
return "image_\\(id)"
}
func loadImagesAllowingFailures(ids: [Int]) async -> [String] {
await withTaskGroup(of: Result<String, Error>.self) { group in
for id in ids {
group.addTask {
do {
let image = try await fetchImage(id: id)
return .success(image)
} catch {
return .failure(error)
}
}
}
var successes: [String] = []
for await result in group {
switch result {
case .success(let image):
successes.append(image)
case .failure(let error):
print("실패 무시: \\(error)")
}
}
return successes
}
}성공한 것만 모으되 입력 순서도 유지하려면 인덱스를 같이 들고 다녀야 한다. 완료 순서와 입력 순서는 다르기 때문이다.
func loadOrderedImagesAllowingFailures(ids: [Int]) async -> [String] {
await withTaskGroup(of: (Int, String?).self) { group in
for (index, id) in ids.enumerated() {
group.addTask {
let image = try? await fetchImage(id: id)
return (index, image)
}
}
var buffer = Array<String?>(repeating: nil, count: ids.count)
for await (index, image) in group {
buffer[index] = image
}
return buffer.compactMap { $0 }
}
}왜 TaskGroup 결과는 입력 순서가 아니라 완료 순서로 오나
TaskGroup은 "병렬 작업 집합" 모델이기 때문이다. 런타임은 어떤 child task가 먼저 끝날지 가정하지 않고, 끝난 결과부터 꺼내 준다. 입력 순서를 유지하려면 인덱스를 같이 저장하고 사후 정렬해야 한다.
OperationQueue dependency가 async/await보다 여전히 유효한 경우는 언제인가
작업 객체 단위의 상태 추적, 취소 전파, 의존성 시각화, 레거시 아키텍처 적응이 중요한 경우다. 단순 순차 비동기 흐름만 필요하면 async/await가 보통 더 낫다.
DispatchGroup과 TaskGroup은 이름만 비슷한가
아니다. 둘 다 "여러 작업을 묶는다"는 점은 같지만, 모델이 다르다. DispatchGroup은 GCD 콜백 기반 묶음이고, TaskGroup은 structured concurrency 안에서 child task 생명주기를 관리한다.
semaphore로 순서를 맞추는 코드가 동작하는데 왜 나쁜가
동작 여부와 좋은 설계는 다르다. semaphore는 스레드를 블로킹하고, 잘못 쓰면 deadlock을 만든다. 특히 async 시스템 위에 block 기반 제어를 덧대면 유지보수성과 디버깅 가능성이 급격히 나빠진다.
`async let`과 throwing TaskGroup은 실패 전파가 어떻게 다른가
둘 다 기본 철학은 "하나라도 실패하면 전체 실패"에 가깝다.
다만 async let은 정적인 소수 작업을 합치는 지점에서 에러가 드러나고,
throwing TaskGroup은 완료된 결과를 일부 소비하다가도 실패를 만나면 그룹 전체가 에러로 종료될 수 있다는 차이가 있다.
왜 일부 실패 허용 패턴에서는 throwing group 대신 non-throwing group을 쓰나
실패를 제어 흐름이 아니라 데이터로 다루기 위해서다.
에러를 child task 내부에서 Result나 optional로 바꾸면,
그룹 전체가 중간에 abort되지 않고 끝까지 성공/실패를 모두 수집할 수 있다.