AsyncSequence는 왜 생겼을까? 어떻게 활용할 수 있을까?

피터·2025년 9월 15일

Concurrency

목록 보기
7/10

AsyncSequence는 기존 콜백 지옥과 복잡한 비동기 처리 문제를 해결하기 위해 등장했습니다. for-await-in 구문으로 스트리밍 데이터를 마치 일반 배열처럼 간단하게 처리할 수 있게 해주는 방식입니다.

용어부터

먼저 AsyncSequence를 이해하기 위해 두 가지 핵심 개념부터 살펴보겠습니다.

Async (비동기)

Async는 "비동기(Asynchronous)"를 의미합니다.

// 동기적 처리 - UI가 멈춘다 😱
let data = downloadImage() // 3초 동안 UI 멈춤
updateUI(with: data)

// 비동기적 처리 - UI가 부드럽다 ✨
Task {
    let data = await downloadImage() // UI는 계속 반응함
    updateUI(with: data)
}

핵심 특징:

  • 동시에 여러 작업을 처리할 수 있는 방식
  • 하나의 작업이 완료될 때까지 기다리지 않고, 다른 작업들을 동시에 수행
  • 네트워크 요청, 파일 읽기, 데이터베이스 쿼리 등 시간이 걸리는 작업에 주로 사용

Sequence (순서/시퀀스)

Sequence는 "순서가 있는 데이터의 집합"입니다:

let numbers = [1, 2, 3, 4, 5]
for number in numbers {
    print(number) // 순차적으로 출력: 1, 2, 3, 4, 5
}

핵심 특징:

  • 순차적으로 접근할 수 있는 데이터들의 모음
  • Array, String, Range 등이 대표적인 예
  • for-in 루프로 순회할 수 있는 것들

AsyncSequence = Async + Sequence

이 두 개념을 합친 AsyncSequence는:

// 실시간으로 들어오는 메시지를 순차적으로 처리
for await message in chatMessages {
    displayMessage(message) // 메시지가 올 때마다 처리
}
  • 시간에 걸쳐 순차적으로 도착하는 데이터들의 집합
  • 각 요소가 비동기적으로 생성되는 시퀀스
  • 예: 웹소켓 메시지, 센서 데이터, 파일 스트림, 실시간 업데이트

그럼 왜 생겼을까? - 기존 방식의 한계

AsyncSequence를 이해하려면, 먼저 기존 방식의 문제점을 살펴봐야 합니다.

콜백 지옥의 현실

웹소켓이나 스트리밍 데이터를 처리할 때, 예전에는 이런 식으로 코딩했습니다:

// 😱 콜백 지옥의 예시
class WebSocketManager {
    func connectToServer() {
        socket.connect { [weak self] success in
            if success {
                self?.socket.onMessage { message in
                    self?.processMessage(message) { result in
                        switch result {
                        case .success(let data):
                            self?.updateUI(data) { completion in
                                // 또 다른 콜백...
                            }
                        case .failure(let error):
                            // 에러 처리...
                        }
                    }
                }
            }
        }
    }
}

🚨 기존 방식의 주요 문제점

1. 가독성 문제

  • 중첩된 클로저로 인한 "콜백 지옥"
  • 코드의 실행 순서를 추적하기 어려움

2. 제어 흐름 문제

  • 데이터가 들어오는 시점을 명확하게 통제하기 어려움
  • 여러 클로저가 동시에 데이터를 처리할 때 예측 불가능한 문제 발생

3. 에러 처리의 복잡성

  • 각 콜백마다 개별적인 에러 처리 필요
  • 에러 전파가 복잡함

🛠️ 기존 해결책들의 한계

NotificationCenter

// 코드가 분산되고 데이터 흐름 파악이 어려움
NotificationCenter.default.addObserver(/* ... */)
NotificationCenter.default.post(/* ... */)

Combine

// 강력하지만 러닝 커브가 높음
publisher
    .sink { completion in /* ... */ }
    .receive(on: DispatchQueue.main)
    .map { /* ... */ }
    .filter { /* ... */ }

✨ AsyncSequence의 등장

이런 문제들을 해결하기 위해 AsyncSequence가 등장했습니다!

// 😍 간단하고 직관적인 AsyncSequence
for await message in webSocketMessages {
    let processedData = processMessage(message)
    await updateUI(processedData)
}

주요 개선점:
1. 가독성 향상: for-await-in 구문으로 비동기 데이터를 순차적으로 처리
2. 제어 흐름 명확화: await 키워드로 다음 데이터가 준비될 때까지 안전하게 대기
3. 익숙한 패턴: 기존 for-in 루프와 동일한 직관적인 문법

AsyncSequence의 핵심 장점

1. 메모리 효율성

// 대용량 파일도 청크 단위로 안전하게 처리
for await chunk in fileDownloadStream {
    await saveChunkToFile(chunk)
    updateProgressBar(chunk.progress)
    // 전체 파일을 메모리에 올리지 않고 스트리밍 처리
}

2. 기존 API와의 쉬운 통합

// NotificationCenter를 AsyncSequence로 변환
let notifications = NotificationCenter.default.notifications(named: .userDidLogin)
for await notification in notifications {
    handleUserLogin(notification)
}

3. 종료 시 안전성 보장

AsyncSequence는 작업이 중단되거나 종료될 때도 안전하게 리소스를 정리합니다.

