2025/07/20

13. Swift 동시성(Concurrency): 비동기 작업을 효율적으로 처리하는 방법

 안녕하세요! 지난 블로그에서는 앱의 안정성을 높이는 오류 처리에 대해 자세히 알아보았죠. 오늘은 앱 개발에서 사용자 경험을 크게 좌우하는 중요한 개념, 바로 동시성(Concurrency)에 대해 정리해 보겠습니다.

앱을 사용하다 보면 네트워크에서 데이터를 가져오거나, 복잡한 계산을 수행하거나, 큰 파일을 처리하는 등 시간이 오래 걸리는 작업들이 있습니다. 이런 작업들을 메인 스레드(UI를 담당하는 스레드)에서 직접 처리하게 되면, 앱이 멈추거나 '버벅이는' 현상이 발생하여 사용자 경험을 해치게 됩니다. 동시성은 이러한 시간을 많이 소모하는 작업을 메인 스레드와 별도의 스레드(혹은 태스크)에서 비동기적으로 실행하여, 앱의 사용자 인터페이스가 계속 반응하도록 유지하는 기법입니다. 리스트 항목에서 이미지를 로딩하는데, 하나씩 로딩하면 리스트 보여주는데 시간이 많이 걸리게 됩니다. 이것을 별도의 스레드로 동작시키면, 조금 느리게 나오지만 리스트 이동하는 것에는 느려짐 없이 동작할 수 있게 됩니다.

Swift는 과거 콜백 기반의 복잡한 비동기 코드에서 벗어나, async/await와 액터(Actor)를 포함한 강력하고 현대적인 동시성 모델을 제공합니다.

1. 동시성(Concurrency)과 비동기(Asynchronous)의 이해

  • 동시성 (Concurrency): 여러 작업을 동시에 진행하는 것처럼 보이게 하는 능력입니다. 실제로 동시에 실행될 수도 있고(멀티코어 CPU), 단일 코어에서 빠르게 전환하며 실행될 수도 있습니다. 목표는 유휴 시간을 최소화하고 자원을 효율적으로 사용하는 것입니다.
  • 비동기 (Asynchronous): 어떤 작업을 시작한 후, 그 작업이 완료되기를 기다리지 않고 다음 작업을 바로 진행하는 방식입니다. 완료되면 나중에 콜백 등을 통해 결과를 통보받습니다.

예를 들어, 웹사이트에서 사진을 다운로드하는 것은 비동기 작업입니다. 사진 다운로드를 시작해 놓고, 사용자는 다른 웹 페이지를 계속 탐색할 수 있죠. 사진 다운로드가 완료되면 알림을 받거나 화면에 사진이 표시됩니다.

2. async/await: 비동기 코드의 혁명

Swift 5.5부터 도입된 async/await는 복잡했던 콜백 기반의 비동기 코드를 마치 동기 코드처럼 순차적으로 작성할 수 있게 하여 코드의 가독성과 유지보수성을 비약적으로 향상시켰습니다.

  • async 키워드: 함수나 메서드가 비동기적으로 실행될 수 있음을 나타냅니다.
  • await 키워드: async 함수 내에서 다른 비동기 함수가 완료되기를 기다리는 지점입니다. await가 있는 곳에서 현재 태스크는 잠시 중단되고, 다른 작업들이 실행될 기회를 얻습니다. 비동기 함수가 결과를 반환하면 await 지점부터 다시 실행됩니다.

2.1. async/await 기본 사용법


// 1. 비동기 함수 정의: async 키워드 사용
func fetchUserName(id: Int) async -> String {
    // 네트워크 지연을 시뮬레이션
    print("사용자 ID \(id)의 이름 가져오기 시작...")
    try? await Task.sleep(nanoseconds: 2 * 1_000_000_000) // 2초 대기 (async 코드)
    print("사용자 ID \(id)의 이름 가져오기 완료.")
    return "사용자 \(id)의 이름"
}

// 2. 비동기 함수 호출: await 키워드 사용
func displayUserInfo() async {
    print("정보 표시 시작...")
    let userName = await fetchUserName(id: 1) // fetchUserName이 완료될 때까지 기다림
    print("표시할 이름: \(userName)")
    print("정보 표시 완료.")
}

// 3. 최상위 레벨에서 비동기 함수 실행 (iOS 15+, Swift 5.5+ 모듈)
// 또는 Task 블록 안에서 실행
Task {
    await displayUserInfo()
}

