🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: 13편
2부 마지막 편입니다. 13편에서 비동기 작업을 시작하는 방법을 배웠습니다. 이번 편에서는 여러 비동기 작업이 같은 데이터를 동시에 건드릴 때 생기는 문제를 다룹니다.
데이터 경쟁이란
두 Task가 동시에 같은 변수를 읽고 쓰면 결과를 예측할 수 없습니다.
// 위험한 코드 — 두 Task가 counter를 동시에 수정
var counter = 0
Task { counter += 1 }
Task { counter += 1 }
// 결과가 1일 수도, 2일 수도 있고, 예측 불가
멀티스레드 환경에서 하나의 읽기-수정-쓰기 과정이 다른 스레드의 것과 뒤섞이는 현상입니다. 이것을 데이터 경쟁(data race)이라고 합니다.
기존에는 DispatchQueue의 직렬 큐나 NSLock으로 직접 동기화해야 했습니다. Swift Concurrency는 actor라는 타입으로 이 문제를 언어 차원에서 해결합니다.
actor — 직렬 접근 보장
actor는 클래스처럼 참조 타입이지만, 내부 상태에 한 번에 하나의 Task만 접근할 수 있도록 자동으로 직렬화합니다.
actor Counter {
private var value = 0
func increment() {
value += 1
}
func get() -> Int {
return value
}
}
let counter = Counter()
// 여러 Task에서 동시에 호출해도 안전
Task { await counter.increment() }
Task { await counter.increment() }
Task { await counter.increment() }
// 잠시 후
let total = await counter.get() // 항상 3
actor 외부에서 내부 메서드를 호출할 때는 반드시 await를 써야 합니다. actor가 다른 작업을 처리 중이면 자동으로 기다리기 때문입니다. actor 내부에서 자기 자신의 메서드를 호출할 때는 await가 필요 없습니다.
actor 격리(actor isolation)
actor의 저장 프로퍼티는 외부에서 직접 접근하는 것도 await가 필요합니다.
actor BankAccount {
var balance: Double = 0
func deposit(_ amount: Double) {
balance += amount
}
}
let account = BankAccount()
await account.deposit(100)
let current = await account.balance // 프로퍼티 접근도 await
nonisolated 키워드를 붙이면 actor 격리에서 제외됩니다. 상태를 읽지 않는 순수 계산 메서드나 상수 프로퍼티에 씁니다.
actor Logger {
let name: String // 상수는 변경 없으므로
init(name: String) { self.name = name }
nonisolated func prefix() -> String {
return "[\(name)]" // await 없이 호출 가능
}
}
@MainActor 다시 보기
13편에서 나온 @MainActor는 사실 특별한 actor입니다. 메인 스레드 위에서 돌아가는 전역 actor로, UI 관련 작업을 보호합니다.
@MainActor
class ViewModel {
var items: [String] = [] // 항상 메인 스레드에서만 접근
func loadItems() async {
let data = await fetchFromNetwork() // 일시 중단 (백그라운드 가능)
items = data // 재개 후 다시 메인 스레드
}
}
SwiftUI의 @Observable 클래스는 기본적으로 @MainActor를 따릅니다. UI 상태를 저장하는 클래스에 @MainActor를 붙이는 것이 권장 패턴입니다.
Sendable — 스레드 간 안전한 타입
Sendable 프로토콜은 “이 타입은 동시성 경계를 넘어 안전하게 전달될 수 있다”는 표시입니다.
- 값 타입(struct, enum): 복사되므로 기본적으로
Sendable - actor: 내부를 보호하므로
Sendable - 일반 class: 공유 가변 상태이므로
Sendable아님 (명시적으로 채택 가능하지만 직접 보장해야 함)
struct Message: Sendable { // 구조체는 자동으로 Sendable
let text: String
let timestamp: Date
}
// Task 클로저는 Sendable 타입만 캡처 가능
Task {
let msg = Message(text: "hello", timestamp: .now)
await send(msg) // 안전
}
AsyncStream — 연속적인 비동기 값
단일 결과가 아니라 시간에 따라 계속 들어오는 값(센서 데이터, 소켓 메시지 등)을 처리할 때 씁니다.
func makeCounterStream() -> AsyncStream<Int> {
AsyncStream { continuation in
Task {
for i in 1...5 {
try? await Task.sleep(for: .seconds(1))
continuation.yield(i) // 값 하나 방출
}
continuation.finish() // 스트림 종료
}
}
}
// for await로 순서대로 받기
for await count in makeCounterStream() {
print(count) // 1초 간격으로 1, 2, 3, 4, 5 출력
}
GCD와 비교
Swift Concurrency 이전에는 DispatchQueue(GCD)로 비동기 처리를 했습니다. 둘의 차이를 간단히 비교합니다.
| GCD (DispatchQueue) | Swift Concurrency | |
|---|---|---|
| 코드 구조 | 중첩 클로저 (콜백) | 선형 (async/await) |
| 에러 전파 | 콜백마다 수동 처리 | throws와 통합 |
| 취소 | 없음 (별도 플래그) | Task.cancel() |
| 데이터 보호 | serial queue, NSLock 수동 사용 | actor 자동 보장 |
| 스레드 효율 | 스레드 블로킹 가능 | 일시 중단, 스레드 재활용 |
| 컴파일 검사 | 없음 | Sendable 위반 경고 |
GCD 코드가 있는 기존 프로젝트에서는 당장 모두 바꿀 필요는 없습니다. 13편에서 배운 withCheckedContinuation으로 기존 GCD 코드를 async로 감싸는 것이 현실적인 마이그레이션 경로입니다.
핵심 요약
- 데이터 경쟁은 여러 Task가 동시에 같은 가변 상태를 접근할 때 생긴다.
actor는 내부 상태에 한 번에 하나의 Task만 접근하도록 자동 직렬화한다.- actor 외부에서의 접근은 항상
await가 필요하다. @MainActor는 메인 스레드 위의 전역 actor다. UI 상태를 보호하는 클래스에 붙인다.Sendable은 동시성 경계를 넘어 안전하게 전달될 수 있는 타입임을 표시한다. 구조체는 기본적으로 Sendable이다.AsyncStream은 시간에 따라 여러 값을 방출하는 비동기 시퀀스다.
2부를 마치며
에러 처리(11편), 메모리 관리(12편), Swift Concurrency(13~14편)까지 실제 앱 개발에 반드시 필요한 고급 주제를 다뤘습니다. 이 네 가지 개념은 처음에 낯설지만, 코드를 쓰다 보면 “왜 이렇게 설계됐는지”가 점점 명확해집니다.
다음은 3부 — SwiftUI입니다. 지금까지 배운 Swift 언어를 바탕으로 실제 화면을 만드는 법을 다룹니다.
🤖 Generated with Claude Code