ABOUT ME

-

  • GCD vs async/await 제대로 알기
    Apple🍎/Cocurrency 2025. 3. 26. 22:49

    Swift에서는 비동기를 처리하는 방법은 크게

    GCD(Grand Central Dispatch)를 이용하는 방식과 새로 도입된 async/await를 이용하는 법이 있습니다. 

    하지만 각 방식에 대한 설명들이 너무 추상적이거나 모호하고 중간 과정들이 생략된 경우가 많아서

    명확한 동작에 대한 이해가 어려웠습니다. 

     

    그래서 직접 각 방법들의 과정들을 자세하게 분석해서 비동기 처리에 대한 껄끄러운 느낌을 없애보도록 하겠습니다. 

    GCD 방식 

    GCD 방식에서는 비동기 동작을 위해서 DispatchQueue에 async 방식으로 작업을 예약합니다. 

    네트워크 요청이나 파일 IO등 처리 시간이 오래 걸리는 작업들을 global 큐에 넘겨버림으로써

    메인 쓰레드의 계속된 작동을 보장하기 위해 주로 사용합니다. ( 화면 드로잉 싸이클이 멈추기 않기 위해서 , 화면 응답성 유지) 

     

    선언부

    fetchDataWithGCD( ) 함수는 내부에서 DispatchQueue.global.async{ ... } 블럭을 통해 비동기 작업을 수행합니다. 

    또한 completion 파라미터를 통해 @escaping 클로저를 받아 fetchDataWithGCD( )  함수가 반환된 이후에도 콜백에 대한 처리를 가능케 합니다. 

     

    호출부

    앞서 정의한 fetchDataWithGCD( ) 함수를 호출하면서 클로저를 통해 비동기 작업이 완료된 이후 콜백이 왔을 때 이에 대한 처리를 어떻게 할지를 정의해줍니다. 

     

    다시 말하면 비동기 함수를 호출하는 쓰레드는 결과에 대한 처리 방식을 '전달'할 뿐 본인이 실행하지 않습니다. 

    현재 앱이 실행되어 메인 스레드에서 코드가 실행중입니다.  

    일반적으로 코드는 메인 쓰레드에서 순차적으로 실행되므로 다음 과정에 따라 실행됩니다. 

     

    1. 첫번째 프린트 함수가 실행되고 다음 라인으로 넘어갑니다. 

    2. fetchDataWithGCD( ) 함수를 호출합니다. 

    3. 메인 쓰레드에서 fetchDataWithGCD( ) 함수가 실행되다가 DispatchQueue.global.async 부분을 만납니다. 

    • 백그라운드 큐에 DispatchQueue.global.async{ ... } 블럭으로 감싸진 작업을 예약합니다. 
    • async(비동기) 방식이므로 예약이후 작업의 실행을 기다리지 않습니다. 

    4. 백그라운드 큐에 작업 예약 후 이를 기다리지 않고 바로 다음 라인으로 넘어가 두번째 프린트 함수를 호출합니다. 

    백그라운 큐는 작업들을 예약된 순서대로 사용 가능한 쓰레드들에 배정해줍니다. 

    fetchDataWithGCD( ) 의 DispatchQueue.global.async{ ... } 블럭이 Thread B3를 할당 받아 실행됩니다. 

    DispatchQueue.global.async{ ... } 블럭을 순차적으로 실행하던 중에 

    네트워크 요청이나 파일 I/O와 같이 요청에 대한 응답이 올때까지 시간이 걸리는 작업을 실행하면 

    해당 쓰레드는 요청 이후 응답이 도달할때까지 기다립니다. 

    즉 ThreadB3는 기다리는 동안 아무것도 하지 않고 멈춥니다. (Block)

    이 시점에 만약 백그라운드 큐가 새로운 작업을 실행하기 위해 쓰레드를 배정하려고 한다고 가정해봅시다. 

    현재 Thread B3는 '아무런 작업도 하고 있지 않지만' 서버의 요청을 '기다리는 상태'이기 때문에 해당 쓰레드를 작업에 사용할 수 없습니다. 따라서 백그라운드 큐는 새로운 작업 실행을 위해 새로운 쓰레드를 가져와서 배정해야합니다. 

    서버로 부터 응답을 받아 결과를 확인한 후 이를 처리하는 작업을 수행합니다.  이때 fetchDataWithGCD( ) 함수를 호출할 때 정의 했던 클로저 부분이 활용됩니다. (이 시점에 이미 fetchDataWithGCD( ) 함수는 반환된 상태이기 때문에 클로저가 @escaping 키워드를 통해 함수의 생명주기를 벗어나야 합니다.) 

     

    클로저 종결판 2

    죽지도 않고 다시 돌아온 클로저 종결판 아직 못 보신 분은 종결판 1부터 보시고 오세요. 츄라이~ 클로저 종결판앱을 개발할 때 꼭 마주치게 되는 '클로저'라는 놈, 이 정도 봤으면 정들 때도 되

    people-analysis.tistory.com

    이후에 응답 결과를 화면에 업데이트 하기 위해 DispatchQueue.main.async{...} 블럭에서 completion( ) 콜백을 호출 합니다. (화면 업데이트는 무조건 메인 쓰레드에서 해야함) 따라서 Thread B3는 메인 큐에 completion 작업을 비동기 방식으로 예약하고 나서야 작업을 종료할 수 있습니다. 

     

    흐름을 다시 정리해보면 

    1. 메인 쓰레드에서 비동기 방식으로 예약된 작업이 쓰레드를 할당 받습니다. 

    2. 작업을 실행할 때 시간이 걸리는 부분이 있으면 쓰레드는 이를 기다립니다. 

    3. 쓰레드는 콜백이 돌아오면 나머지 작업을 마저 수행하고 작업을 완료합니다. 

    위의 과정에서 알다시피 비동기 함수는 사용하는 쓰레드를 작업이 완료할 때까지 점유 합니다. ( 실제 사용 여부와 관계없이 )

    따라서 비동기 함수가 한꺼번에 너무 많이 호출되면 각 비동기 함수들이 쓰레드들을 점유하고 이를 쓰레드 풀에 반납하지 않으면서

    새로운 작업 할당을 위해 계속 쓰레드들을 생성하는 "쓰레드 폭발"(리소스를 너무 많이 사용하는 현상)이 발생해 성능을 저해할 수 있습니다. 

     

    그렇다면 async/await 는 어떠한 방식으로 비동기를 처리할까요?

    async/await 방식

    async/await 방식에서는 Task와 async 키워드를 이용해 cocurrent context 블럭을 만들고 여기에 작성된 코드들은 순서대로 실행합니다. 또한 await 키워드를 통해 중단'가능'점을 표시하여 시간이 오래 걸리는 작업이 발생시 쓰레드를 반납하여 다른 작업들이 해당 쓰레드 이용할 수 있도록 합니다. 

     

    선언부

    fetchDataWithAsync( ) 함수는 함수 시그니쳐 부분에 async 키워드를 이용해 해당 함수가 비동기함수임을 알립니다.

    따라서 해당 함수를 호출하기 위해서는 await 키워드를 이용하여 해당 함수를 실행시 중단 발생 가능성을 명시합니다. 

    호출부

    async 로 표시된 비동기 함수를 호출하기 위해서는 Task 또는 다른 async 키워드를 통해 cocurrent context를 만들어 주어야합니다. 

    cocurrent context를 구성해주어야만 시스템이 해당 작업에 대한 쓰레드 관리를 할 수 있기 때문입니다. 

    processData( ) 함수의 경우에는 async 키워드를 통해 본문에 cocurrent context를 형성하여 내부에서 fetchDataWithAsync( ) 함수를 호출 할 수 있으며 onButtonTap( ) 함수의 경우에는 Task를 통해 현재 본문 내부에 cocurrent context를 만들어 해당 비동기 작업의 시스템 관리를 가능케 합니다. 

    사용자가 버튼을 누름으로써 메인 쓰레드에서 onButtonTap( ) 이 호출되며 다음과 같은 순서로 코드가 실행됩니다. 

    1.  메인 쓰레드에서 onButtonTap( ) 함수의 첫번째 프린트 함수가 호출됩니다. 

    2. Task 블럭에 진입하면서 Task( 비동기 작업 단위 )를 생성합니다. 

    3. 앞서 생성한 Task를 협력적 스케쥴러에 제출합니다. 

    4. 메인 쓰레드는 Task를 제출한 이후에는 이를 신경 쓰지 않고 다음 라인의 두번째 프린트 함수를 실행하고 onButtonTap( ) 함수를 반환합니다. 

     

    GCD의 Queue 방식과 다르게 asnyc/awiat 에서는 작업들이 Swift 런타임의 협력적 스케쥴러에 의해 관리됩니다. 

    협력적 스케쥴러는 쓰레드 풀을 관리하며 Task에 적절한 쓰레드를 할당하는 역할을 합니다. ( cocurrent context에서 시스템이 알아서 작업들을 관리 하는 방식)

    비동기 스케쥴러에 의해 관리되기 위해서는 맨 처음 한번은 Task{ } 블럭을 통해 비동기 작업을 제출하는 절차가 필요합니다.

    한 번 제출된 이후 그 안에서 호출되는 모든 async 함수들은 함께 비동기 스케쥴러에 의해 관리됩니다. 

     

    비동기 스케쥴러는 자신이 관리하는 Task들을 사용가능한 쓰레드들에 분배합니다. 

    그리고 Task 블럭 안의 코드가 순차적으로 실행되다가 await(중단 가능 지점)을 만났을 때 해당 Task가 현재 사용하는 쓰레드를 계속 사용할지 반납할지를 결정합니다. 

    1. Thread B3를 작업에 할당합니다. 

    2. Task{ ... } 블럭을 실행하다가 await 지점에 도달합니다. 

    3. 비동기 함수인 processData( )를 단순 호출하는 코드임을 확인합니다. ( 실제 기다려야할 요소가 없습니다.)

    4. 작업이 중단되지 않고 Thread B3에서 계속해서 processData( ) 함수 실행을 시작합니다. 

    1. Thread B3에서 processData( ) 함수를 순차적으로 실행합니다. 첫번째 프린트문을 실행합니다. 

    2. await 지점에 도달합니다. 

    3. 비동기 함수인 fetchDataWithAsnyc( )를 단순 호출하는 코드임을 확인합니다. (실제 기다려야할 요소가 없습니다.)

    4. 작업이 중단되지 않고 Thread B3에서 계속해서 fetchDataWithAsync( ) 함수 실행을 시작합니다. 

    1. Thread B3에서  fetchDataWithAsync( ) 를 실행하다가 await 지점에 도달합니다. 

    2. 네트워킹 작업을 요청하는 코드임을 확인합니다. ( 응답이 올때까지 기다려야 합니다.)

    3. 작업을 중단시키고 fetchDataWithAsnyc( )가 실행되던 환경 값(어디까지 실행?, 누가 호출?등)을 저장하고 ThreadB3를 반납시킵니다.

    4.  fetchDataWithAsnyc( )는 응답이 올 때까지 기다리며 그 사이에 ThreadB3에 다른 작업인 TaskB를 배정하여 작업을 실행합니다. 

     

    위에서 processData( ), fetchDataWithAsnyc( ) , Task.sleep(네트워킹 시뮬레이션) 까지 총 3번의 await 지점을 만났습니다. 

    하지만 실제 작업이 중단되고 쓰레드을 반납한 지점은 마지막 지점뿐입니다. 이를 통해 await가 왜 중단'가능'점을 의미하는 지를 알 수 있습니다. 

     

    비동기 함수를 호출할 때 사용한 await는 이후 호출되는 비동기 호출 체인에서 실제로 중단이 발생할 가능성이 있으므로 요구하는 문법적 요구사항입니다. 그리고 실제로 작업이 중단되고 쓰레드를 반납하는 것은 아래와 같이 실제로 시간이 걸리는 작업을 만났을 때 발생합니다. 

    1. URLSession 과 같은 네트워크 요청
    2. Task.sleep( )
    3. 디스크 I/O 작업
    4. DB 쿼리
    5. 다른 엑터로 전환 ( await MainActor.run { } ) 

    이후에 fetchDataWithAsnyc( )가 네트워크 요청에 대한 응답을 받은 것을 시스템에 알립니다. 

    그러면 스케쥴러는 쓰레드 중에서 현재 이용 가능한 쓰레드인 ThreadB1을 fetchDataWithAsnyc( )에게 할당해줍니다. 

    이때 중요한 점은 새로 할당 받은 쓰레드 ThreadB1은 이전에 fetchDataWithAsnyc( )가 실행되던 ThreadB3와 다를 수 있습니다.

    앞서 실제 중단이 발생해서 쓰레드들을 반납하는 시점에 쓰레드에서 실행하던 모든 작업 내용을 따로 저장해놓았기 때문에 새로운 쓰레드를 할당 받아도 이전에 하던 작업을 계속 이어서 할 수 있습니다.  

    이렇게 실제 함수 실행이 중단되었을 때 이전 작업 내용에 대한 맥락(context)을 저장하는 매커니즘을 continuation 라고 합니다. 

     

    Continuation에 저장되는 정보는 다음과 같습니다. 

    1. 함수의 실행 상태

    • 실행 위치(instruction pointer): 코드의 어느 부분까지 실행했는지 정확한 위치
    • 로컬 변수: 함수 내부에서 선언된 모든 변수와 그 값들
    • 레지스터 상태: 함수 실행 중 CPU 레지스터에 저장된 값들

    2. 호출 컨텍스트 정보

    • 호출 함수에 대한 정보: 예, 당신이 물어본 것처럼 호출한 함수(caller)에 대한 참조가 포함됩니다
    • 반환 주소: 비동기 작업이 완료된 후 어디로 결과를 전달해야 하는지에 대한 정보
    • 실행 체인: 비동기 함수 호출의 전체 체인에 대한 정보

    3. 작업 관련 정보

    • Task 참조: 이 continuation이 속한 Task 객체에 대한 참조
    • 우선순위 정보: 작업의 실행 우선순위
    • 취소 상태: 작업이 취소되었는지 여부

    4. 에러 처리 정보

    • 에러 처리 컨텍스트: try-catch 블록의 상태와 어떤 에러 핸들러로 점프해야 하는지 정보

    새로운 쓰레드를 할당 받은 이후에는 각 함수들이 실행을 완료되고 반환되며 호출 스택의 함수들을 차례로 실행하고 최종적으로 Task의 실행을 종료합니다. 

     

    정리 : GCD와 async/await: Swift 비동기 처리의 두 가지 패러다임 

    지금까지 Swift에서 비동기 처리를 위한 두 가지 주요 방식인 GCD와 async/await에 대해 자세히 살펴보았습니다. 이제 두 접근 방식의 핵심적인 특징과 차이점을 명확하게 정리해 보겠습니다.

    근본적인 패러다임 차이

    GCD(Grand Central Dispatch) 는 작업 큐 기반 모델로, 비동기 작업을 DispatchQueue에 예약하고 작업 완료 후 콜백 함수를 통해 결과를 처리합니다. 반면 async/await 는 구조화된 동시성 모델로, 비동기 코드를 마치 동기 코드처럼 순차적으로 작성할 수 있게 해줍니다.

    절차적 vs 선언적 접근방식

    GCD는 본질적으로 절차적(procedural) 접근 방식을 취합니다. 개발자가 어떤 큐를 사용할지, 언제 비동기 작업을 시작할지, 완료 후 콜백을 어떻게 처리할지 등 모든 과정을 명시적으로 지정해야 합니다. 즉, '어떻게(how)' 비동기 작업을 수행할지 세부적인 지시를 제공해야 합니다.

    // 절차적 접근: 개발자가 모든 단계를 명시적으로 지정
    DispatchQueue.global().async {
        let data = fetchSomeData()
        
        DispatchQueue.main.async {
            // UI 업데이트 코드
            self.updateUI(with: data)
        }
    }
    

    async/await선언적(declarative) 접근 방식을 취합니다. 개발자는 '무엇을(what)' 할 것인지 선언하고, 시스템이 쓰레드 관리, 작업 중단 및 재개 등의 복잡한 처리를 자동으로 수행합니다. 이를 통해 개발자는 비즈니스 로직에만 집중할 수 있습니다. 이러한 선언적 특성은 코드가 의도하는 바를 더 명확하게 전달하며, 동시성 관리의 복잡성을 추상화하여 개발자의 부담을 줄여줍니다.

    쓰레드 관리 방식

    GCD 에서는 비동기 작업이 시작되면 할당된 쓰레드를 작업 완료까지 완전히 점유합니다. 네트워크 요청과 같이 시간이 오래 걸리는 작업이 응답을 기다리는 동안에도 해당 쓰레드는 아무 작업을 하지 않은 채로 블록됩니다. 많은 비동기 작업이 동시에 실행될 경우 "쓰레드 폭발" 현상이 발생할 수 있습니다.

    async/await 에서는 await 키워드가 중단 가능점을 표시하여, 실제로 대기가 필요한 시점에서 쓰레드를 시스템에 반납합니다. 이렇게 반납된 쓰레드는 다른 작업에 재할당될 수 있어 시스템 리소스가 효율적으로 사용됩니다. 작업이 재개될 때는 다른 쓰레드에서도 이전 작업 상태를 그대로 복원하여 계속 실행할 수 있습니다.

    코드 구조와 가독성

    GCD 는 중첩된 클로저와 콜백 함수를 사용하므로, 복잡한 비동기 작업이 연속될 경우 "콜백 지옥"이 발생할 수 있습니다. 또한 @escaping 클로저를 통해 함수의 생명주기를 벗어나는 콜백을 처리해야 합니다.

    fetchDataWithGCD { result in
        processData(result) { processedData in
            saveData(processedData) { success in
                updateUI(success)
            }
        }
    }
    

    async/await 는 비동기 코드를 마치 동기 코드처럼 순차적으로 작성할 수 있어 가독성이 크게 향상됩니다. 에러 처리도 일반적인 try-catch 구문을 사용할 수 있어 더 직관적입니다.

    Task {
        let result = try await fetchDataWithAsync()
        let processedData = try await processData(result)
        let success = try await saveData(processedData)
        await updateUI(success)
    }
    

    시스템 관리 및 성능

    GCD 에서는 개발자가 직접 큐를 선택하고 작업 우선순위를 지정해야 합니다. 시스템의 자동 최적화 기능이 제한적이어서 개발자가 세심하게 튜닝해야 합니다.

    async/await 에서는 Swift 런타임의 협력적 스케줄러가 작업을 관리합니다. Continuation 메커니즘을 통해 작업 상태를 저장하고 복원할 수 있어, 시스템이 더 효율적으로 리소스를 배분할 수 있습니다. 또한 작업 우선순위, 취소 처리 등이 통합적으로 관리됩니다.

    'Apple🍎 > Cocurrency' 카테고리의 다른 글

    테스크는 뭘까?  (0) 2025.02.07
    쓰레드는 뭘까?  (0) 2025.01.27
    프로세스는 뭘까?  (0) 2025.01.27
    동시성 이해를 위한 컴퓨터 기본 구조  (0) 2025.01.26

    댓글

Designed by Tistory.