콘텐츠 대표 이미지 - Swift 동시성과 async/awaitActor를 통한 비동기 프로그래밍
🚀 Swift Concurrency Deep Dive

Swift 동시성과 async/await
Actor를 통한 비동기 프로그래밍

⚡ 복잡한 비동기 코드, 이제 진짜 쉽게 이해해보자!

야, 솔직히 말해봐. 예전에 Swift로 비동기 코드 짜다가 콜백 지옥에 빠져서 멘탈 나간 적 있지? 😅
클로저 안에 클로저, 그 안에 또 클로저... 코드가 피라미드처럼 쌓여가는 그 느낌. 거기다 스레드 안전성 문제까지 터지면 진짜 손 놓고 싶어지잖아.

근데 Apple이 Swift 5.5부터 async/await와 Actor를 들고 나타났어. 그리고 이게 진짜 게임 체인저야. 오늘은 이 녀석들을 처음부터 끝까지 제대로 파헤쳐볼 거야. 어렵게 생각하지 말고, 친구한테 설명하듯 쉽게 풀어줄게! 🎯

Swift 동시성 진화의 역사 😱 콜백 지옥 클로저 중첩 가독성 최악 Swift 초기 🔧 GCD / OperationQueue 스레드 관리 복잡한 동기화 Swift 4~5.4 ✨ async/await 직관적인 문법 에러 처리 통합 가독성 극대화 Swift 5.5+ 🛡️ Actor 데이터 레이스 방지 스레드 안전 보장 컴파일 타임 검증 Swift 5.5+ 🎯 Swift Concurrency = async/await + Actor + Structured Concurrency Apple이 설계한 현대적 비동기 프로그래밍의 완성형

🤔 왜 비동기 프로그래밍이 필요한 거야?

먼저 기초부터 잡고 가자. 비동기 프로그래밍이 왜 필요한지 모르면 async/await도 그냥 외우는 거에 불과하거든.

동기(Synchronous) vs 비동기(Asynchronous)

상상해봐. 너가 카페에서 커피를 주문했어. 두 가지 방식이 있어:

😤 동기 방식

카운터 앞에 서서 커피가 나올 때까지 꼼짝도 안 하고 기다림.
커피 나오기 전까지 다른 건 아무것도 못 함.
뒤에 줄 선 사람들? 알 바 없음.

😎 비동기 방식

주문하고 진동벨 받아서 자리에 앉음.
그 사이에 폰도 보고, 친구랑 얘기도 함.
벨 울리면 그때 가서 커피 픽업!

앱에서도 똑같아. 서버에서 데이터 받아오는 동안 UI가 얼어붙으면 사용자 입장에서 얼마나 짜증나겠어? 그래서 비동기 처리가 필수인 거야. 🎯

메인 스레드(Main Thread)의 소중함

iOS 앱에서 메인 스레드는 UI 업데이트를 담당하는 유일한 스레드야. 이 친구가 바빠지면 앱이 버벅거리고, 심하면 ANR(앱 응답 없음) 상태가 돼버려.

📌 핵심 규칙
네트워크 요청, 파일 I/O, 무거운 연산 → 백그라운드 스레드에서 처리
UI 업데이트, 사용자 인터랙션 → 메인 스레드에서만 처리

이걸 지키면서 코드를 짜는 게 예전엔 진짜 힘들었어. GCD(Grand Central Dispatch)로 DispatchQueue.global().async 쓰고, 결과 받으면 DispatchQueue.main.async로 다시 메인으로 넘기고... 이게 반복되면 코드가 어떻게 되는지 볼까?

// 😱 예전 방식 - 콜백 지옥의 시작
func loadUserProfile(userId: String) {
    networkManager.fetchUser(userId: userId) { result in
        switch result {
        case .success(let user):
            networkManager.fetchPosts(userId: user.id) { postsResult in
                switch postsResult {
                case .success(let posts):
                    networkManager.fetchComments(postId: posts.first!.id) { commentsResult in
                        switch commentsResult {
                        case .success(let comments):
                            DispatchQueue.main.async {
                                self.updateUI(user: user, posts: posts, comments: comments)
                            }
                        case .failure(let error):
                            DispatchQueue.main.async {
                                self.showError(error)
                            }
                        }
                    }
                case .failure(let error):
                    DispatchQueue.main.async {
                        self.showError(error)
                    }
                }
            }
        case .failure(let error):
            DispatchQueue.main.async {
                self.showError(error)
            }
        }
    }
}

⚠️ 이게 바로 콜백 지옥(Callback Hell)이야!
들여쓰기가 점점 깊어지고, 에러 처리가 각 단계마다 중복되고, 코드 흐름을 따라가기가 너무 힘들어. 유지보수? 그냥 포기하고 싶어지는 수준이지.

✨ async/await: 비동기 코드를 동기처럼!

자, 이제 진짜 주인공 등장! Swift 5.5에서 도입된 async/await는 비동기 코드를 마치 동기 코드처럼 읽히게 만들어주는 마법이야. 위의 콜백 지옥 코드를 async/await로 바꿔보면:

// ✨ async/await 방식 - 얼마나 깔끔해!
func loadUserProfile(userId: String) async throws {
    let user = try await networkManager.fetchUser(userId: userId)
    let posts = try await networkManager.fetchPosts(userId: user.id)
    let comments = try await networkManager.fetchComments(postId: posts.first!.id)
    
    await MainActor.run {
        updateUI(user: user, posts: posts, comments: comments)
    }
}

🎉 차이가 느껴지지?
코드가 위에서 아래로 자연스럽게 읽혀. 에러 처리도 throws 하나로 통합됐고, 들여쓰기 지옥도 사라졌어. 이게 async/await의 힘이야!

async 함수 선언하기

async 키워드는 "이 함수는 비동기적으로 실행될 수 있어"라고 선언하는 거야. 함수 시그니처에서 반환 타입 앞에 붙여줘:

// 기본 async 함수
func fetchData() async -> Data {
    // 비동기 작업 수행
    return someData
}

// 에러를 던질 수 있는 async 함수
func fetchUser(id: String) async throws -> User {
    // 실패할 수 있는 비동기 작업
    return user
}

// async 함수는 async 컨텍스트에서만 호출 가능!
func someAsyncFunction() async {
    let data = await fetchData()           // await 필수!
    let user = try await fetchUser(id: "1") // throws + async = try await
}

await: "여기서 잠깐 기다릴게"

await는 "이 비동기 작업이 완료될 때까지 여기서 일시 정지할게"라는 의미야. 중요한 건 스레드를 블로킹하지 않는다는 거야!

💡 await의 동작 원리
1️⃣ await를 만나면 현재 함수 실행이 일시 중단(suspend)됨
2️⃣ 스레드는 다른 작업을 처리하러 감 (블로킹 X!)
3️⃣ 비동기 작업이 완료되면 함수가 재개(resume)됨
4️⃣ 재개될 때 꼭 같은 스레드일 필요는 없음

// await의 동작을 이해하는 예시
func processImages() async {
    print("1. 이미지 처리 시작")
    
    // 여기서 일시 중단 - 스레드는 다른 일 처리
    let image = await downloadImage(url: imageURL)
    
    // 다운로드 완료 후 재개
    print("2. 이미지 다운로드 완료")
    
    // 또 일시 중단
    let processed = await applyFilter(to: image)
    
    // 필터 적용 완료 후 재개
    print("3. 필터 적용 완료")
    
    await saveImage(processed)
    print("4. 저장 완료!")
}

