[Swift] Sync & Async / Serial & Concurrent 이해하기 (동기, 비동기, 직렬, 동시)

Deah (김준희)·2024년 6월 21일
1

Swift

목록 보기
2/2
post-thumbnail

안녕하세요. Deah 입니다.

오늘은 iOS 프로그래밍에서 아주 중요한 개념 중 하나인 동기와 비동기, 그리고 직렬과 동시에 대해 알아보려고 합니다.

동기와 비동기, 그리고 직렬과 동시 개념에 대해 알기 위해서는 먼저 프로세스와 스레드 개념이 필요한데요! 이전에 정리해놓은 포스팅이 있으니 사전 지식이 없으신 분들은 한 번씩 읽어보시면 좋을 거 같습니다.

프로세스와 스레드 (Process & Thread)


Serial vs Concurrent (직렬과 동시)

Serial의 사전적 의미를 찾아보면 '순차적인', '계속하는' '연쇄적인' 이라는 뜻을 가진다는 걸 알 수 있습니다.
이처럼 Serial은 Queue에 들어온 작업을 하나의 스레드에서 '순차적으로' 처리해 줍니다.

이와 달리 Concurrent는 사전적으로 '동시에 발생하는' 이라는 뜻을 가지고 있습니다.
Queue에 들어온 작업들을 동시에 여러 스레드에 분산시켜 처리해주겠다는 거죠!


Sync vs Async (동기와 비동기)

비슷하지만 다른 개념인 동기와 비동기에 대해서도 알아보겠습니다.

Synchronous를 줄여서 보통 Sync라고 이야기 합니다.
Synchronous는 사전적으로 '동시 발생하는' 이라는 뜻을 가지고 있어요.

여러 작업이 동시에 발생한다는 의미가 아니라,
요청(request)과 응답(response)이 동시에 일어난다는 의미입니다.

즉, 어떤 요청에 대한 응답이 발생하기 전까지 다른 작업을 수행하지 못한다는 것을 말해요.

반대로 Asynchronous는 '동시에 발생하지 않는' 다는 의미를 가지고 있습니다.
즉, 어떤 요청이 있을 때 응답이 바로 오지 않더라도 그 사이에 다른 작업을 수행할 수 있다는 말입니다.

그림으로 살펴볼까요?

위 그림은 어떤 작업에 대한 실행 시간을 동기적으로, 비동기적으로 실행해본 내용이 담겨있습니다.

왼쪽 그림을 보면, 동기(Synchronous)는 어떤 작업이 완료되기 전까지 다른 작업을 실행할 수 없기 때문에 하나의 작업이 끝마칠 때까지 기다린 후 다음 작업을 이어 가는 모습을 볼 수 있습니다. 즉, 작업의 실행 순서가 보장된다고 할 수 있습니다.

따라서 동기에서는 A작업에 10초, B작업에 20초, C작업에 15초가 소요되어 총 45초의 실행 시간이 소요됩니다. 전체 실행 시간보다 작업의 실행 순서(= 결과 순서)가 중요하다면 동기적으로 작업하는 것이 좋습니다.

반면 오른쪽 그림을 살펴보면, 비동기(Asynchronous)는 어떤 작업이 완료되지 않더라도 다른 작업을 실행할 수 있기 때문에 여러 개의 작업이 동시에 실행되는 모습을 볼 수 있습니다.

이 경우에 전체 실행 시간은 동시적으로 실행되고 있는 여러 개의 작업 중 가장 오래 걸리는 작업의 시간인 20초가 되는 것이죠.

하지만 비동기에서는 먼저 시작된 작업이 가장 먼저 끝난다는 보장은 해주지 않습니다. 즉, 작업의 순서가 보장되지 않아요. 따라서 비동기가 여러 작업을 동시에 실행하기 때문에 어떤 면에선 효율적이라고 생각할 수 있지만 실행 순서가 중요한 경우에는 앞서 말했듯 동기적으로 코드를 작성하는 것이 중요합니다.✨


헷갈리는데요?

😕❓ 결국 직렬 = 동기, 동시 = 비동기다. 이거 아니에요?

Serial(직렬)과 Synchronous(동기),
Concorrent(동시)와 Asynchronous(비동기)

이렇게 묶어서 같은 개념이라고 생각하시는 분들이 많을 거 같습니다. (저 또한...)
위에서도 말했지만 이들은 서로 비슷하지만 다른! 개념입니다.

서로 다른 4개의 개념을 간단한 코드를 통해 이해해 봅시다.
하지만 그 전에 DispatchQueue에 대해 훑고 가겠습니다. 🏃‍♀️


