Advanced Task 이야기

Tabber·어제

Swift Concurrency

목록 보기
4/7

왜 Swift Concurrency가 필요했을까?

예전에는 GCD나 Operation을 써서 비동기 처리를 했는데, 문제가 많았습니다.

// 예전 방식 - 콜백 지옥
fetchUser(id: "123") { user in
    fetchProfile(user: user) { profile in
        fetchPosts(user: user) { posts in
            updateUI(profile: profile, posts: posts) { success in
                // 또 다른 콜백...
            }
        }
    }
}

이런 코드는 읽기도 어렵고, 에러 처리도 복잡하고, 메모리 누수도 쉽게 생겨요.

1. TaskGroup - "여러 일을 동시에 시키기"

식당에서 요리사가 여러 요리를 동시에 만드는 것과 같아요.

언제 쓸까?

  • 여러 개의 독립적인 작업을 동시에 처리하고 싶을 때
  • 예시 : 사진 여러 장 동시 다운로드, API 여러 개 동시 호출
// 3개 이미지를 순차적으로 다운로드 (느림)
let image1 = await downloadImage(url1)  // 3초
let image2 = await downloadImage(url2)  // 3초  
let image3 = await downloadImage(url3)  // 3초
// 총 9초 걸림!

// TaskGroup으로 동시에 다운로드 (빠름)
await withTaskGroup(of: UIImage?.self) { group in
    group.addTask { await downloadImage(url1) }  // 동시에
    group.addTask { await downloadImage(url2) }  // 동시에
    group.addTask { await downloadImage(url3) }  // 동시에
    // 총 3초만 걸림!
}

AsyncSequence - "데이터가 계속 들어오는 상황"

유튜브 라이브 스트리밍처럼 데이터가 계속 들어오는 상황이에요.

언제 쓸까?

  • 위치 정보가 계속 업데이트 될 때
  • 채팅 메시지가 실시간으로 올 때
  • 센서 데이터가 계속 들어올 때

기존 방식의 문제

// 예전에는 delegate 패턴
class LocationManager: NSObject, CLLocationManagerDelegate {
    var onLocationUpdate: ((CLLocation) -> Void)?
    
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        // 콜백으로 처리... 복잡함!
    }
}

AsyncSequence로 깔끔하게

// for-await 루프로 간단하게!
for await location in locationStream {
    print("새 위치: \(location)")
    // 필요하면 break로 언제든 중단
}

AsyncStream - "데이터 파이프라인"

AsyncStream은 "데이터가 흘러가는 파이프"라고 생각하면 돼요.
실생활로 비유하자면,

  • 컨베이어 벨트에 물건을 올리면 -> continuation.yield(data)
  • 다른 쪽에서 물건을 하나씩 받아요 -> for await data in stream
// 파이프 만들기
let stream = AsyncStream<String> { continuation in
    // 데이터를 파이프에 넣기
    continuation.yield("첫 번째 데이터")
    continuation.yield("두 번째 데이터")
    continuation.finish() // 파이프 닫기
}

// 파이프에서 데이터 받기
for await message in stream {
    print(message) // "첫 번째 데이터", "두 번째 데이터"
}

언제 AsyncStream을 쓸까?

콜백을 스트림으로 바꿀 때

// 예전 방식: 콜백 지옥
class LocationService {
    var onLocationUpdate: ((CLLocation) -> Void)?
    
    func startLocationUpdates() {
        locationManager.delegate = self
        // 복잡한 delegate 처리...
    }
}

// AsyncStream 방식: 깔끔!
func createLocationStream() -> AsyncStream<CLLocation> {
    AsyncStream { continuation in
        let manager = CLLocationManager()
        manager.delegate = LocationDelegate { location in
            continuation.yield(location) // 콜백을 yield로 변환!
        }
        manager.startUpdatingLocation()
        
        // 정리 작업도 자동으로
        continuation.onTermination = { _ in
            manager.stopUpdatingLocation()
        }
    }
}

// 사용할 때
for await location in createLocationStream() {
    print("새 위치: \(location)")
}

실시간 데이터 처리

// 채팅 메시지 스트림
func createChatStream() -> AsyncStream<String> {
    AsyncStream { continuation in
        let webSocket = URLSessionWebSocketTask(...)
        
        // 메시지가 올 때마다 yield
        webSocket.onMessage = { message in
            continuation.yield(message)
        }
        
        webSocket.onClose = { _ in
            continuation.finish()
        }
    }
}

// 채팅 UI에서 사용
for await message in createChatStream() {
    DispatchQueue.main.async {
        self.addMessage(message)
    }
}

AsyncStream의 핵심 구성요소

1. Continuation (데이터를 넣는 도구)

