정보/레벨 0

동시성 프로그래밍의 개념과 iOS에서의 동시성 처리 방식에 대해 설명해주세요.

밤새는 탐험가89 2024. 10. 2. 13:25

 동시성 프로그래밍은 여러 작업이 동시에 실행될 수 있도록 설계된 프로그래밍 기법입니다. 작업들이 정확히 동시에 실행되는 것은 아니더라도, 프로그램의 흐름 상에서 여러 작업이 병렬적으로 진행되는 것처럼 보이도록 설계됩니다. 동시성의 목적은 시스템 자원을 더 효율적으로 사용하고, 사용자 경험을 개선하기 위함입니다.

 

 

병렬 처리와 동시 처리의 차이

  • 동시 처리(Concurrency): 여러 작업이 같은 시간에 실행될 수 있도록 하는 프로그래밍 기법입니다. 다만, 작업들이 반드시 동시에 실행되는 것은 아니며, CPU가 각 작업을 빠르게 전환하여 실행하는 것처럼 보일 수 있습니다.
  • 병렬 처리(Parallelism): 실제로 여러 작업이 동시에 실행되는 것을 의미합니다. 병렬 처리는 다중 코어 CPU나 여러 장치에서 각 작업을 별도의 CPU 코어에서 독립적으로 실행할 수 있을 때 가능해집니다.

 쉽게 말하면, 동시성은 작업의 스케줄링에 관한 것이고, 병렬성은 실제로 여러 작업이 동시에 실행되는 것을 의미합니다. 병렬성은 동시성의 한 부분으로, 하드웨어의 지원을 통해 이루어집니다.

 

iOS에서의 동시성 처리 방식

iOS는 동시성 처리를 위해 다양한 방법과 기술을 제공합니다. 주요한 동시성 처리 방식은 다음과 같습니다.

  1. Grand Central Dispatch (GCD)
    • GCDApple의 저수준 동시성 API로, 작업을 큐(queue)에 넣고 적절한 쓰레드에서 실행되도록 스케줄링해줍니다. GCD를 사용하면 복잡한 쓰레드 관리 없이, 병렬적인 작업을 쉽게 수행할 수 있습니다.
    • GCD에서 사용할 수 있는 주요 큐는 다음과 같습니다:
      • Main Queue: 메인 스레드에서 작업을 실행하는 큐로, UI 업데이트 작업은 반드시 메인 큐에서 처리되어야 합니다.
      • Global Queues: 시스템에서 제공하는 전역 큐로, 백그라운드에서 병렬 작업을 실행할 수 있습니다. 우선순위에 따라 여러 큐가 제공됩니다 (e.g., high, default, low).
      • Custom Queues: 개발자가 직접 만든 큐로, 특정 작업을 직렬적으로 처리하거나 병렬적으로 처리할 수 있습니다.
    • GCD는 비동기(Asynchronous)동기(Synchronous) 작업 처리를 지원하며, 비동기 작업은 작업이 완료될 때까지 현재 실행 흐름을 블록하지 않습니다.
  2. Operation Queue
    • OperationOperationQueue는 GCD보다 더 상위 수준의 동시성 API입니다. GCD보다 더 많은 제어권을 제공하며, 작업 간의 의존성 설정, 작업 취소, 우선순위 설정 등 다양한 기능을 사용할 수 있습니다.
    • OperationQueue는 비동기 작업을 관리하는 데 적합하며, GCD와 달리 작업의 상태를 추적할 수 있는 장점이 있습니다.
  3. Async/Await (iOS 13 이상)
    • Swift에서는 **비동기 함수(async/await)**를 사용하여 동시성을 처리할 수 있습니다. 이는 기존의 콜백 기반 비동기 처리 방식을 대체하는 직관적이고 깔끔한 코드 구조를 제공합니다.
    • async/await를 사용하면 비동기 작업을 마치 동기 작업처럼 다룰 수 있어 코드 가독성을 크게 향상시킵니다.
func fetchData() async throws -> Data {
    let url = URL(string: "https://example.com")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return data
}

 

 

iOS에서 멀티코어 활용 방안

iOS 기기는 대부분 멀티코어 CPU를 가지고 있기 때문에, 멀티코어를 활용하는 것이 동시성 프로그래밍에서 중요한 부분입니다. iOS는 여러 코어를 효과적으로 사용하여 애플리케이션의 성능을 높일 수 있는 다양한 방법을 제공합니다.

  • GCD와 OperationQueue는 멀티코어 활용을 자동으로 처리합니다. iOS는 작업을 여러 CPU 코어에 분산시켜, 병렬로 실행할 수 있는 작업들을 나눠 처리합니다.
  • QoS(Quality of Service): GCD와 OperationQueue는 작업의 중요도에 따라 작업을 스케줄링합니다. 시스템은 각 작업의 우선순위와 장치의 상태를 기반으로 멀티코어에서 최적화된 방식으로 작업을 분배합니다.
    • 예: 사용자 인터페이스 업데이트는 메인 큐에서, 백그라운드 데이터 처리 작업은 글로벌 큐에서 실행됩니다.