기존 코드를 async/await로 변환하기

기존에 completion handler 방식으로 작성된 코드를 async/await로 변환할 때는 withCheckedContinuation이나 withCheckedThrowingContinuation을 사용해:

// 기존 completion handler 방식
func oldFetchData(completion: @escaping (Result<Data, Error>) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
        if let error = error {
            completion(.failure(error))
        } else if let data = data {
            completion(.success(data))
        }
    }.resume()
}

// async/await 래퍼로 변환
func fetchData() async throws -> Data {
    return try await withCheckedThrowingContinuation { continuation in
        oldFetchData { result in
            switch result {
            case .success(let data):
                continuation.resume(returning: data)
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }
}

// 이제 깔끔하게 사용 가능!
func loadContent() async throws {
    let data = try await fetchData()
    // data 사용...
}

💡 Tip! withCheckedContinuation은 에러 없는 경우, withCheckedThrowingContinuation은 에러가 발생할 수 있는 경우에 사용해. "Checked"가 붙은 버전은 continuation을 정확히 한 번만 호출하는지 런타임에서 검증해줘서 안전해!

🏗️ Task와 구조적 동시성(Structured Concurrency)

async/await를 배웠으면 이제 Task를 알아야 해. Task는 비동기 작업의 기본 단위야. 동기 컨텍스트에서 비동기 코드를 실행하거나, 여러 작업을 동시에 실행할 때 사용해.

Task 기본 사용법

// 동기 컨텍스트에서 비동기 코드 실행
class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Task로 비동기 작업 시작
        Task {
            do {
                let user = try await fetchUser(id: "123")
                // UI 업데이트는 메인 스레드에서!
                await MainActor.run {
                    self.updateUI(with: user)
                }
            } catch {
                print("에러 발생: \(error)")
            }
        }
    }
}

// Task에 우선순위 설정
Task(priority: .high) {
    await importantWork()
}

Task(priority: .background) {
    await lowPriorityWork()
}

Task 우선순위 레벨

우선순위 설명 사용 예시
.userInteractive 가장 높음, UI 반응 애니메이션, 즉각적 UI 업데이트
.userInitiated 높음, 사용자 요청 버튼 탭 후 데이터 로드
.medium 기본값 일반적인 비동기 작업
.utility 낮음 파일 다운로드, 데이터 처리
.background 가장 낮음 백업, 인덱싱, 동기화

async let: 병렬 실행의 마법 ✨

여러 비동기 작업을 동시에 실행하고 싶을 때 async let을 써봐. 이게 진짜 강력해!

// ❌ 순차 실행 - 총 3초 소요 (1초 + 1초 + 1초)
func loadDataSequentially() async throws {
    let user = try await fetchUser()        // 1초 기다림
    let posts = try await fetchPosts()      // 1초 기다림
    let settings = try await fetchSettings() // 1초 기다림
    
    updateUI(user: user, posts: posts, settings: settings)
}

// ✅ 병렬 실행 - 총 1초 소요 (동시에 실행!)
func loadDataInParallel() async throws {
    async let user = fetchUser()        // 시작만 하고 넘어감
    async let posts = fetchPosts()      // 시작만 하고 넘어감
    async let settings = fetchSettings() // 시작만 하고 넘어감
    
    // 여기서 세 작업 모두 완료될 때까지 기다림
    let (u, p, s) = try await (user, posts, settings)
    
    updateUI(user: u, posts: p, settings: s)
}

🚀 성능 차이가 엄청나지?
async let을 사용하면 독립적인 비동기 작업들을 병렬로 실행해서 전체 시간을 크게 줄일 수 있어. 네트워크 요청이 여러 개일 때 특히 효과적이야!

TaskGroup: 동적인 병렬 작업

작업 개수가 런타임에 결정되는 경우엔 TaskGroup을 써야 해. 예를 들어 이미지 배열을 받아서 모두 다운로드해야 할 때:

// TaskGroup으로 동적 병렬 처리
func downloadImages(urls: [URL]) async throws -> [UIImage] {
    return try await withThrowingTaskGroup(of: UIImage.self) { group in
        // 각 URL에 대해 Task 추가
        for url in urls {
            group.addTask {
                return try await downloadImage(from: url)
            }
        }
        
        // 모든 결과 수집
        var images: [UIImage] = []
        for try await image in group {
            images.append(image)
        }
        return images
    }
}

// 에러 없는 버전
func processItems(items: [Item]) async -> [ProcessedItem] {
    return await withTaskGroup(of: ProcessedItem.self) { group in
        for item in items {
            group.addTask {
                return await processItem(item)
            }
        }
        
        var results: [ProcessedItem] = []
        for await result in group {
            results.append(result)
        }
        return results
    }
}

Task 취소(Cancellation)

Task는 취소할 수 있어. 예를 들어 사용자가 화면을 떠났을 때 진행 중인 네트워크 요청을 취소하고 싶다면:

class ImageLoader: ObservableObject {
    private var loadingTask: Task<Void, Never>?
    
    func loadImage(url: URL) {
        // 기존 Task 취소
        loadingTask?.cancel()
        
        // 새 Task 시작
        loadingTask = Task {
            do {
                let image = try await downloadImage(from: url)
                
                // 취소됐는지 확인
                guard !Task.isCancelled else { return }
                
                await MainActor.run {
                    self.image = image
                }
            } catch is CancellationError {
                print("작업이 취소됐어!")
            } catch {
                print("에러: \(error)")
            }
        }
    }
    
    func cancelLoading() {
        loadingTask?.cancel()
    }
}

// 취소 가능한 작업 작성 시 주의사항
func longRunningWork() async throws {
    for i in 0..<1000 {
        // 주기적으로 취소 여부 확인!
        try Task.checkCancellation()
        
        await processItem(i)
    }
}

💡 취소 처리 팁!
Task.isCancelled: 취소 여부 확인 (Bool 반환)
Task.checkCancellation(): 취소됐으면 CancellationError throw
TaskGroup에서 부모 Task가 취소되면 자식 Task들도 자동으로 취소돼!

async/await 실행 흐름 시각화 시간 메인 스레드 백그라운드 스레드 네트워크 I/O 함수 실행 시작 await 만남 ⏸ ← 일시 중단! 비동기 작업 처리 네트워크 요청 데이터 수신 대기 응답 파싱 완료! 스레드 해방! 다른 UI 작업 이벤트 처리 애니메이션 등 처리 가능 ✓ ✓ 재개! 함수 재개 결과값 사용 UI 업데이트 완료! 🎉 🔑 핵심 포인트 • await = 스레드 블로킹 X, 함수만 일시 중단 • 중단된 동안 스레드는 다른 작업 처리 가능 • 재개 시 같은 스레드 보장 안 됨 (Actor 제외) • 코드는 위→아래로 읽히지만 실제론 비동기!

🛡️ Actor: 데이터 레이스의 종말

자, 이제 진짜 핵심 중의 핵심! Actor야. 멀티스레드 환경에서 가장 골치 아픈 문제가 뭔지 알아? 바로 데이터 레이스(Data Race)야.

데이터 레이스가 뭔데?

