Swift Concurrency 정리 (Swift 6 · iOS 18 · Xcode 26)

이경규·2025년 9월 17일

Swift Concurrency 정리 (Swift 6 · iOS 18 · Xcode 26)

Swift 6의 actor, Task, TaskGroup, 취소(cancellation), @MainActor 등 동시성 도구를 단계별로 정리합니다. 코드 예제와 함께 실제로 어떻게 쓰는지 바로 복사해서 붙여넣을 수 있도록 구성했습니다.

전제(환경)
• Swift 6 이상
• iOS 18 이상
• Xcode 26 이상

들어가기 전 — 비유로 빠르게 이해하기

앱을 한 명의 바텐더라고 생각하면 이해하기 쉽습니다. 여러 손님 주문을 동시에 처리해야 하고, 전화도 오고, 새 음료도 만들어야 합니다. 동시성은 앱이 여러 작업을 ‘겉보기상 동시에’ 효율적으로 처리하게 해주지만, 손님끼리 음료가 뒤섞이면 안 되므로 데이터 소유권과 동기화가 중요합니다.

  1. 핵심 개념 정리
    • Task: 비동기 작업 단위. async/await와 함께 사용.
    • TaskGroup: 여러 Task를 묶어 병렬 실행하고 결과를 모으는 구조.
    • actor: 내부 상태를 순차적으로 보호하는 격리(격리된 상태 소유자).
    • @MainActor: UI 업데이트를 메인 스레드에서 안전하게 수행하도록 보장.
    • 취소(cancellation): Task를 중간에 멈추는 메커니즘. 긴 작업은 중간중간 취소 신호를 확인해야 한다.

  1. actor — 상태 격리
actor Counter {
    private var value: Int = 0

    func increment() {
        value += 1
    }

    func get() -> Int {
        return value
    }
}

사용 예:

let counter = Counter()

Task {
    await counter.increment()
    let v = await counter.get()
    print("counter = \(v)")
}

설명: actor는 내부의 가변 상태에 대한 동시 접근을 자동으로 직렬화한다. 외부에서 바로 상태를 변경할 수 없으므로 데이터 경쟁을 방지한다.

  1. Task와 async/await — 네트워크 등 비동기 기본
func fetchData(from url: URL) async throws -> Data {
    let (data, _) = try await URLSession.shared.data(from: url)
    return data
}

Task {
    do {
        let data = try await fetchData(from: URL(string: "https://example.com")!)
        print("data size: \(data.count)")
    } catch {
        print("fetch error:", error)
    }
}

설명: await는 비동기 작업이 끝날 때까지 현재 Task를 일시 중단한다. Task {} 내부에서 await를 사용할 수 있다.

  1. TaskGroup — 병렬 작업과 결과 집계
func fetchAll(_ urls: [URL]) async throws -> [Data] {
    return try await withThrowingTaskGroup(of: Data?.self) { group in
        for url in urls {
            group.addTask {
                let (data, _) = try await URLSession.shared.data(from: url)
                return data
            }
        }

        var results = [Data]()
        for try await data in group {
            if let d = data { results.append(d) }
        }
        return results
    }
}

설명: withThrowingTaskGroup를 사용하면 자식 Task의 생명주기를 부모가 관리하므로 에러/취소 전파가 일관된다. 작업이 완료되는 순서대로 결과를 처리한다.

  1. 취소 처리 — 중간에 멈추기
func performLongOperation() async throws {
    try Task.checkCancellation()    // 시작 즉시 취소 확인

    let handle = openSomeResource()
    defer { handle.close() }        // 취소되어도 리소스 정리

    for i in 0..<1000 {
        try Task.checkCancellation() // 루프 중간중간 취소 확인
        // 긴 작업 수행...
    }
}

설명: Task.checkCancellation()은 취소 시 CancellationError를 던져 빠르게 종료한다. defer로 리소스 정리를 보장한다.

  1. UI 업데이트: @MainActor 사용
@MainActor
class ViewModel: ObservableObject {
    @Published private(set) var items: [String] = []

    func load(urls: [URL]) {
        Task {
            do {
                let dataList = try await fetchAll(urls)
                items = dataList.map { String(data: $0, encoding: .utf8) ?? "" }
            } catch {
                // 에러 처리
            }
        }
    }
}

설명: @MainActor를 적용하면 ViewModel의 상태 변경이 메인 스레드에서 안전하게 일어난다. UI 바인딩이 안전해진다.

  1. 중복 요청 방지 (in-flight dedupe)
@MainActor
final class ImageService {
    private var inFlight = [URL: Task<UIImage?, Error>]()

    func image(for url: URL) async throws -> UIImage? {
        if let task = inFlight[url] {
            return try await task.value
        }

        let task = Task {
            defer { Task { await removeInFlight(url: url) } }
            let (data, _) = try await URLSession.shared.data(from: url)
            return UIImage(data: data)
        }

        inFlight[url] = task
        return try await task.value
    }

    private func removeInFlight(url: URL) {
        inFlight.removeValue(forKey: url)
    }
}

설명: 동일 URL에 대한 기존 Task가 있으면 재사용해 네트워크/CPU 낭비를 줄인다.

  1. 실제로 해볼 연습 과제
    1. 버튼을 누르면 5개의 URL을 fetchAll로 동시에 받아오고, 결과 크기 합계를 화면에 보여주는 앱을 만들어 보세요.
    2. 취소 버튼을 만들어 긴 루프 작업을 중간에 멈추도록 구현해 보세요.
    3. 같은 이미지를 여러 셀에서 동시에 요청하는 목록을 만들어 요청 수가 중복되지 않는지 로깅해 보세요.

  1. 흔한 실수와 해결법
    • actor 내부에서 오래 걸리는 작업을 실행하면 actor가 막혀 다른 요청을 지연시킬 수 있다. → actor는 상태 관리만 하고, 네트워크 호출은 actor 밖에서 수행하거나 별도 Task로 분리한다.
    • UI 업데이트를 백그라운드에서 수행하면 불안정해진다. → @MainActor로 ViewModel을 지정하거나 await MainActor.run { ... }을 사용한다.
    • 취소를 확인하지 않고 루프를 계속 실행하면 불필요한 연산과 리소스 낭비가 발생한다. → 루프 내부에 try Task.checkCancellation()를 넣어 취소에 즉시 응답하도록 한다.

  1. 코드 리뷰 체크리스트 (복사해서 사용 가능)
    • 공유하는 값(mutable state)은 actor 또는 @MainActor로 보호되어 있는가?
    • 장기 작업(파일 처리, 대량 연산 등)에 취소 체크가 있는가?
    • 동일 리소스에 대한 중복 네트워크 요청을 방지하는가(in-flight dedupe)?
    • UI 업데이트는 MainActor 내에서 실행되는가?
    • TaskGroup / async let 등 구조화된 동시성을 사용해 작업 라이프사이클을 관리하는가?

결론

동시성 도구를 적절히 사용하면 여러 작업을 안전하고 효율적으로 처리할 수 있습니다. actor로 상태 소유권을 명확히 하고, Task/TaskGroup으로 작업 라이프사이클을 구조화하며, 취소와 메인 스레드 접근을 일관되게 처리하면 안정성과 성능이 개선됩니다. 위 코드를 직접 타이핑해 실행해 보면 개념이 더 빨리 체화됩니다.

profile
iOS 앱 개발자

0개의 댓글