멀티코어를 활용하는 방식은 기본적으로 iOS가 제공하는 동시성 API를 사용할 때 자동으로 이루어지며, 개발자는 작업의 우선순위를 적절히 설정하고 적합한 큐에서 작업을 실행하기만 하면 됩니다.

 

 

 외부에서 데이터를 받아와서 UI 요소에 반영하는 상황에서는 비동기적으로 데이터를 처리하고 UI 업데이트를 할 수 있도록 동시성 프로그래밍이 활용됩니다. 이 과정에서 UI와 네트워크 통신은 각각 별도의 작업으로 처리되어, UI가 멈추지 않도록 설계하는 것이 중요합니다. 일반적으로 iOS에서 이 작업을 처리하는 방식은 다음과 같습니다

 

1. 네트워크 요청을 비동기적으로 처리

네트워크 요청(예: API 호출)은 시간이 걸릴 수 있는 작업이므로 메인 스레드에서 직접 실행하면 앱이 멈추거나 느려질 수 있습니다. 이 때문에 네트워크 작업은 백그라운드에서 실행하고, 데이터가 도착한 후에 UI 업데이트는 메인 스레드에서 수행됩니다.

 

예시 1: GCD를 사용한 비동기 네트워크 요청

DispatchQueue.global(qos: .background).async {
    // 백그라운드에서 네트워크 요청
    let data = fetchDataFromNetwork()

    DispatchQueue.main.async {
        // 데이터를 받은 후 메인 스레드에서 UI 업데이트
        self.label.text = data.title
    }
}

 

 

  • DispatchQueue.global(qos: .background).async: 네트워크 요청을 백그라운드 스레드에서 실행하여, 메인 스레드에서 다른 UI 작업들이 계속 실행될 수 있도록 합니다.
  • DispatchQueue.main.async: UI 요소 업데이트는 반드시 메인 스레드에서 수행해야 하므로, 네트워크 요청이 끝난 후에 메인 큐에서 UI를 업데이트합니다.

 

예시 2: async/await을 사용한 비동기 처리 (iOS 13 이상)

func updateUIWithNetworkData() async {
    do {
        let data = try await fetchDataFromNetwork()
        
        // 메인 스레드에서 UI 업데이트
        self.label.text = data.title
    } catch {
        print("Failed to fetch data")
    }
}

Task {
    await updateUIWithNetworkData()
}

 

 

 

  • await fetchDataFromNetwork(): 비동기적으로 네트워크 데이터를 받아옵니다.
  • UI 업데이트는 따로 명시하지 않아도 자동으로 메인 스레드에서 실행되기 때문에 DispatchQueue.main을 명시할 필요가 없습니다.

2. 동시성의 목적

위 예시에서 볼 수 있듯이, 동시성 프로그래밍을 사용하는 이유는 네트워크 작업처럼 시간이 오래 걸리는 작업이 UI 업데이트를 방해하지 않도록 하는 데 있습니다. 네트워크 요청은 시간이 걸리지만, 그동안 사용자는 UI에서 다른 작업을 할 수 있고, 작업이 완료된 후에 UI 요소에 데이터를 반영합니다.

3. 동시성 처리 시 주의점

  • UI 업데이트는 항상 메인 스레드에서 해야 합니다. 비동기적으로 데이터를 받아오는 작업은 백그라운드에서 처리하더라도, UI 관련 작업은 메인 큐에서 실행되어야 합니다. 그렇지 않으면 앱이 크래시하거나 UI가 제대로 반영되지 않습니다.
  • 경합 조건(Race Conditions): 여러 스레드에서 동시에 데이터를 수정할 경우 예상치 못한 결과가 발생할 수 있으므로, 필요한 경우 동기화 처리 또는 순서 제어가 필요합니다.

4. 동시성 처리의 활용 시나리오

  • 데이터 로드 및 화면 반영: 외부에서 데이터를 받아와 테이블뷰, 컬렉션뷰, 또는 특정 UI 요소에 반영할 때.
  • 이미지 다운로드 및 표시: 네트워크에서 이미지를 비동기적으로 다운로드한 후 UIImageView에 반영할 때.
  • 사용자 입력과 백그라운드 작업 분리: 사용자가 UI 상에서 작업을 하는 동시에 백그라운드에서 네트워크 요청, 데이터 처리, 파일 저장 등의 작업이 진행되는 경우.

 

 