두 스레드가 동시에 같은 데이터에 접근하는데, 그 중 하나 이상이 쓰기 작업을 할 때 발생해. 결과가 예측 불가능해지고, 버그 재현도 어렵고, 디버깅은 지옥이야 😈

// 😱 데이터 레이스 예시 - 이런 코드 절대 쓰면 안 돼!
class BankAccount {
    var balance: Int = 1000
    
    func deposit(amount: Int) {
        // 여러 스레드가 동시에 이 코드를 실행하면?
        let current = balance    // 스레드 A: 1000 읽음
                                 // 스레드 B: 1000 읽음 (동시에!)
        balance = current + amount // 스레드 A: 1100 씀
                                   // 스레드 B: 1100 씀 (200 입금했는데 100만 반영!)
    }
}

// 두 스레드가 동시에 100씩 입금하면 결과가 1200이어야 하는데
// 데이터 레이스로 인해 1100이 될 수 있어!

⚠️ 데이터 레이스의 무서운 점
항상 발생하지 않아서 테스트에서 잡기 어려워. 특정 타이밍에만 발생하는 간헐적 버그가 되고, 프로덕션에서 터지면 원인 파악이 거의 불가능해. 이게 바로 Actor가 필요한 이유야!

Actor 선언과 기본 사용법

Actor는 class처럼 참조 타입이지만, 내부 상태에 대한 접근을 직렬화(serialize)해서 한 번에 하나의 작업만 처리하도록 보장해:

// ✅ Actor로 안전한 BankAccount
actor BankAccount {
    private(set) var balance: Int = 1000
    
    func deposit(amount: Int) {
        balance += amount  // Actor가 직렬화 보장!
    }
    
    func withdraw(amount: Int) throws {
        guard balance >= amount else {
            throw BankError.insufficientFunds
        }
        balance -= amount
    }
    
    func transfer(amount: Int, to other: BankAccount) async throws {
        try withdraw(amount: amount)
        await other.deposit(amount: amount)  // 다른 Actor 접근 시 await 필요!
    }
}

// Actor 사용
let account = BankAccount()

Task {
    await account.deposit(amount: 500)
    print(await account.balance)  // 프로퍼티 접근도 await!
}

Task {
    try await account.withdraw(amount: 200)
}

🔑 Actor의 핵심 규칙
1️⃣ Actor 외부에서 내부 상태에 접근할 때는 항상 await 필요
2️⃣ Actor 내부에서는 await 없이 자신의 상태에 접근 가능
3️⃣ 다른 Actor의 메서드 호출 시 await 필요
4️⃣ Actor는 한 번에 하나의 작업만 처리 (직렬 실행 보장)

nonisolated: Actor 격리 해제

Actor 내부의 모든 메서드가 격리(isolation)될 필요는 없어. 공유 상태에 접근하지 않는 메서드는 nonisolated로 표시하면 await 없이 호출 가능해:

actor UserManager {
    private var users: [String: User] = [:]
    var name: String = "UserManager"
    
    // 격리된 메서드 - await 필요
    func addUser(_ user: User) {
        users[user.id] = user
    }
    
    // nonisolated - await 불필요, 공유 상태 접근 안 함
    nonisolated func greet() -> String {
        return "안녕하세요! UserManager입니다."
    }
    
    // nonisolated - 계산만 하고 상태 변경 없음
    nonisolated func formatUserId(_ id: String) -> String {
        return "USER_\(id.uppercased())"
    }
}

// 사용
let manager = UserManager()
print(manager.greet())           // await 불필요!
print(manager.formatUserId("abc")) // await 불필요!
await manager.addUser(someUser)   // await 필요

MainActor: UI 스레드의 Actor

@MainActor는 특별한 전역 Actor야. 메인 스레드에서만 실행되도록 보장해줘. UI 업데이트에 필수적이야!

// @MainActor 클래스 - 모든 메서드가 메인 스레드에서 실행
@MainActor
class ViewModel: ObservableObject {
    @Published var isLoading = false
    @Published var users: [User] = []
    @Published var errorMessage: String?
    
    func loadUsers() async {
        isLoading = true  // 메인 스레드 보장!
        
        do {
            // 백그라운드에서 네트워크 요청
            let fetchedUsers = try await UserService.fetchUsers()
            
            // 결과를 메인 스레드에서 처리 (자동!)
            users = fetchedUsers
        } catch {
            errorMessage = error.localizedDescription
        }
        
        isLoading = false  // 메인 스레드 보장!
    }
}

// 특정 메서드만 @MainActor 지정
class DataManager {
    func fetchData() async throws -> [Item] {
        // 백그라운드에서 실행
        return try await networkCall()
    }
    
    @MainActor
    func updateUI(with items: [Item]) {
        // 메인 스레드에서만 실행
        tableView.reloadData()
    }
}

// 코드 블록을 메인 스레드에서 실행
func someFunction() async {
    let data = await fetchData()
    
    await MainActor.run {
        // 이 블록은 메인 스레드에서 실행
        label.text = "완료!"
        spinner.stopAnimating()
    }
}

💡 @MainActor 활용 팁!
SwiftUI에서 ObservableObject를 사용하는 ViewModel은 @MainActor로 표시하는 게 좋아. 그러면 모든 @Published 프로퍼티 업데이트가 자동으로 메인 스레드에서 이루어져서 UI 업데이트 안전성이 보장돼!

📦 Sendable: 스레드 간 안전한 데이터 전달

Actor를 쓰다 보면 Sendable이라는 프로토콜을 만나게 될 거야. 이게 뭔지 알아야 Actor를 제대로 쓸 수 있어!

Sendable이 필요한 이유

Actor 경계를 넘어서 데이터를 전달할 때, 그 데이터가 안전한지 컴파일러가 검증해야 해. Sendable은 "나는 여러 스레드에서 안전하게 사용될 수 있어"라는 약속이야:

// Sendable 프로토콜 채택
struct UserProfile: Sendable {
    let id: String
    let name: String
    let age: Int
    // 모든 프로퍼티가 Sendable이면 자동으로 Sendable
}

// 값 타입(struct, enum)은 대부분 자동으로 Sendable
struct Point: Sendable {
    var x: Double
    var y: Double
}

// 클래스는 명시적으로 선언 + 조건 충족 필요
final class ImmutableConfig: Sendable {
    let apiKey: String  // let만 사용 (불변)
    let baseURL: URL
    
    init(apiKey: String, baseURL: URL) {
        self.apiKey = apiKey
        self.baseURL = baseURL
    }
}

// @unchecked Sendable - 직접 스레드 안전성 보장할 때
final class ThreadSafeCache: @unchecked Sendable {
    private var cache: [String: Any] = [:]
    private let lock = NSLock()
    
    func set(_ value: Any, for key: String) {
        lock.lock()
        defer { lock.unlock() }
        cache[key] = value
    }
    
    func get(for key: String) -> Any? {
        lock.lock()
        defer { lock.unlock() }
        return cache[key]
    }
}

🔍 Sendable 자동 채택 조건
✅ 자동 Sendable 모든 저장 프로퍼티가 Sendable인 struct/enum
✅ 자동 Sendable Int, String, Double 등 기본 타입
✅ 자동 Sendable Optional, Array, Dictionary (요소가 Sendable인 경우)
⚠️ 명시 필요 class (final + 불변 프로퍼티만 있을 때)
❌ Sendable 불가 가변 상태를 가진 class