// 예상 출력 순서:
// 정보 표시 시작...
// 사용자 ID 1의 이름 가져오기 시작...
// (2초 후)
// 사용자 ID 1의 이름 가져오기 완료.
// 표시할 이름: 사용자 1의 이름
// 정보 표시 완료.

await fetchUserName(id: 1) 라인에서 displayUserInfo 함수는 fetchUserName 함수가 완료될 때까지 일시 중단됩니다. 이 동안 다른 비동기 작업이나 UI 작업이 계속 진행될 수 있습니다.

2.2. 오류 처리와 async/await

오류를 던질 수 있는 비동기 함수는 async throws로 선언하고, 호출 시에는 try await를 사용합니다.


enum NetworkError: Error {
    case invalidURL
    case serverError
    case noData
}

func downloadImage(from urlString: String) async throws -> String {
    guard let url = URL(string: urlString) else {
        throw NetworkError.invalidURL
    }

    print("이미지 다운로드 시작: \(url)")
    try await Task.sleep(nanoseconds: 3 * 1_000_000_000) // 3초 대기
    
    if urlString.contains("fail") {
        throw NetworkError.serverError
    }
    print("이미지 다운로드 완료: \(url)")
    return "Downloaded: \(url)"
}

func processImageDownload() async {
    do {
        let imagePath = try await downloadImage(from: "https://example.com/image.jpg")
        print("이미지 처리 성공: \(imagePath)")
    } catch NetworkError.invalidURL {
        print("오류: 잘못된 이미지 URL입니다.")
    } catch NetworkError.serverError {
        print("오류: 서버 문제로 이미지 다운로드에 실패했습니다.")
    } catch {
        print("알 수 없는 오류 발생: \(error.localizedDescription)")
    }
}

Task {
    await processImageDownload() // 정상 케이스
    // try? await downloadImage(from: "invalid-url") // 실패 케이스 테스트
}

3. Task와 TaskGroup: 동시성 제어의 핵심

Swift 동시성 모델의 기본 단위는 Task입니다. Task는 비동기 작업을 실행하는 독립적인 단위이며, 경량 스레드처럼 작동합니다. TaskGroup은 여러 관련 태스크를 그룹화하여 관리할 수 있게 해줍니다.

3.1. Task: 독립적인 비동기 작업 단위

Task는 독립적으로 실행되는 비동기 코드 블록을 생성할 때 사용됩니다. UI 업데이트와 같은 메인 스레드 작업이 끝나지 않은 채로 백그라운드 작업을 시작해야 할 때 유용합니다.


func performBackgroundCalculations() {
    print("백그라운드 계산 시작 (메인 스레드)")
    Task { // 새로운 비동기 Task 시작
        print("Task 내부 시작 (새로운 태스크)")
        let sum = (1...1_000_000).reduce(0, +)
        print("계산 결과: \(sum) (Task 내부)")
    }
    print("백그라운드 계산 요청 완료 (메인 스레드)")
}

performBackgroundCalculations()
// 예상 출력 순서 (정확한 순서는 보장되지 않을 수 있음):
// 백그라운드 계산 시작 (메인 스레드)
// 백그라운드 계산 요청 완료 (메인 스레드)
// Task 내부 시작 (새로운 태스크)
// 계산 결과: 500000500000 (Task 내부)

Task 블록 내의 코드는 비동기적으로 실행되므로, performBackgroundCalculations() 함수는 Task가 완료되기를 기다리지 않고 바로 다음 라인을 실행합니다.

3.2. TaskGroup: 여러 비동기 작업을 동시에 실행하고 결과 합치기

TaskGroup을 사용하면 여러 비동기 작업을 동시에 시작하고, 이들이 모두 완료될 때까지 기다리거나 개별 결과를 수집할 수 있습니다. await withTaskGroup을 사용하여 그룹을 생성합니다.


func fetchMultipleUserNames(ids: [Int]) async -> [String] {
    var names: [String] = []
    
    // TaskGroup 생성
    await withTaskGroup(of: String.self) { group in
        for id in ids {
            group.addTask { // 각 ID에 대해 새로운 Task 추가
                return await fetchUserName(id: id) // 위에서 정의한 async 함수 재사용
            }
        }
        
        // 모든 Task의 결과를 기다리고 수집
        for await name in group {
            names.append(name)
        }
    }
    return names.sorted() // 순서가 보장되지 않으므로 정렬
}