1. GCD의 큐 종류

  • Main Queue:
    • 메인 스레드에서 실행되는 작업을 처리합니다.
    • UI 업데이트 작업은 반드시 메인 큐에서 실행해야 합니다.
    • 예를 들어, 사용자 인터페이스의 버튼 클릭에 반응하여 UI 요소를 업데이트할 때 사용됩니다.
  • Global Queue:
    • 백그라운드에서 실행되는 작업을 처리합니다.
    • 여러 가지 QoS(Quality of Service) 레벨이 있으며, 각각 우선순위에 따라 다르게 설정할 수 있습니다 (e.g., .userInteractive, .userInitiated, .utility, .background).
    • CPU에 부하를 주지 않으면서 동시에 여러 작업을 처리할 수 있습니다.

차이점:

  • 메인 큐는 UI 작업을 위한 것이고, 글로벌 큐는 비UI 작업을 위한 것입니다. 메인 큐는 단일 스레드에서 실행되기 때문에 UI가 중단되지 않도록 설계되어 있습니다. 반면, 글로벌 큐는 여러 스레드에서 병렬로 작업을 수행할 수 있어, CPU 자원을 효율적으로 활용할 수 있습니다.

2. 비동기 작업의 순서 관리

비동기 작업의 순서를 관리하기 위해 여러 가지 방법을 사용할 수 있습니다. 일반적으로 두 가지 접근 방식이 있습니다: **체이닝(Chaining)**과 Dispatch Group을 사용하는 것입니다.

예시 1: 체이닝 (Chaining)

체이닝 방식은 각 비동기 함수가 이전 작업이 완료된 후에 호출되는 구조입니다. 이 경우, 각 작업을 비동기적으로 수행하고 결과를 바탕으로 다음 작업을 실행합니다.

func fetchTouristAttractions(keyword: String) async -> [TouristAttraction] {
    // "경기" 키워드로 관광지 정보 가져오기
}

func fetchNearbyRestaurants(for attractions: [TouristAttraction]) async -> [Restaurant] {
    // 주어진 관광지 근처 맛집 정보 가져오기
}

func fetchData() async {
    let attractions = await fetchTouristAttractions(keyword: "경기")
    let restaurants = await fetchNearbyRestaurants(for: attractions)
    
    // 결과를 UI에 업데이트
}

 

 

  • await 키워드를 사용하여 각 함수가 완료될 때까지 기다린 후 다음 작업을 실행합니다. 이 방법은 각 작업의 순서를 명확히 할 수 있습니다.

예시 2: Dispatch Group

여러 비동기 작업을 동시에 실행하면서 결과를 집계할 필요가 있을 때는 Dispatch Group을 사용할 수 있습니다.

let group = DispatchGroup()

var attractions: [TouristAttraction] = []
var restaurants: [Restaurant] = []

group.enter()
fetchTouristAttractions(keyword: "경기") { result in
    attractions = result
    group.leave()
}

group.enter()
fetchNearbyRestaurants(for: attractions) { result in
    restaurants = result
    group.leave()
}

group.notify(queue: .main) {
    // 모든 비동기 작업이 완료된 후 UI 업데이트
    updateUI(attractions: attractions, restaurants: restaurants)
}

 

DispatchGroup을 사용하면 여러 작업을 동시에 수행하면서, 모든 작업이 완료될 때까지 기다릴 수 있습니다. notify 메소드를 사용하여 모든 작업이 끝난 후 UI 업데이트를 수행할 수 있습니다.

 

 

DispatchGroup은 여러 비동기 작업을 묶어서 동시에 처리할 때 매우 유용합니다. 이렇게 하면 각 작업이 완료될 때까지 기다렸다가, 모든 작업이 끝난 후에 필요한 후속 처리를 수행할 수 있습니다.

예시: 두 개의 컬렉션 뷰를 위한 Dispatch Group 사용

홈 화면에 두 개의 컬렉션 뷰가 있고, 각각 전국 기준 관광지 정보와 위치 기준 관광지 정보를 동시에 로드하고 싶을 때 DispatchGroup을 사용할 수 있습니다. 이 방법으로 두 비동기 작업이 모두 완료된 후에 UI를 업데이트하면 코드도 깔끔해집니다.

 

코드 예시

let group = DispatchGroup()
var nationalAttractions: [TouristAttraction] = []
var locationAttractions: [TouristAttraction] = []

// 전국 기준 관광지 정보 가져오기
group.enter()
fetchNationalAttractions { attractions in
    nationalAttractions = attractions
    group.leave()
}

// 위치 기준 관광지 정보 가져오기
group.enter()
fetchLocationBasedAttractions { attractions in
    locationAttractions = attractions
    group.leave()
}