🌊 AsyncSequence: 비동기 스트림 처리

데이터가 한 번에 오는 게 아니라 스트림처럼 흘러올 때 어떻게 처리할까? 바로 AsyncSequence야! 파일 읽기, 웹소켓, 알림 스트림 등에 딱이야.

for await in: 비동기 반복문

// AsyncSequence 기본 사용
func processNotifications() async {
    // for await in으로 비동기 스트림 처리
    for await notification in NotificationCenter.default.notifications(named: .someNotification) {
        handleNotification(notification)
    }
}

// URL 응답을 스트림으로 처리
func downloadLargeFile(url: URL) async throws {
    let (bytes, response) = try await URLSession.shared.bytes(from: url)
    
    var receivedData = Data()
    for try await byte in bytes {
        receivedData.append(byte)
        
        // 진행률 업데이트
        let progress = Double(receivedData.count) / Double(response.expectedContentLength)
        await updateProgress(progress)
    }
    
    await processData(receivedData)
}

// 파일 라인 단위 읽기
func processLogFile(url: URL) async throws {
    let fileHandle = try FileHandle(forReadingFrom: url)
    
    for try await line in fileHandle.bytes.lines {
        processLogLine(line)
    }
}

AsyncStream: 커스텀 비동기 스트림 만들기

직접 비동기 스트림을 만들고 싶을 때는 AsyncStream을 써:

// AsyncStream으로 위치 업데이트 스트림 만들기
func locationUpdates() -> AsyncStream<CLLocation> {
    return AsyncStream { continuation in
        let locationManager = CLLocationManager()
        let delegate = LocationDelegate { location in
            continuation.yield(location)  // 새 값 전달
        }
        locationManager.delegate = delegate
        locationManager.startUpdatingLocation()
        
        // 스트림 종료 시 정리
        continuation.onTermination = { _ in
            locationManager.stopUpdatingLocation()
        }
    }
}

// 사용
func trackLocation() async {
    for await location in locationUpdates() {
        print("현재 위치: \(location.coordinate)")
        
        if shouldStop {
            break  // 스트림 종료
        }
    }
}

// AsyncThrowingStream - 에러 발생 가능한 스트림
func dataStream() -> AsyncThrowingStream<Data, Error> {
    return AsyncThrowingStream { continuation in
        Task {
            do {
                for i in 0..<10 {
                    let data = try await fetchChunk(index: i)
                    continuation.yield(data)
                }
                continuation.finish()  // 정상 종료
            } catch {
                continuation.finish(throwing: error)  // 에러로 종료
            }
        }
    }
}

🎯 AsyncStream vs AsyncThrowingStream
AsyncStream: 에러 없이 값만 전달하는 스트림
AsyncThrowingStream: 에러가 발생할 수 있는 스트림
continuation.yield(value): 새 값 전달
continuation.finish(): 스트림 정상 종료
continuation.finish(throwing: error): 에러로 종료

Actor의 메일박스 패턴 (직렬 실행 보장) 🛡️ Actor 내부 상태 (격리됨) var balance: Int = 1000 var users: [User] = [] 한 번에 하나씩만 처리! ⚡ 직렬 실행 보장 스레드 A deposit(100) 스레드 B withdraw(50) 스레드 C getBalance() 📬 메일박스 큐 ① deposit(100) ② withdraw(50) ③ getBalance() 순서대로 처리 ↓ 데이터 레이스 없음! ✅ 안전한 결과 ① deposit(100) 완료 balance: 1000 → 1100 ② withdraw(50) 완료 balance: 1100 → 1050 ③ getBalance() 완료 반환값: 1050 ✓ 데이터 레이스 ZERO! 🎉 Actor는 내부적으로 직렬 큐(Serial Queue)처럼 동작해 여러 스레드가 동시에 접근해도 한 번에 하나씩만 처리 → 데이터 레이스 원천 차단!

🔥 실전 패턴: 이렇게 써먹어!

이론은 충분히 배웠으니까 이제 실제로 어떻게 쓰는지 패턴별로 알아보자. 재능넷 같은 플랫폼을 만든다고 가정하고 예시를 들어볼게! 🎨

패턴 1: Repository 패턴 + async/await

// 프로토콜 정의
protocol UserRepositoryProtocol {
    func fetchUser(id: String) async throws -> User
    func updateUser(_ user: User) async throws
    func deleteUser(id: String) async throws
}

// 실제 구현
actor UserRepository: UserRepositoryProtocol {
    private let networkService: NetworkService
    private var cache: [String: User] = [:]
    
    init(networkService: NetworkService) {
        self.networkService = networkService
    }
    
    func fetchUser(id: String) async throws -> User {
        // 캐시 확인
        if let cached = cache[id] {
            return cached
        }
        
        // 네트워크 요청
        let user = try await networkService.get("/users/\(id)", as: User.self)
        cache[id] = user  // 캐시 저장 (Actor가 안전하게 보장!)
        return user
    }
    
    func updateUser(_ user: User) async throws {
        try await networkService.put("/users/\(user.id)", body: user)
        cache[user.id] = user  // 캐시 업데이트
    }
    
    func deleteUser(id: String) async throws {
        try await networkService.delete("/users/\(id)")
        cache.removeValue(forKey: id)  // 캐시에서 제거
    }
}

패턴 2: ViewModel + @MainActor

// SwiftUI ViewModel
@MainActor
class TalentListViewModel: ObservableObject {
    @Published var talents: [Talent] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    @Published var searchQuery = ""
    
    private let repository: TalentRepository
    private var searchTask: Task<Void, Never>?
    
    init(repository: TalentRepository) {
        self.repository = repository
    }
    
    func loadTalents() async {
        isLoading = true
        errorMessage = nil
        
        do {
            talents = try await repository.fetchTalents()
        } catch {
            errorMessage = "재능 목록을 불러오는데 실패했어요: \(error.localizedDescription)"
        }
        
        isLoading = false
    }
    
    func search(query: String) {
        // 이전 검색 취소
        searchTask?.cancel()
        
        searchTask = Task {
            // 디바운싱 - 0.3초 대기
            try? await Task.sleep(nanoseconds: 300_000_000)
            
            guard !Task.isCancelled else { return }
            
            do {
                talents = try await repository.searchTalents(query: query)
            } catch is CancellationError {
                // 취소된 경우 무시
            } catch {
                errorMessage = error.localizedDescription
            }
        }
    }
    
    func loadMoreIfNeeded(currentItem: Talent) async {
        guard let lastItem = talents.last,
              lastItem.id == currentItem.id else { return }
        
        // 페이지네이션
        do {
            let moreTalents = try await repository.fetchNextPage()
            talents.append(contentsOf: moreTalents)
        } catch {
            print("추가 로드 실패: \(error)")
        }
    }
}

패턴 3: 재시도(Retry) 로직

// 재시도 가능한 네트워크 요청
func fetchWithRetry<T>(
    maxAttempts: Int = 3,
    delay: Duration = .seconds(1),
    operation: () async throws -> T
) async throws -> T {
    var lastError: Error?
    
    for attempt in 1...maxAttempts {
        do {
            return try await operation()
        } catch {
            lastError = error
            
            // 마지막 시도가 아니면 대기 후 재시도
            if attempt < maxAttempts {
                print("시도 \(attempt) 실패, \(delay)후 재시도...")
                try await Task.sleep(for: delay)
            }
        }
    }
    
    throw lastError!
}

