반응형

Swift Concurrency — 구조적 동시성 완전 이해

 

1. 들어가며

Swift Concurrency(async/await)는 iOS 개발이 2021년 이후 근본적으로 변화한 핵심 기술입니다.
기존 GCD(DispatchQueue), OperationQueue 기반의 비동기 처리보다 안전하고 읽기 쉽고 유지보수성이 월등히 뛰어납니다.
그러나 실무에서 제대로 사용하지 않으면 다음과 같은 문제가 발생합니다.

  • Task가 해제되지 않아 메모리 누수
  • Task 취소가 정상적으로 전파되지 않음
  • UI 업데이트 타이밍 문제
  • Actor 정리 부족으로 race condition 발생
  • async let / TaskGroup 오용
  • MainActor 누락으로 UI 크래시 발생

이 문서에서는 iOS 실무에서 Swift Concurrency를 안전하게, 성능 좋게, 테스트 가능하게사용하는 모든 팁을 깊이 있게 설명합니다.

 

2. 기존 비동기 처리와의 차이


2.1 GCD(DispatchQueue)

  • 클로저 기반
  • 캡처 관리 필수
  • race condition 위험 큼
  • cancel 불가

2.2 OperationQueue

  • cancel 가능
  • dependency 설정 가능
  • 오버엔지니어링 위험

2.3 Swift Concurrency(async/await)

  • 구조적 동시성(Structured Concurrency) 기반
  • Task hierarchy 존재
  • cancel 전파 자동
  • 문법이 동기 코드와 유사하여 가독성 높음
  • Actor로 데이터 경쟁 방지

 

3. Swift Concurrency 핵심 구성 요소


3.1 Task

비동기 작업을 실행하는 단위

Task {
    await loadData()
}

3.2 async/await

비동기 코드를 동기처럼 작성

let value = try await api.fetch()

3.3 Actor

데이터 경쟁을 원천 차단하는 “스레드 세이프 객체”

actor Counter {
    private var value = 0
    func increment() { value += 1 }
}

3.4 TaskGroup / async let

병렬 작업의 핵심

 

4. Structured Concurrency(구조적 동시성)란?

Swift Concurrency가 기존 비동기 방식과 가장 크게 다른 점.

  • 작업(Task)이 부모-자식 계층 구조를 가짐
  • 부모 Task가 종료되면 자식 Task도 자동 종료
  • cancel이 계층적으로 전파됨

즉, “비동기 작업이 시스템적으로 관리된다”는 것.

 

5. 실무에서 반드시 지켜야 할 원칙


5.1 Task는 필요할 때만 만들고, 필요 없으면 cancel

나쁜 예:

Task {
    await viewModel.load()
}

좋은 예:

private var task: Task<Void, Never>?

func reload() {
    task?.cancel()
    task = Task { [weak self] in
        await self?.load()
    }
}

 

5.2 UI 업데이트는 항상 MainActor에서

나쁜 예:

data = await repository.fetch()
label.text = data.name   // ❌ 백그라운드 스레드에서 UI 접근

해결:

@MainActor
final class MyViewModel {
    func updateUI(with data: Model) { ... }
}

또는

await MainActor.run {
    self.label.text = data.name
}


5.3 Task 취소 처리

func load() async throws {
    if Task.isCancelled { return }
    let result = try await api.fetch()
    if Task.isCancelled { return }
}


5.4 Task를 Fire-and-Forget으로 사용하지 말 것

❌ 잘못된 예:

func viewDidLoad() {
    Task { await self.fetch() }
}

→ 누수가 생길 가능성 높음

→ Task 생명 주기 관리 불가

 

6. async let / TaskGroup — 병렬 처리의 핵심


6.1 async let

async let a = fetchA()
async let b = fetchB()
let result = await (try a, try b)
  • 가장 빠르고 단순
  • 부모 Task가 관리

6.2 TaskGroup

let values = try await withThrowingTaskGroup(of: Int.self) { group in
    group.addTask { await fetchA() }
    group.addTask { await fetchB() }

    return try await group.reduce(into: []) { $0.append($1) }
}

복잡한 병렬 처리에 적합.

 

7. Actor — 실무에서 반드시 필요한 이유


문제:

  • 똑같은 값이 여러 스레드에서 동시에 접근
  • race condition
  • UI 튐
  • 데이터 불일치 발생

해결:

actor SafeCache {
    private var dict: [String: Data] = [:]

    func save(key: String, data: Data) {
        dict[key] = data
    }

    func load(key: String) -> Data? {
        dict[key]
    }
}

MainActor는 UIViewController 역할과 완벽한 호환

@MainActor
class MyViewController: UIViewController {}

 

8. SwiftUI와 Concurrency 조합

SwiftUI에서 async/await은 매우 강력하지만 memory leak 주의 필요.

.task {
    await viewModel.load()
}

주의:

  • SwiftUI는 body 재렌더링 됨
  • .task 내부에서 Task 생성 반복 가능 → 누수 위험

해결:

@StateObject private var viewModel = MyViewModel()

 

9. 실무 예제 — 검색 자동완성(디바운스 + 취소 처리)

func search(query: String) {
    searchTask?.cancel()

    searchTask = Task { [weak self] in
        try await Task.sleep(nanoseconds: 300_000_000) // 디바운스
        let result = try await api.search(query)
        await MainActor.run { self?.items = result }
    }
}

 

10. 체크리스트 

  •  Task를 저장하고 필요할 때 cancel 한다
  •  UI 업데이트는 항상 MainActor
  •  Task는 Fire-and-Forget 방식으로 만들지 않는다
  •  Actor 또는 MainActor를 이용해 데이터 경합 방지
  •  async let / TaskGroup으로 병렬 처리 최적화
  •  SwiftUI에서 .task 사용 시 무한 Task 생성 피하기
  •  ViewModel 내부 Task들은 deinit에서 cancel

 

11. 결론

Swift Concurrency는 iOS 개발을 근본적으로 더 안전하고 이해하기 쉬운 방식으로 변화시켰습니다.
그러나 잘못 사용하면 leak 증가, race condition 심화, UI 크래시 증가라는 부작용도 커집니다.

반응형
Posted by 까칠코더
,