// 모든 작업이 완료된 후 UI 업데이트
group.notify(queue: .main) {
    // 컬렉션 뷰 데이터 설정
    self.nationalCollectionView.reloadData()
    self.locationCollectionView.reloadData()
    
    // 추가적인 UI 업데이트가 필요하다면 여기서 처리
}

 

설명

  1. DispatchGroup 생성: DispatchGroup 인스턴스를 생성합니다.
  2. 비동기 작업 추가: 각각의 비동기 작업에서 group.enter()를 호출하여 작업이 시작됨을 알리고, 작업이 완료되면 group.leave()를 호출하여 작업이 완료됨을 알립니다.
  3. 작업 완료 대기: group.notify(queue: .main)를 사용하여 모든 작업이 완료된 후 UI를 업데이트하는 클로저를 지정합니다. 이 클로저는 메인 스레드에서 실행되므로 UI 업데이트에 안전합니다.

장점

  • 코드 가독성: 비동기 작업을 묶어서 처리하면 코드가 더 간결하고 이해하기 쉬워집니다.
  • 동시성: 두 작업이 동시에 진행되므로, 네트워크 요청 시간을 단축할 수 있습니다.
  • UI 업데이트의 일관성: 두 작업이 완료된 후에 한 번에 UI를 업데이트하므로, 사용자에게 일관된 경험을 제공합니다.

 

DispatchGroup과 async/await은 비동기 작업을 처리하는 두 가지 다른 접근 방식입니다. 각각의 사용 사례와 장단점이 있으며, 상황에 따라 선택할 수 있습니다.

 

1. DispatchGroup vs. async/await

  • DispatchGroup:
    • 여러 비동기 작업의 완료를 기다리는 데 사용합니다.
    • 복잡한 비동기 흐름을 관리할 수 있지만, 코드가 다소 복잡해질 수 있습니다.
    • 콜백 기반의 프로그래밍 모델을 사용하므로 가독성이 떨어질 수 있습니다.
  • async/await:
    • 비동기 코드를 동기적으로 작성할 수 있게 해 주는 구문입니다.
    • 코드 가독성이 높고, 흐름을 더 쉽게 이해할 수 있습니다.
    • Swift 5.5 이후에 도입되었으며, 비동기 함수가 다른 비동기 함수를 호출할 수 있습니다.

2. 예시: DispatchGroup을 async/await으로 변환

다음은 DispatchGroup을 사용한 예제와 이를 async/await로 변환한 예제입니다.

 

DispatchGroup 예제

let group = DispatchGroup()
var nationalAttractions: [TouristAttraction] = []
var locationAttractions: [TouristAttraction] = []

// 전국 기준 관광지 정보 가져오기
group.enter()
fetchNationalAttractions { attractions in
    nationalAttractions = attractions
    group.leave()
}

// 위치 기준 관광지 정보 가져오기
group.enter()
fetchLocationBasedAttractions { attractions in
    locationAttractions = attractions
    group.leave()
}

// 모든 작업이 완료된 후 UI 업데이트
group.notify(queue: .main) {
    self.nationalCollectionView.reloadData()
    self.locationCollectionView.reloadData()
}

 

 

async/await로 변환

func fetchAttractions() async {
    async let nationalAttractions = fetchNationalAttractions()
    async let locationAttractions = fetchLocationBasedAttractions()
    
    // 두 작업이 모두 완료될 때까지 대기
    let attractions1 = await nationalAttractions
    let attractions2 = await locationAttractions

    // UI 업데이트는 메인 스레드에서 수행
    DispatchQueue.main.async {
        self.nationalCollectionView.reloadData()
        self.locationCollectionView.reloadData()
    }
}

 

설명

  1. async let:
    • 두 비동기 작업을 동시에 시작합니다. 각 작업이 완료될 때까지 대기하지 않으며, 작업이 끝나는 대로 결과를 받아올 수 있습니다.
  2. await:
    • await 키워드를 사용하여 각 비동기 작업의 완료를 기다립니다. 이 경우, nationalAttractions와 locationAttractions는 두 작업이 모두 완료된 후에 사용됩니다.
  3. UI 업데이트:
    • DispatchQueue.main.async를 사용하여 UI 업데이트를 메인 스레드에서 수행합니다.

구분하여 사용하기

  • 복잡한 비동기 흐름: 여러 비동기 작업을 그룹으로 묶고, 모든 작업이 완료된 후에 처리할 필요가 있을 때는 DispatchGroup을 사용할 수 있습니다.
  • 간결하고 직관적인 코드: 코드의 가독성을 높이고 싶다면 async/await를 사용하는 것이 좋습니다. 특히 Swift 5.5 이상에서는 async/await의 장점이 큽니다.