// 사용 예시
func loadUserData(id: String) async throws -> User {
    return try await fetchWithRetry(maxAttempts: 3, delay: .seconds(2)) {
        try await userRepository.fetchUser(id: id)
    }
}

패턴 4: 타임아웃 처리

// 타임아웃 처리
func withTimeout<T>(
    seconds: Double,
    operation: @escaping () async throws -> T
) async throws -> T {
    return try await withThrowingTaskGroup(of: T.self) { group in
        // 실제 작업
        group.addTask {
            return try await operation()
        }
        
        // 타임아웃 Task
        group.addTask {
            try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
            throw TimeoutError()
        }
        
        // 먼저 완료된 결과 반환
        let result = try await group.next()!
        group.cancelAll()  // 나머지 Task 취소
        return result
    }
}

struct TimeoutError: Error {
    var localizedDescription: String { "요청 시간이 초과됐어요!" }
}

// 사용
func fetchWithTimeout() async throws -> Data {
    return try await withTimeout(seconds: 10) {
        try await networkService.fetchLargeData()
    }
}

패턴 5: 공유 상태 관리 Actor

// 앱 전체 상태 관리
actor AppStateManager {
    static let shared = AppStateManager()
    
    private var currentUser: User?
    private var authToken: String?
    private var settings: AppSettings = AppSettings()
    
    private init() {}
    
    // 로그인
    func login(email: String, password: String) async throws {
        let response = try await AuthService.login(email: email, password: password)
        currentUser = response.user
        authToken = response.token
        
        // 토큰 저장
        await KeychainManager.shared.save(token: response.token)
    }
    
    // 로그아웃
    func logout() async {
        currentUser = nil
        authToken = nil
        await KeychainManager.shared.deleteToken()
    }
    
    // 현재 사용자 가져오기
    func getCurrentUser() -> User? {
        return currentUser
    }
    
    // 인증 토큰 가져오기
    func getAuthToken() -> String? {
        return authToken
    }
    
    // 설정 업데이트
    func updateSettings(_ newSettings: AppSettings) async {
        settings = newSettings
        await SettingsStorage.save(settings)
    }
}

// 사용
Task {
    do {
        try await AppStateManager.shared.login(email: "user@example.com", password: "password")
        let user = await AppStateManager.shared.getCurrentUser()
        print("로그인 성공: \(user?.name ?? "")")
    } catch {
        print("로그인 실패: \(error)")
    }
}

🚨 에러 처리: 우아하게 실패하기

비동기 코드에서 에러 처리는 정말 중요해. async/await 덕분에 에러 처리가 훨씬 깔끔해졌는데, 제대로 알고 써야 해!

커스텀 에러 타입 정의

// 잘 설계된 에러 타입
enum NetworkError: Error, LocalizedError {
    case invalidURL
    case noConnection
    case timeout
    case serverError(statusCode: Int, message: String)
    case decodingFailed(underlying: Error)
    case unauthorized
    case notFound
    
    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "잘못된 URL이에요."
        case .noConnection:
            return "인터넷 연결을 확인해주세요."
        case .timeout:
            return "요청 시간이 초과됐어요. 다시 시도해주세요."
        case .serverError(let code, let message):
            return "서버 오류 (\(code)): \(message)"
        case .decodingFailed(let error):
            return "데이터 파싱 실패: \(error.localizedDescription)"
        case .unauthorized:
            return "로그인이 필요해요."
        case .notFound:
            return "요청한 데이터를 찾을 수 없어요."
        }
    }
    
    // 재시도 가능 여부
    var isRetryable: Bool {
        switch self {
        case .timeout, .noConnection, .serverError(500..., _):
            return true
        default:
            return false
        }
    }
}

에러 처리 패턴

// 1. do-catch 패턴
func loadProfile() async {
    do {
        let user = try await userRepository.fetchUser(id: currentUserId)
        updateUI(with: user)
    } catch NetworkError.unauthorized {
        navigateToLogin()
    } catch NetworkError.noConnection {
        showOfflineMessage()
    } catch let networkError as NetworkError {
        showError(networkError.errorDescription ?? "알 수 없는 오류")
    } catch {
        showError("예상치 못한 오류: \(error.localizedDescription)")
    }
}

// 2. Result 타입으로 에러 전파 방지
func safeFetchUser(id: String) async -> Result<User, NetworkError> {
    do {
        let user = try await userRepository.fetchUser(id: id)
        return .success(user)
    } catch let error as NetworkError {
        return .failure(error)
    } catch {
        return .failure(.serverError(statusCode: 0, message: error.localizedDescription))
    }
}

// 사용
let result = await safeFetchUser(id: "123")
switch result {
case .success(let user):
    print("사용자: \(user.name)")
case .failure(let error):
    print("실패: \(error.errorDescription ?? "")")
}

// 3. TaskGroup에서 에러 처리
func loadAllData() async throws {
    try await withThrowingTaskGroup(of: Void.self) { group in
        group.addTask { try await self.loadUsers() }
        group.addTask { try await self.loadPosts() }
        group.addTask { try await self.loadSettings() }
        
        // 하나라도 실패하면 전체 취소
        try await group.waitForAll()
    }
}

💡 에러 처리 베스트 프랙티스!
✅ 구체적인 에러 타입 정의 (사용자에게 의미있는 메시지)
✅ 재시도 가능한 에러와 불가능한 에러 구분
✅ 에러 로깅은 항상 (디버깅을 위해)
✅ UI 에러 메시지는 사용자 친화적으로
❌ 에러를 무시하거나 빈 catch 블록 사용 금지

⚡ 성능 최적화: 더 빠르게, 더 효율적으로

Task.sleep vs DispatchQueue.asyncAfter

// ❌ 구식 방법
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
    self.doSomething()
}

// ✅ 현대적 방법 - Task.sleep
Task {
    try await Task.sleep(for: .seconds(2))
    await doSomething()
}

// ✅ 더 정밀한 제어
Task {
    try await Task.sleep(until: .now + .seconds(2), clock: .continuous)
    await doSomething()
}

Actor 성능 고려사항

// ⚠️ Actor 호핑(Actor Hopping) 주의
actor DataProcessor {
    func processItems(_ items: [Item]) async -> [Result] {
        var results: [Result] = []
        
        for item in items {
            // ❌ 매 반복마다 다른 Actor로 hop - 성능 저하
            let result = await otherActor.process(item)
            results.append(result)
        }
        
        return results
    }
    
    func processItemsOptimized(_ items: [Item]) async -> [Result] {
        // ✅ 한 번에 모든 아이템 전달 - Actor hop 최소화
        return await otherActor.processAll(items)
    }
}

// ✅ 배치 처리로 Actor hop 최소화
actor BatchProcessor {
    func processAll(_ items: [Item]) async -> [Result] {
        // 내부에서 처리 - hop 없음
        return items.map { process($0) }
    }
    
    private func process(_ item: Item) -> Result {
        // 동기 처리
        return Result(item)
    }
}

메모리 관리: weak self와 Task