let stream = AsyncStream<Int> { continuation in
    // continuation = "데이터를 파이프에 넣는 도구"
    
    continuation.yield(1)        // 데이터 추가
    continuation.yield(2)        // 또 추가
    continuation.finish()        // 스트림 종료
    
    // 에러로 종료하고 싶다면 (ThrowingStream에서)
    // continuation.finish(throwing: MyError.failed)
}

2. Buffering Policy (버퍼링 정책)

// 기본: 무제한 버퍼링 (위험할 수 있음)
AsyncStream<String> { continuation in
    // 메모리가 가득 찰 때까지 계속 쌓임
}

// 최신 N개만 유지 (나머지는 버림)
AsyncStream<String>(String.self, bufferingPolicy: .bufferingNewest(5)) { continuation in
    // 5개만 유지, 새 데이터가 오면 오래된 것 삭제
}

// 오래된 N개만 유지 (새 데이터를 버림)
AsyncStream<String>(String.self, bufferingPolicy: .bufferingOldest(5)) { continuation in
    // 5개 채워지면 새 데이터는 무시
}

3. Actor Reentrancy - "가장 헷갈리는 개념"

이게 정말 중요해요.
Actor는 "한 번에 하나씩만 처리하는 안전한 공간" 인데, await 이 들어가면 상황이 복잡해져요.

실생활 예시로 들어볼게요.
나는 은행 창구 직원이고, 고객의 계좌 잔액을 확인하는 상황이에요.

actor BankAccount {
    private var balance = 1000
    
    func withdraw(amount: Int) async -> Bool {
        // 1. 철수: 1000원에서 500원 출금 가능한지 확인
        guard balance >= amount else { 
            return false 
        }
        
        // 2. 은행 전화 중... (await 지점!)
        await confirmWithOtherBank()
        
        // 3. 철수 전화 끝: balance는 이제 700원인데...
        //    철수는 1000원이었을 때 확인한 걸로 그대로 출금!
        balance -= amount  // 700 - 500 = 200원 (마이너스!)
        return true
    }
}

무슨 일이 일어날까?

  1. 철수: withdraw(500) 시작 → balance=1000, 출금 가능 확인
  2. 영희: withdraw(300) → balance=700으로 변경
  3. 철수: balance=700에서 500 빼기 → balance=200 (원래는 불가능해야 함!)

문제가 뭘까?

시간차 공격이라고 생각하면 될 것 같아요.
1. 확인 시점에는 조건이 맞습니다. (1000원, 500원 출금 시도)
2. 대기 시간에 상태가 바뀌어요. (await으로 다른 작업이 가능해요)
3. 실행 시점에는 조건이 바뀐 상태이지만 모르는 상태에요. (700원, 500원 출금 시도)
실행이 불가능해야하는데 말이죠.

문제 해결

func safeWithdraw(amount: Int) async -> Bool {
    let initialBalance = balance
    
    await confirmWithOtherBank()
    
    // 다시 확인!
    if balance == initialBalance && balance >= amount {
        balance -= amount
        return true
    }
    return false  // 상태가 변했으니 실패
}

상태를 재확인하여 상태가 바뀌었는지 확인이 필요해요.

Actor의 reentrancy는 확인했을 때 상태가 달라질 수 있다는게 핵심 문제에요.

4. Sendable - "안전하게 데이터 주고받기"

Actor 간에 데이터를 주고받을 때 "이 데이터가 안전한가?"를 보장하는 방법이에요.

위험한 상황

class UnsafeData {
    var count = 0  // 여러 스레드에서 동시에 바꿀 수 있어서 위험!
}

// 이렇게 하면 안 돼요!
let data = UnsafeData()
Task { data.count += 1 }  // 스레드1에서 수정
Task { data.count += 1 }  // 스레드2에서 수정 - 충돌 위험!

안전한 방법

struct SafeData: Sendable {
    let count: Int  // 읽기 전용이라 안전!
}

// 또는 값 타입 사용
let numbers = [1, 2, 3]  // 배열을 복사해서 전달하므로 안전

5. Combine vs async/await 언제 뭘 사용해야할까?

Combine을 사용하기 좋은 경우

  • 여러 데이터 스트림을 합치거나 변환할 때
  • UI 이벤트 처리

async/await를 사용하기 좋은 경우

  • 단순한 네트워크 요청
  • 파일 읽기 / 쓰기
  • 일반적인 비동기 작업
// 이런 건 async/await가 적합
func login(email: String, password: String) async throws -> User {
    let response = await networkService.login(email, password)
    return response.user
}

// 이런 건 Combine이 적합
searchTextField.textPublisher
    .debounce(for: 0.3, scheduler: RunLoop.main)  // 0.3초 대기
    .removeDuplicates()  // 중복 제거
    .flatMap { searchAPI($0) }  // 검색 API 호출
    .sink { results in
        // UI 업데이트
    }
profile
iOS 정복중인 Tabber 입니다.

0개의 댓글