Task {
    print("\n--- 여러 사용자 이름 가져오기 시작 ---")
    let userIDs = [2, 3, 1]
    let allNames = await fetchMultipleUserNames(ids: userIDs)
    print("모든 사용자 이름: \(allNames)")
    print("--- 여러 사용자 이름 가져오기 완료 ---")
}
// 예상 출력:
// --- 여러 사용자 이름 가져오기 시작 ---
// 사용자 ID 2의 이름 가져오기 시작...
// 사용자 ID 3의 이름 가져오기 시작...
// 사용자 ID 1의 이름 가져오기 시작...
// (2초 후, 3개의 fetchUserName이 거의 동시에 완료)
// 사용자 ID 2의 이름 가져오기 완료.
// 사용자 ID 3의 이름 가져오기 완료.
// 사용자 ID 1의 이름 가져오기 완료.
// 모든 사용자 이름: ["사용자 1의 이름", "사용자 2의 이름", "사용자 3의 이름"]
// --- 여러 사용자 이름 가져오기 완료 ---

withTaskGroup은 각 fetchUserName 호출이 병렬적으로(동시에) 실행되도록 하여 전체 대기 시간을 줄여줍니다.

4. Actor: 동시성 환경에서 데이터 경쟁 방지

멀티스레드 환경에서는 여러 스레드가 동시에 같은 데이터에 접근하여 수정하려 할 때 데이터 경쟁(Data Race)이라는 문제가 발생할 수 있습니다. 이는 예측 불가능한 결과를 초래하며 디버깅하기 매우 어렵습니다.

액터(Actor)는 이러한 데이터 경쟁을 방지하기 위해 도입된 개념입니다. 액터는 자신이 관리하는 모든 데이터에 대한 접근을 직렬화(Serialization)하여, 한 번에 하나의 태스크만 액터의 상태에 접근하고 수정할 수 있도록 보장합니다.


actor BankAccount {
    var balance: Double

    init(initialBalance: Double) {
        self.balance = initialBalance
    }

    func deposit(amount: Double) {
        self.balance += amount
        print("입금: \(amount), 현재 잔액: \(self.balance)")
    }

    func withdraw(amount: Double) {
        if self.balance >= amount {
            self.balance -= amount
            print("출금: \(amount), 현재 잔액: \(self.balance)")
        } else {
            print("잔액 부족! (시도: \(amount), 현재: \(self.balance))")
        }
    }
}

func simulateTransactions() async {
    let account = BankAccount(initialBalance: 1000.0)

    // 여러 Task에서 동시에 잔액에 접근 시도
    await withTaskGroup(of: Void.self) { group in
        for _ in 1...5 {
            group.addTask {
                await account.deposit(amount: 100.0)
            }
            group.addTask {
                await account.withdraw(amount: 50.0)
            }
        }
    }
    print("최종 잔액: \(await account.balance)") // 액터의 프로퍼티 접근 시에도 await 필요
}

Task {
    print("\n--- 트랜잭션 시뮬레이션 시작 ---")
    await simulateTransactions()
    print("--- 트랜잭션 시뮬레이션 완료 ---")
}
// 액터 덕분에 입금/출금 순서는 달라질 수 있지만, 최종 잔액은 정확히 계산됨

BankAccount 액터는 balance 프로퍼티에 대한 동시 접근을 자동으로 관리합니다. deposit이나 withdraw 메서드를 호출할 때는 암시적으로 await가 필요하며, 액터 내부의 상태를 변경하는 작업은 항상 안전하게 순차적으로 처리됩니다.

정리하며

오늘은 Swift의 현대적인 동시성(Concurrency) 모델에 대해 자세히 알아보았습니다.

  • 동시성 vs. 비동기: 여러 작업을 동시에 진행하는 것처럼 보이게 하는 능력과, 작업이 완료되기를 기다리지 않고 다음 작업을 진행하는 방식입니다.
  • async/await: 비동기 코드를 동기 코드처럼 쉽게 작성할 수 있게 해주는 Swift의 핵심 기능입니다.
  • Task: 독립적인 비동기 작업을 실행하는 단위입니다.
  • TaskGroup: 여러 관련 비동기 작업을 그룹으로 묶어 효율적으로 관리하고 결과를 수집합니다.
  • Actor: 동시성 환경에서 공유 데이터에 대한 안전한 접근을 보장하여 데이터 경쟁을 방지합니다.

Swift의 새로운 동시성 기능들은 복잡했던 비동기 프로그래밍을 훨씬 더 안전하고 직관적으로 만들어 줍니다. 사용자 인터페이스의 반응성을 유지하고, 복잡한 백그라운드 작업을 효율적으로 처리하는 데 필수적이므로, 이 개념들을 충분히 익히는 것이 중요합니다.

궁금한 점이 있다면 언제든지 댓글로 남겨주세요! 


0 comments:

댓글 쓰기