DispatchQueue

iOS는 두 종류의 Queue를 가지고 있습니다.

  • DispatchQueue
  • OperationQueue

WWDC15에서 발표된 OperationQueue는 기존에 사용해오던 DisaptchQueue를 기반으로 더 많은 기능이 추가된 것이라고 할 수 있습니다. 일반적으로는 DispatchQueue를 많이 사용하지만 조금 더 고도화된 작업이 필요한 경우에는 OperationQueue를 사용할 수 있어요. 하지만 오늘은 DispatchQueue를 주로 다뤄보려고 합니다!

DispatchQueue의 종류는 총 3가지 입니다.

  • main
  • global
  • custom

각각의 Queue에서 작업을 동기적 또는 비동기적으로 처리할 수 있으며,
특히 global()에서는 작업 순서에 대한 우선순위(QoS)를 설정할 수 있습니다.
(이 대목에서 왜 Serial과 동기가 다른 개념인지 힌트를 얻는 분도 계실 수 있겠네요 ㅎ_ㅎ)

  • DispatchQueue.main

iOS의 Main Queue는 Serial Queue입니다.
작업을 여러 스레드로 분산하지 않고, 하나의 메인 스레드에서 처리하게 됩니다.

  • DispatchQueue.global()

반면에 DispatchQueue.global()의 경우 Concurrent Queue가 기본이기 때문에 작업을 여러 스레드로 나누어 '동시'에 처리합니다.


코드로 이해하기

첫 번째 예제

func synchronous() {
    print("시작!")
    
    for item in 1...10 {
        print(item, terminator: " ")
    }
    
    for item in 11...20 {
        print(item, terminator: " ")
    }
    
    print("끝!")
}

일반적으로 코드의 실행 순서는 '동기' 입니다.
따라서 개발자가 별다른 설정을 해주지 않는한 코드는 모두 동기적으로 실행됩니다!

따라서 위와 같은 코드를 실행하면 결과로는 순차적으로 print()가 실행되어 1부터 20까지 숫자가 차례대로 나타나게 되는 것이죠.

조금 더 자세하게 접근해본다면, iOS의 Main Queue는 Serial Queue이기 때문에 Queue에 들어온 작업을 하나의 스레드에서 '순차적으로' 처리하게 됩니다. 따라서 print("시작!") 부터 첫 번째 for문을 거쳐, 두 번째 for문, 그리고 마지막 print("끝!")까지 순차적으로 처리되어 위와 같은 결과가 나타나게 되는 것입니다.


두 번째 예제

두 번째 예제에서는 앞서 훑어보았던 DispatchQueue를 활용해보겠습니다.

func serialSync() {   
	 print("시작!")
     
     for item in 1...10 {
         print(item, terminator: " ")
     }
     
     DispatchQueue.main.sync {
         for item in 11...20 {
             print(item, terminator: " ")
         }
     } 
     print("끝!")
}

두 번째 for문을 DispatchQueue.main.sync를 통해 Main Queue에 동기적으로 실행할 수 있도록 처리했습니다. 결과는 어떨까요?

결과는 앱이 터지게 됩니다.... 🧨
왜 그럴까요?

DispatchQueue.main.sync는 현재 실행되고 있는 스레드에서 Main으로 작업을 동기적으로 보내주게 됩니다. 즉 Main의 작업이 끝난 뒤 2번째 for문을 실행할 수 있도록 기다리고 있습니다. 하지만 이미 이 코드가 Main에서 실행되고 있기 때문에 Main이 끝나길 기다리는 것은 말이 안 되는 이야기가 됩니다. 따라서 무한 교착 상태(Deadlock)에 빠지게 될 수밖에 없어 앱이 종료되게 됩니다.


세 번째 예제

그럼 두 번째 예제와 같은 작업을 '비동기'로 보내면 어떻게 될까요?

func serialAsync() {
    print("시작!")
    
    DispatchQueue.main.async {
        for item in 1...10 {
            print(item, terminator: " ")
        }
    }
    
    for item in 11...20 {
        print(item, terminator: " ")
    }
    
    print("끝!")
}

코드가 실행되고 가장 처음 만나는 print("시작!")이 가장 먼저 찍히게 되고, 첫 번째 for문은 Main Queue에 비동기적으로 보내지게 됩니다. 따라서 첫 번째 for문이 실행되기 위해서는 Main Queue에서 실행 중이던 모든 작업이 종료되어야 하죠.

때문에 두 번째 for문과 마지막 프린트문인 print("끝!")이 모두 실행되고 나서야 첫 번째 for문의 내용이 찍히는 걸 확인할 수 있습니다.