// Task 취소 시에도 안전하게 정리
let task = Task {
    do {
        for await data in networkStream {
            await processData(data)
        }
    } catch {
        // 에러나 취소 시 자동으로 정리
        print("작업 중단: \(error)")
    }
}

// 나중에 취소해도 안전
task.cancel() // 현재 처리 중인 데이터까지 완료 후 안전하게 종료

주요 안전성 기능:

  • 자동 리소스 정리: 네트워크 연결, 파일 핸들 등 자동 해제
  • 협력적 취소: 현재 작업을 안전하게 마친 후 종료
  • 메모리 안전: 순환 참조나 메모리 누수 방지

비교: 지진 데이터 처리 예시

WWDC에서 소개된 지진 데이터 예시를 통해 이전 방식AsyncSequence 방식의 차이를 비교해보겠습니다.

이전 방식 (콜백 기반)

// 콜백 지옥의 현실...
func processEarthquakeDataOldWay() {
    let feed = EarthquakeFeed()
    var processedCount = 0
    let maxCount = 3

    print("지진 데이터 처리 시작 (콜백 방식)...")

    feed.getFeed { (line, error) in
        // 1. 종료 조건 확인
        guard let line = line else {
            print("데이터 처리 완료.")
            return
        }

        // 2. String -> Earthquake 객체로 변환
        guard let earthquake = parse(line: line) else {
            return
        }

        // 3. 필터링 로직
        if earthquake.magnitude < 5.0 {
            return
        }

        // 4. 개수 제한 로직 (상태 관리가 복잡!)
        if processedCount >= maxCount {
            return
        }

        // 5. 데이터 처리
        print("규모 \(String(format: "%.1f", earthquake.magnitude)) 지진 감지 - \(earthquake.location)")
        processedCount += 1
    }
}

문제점:

  • 상태 관리(processedCount) 복잡
  • 에러 처리가 각 단계마다 필요
  • 코드 로직이 분산됨

AsyncSequence 방식

// 깔끔하고 직관적인 코드!
func processEarthquakeData() async {
    let feed = EarthquakeFeed()
    print("지진 데이터 처리 시작...")

    do {
        // 체이닝으로 간결하게 처리
        for await earthquake in feed.lines
            .map(parse)                     // String -> Earthquake 변환
            .compactMap({ $0 })            // nil 필터링
            .filter({ $0.magnitude >= 5.0 }) // 규모 필터링
            .prefix(3)                     // 개수 제한
        {
            print("규모 \(String(format: "%.1f", earthquake.magnitude)) 지진 감지 - \(earthquake.location)")
        }

        print("필터링된 지진 데이터 3개 처리 완료.")
    } catch {
        print("오류 발생: \(error.localizedDescription)")
    }
}

개선점:

  • 체이닝: 함수형 프로그래밍 스타일로 처리 과정이 명확
  • 상태 관리 불필요: prefix(3)로 개수 제한 자동 처리
  • 통합 에러 처리: do-catch로 모든 에러를 한 곳에서 처리
  • 가독성: 위에서 아래로 순차적인 데이터 흐름

핵심 차이점 요약

구분이전 방식AsyncSequence
가독성콜백 중첩으로 복잡순차적이고 직관적
상태 관리수동으로 변수 관리자동으로 처리
에러 처리각 단계마다 개별 처리통합된 에러 처리
데이터 흐름분산되어 파악 어려움체이닝으로 명확한 흐름

onTerminate와 생명주기 관리

AsyncSequence는 작업 종료 시 안정성을 보장하는 여러 메커니즘을 제공합니다.

Task 취소와 정리

class DataProcessor {
    private var processingTask: Task<Void, Never>?

    func startProcessing() {
        processingTask = Task {
            defer {
                // 작업 종료 시 항상 실행되는 정리 코드
                print("리소스 정리 완료")
            }

            do {
                for await data in dataStream {
                    // 취소 확인 - 협력적 취소
                    try Task.checkCancellation()
                    await processData(data)
                }
            } catch is CancellationError {
                print("작업이 안전하게 취소되었습니다")
            } catch {
                print("에러 발생: \(error)")
            }
        }
    }

    func stopProcessing() {
        processingTask?.cancel() // 안전한 취소
        processingTask = nil
    }
}

AsyncStream의 onTermination

let customStream = AsyncStream<String> { continuation in
    // 데이터 생성 로직
    let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
        continuation.yield("데이터 \(Date())")
    }

    // 종료 시 정리 작업
    continuation.onTermination = { @Sendable termination in
        timer.invalidate() // 타이머 정리
        print("스트림 종료: \(termination)")
    }
}

// 사용 시에도 안전하게 종료
for await data in customStream {
    print(data)
    // break나 return으로 종료해도 onTermination 호출됨
    if someCondition { break }
}

실제 활용 예시

// 실시간 채팅 - 연결 종료 시 자동 정리
for await message in chatSocket.messages {
    await updateChatUI(with: message)
}
// 루프 종료 시 소켓 연결 자동 해제

// 파일 업로드 - 취소 시 임시 파일 정리
for await progress in uploadTask.progressUpdates {
    updateProgressBar(progress.percentComplete)
    if progress.isCancelled { break } // 안전한 중단
}

📚 참고: WWDC 2021 - Meet AsyncSequence
🔗 관련 학습: Swift Concurrency, async/await, Actor 모델

profile
iOS 개발자입니다.

0개의 댓글