class ViewController: UIViewController {
    private var loadTask: Task<Void, Never>?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        startLoading()
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        // 화면 사라질 때 Task 취소 - 메모리 누수 방지!
        loadTask?.cancel()
    }
    
    func startLoading() {
        loadTask = Task { [weak self] in
            guard let self = self else { return }
            
            do {
                let data = try await self.fetchData()
                
                // Task가 취소됐는지 확인
                guard !Task.isCancelled else { return }
                
                await MainActor.run { [weak self] in
                    self?.updateUI(with: data)
                }
            } catch {
                print("에러: \(error)")
            }
        }
    }
}

// ✅ Task 내부에서 self 캡처 주의
// - Task는 강한 참조를 만들 수 있어
// - [weak self] 캡처 리스트 사용 권장
// - 또는 Task를 저장하고 적절히 취소

⚡ 성능 최적화 체크리스트
✅ 독립적인 작업은 async let으로 병렬 실행
✅ Actor hop 최소화 (배치 처리 활용)
✅ 화면 사라질 때 Task 취소
✅ [weak self]로 메모리 누수 방지
✅ 무거운 동기 작업은 Task.detached로 분리
✅ 캐싱으로 불필요한 네트워크 요청 줄이기

Task.detached: 현재 컨텍스트에서 완전히 분리

// Task.detached - 부모 Task의 우선순위, Actor 컨텍스트 상속 안 함
func processHeavyData() {
    // 현재 @MainActor 컨텍스트에서 완전히 분리
    Task.detached(priority: .background) {
        // 이 코드는 메인 스레드와 무관하게 실행
        let result = await heavyComputation()
        
        // UI 업데이트는 명시적으로 메인 스레드로
        await MainActor.run {
            self.updateUI(with: result)
        }
    }
}

// 일반 Task vs Task.detached 차이
@MainActor
class ViewModel {
    func doWork() {
        // 일반 Task: @MainActor 컨텍스트 상속 (메인 스레드에서 시작)
        Task {
            // 여기도 메인 스레드에서 시작!
            await someWork()
        }
        
        // Task.detached: 컨텍스트 상속 없음
        Task.detached {
            // 메인 스레드와 무관한 스레드에서 실행
            await someWork()
        }
    }
}

🧰 Swift Concurrency 도구 총정리

지금까지 배운 내용을 한 번에 정리해보자. 어떤 상황에서 어떤 도구를 써야 하는지 명확하게 알아두면 실전에서 바로 써먹을 수 있어!

도구 용도 특징
async/await 비동기 함수 정의 및 호출 가독성 최고, 에러 처리 통합
Task 비동기 작업 단위 생성 취소 가능, 우선순위 설정
async let 병렬 비동기 작업 고정 개수 작업 동시 실행
TaskGroup 동적 병렬 작업 런타임에 작업 개수 결정
Actor 공유 상태 보호 데이터 레이스 방지, 직렬 실행
@MainActor 메인 스레드 보장 UI 업데이트 안전성
AsyncSequence 비동기 스트림 처리 for await in 사용
AsyncStream 커스텀 비동기 스트림 이벤트 스트림 생성
Sendable 스레드 간 안전한 데이터 컴파일 타임 검증
Task.detached 독립적 비동기 작업 컨텍스트 상속 없음

상황별 선택 가이드

🤔 "어떤 걸 써야 할지 모르겠어!" → 이 플로우차트를 따라가봐:

Q1. 비동기 작업이 몇 개야?
→ 1개: async/await + Task
→ 고정 여러 개: async let
→ 동적 여러 개: TaskGroup

Q2. 공유 상태가 있어?
→ 있음: Actor
→ UI 상태: @MainActor
→ 없음: 그냥 async 함수

Q3. 데이터가 스트림으로 와?
→ 기존 API: AsyncSequence
→ 직접 만들기: AsyncStream

🔄 기존 코드 마이그레이션 가이드

기존 프로젝트에 Swift Concurrency를 도입할 때 어떻게 해야 할지 단계별로 알아보자. 한 번에 다 바꾸려고 하면 망해. 점진적으로 가야 해! 😅

1

새 코드부터 async/await로 작성
기존 코드는 건드리지 말고, 새로 추가하는 기능부터 async/await를 사용해. 기존 completion handler API를 withCheckedContinuation으로 래핑해서 사용하면 돼.

2

Leaf 함수부터 변환
의존성이 없는 가장 하위 레벨 함수부터 변환해. 위에서 아래로 변환하면 중간에 async/sync 경계가 생겨서 복잡해져.

3

DispatchQueue → Actor/MainActor
DispatchQueue.main.async는 await MainActor.run으로,
공유 상태를 보호하는 직렬 큐는 actor로 변환해.

4

NotificationCenter → AsyncStream
NotificationCenter 옵저버 패턴을 AsyncStream으로 변환하면 더 깔끔해져.

5

테스트 업데이트
XCTest에서 async 테스트는 func testSomething() async throws로 선언하면 돼. XCTestExpectation 없이도 비동기 테스트 가능!

// 마이그레이션 예시: NotificationCenter → AsyncStream
extension NotificationCenter {
    func notifications(
        named name: Notification.Name,
        object: AnyObject? = nil
    ) -> AsyncStream<Notification> {
        return AsyncStream { continuation in
            let observer = self.addObserver(
                forName: name,
                object: object,
                queue: nil
            ) { notification in
                continuation.yield(notification)
            }
            
            continuation.onTermination = { _ in
                self.removeObserver(observer)
            }
        }
    }
}

// 사용
func observeKeyboardChanges() async {
    for await notification in NotificationCenter.default.notifications(
        named: UIResponder.keyboardWillShowNotification
    ) {
        handleKeyboardShow(notification)
    }
}

// XCTest에서 async 테스트
class UserServiceTests: XCTestCase {
    func testFetchUser() async throws {
        let service = UserService()
        let user = try await service.fetchUser(id: "test123")
        
        XCTAssertEqual(user.id, "test123")
        XCTAssertFalse(user.name.isEmpty)
    }
    
    func testFetchUserFails() async throws {
        let service = UserService()
        
        do {
            _ = try await service.fetchUser(id: "invalid")
            XCTFail("에러가 발생해야 해!")
        } catch NetworkError.notFound {
            // 예상된 에러 - 테스트 통과
        }
    }
}

⚠️ 주의사항과 안티패턴: 이것만은 피해!

Swift Concurrency를 쓰다 보면 흔히 빠지는 함정들이 있어. 미리 알아두면 삽질을 줄일 수 있어! 🕳️

안티패턴 1: Actor 내부에서 await 남발

// ❌ 안티패턴: Actor 내부에서 불필요한 await
actor DataManager {
    var data: [String] = []
    
    func processData() async {
        // 이 함수 내부에서 self.data에 접근할 때 await 불필요!
        // Actor 내부에서는 자신의 상태에 동기적으로 접근 가능
        for item in data {  // ✅ await 없이 접근
            await processItem(item)  // 외부 비동기 함수 호출 시만 await
        }
    }
}

// ✅ 올바른 패턴
actor DataManager {
    var data: [String] = []
    
    func processData() async {
        let snapshot = data  // 현재 상태 스냅샷 (동기)
        
        for item in snapshot {
            await processItem(item)  // 외부 호출만 await
        }
    }
}

안티패턴 2: Task 저장 없이 생성

