반응형
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 크래시 증가라는 부작용도 커집니다.
반응형
'Dev Study > iOS' 카테고리의 다른 글
| Transition / Animation 최적화 (0) | 2025.11.14 |
|---|---|
| AutoLayout — Hugging / Compression Resistance 이해하기 (0) | 2025.11.14 |
| 모듈화(Modularization)로 빌드 속도 최적화 (0) | 2025.11.14 |
| 네트워크 계층 — DTO / Domain 분리 (0) | 2025.11.14 |
| 메모리 누수 방지 — weak / unowned 정확히 사용하기 (0) | 2025.11.14 |
| UIKit ↔ SwiftUI 혼용 시 Life-Cycle 이해하기 (0) | 2025.11.14 |
| ViewController 비만화 방지 — 비즈니스 로직 분리 (0) | 2025.11.14 |
| WKWebView 쿠키(Cookie) 가이드 (0) | 2025.11.10 |