네 번째 예제

이제 DispatchQueue.global()을 통해 Concurrent Queue를 사용해보겠습니다.

func concurrentSync() {
    print("시작!")
        
    DispatchQueue.global().sync {
        for item in 1...10 {
            print(item, terminator: " ")
        }
    }
        
    for item in 11...20 {
        print(item, terminator: " ")
    }
        
    print("끝!")
}

결과가 어때보이나요? 이 예제와 같아보이지 않나요?

DispatchQueue.global()은 작업을 여러 스레드에 분산하여 처리하게 되지만 sync, 즉 동기로 보낼 경우 Main에서 한 번에 처리하는 것과 유사하기 때문에 실질적으로 Main 스레드에서 작업을 수행하게 되게 됩니다.

여러 스레드에 분산하고 싶지만, 결과적으로는 그렇지 못한 작업이 되기 때문에 실제로 잘 사용되지는 않습니다.


다섯 번째 예제

func concurrentAsync() {
    print("시작!")
        
    DispatchQueue.global().async {
        for item in 1...10 {
            print(item, terminator: " ")
        }
    }
        
	for item in 11...20 {
        print(item, terminator: " ")
    }
        
    print("끝!")
}

print("시작!")을 지나서 첫 번째 for문을 만나면 DispatchQueue.global().async를 통해 비동기적으로 처리되도록 되어있습니다. 따라서 해당 코드를 실행할 스레드가 하나 더 생기게 돼요!

두 번째 for문은 그대로 메인 스레드에서 실행되기 때문에 두 개의 for문 내용이 겹쳐서 실행되는 모습을 볼 수 있습니다.

같은 코드를 다시 실행해볼까요?

순서가 달라진게 눈에 보이시나요?
비동기로 처리되기 때문에 실행 순서가 보장되지 않아 매번 출력 순서가 달라지게 됩니다.


여섯 번째 예제

DispatchQueue.global()을 for문 안쪽으로 넣어보겠습니다.

다섯 번째 예제에서 for문 전체를 DispatchQueue.global()로 감싸주었을 때와 달리 이 경우에는 for문에서 순회하는 item 1개마다 1개의 스레드가 생성됩니다.

즉 10번을 돌 때까지 10개의 스레드가 생성되는 거죠!

func concurrentAsync2() {
    print("시작!")
        
    for item in 1...10 {
        DispatchQueue.global().async {
            print(item, terminator: " ")
        }
    }
        
    for item in 11...20 {
        print(item, terminator: " ")
    }
        
    print("끝!")
}

메인과 또 다른 스레드, 두 곳에서 진행했을 때보다 더 많은 스레드에서 실행되니 프린트 출력이 더 섞여있는 느낌적인 느낌이 듭니다.🤓


QoS

마지막으로 우선순위를 설정해보겠습니다.

앞서 DispatchQueue.global()은 QoS를 통해 우선순위를 정할 수 있다고 말씀드렸는데요,
QoS는 Quaily of Service를 뜻합니다.

QoS를 사용하는 이유는 공식 문서에 잘 설명되어 있어요.

Overview
Because higher priority work is performed more quickly and with more resources than lower priority work, it typically requires more energy than lower priority work. Accurately specifying appropriate QoS classes for the work your app performs ensures that your app is responsive and energy efficient.

우선순위가 높은 작업은 우선순위가 낮은 작업보다 더 많은 에너지와 리소스를 사용하기 때문에 적절하게 우선순위를 조절해가며 사용하라는 내용입니다.

QoS 종류

  • userInteractive : 사용자와 상호작용 하는 부분
  • userInitiated : 사용자의 인터랙션을 막는 부분
  • default : 기본값
  • utility : 즉각적인 결과가 필요하지 않은 부분
  • background : 우선순위가 높지 않은 부분
  • unspecified : 우선순위 없음

더 자세한 내용은 QoS 종류 문서에서 확인!

func concurrentAsyncQoS() {
    print("시작!")
    
    DispatchQueue.global(qos: .background).async {
        print("START", terminator: " ")
        for item in 1...10 {
            print(item, terminator: " ")
        }
    }
    
    DispatchQueue.global().async {
        for item in 11...20 {
            print(item, terminator: " ")
        }
    }
    
    DispatchQueue.global().async {
        for item in 21...30 {
            print(item, terminator: " ")
        }
    }
    
    DispatchQueue.global().async {
        for item in 31...40 {
            print(item, terminator: " ")
        }
    }
    
    DispatchQueue.global().async {
        for item in 41...50 {
            print(item, terminator: " ")
        }
    }
    
    print("끝!")
}