// ❌ 안티패턴: Task를 저장하지 않으면 취소 불가
class ViewController: UIViewController {
    func viewDidLoad() {
        super.viewDidLoad()
        
        // 이 Task는 취소할 방법이 없어!
        Task {
            await loadData()
        }
    }
}

// ✅ 올바른 패턴: Task 저장 및 취소
class ViewController: UIViewController {
    private var loadingTask: Task<Void, Never>?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        loadingTask = Task {
            await loadData()
        }
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        loadingTask?.cancel()
    }
}

안티패턴 3: 동기 함수에서 async 함수 블로킹 호출

// ❌ 절대 하면 안 되는 패턴!
func synchronousFunction() {
    // 이렇게 하면 데드락 발생 가능!
    let result = try! await asyncFunction()  // 컴파일 에러
    
    // RunLoop 블로킹도 위험해
    let semaphore = DispatchSemaphore(value: 0)
    Task {
        result = await asyncFunction()
        semaphore.signal()
    }
    semaphore.wait()  // 메인 스레드 블로킹 → 데드락!
}

// ✅ 올바른 방법: Task로 감싸기
func synchronousFunction() {
    Task {
        let result = await asyncFunction()
        await MainActor.run {
            updateUI(with: result)
        }
    }
}

안티패턴 4: Actor 재진입(Reentrancy) 문제

// ⚠️ Actor 재진입 주의!
actor ImageCache {
    private var cache: [URL: UIImage] = [:]
    
    // ❌ 재진입 문제 발생 가능
    func loadImage(url: URL) async throws -> UIImage {
        if let cached = cache[url] {
            return cached
        }
        
        // await 지점에서 다른 Task가 같은 URL로 진입 가능!
        let image = try await downloadImage(url: url)
        
        // 이미 다른 Task가 같은 이미지를 저장했을 수 있어
        cache[url] = image
        return image
    }
    
    // ✅ 재진입 안전한 버전
    private var loadingTasks: [URL: Task<UIImage, Error>] = [:]
    
    func loadImageSafe(url: URL) async throws -> UIImage {
        if let cached = cache[url] {
            return cached
        }
        
        // 이미 로딩 중인 Task가 있으면 재사용
        if let existingTask = loadingTasks[url] {
            return try await existingTask.value
        }
        
        let task = Task {
            try await downloadImage(url: url)
        }
        loadingTasks[url] = task
        
        do {
            let image = try await task.value
            cache[url] = image
            loadingTasks.removeValue(forKey: url)
            return image
        } catch {
            loadingTasks.removeValue(forKey: url)
            throw error
        }
    }
}

⚠️ Actor 재진입(Reentrancy)이란?
Actor는 await 지점에서 다른 Task의 실행을 허용해. 이 때문에 await 전후로 Actor의 상태가 변경될 수 있어. 이걸 "Actor 재진입"이라고 해. 상태 검사 후 await, 그 후 상태 사용 패턴에서 특히 주의해야 해!

Swift Concurrency 생태계 전체 구조 Swift Concurrency 5.5+ async/await 비동기 함수 선언 일시 중단/재개 에러 처리 통합 Task / TaskGroup 비동기 작업 단위 취소 / 우선순위 병렬 실행 Actor 데이터 레이스 방지 직렬 실행 보장 @MainActor AsyncSequence 비동기 스트림 for await in AsyncStream Sendable 스레드 안전 타입 컴파일 타임 검증 @unchecked Structured Concurrency 계층적 Task 관리 자동 취소 전파 수명 주기 관리 🎯 이 모든 요소들이 함께 작동해서 안전하고 효율적인 동시성 프로그래밍을 가능하게 해! Swift 5.5+ | iOS 15+ | macOS 12+ | Xcode 13+

🚀 실전 미니 프로젝트: 재능 공유 앱 만들기

지금까지 배운 모든 걸 활용해서 간단한 재능 공유 앱의 핵심 로직을 만들어보자. 재능넷처럼 다양한 재능을 거래하는 플랫폼을 상상하면서 코드를 짜볼게! 🎨

import Foundation

// MARK: - 모델 정의
struct Talent: Codable, Sendable, Identifiable {
    let id: String
    let title: String
    let description: String
    let price: Int
    let category: TalentCategory
    let seller: User
    let rating: Double
    let reviewCount: Int
}

struct User: Codable, Sendable, Identifiable {
    let id: String
    let name: String
    let profileImageURL: URL?
    let bio: String
}

enum TalentCategory: String, Codable, Sendable {
    case design, development, marketing, music, writing
}

// MARK: - 네트워크 서비스
actor NetworkService {
    private let session: URLSession
    private let baseURL: URL
    private var requestCount = 0
    
    init(baseURL: URL) {
        self.baseURL = baseURL
        self.session = URLSession.shared
    }
    
    func get<T: Decodable>(_ path: String, as type: T.Type) async throws -> T {
        requestCount += 1
        
        guard let url = URL(string: path, relativeTo: baseURL) else {
            throw NetworkError.invalidURL
        }
        
        let (data, response) = try await session.data(from: url)
        
        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.serverError(statusCode: 0, message: "Invalid response")
        }
        
        switch httpResponse.statusCode {
        case 200...299:
            return try JSONDecoder().decode(T.self, from: data)
        case 401:
            throw NetworkError.unauthorized
        case 404:
            throw NetworkError.notFound
        default:
            throw NetworkError.serverError(
                statusCode: httpResponse.statusCode,
                message: "Server error"
            )
        }
    }
    
    func getRequestCount() -> Int {
        return requestCount
    }
}

// MARK: - 재능 Repository
actor TalentRepository {
    private let networkService: NetworkService
    private var cache: [String: Talent] = [:]
    private var listCache: [String: [Talent]] = [:]
    
    init(networkService: NetworkService) {
        self.networkService = networkService
    }
    
    func fetchTalents(category: TalentCategory? = nil) async throws -> [Talent] {
        let cacheKey = category?.rawValue ?? "all"
        
        if let cached = listCache[cacheKey] {
            return cached
        }
        
        let path = category.map { "/talents?category=\($0.rawValue)" } ?? "/talents"
        let talents = try await networkService.get(path, as: [Talent].self)
        
        listCache[cacheKey] = talents
        talents.forEach { cache[$0.id] = $0 }
        
        return talents
    }
    
    func fetchTalent(id: String) async throws -> Talent {
        if let cached = cache[id] {
            return cached
        }
        
        let talent = try await networkService.get("/talents/\(id)", as: Talent.self)
        cache[id] = talent
        return talent
    }
    
    func searchTalents(query: String) async throws -> [Talent] {
        let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
        return try await networkService.get("/talents/search?q=\(encoded)", as: [Talent].self)
    }
    
    func clearCache() {
        cache.removeAll()
        listCache.removeAll()
    }
}

// MARK: - ViewModel
@MainActor
class TalentMarketViewModel: ObservableObject {
    @Published var featuredTalents: [Talent] = []
    @Published var recentTalents: [Talent] = []
    @Published var searchResults: [Talent] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private let repository: TalentRepository
    private var searchTask: Task<Void, Never>?
    private var loadTask: Task<Void, Never>?
    
    init(repository: TalentRepository) {
        self.repository = repository
    }
    
