오늘은 iOS 프로젝트를 작업하다보면 꽤나 자주 접하게 되는 동기, 비동기에 대한 개념을 정리해보고자 한다.
Sync vs Async
동기(Sync)
동기는 단어 그대로 동시에 일어난다는 의미인데, 이는 작업이 전부 동시에 일어난다는 뜻이 아니라 "요청과 응답"이 동시에 일어난다는 뜻이다.
즉, 카페를 예시로 들면 주인이 카운터에서 주문을 받자 해당 커피를 곧장 제작을 해서 손님에게 주는 방식이라고 볼 수 있다. 따라서 해당 카페는 손님이 많이 올 수록 주문이 점점 밀릴 수 밖에 없다.
일반적으로 코드를 작성하게 되면 이러한 동기 방식으로 작동하기 때문에 코드 작성 순서대로 호출되고 결과가 출력된다.
비동기(Async)
그렇다면 비동기는 무엇일까? 당연히 동기와 반대로 생각하면 될 것이다. "요청과 응답"이 동시에 일어나지 않는 방식으로 요청하면 상황에 따라서 뒤늦게 응답되거나 아예 응답되지 않을 수도 있다.
위와 같이 카페로 예를 들자면 사장이 주문을 받자 바리스타들이 커피 제작을 진행하게 된다. 다만 커피 종류에 따라 걸리는 시간 차가 있어 먼저 온 손님의 커피가 뒤에 온 손님보다 늦게 나올 수도 있는 상황과 유사하다고 볼 수 있다.
Serial vs Concurrent
다만 비동기의 경우에도 큐의 구조가 직렬로 설정되어있기 때문에 동기처럼 작동하게 되는데 이러한 직렬은 무엇일까?
직렬(Serial)
직렬은 순차적으로 진행하겠다는 의미인데 동기와 비슷한 느낌이라 헷갈리기 십상이다. 다만 동기는 "요청과 응답"이 동시라는 의미이고, 직렬은 "작업"들을 순차적으로 진행한다는 의미다.
따라서 비동기로 실행이 되더라도 큐에 들어온 순서에 따라서 해당 작업들이 순차적으로 실행된다. 물론 이는 같은 쓰레드에서 실행될 때이며, 각각 다른 쓰레드로 동작한다면 비동기로 모두 한 번에 실행할 수 있게 된다.
동시(Concurrent)
동시는 말 그대로 동시에 작업을 수행해준다는 의미이다. 즉, 앞서 말한 카페의 동작방식 그대로라고 볼 수 있다.
그래서 비동기 어떻게 실행해?
swift에서는 가장 대표적인 처리 방식으로 GCD(Grand Central Dispatch)라는 API가 존재. GCD는 Queue에 작업을 보내면 이에 맞춰 스레드를 적절히 생성하여 분배해주는 역할을 하며, 여기서 사용되는 Queue가 Dispatch Queue이다.
즉, DispatchQueue에 task를 추가하면 GCD가 task에 맞춰 스레드를 자동으로 생성하여 실행하고, task가 완료되면 스레드를 제거하는 일련의 과정을 관리해준다.
( ex. DispatchQueue.global().async{ //task } )
위의 예시의 클로저 내부의 task는 하나의 작업 그룹이므로 해당 클로저 내부의 task의 동작들은 순차적으로 처리된다. 즉, { task } 내부는 기입된 동작을 차례대로 처리.
DispatchQueue 사용 시, 동기와 비동기(sync, async), 직렬과 병렬(serial, concurrent)로 작업을 처리할 수 있다. (sync, async는 task를 queue에 보낸 시점에서 작업 완료를 기다릴지 말지를 다루는 것이며, serial과 concurrent는 queue에 보내진 작업을 하나의 스레드에만 보낼 건 지, 여러 스레드에 보낼 건 지를 다루는 것). 따라서 총 4가지의 조합이 가능.
현재 제공되는 Dispatch Queue는 메인 큐, 글로벌 큐, 커스텀(프라이빗) 큐, 총 3가지의 종류가 존재. 각 큐의 종류에 따라 특성이 다르므로 task의 처리 방식이나 특성에 맞춰 적절히 배치할 필요가 있다.
MainQueue는 한 개만 존재하며 Serial 특성을 가지고 있다. 해당 큐에 할당된 task는 메인 스레드에서 처리한다 (보통 UI 업데이트 내용을 처리하는데 사용되며, 코드 작성 시 별도 처리 없으면 모든 task는 메인 스레드가 작업한다. 즉, MainQueue에 할당된다는 의미).
GlobalQueue는 Cuncurrent 특성을 가졌으며 QoS(Quality of Service)에 따라 여러 종류로 나뉜다. 해당 큐는 여러개의 스레드로 task를 분산시키므로 순서가 중요치 않은 task는 GlobalQueue에 전달하는 것이 좋다. 또한 해당 큐는 DispatchQueue.global(qos: .utility).async{}와 같이 qos를 지정함으로 작업의 중요도를 정해줄 수 있다.
(qos는 6종류. userInteractive = 사용자와 직접 상호작용하는 작업 { aka. UI업데이트, 애니메이션 등 }/ userInitiated = 클릭 시, 작업을 수행하는 것과 같은 즉각적 결과가 필요한 작업 { aka. 저장된 문서 열기 } / default = 일반적 / utility = progress bar와 함께 길게 실행되는 작업 { aka. 데이터 다운로드 } / background = 유저가 직접적으로 인지하지 않는 등 시간이 덜 중요한 작업 { aka. 동기화 및 백업 } / unspecified = qos 정보 없음 { aka. 거의 사용X })
마지막으로 CustomQueue는 사용자가 직접 만드는 큐이므로 디폴트로는 Serial 특성이지만 Concurrent로 변경도 가능하며, QoS 설정이 가능하다. 생성 시, 매개변수 label을 붙이면 CustomQueue가 되며, 추가적으로 attributes나 qos 등의 매개변수를 사용하면 특성과 qos 설정 변경도 가능.
( ex. let custom = DispatchQueue(label: "Zeto", qos: .background, attributes: .concurrent)
이와 함께 Operation도 존재. GCD와 별개의 API가 아닌, 내부적으로는 GCD 위에서 동작하지만 부가적인 기능들이 추가되었고 사용하는 Queue 또한 OperationQueue로 다소 다르다.
Operation은 GCD와 달리 동시 실행 연산의 최대 수 지정, KVO(Key Value Observing)을 사용하여 작업 진행 상황 감시, 연산의 일시정지를 비롯한 재시작, 취소, 작업 실행 순서 조정 등의 기능이 추가적으로 존재한다.
따라서 비동기적으로 실행되어야 하는 작업을 객체 지향적으로 설계하여 사용할 때 적합하나, GCD보다 구현에 있어 다소 복잡한 부분이 있어 상황에 따라 적합한 방식을 사용해야 한다.