DispatchQueue.global()을 통해 여러 스레드로 작업을 분산시키고 첫 번째 for문의 우선순위를 백그라운드로 작업했습니다. 백그라운드에 포함된 작업만 거의 마지막에 진행되는 걸 볼 수 있어요.

DispatchQueue.global(qos: .userInteractive).async {
    for item in 41...50 {
        print(item, terminator: " ")
    }
}

마지막 for문에는 userInteractive로 우선순위를 추가해보겠습니다.

이제는 userInteractive로 우선순위를 준 for문이 높은 우선순위를 가져 먼저 실행되는 걸 볼 수 있죠?
상황에 맞게 적절히 활용해서 써주면 애플리케이션의 효율성을 챙길 수 있답니다.


그래서 뭐가 달라요?

😕❓ 결국 직렬 = 동기, 동시 = 비동기다. 이거 아니에요?

앞서서 헷갈렸던 질문을 다시 한 번 짚어보고 가보겠습니다.
개념의 차이에 대해 이해하셨나요?

  • Serial(직렬) & Concurrent(동시)

직렬과 동시는 '어떤 대기열을 사용하는가'의 관점입니다.
Serial Queue는 하나의 대기열에서 작업을 처리하고, Concurrent Queue는 여러 개의 대기열로 작업을 분산 처리합니다.
즉, 대기열 안에 있는 각각의 작업들이 동기인지 비동기인지에 관계 없이 그 작업들을 순차적으로 실행할지, 분산하여 실행할지를 정하는 것이라고 보시면 됩니다.

코스요리가 나오는 식당에서 주방장의 관점으로 생각해보면,
손님이 테이블 위에 있는 요리를 어떤 순서로 먹던지에 상관 없이 식당에서는 메뉴판에 준비되어 있는 순서대로 요리를 만들어 손님에게 내는 것이 직렬(Serial)이라고 할 수 있을 거 같아요. 포인트는 주방장 1명이 모든 요리를 진행한다는 점입니다.

반면에 동시(Concurrent)는 같은 식당에서 여러 명의 주방장이 각각의 코스 요리를 담당해서 동시에 손님에게 내는 것으로 비유할 수 있을 거 같습니다. 손님이 어떤 순서로 먹던지에 상관 없이 말이죠.

  • Sync(동기) & Async(비동기)

동기와 비동기는 '대기열을 어떻게 처리하는가' 입니다.
즉, 대기열 안에 있는 각각의 작업을 어떻게 처리해줄지 정하는 것이라고 봐도 될 거 같아요.

A작업 - B작업 - C작업이 있을 때,
A작업이 완료되지 않아도 B작업을 실행할 것인가?
혹은 A작업이 끝날 때까지 B작업을 기다릴 것인가? 를 정하는 것이 동기와 비동기입니다.

식당에서 준비해준 코스 요리를 먹는 손님이라고 생각해볼게요.
주방장 1명이 준비해서 순서대로 요리가 준비되더라도, 혹은 여러 명의 주방장이 동시에 요리를 준비해주더라도 그 요리를 먹는 순서를 정하는 것은 '손님' 입니다.

순서대로 먹을 것인지(= 동기), 먼저 나온 요리를 다 먹지 않아도 다른 요리를 먹어볼 것인지(= 비동기)를 정하는 게 동기와 비동기라고 이해해주시면 될 거 같아요.


마무리

오늘은 DispatchQueue를 통해 동기와 비동기, 직렬과 동시를 알아보았습니다.
저도 포스팅을 하면서 네 가지 개념에 대해 조금 더 이해가 된 거 같네요!

부족하거나 틀린 내용 등 글에 대한 피드백은 댓글에 남겨주시길 부탁드립니다 :)

참고자료

Apple Developer - DispatchQueue
Apple Developer - DispatchQueue-QoS
iOS) Sync vs Async / Serial vs Concurrent
[Swift] DispatchQueue의 qos 사용하기
[iOS - Swift] DispatchQueue의 종류와 DispatchQueue(qos:) qos의 종류

profile
기록 중독 개발자의 기록하는 습관

1개의 댓글

comment-user-thumbnail
2일 전

좋은글 감사합니다.
그렇다면 어떤 시점으로 바라 보느냐 차이인가요?
a 작업자가 1,2,3 작업을 처리하면 -> 직렬
1,2,3 작업을 순서대로 하면 -> 동기 ? 이렇게 이해하면 좋을까요>?

답글 달기