    // 홈 화면 데이터 로드 - 병렬 처리!
    func loadHomeData() {
        loadTask?.cancel()
        
        loadTask = Task {
            isLoading = true
            errorMessage = nil
            
            do {
                // 두 카테고리를 동시에 로드!
                async let featured = repository.fetchTalents(category: .design)
                async let recent = repository.fetchTalents(category: .development)
                
                let (f, r) = try await (featured, recent)
                
                guard !Task.isCancelled else { return }
                
                featuredTalents = f
                recentTalents = r
                
            } catch is CancellationError {
                // 취소 - 무시
            } catch {
                errorMessage = "데이터를 불러오는데 실패했어요 😢"
                print("에러: \(error)")
            }
            
            isLoading = false
        }
    }
    
    // 검색 - 디바운싱 적용
    func search(query: String) {
        searchTask?.cancel()
        
        guard !query.isEmpty else {
            searchResults = []
            return
        }
        
        searchTask = Task {
            // 0.4초 디바운싱
            try? await Task.sleep(for: .milliseconds(400))
            guard !Task.isCancelled else { return }
            
            do {
                searchResults = try await repository.searchTalents(query: query)
            } catch is CancellationError {
                // 취소 - 무시
            } catch {
                errorMessage = "검색 중 오류가 발생했어요"
            }
        }
    }
    
    // 여러 카테고리 동시 로드
    func loadAllCategories() async throws -> [TalentCategory: [Talent]] {
        return try await withThrowingTaskGroup(of: (TalentCategory, [Talent]).self) { group in
            for category in TalentCategory.allCases {
                group.addTask {
                    let talents = try await self.repository.fetchTalents(category: category)
                    return (category, talents)
                }
            }
            
            var result: [TalentCategory: [Talent]] = [:]
            for try await (category, talents) in group {
                result[category] = talents
            }
            return result
        }
    }
    
    deinit {
        loadTask?.cancel()
        searchTask?.cancel()
    }
}

🎉 이 코드에서 사용된 Swift Concurrency 기능들
✅ actor: NetworkService, TalentRepository (스레드 안전한 캐시)
✅ @MainActor: ViewModel (UI 업데이트 안전성)
✅ async let: 두 카테고리 병렬 로드
✅ Task: 취소 가능한 비동기 작업
✅ TaskGroup: 모든 카테고리 동시 로드
✅ Task.sleep: 검색 디바운싱
✅ Sendable: 모델 타입들
✅ 취소 처리: Task.isCancelled 확인

🔍 디버깅 팁: 문제 생겼을 때 이렇게 해!

Swift Concurrency 관련 컴파일러 경고/에러 해석

🔴 "Passing argument of non-sendable type across actor boundaries"
→ Actor 경계를 넘어서 Sendable이 아닌 타입을 전달하려 할 때 발생
→ 해결: 해당 타입을 Sendable로 만들거나, @unchecked Sendable 사용

🟠 "Expression is 'async' but is not marked with 'await'"
→ async 함수를 await 없이 호출할 때 발생
→ 해결: 함수 호출 앞에 await 추가

🟣 "'async' call in a function that does not support concurrency"
→ 동기 함수에서 async 함수를 호출할 때 발생
→ 해결: 함수를 async로 만들거나, Task { }로 감싸기

Instruments로 동시성 문제 디버깅

Xcode의 Instruments에는 Swift Concurrency 전용 도구들이 있어:

🛠️ Swift Concurrency 디버깅 도구
Swift Concurrency 템플릿: Task 생성/취소/완료 추적
Thread Sanitizer: 데이터 레이스 감지 (빌드 설정에서 활성화)
Main Thread Checker: 메인 스레드 위반 감지
Memory Graph Debugger: Task로 인한 메모리 누수 확인

// Thread Sanitizer 활성화 방법
// Xcode → Product → Scheme → Edit Scheme → Run → Diagnostics
// → Thread Sanitizer 체크

// 런타임 데이터 레이스 감지 예시
// Thread Sanitizer가 이런 코드를 잡아줘:
class UnsafeCounter {
    var count = 0  // ⚠️ 여러 스레드에서 동시 접근 시 데이터 레이스!
    
    func increment() {
        count += 1  // Thread Sanitizer가 경고!
    }
}

// ✅ Actor로 수정
actor SafeCounter {
    var count = 0
    
    func increment() {
        count += 1  // 안전!
    }
}

로깅으로 비동기 흐름 추적

import OSLog

// 구조화된 로깅
let logger = Logger(subsystem: "com.myapp", category: "networking")

func fetchData() async throws -> Data {
    logger.info("데이터 페치 시작")
    
    let startTime = Date()
    
    do {
        let data = try await networkService.getData()
        let elapsed = Date().timeIntervalSince(startTime)
        logger.info("데이터 페치 완료: \(elapsed, format: .fixed(precision: 3))초")
        return data
    } catch {
        logger.error("데이터 페치 실패: \(error.localizedDescription)")
        throw error
    }
}

// Task ID로 추적
func trackedTask() async {
    let taskID = UUID().uuidString.prefix(8)
    logger.debug("Task \(taskID) 시작")
    
    await doWork()
    
    logger.debug("Task \(taskID) 완료")
}

🎓 마무리: 오늘 배운 것들 정리

와, 진짜 많이 배웠다! 처음에 콜백 지옥 얘기로 시작해서 여기까지 왔네. 오늘 배운 핵심 내용들을 한 번 더 정리해볼게:

1

async/await: 비동기 코드를 동기처럼 읽히게 만드는 핵심 문법. async로 함수 선언, await로 일시 중단. 스레드 블로킹 없이 함수만 일시 중단!

2

Task와 구조적 동시성: Task로 비동기 작업 생성, async let으로 병렬 실행, TaskGroup으로 동적 병렬 처리. 취소 처리도 잊지 말기!

3

Actor: 데이터 레이스의 종말. 내부 상태를 직렬화해서 한 번에 하나씩만 처리. @MainActor로 UI 스레드 안전성 보장!

4

AsyncSequence/AsyncStream: 비동기 스트림 처리. for await in으로 깔끔하게 처리. 이벤트 스트림, 파일 읽기, 웹소켓에 딱!

5

Sendable: 스레드 간 안전한 데이터 전달 보장. 컴파일 타임에 검증해줘서 런타임 버그 예방!

💡 마지막 조언!
Swift Concurrency는 처음엔 개념이 많아서 어렵게 느껴질 수 있어. 하지만 핵심은 단순해:
"비동기 코드를 안전하고 읽기 쉽게 만들자"
이 목표를 항상 기억하면서 코드를 짜면 자연스럽게 올바른 패턴을 선택하게 될 거야. 재능넷처럼 복잡한 플랫폼도 이 원칙 하나로 깔끔하게 만들 수 있어! 💪

Swift Concurrency는 계속 발전하고 있어. Swift 6에서는 더 엄격한 동시성 검사가 기본으로 활성화될 예정이야. 지금부터 올바른 패턴을 익혀두면 미래에도 걱정 없어! 🚀

프로그래밍 실력을 키우고 싶다면 이런 기술적인 내용들을 꾸준히 공부하는 게 중요해. 혹시 Swift 개발 관련 도움이 필요하다면 재능넷에서 실력 있는 iOS 개발자들의 도움을 받아볼 수도 있어. 다양한 재능을 가진 개발자들이 모여있거든! 😊

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.