Swift Concurrency 이전의 동시성 프로그래밍

Swift Concurrency 이전의 동시성 프로그래밍
Photo by Denley Photography / Unsplash

들어가며

안녕하세요! 오늘은 Swift Concurrency가 등장하기 이전에는 동시성 프로그래밍을 구현하기 위해 어떠한 도구들을 사용했는지, 어떠한 맥락으로 동시성 프로그래밍을 도와주는 도구들이 등장하게 되었는지 더 나아가 Swift Concurrency는 어떠한 문제를 해결하기 위해 등장하게 되었는지 살펴보도록 하겠습니다.

Thread

동시성 프로그래밍을 할 때 '스레드'라는 단어가 많이 언급되는데요, 스레드를 많이 쓰면 쓸수록 더 많은 작업을 동시적으로 실행할 수 있으니, 스레드는 많을수록 좋은 걸까요?

"동시"라는 단어에 대한 맥락은 이전 포스트와 같이합니다!

아래의 표를 볼까요?

출처 : https://www.baeldung.com/cs/servers-threads-number

스레드의 수가 많아짐에 따라 퍼포먼스(시간 당 작업효율)가 올라가지만, 어느 순간부터는 더 이상 퍼포먼스가 향상되지 않는 구간이 나타나는데요,

이는 CPU의 스레드가 많아질수록 스레드 스위칭 등의 오버헤드가 발생하게 되어 코어당 일정 수의 스레드가 넘어가게 될 경우 성능에 좋지 않은 영향을 미치게 되기 때문입니다.

이를 thread-explosion이라고 합니다.

따라서 대부분의 CPU 제조사는 스레드가 아닌, 코어 수를 증가시켜 병렬처리로 인한 성능 향상을 꾀하는 식으로 CPU를 발전시켜 나가고 있습니다.

늘어난 코어만큼 사용자가 컴퓨터가 빨라졌다는걸 느끼게 하려면 스레드는 CPU 코어 수에 맞추어 적당하게 만들고, 만들어놓은 스레드를 활용하기 위해 스레드에서 돌아가는 코드들을 빠르게 다른 코드로 교체해 줘야 효율적으로 돌아가는 것으로 느끼게 될 것입니다.

프로세스 내의 user-level 스레드 역시 동일한 논리를 따릅니다.

방금 언급한 스레드는 CPU 코어에서 동작하는 CPU 스레드를 말합니다.
밑에서 살펴보겠지만 우리가 이번 글에서 다루게 될 스레드는 user-level 스레드, 프로세스 내에서 실행되는 세부 작업의 단위입니다.
따라서 아래의 글에서 등장하는 스레드는 특별한 언급이 없는 이상 user-level 스레드를 말합니다 :)

따라서 프로그래밍 방식도 역시 과도하게 스레드를 많이 만들어 스레드 스위치와 같은 오버헤드를 비용을 발생시키는 것보다 시스템이 만들어 놓은 스레드에 실행할 코드를 보내는 식으로 발전하게 됩니다.

애플 개발 생태계에서도 역시 스레드를 생성해 사용하는 API가 있지만, 프로그래밍 방식이 변화함에 따라 이제는 잘 사용하지 않고 있습니다.

스레드, 한번 만들어볼까요?

NSThread

URL로부터 데이터를 가져온 후, 콘솔에 출력하는 함수를 동기적으로 구성한 다음, 스레드를생성한 후에 해당 함수를 생성한 user-level 스레드에서 실행하는 간단한 예제를 만들어봅시다.

먼저 URL로부터 데이터를 가져온 후, 콘솔에 출력하는 함수를 아래와 같이 동기적으로 구성해 봅시다.

func fetchAndPrintHTML(from url: URL) {
  let responseData = try! Data(contentsOf: url)
  print(String(data: responseData, encoding: .utf8)!)
}

위 함수의 첫 번째 줄을 살펴보면 파라미터로 받은 url을 이용해 Data 생성자를 이용해 동기적으로 데이터를 가져오기 때문에 네트워크 요청이 완료될 때까지 해당 함수를 실행하는 스레드가 차단될 것입니다.

그다음으로 해당 함수를 실행할 스레드를 생성해 보도록 하겠습니다.

final class NetworkRequestManager {
  private var networkRequestThread: Thread? = nil

  func startRequest() {
    self.networkRequestThread = Thread(
      target: self,
      selector: #selector(self.fetchAndPrintHTML(from:)),
      object: URL(string: "https://www.naver.com")!
    )
    self.networkRequestThread?.start()
  }
}

startRequest 함수에서는 NSThread(일명 Cocoa thread)를 이용해 스레드를 생성해 주고 있는데요, NSThread는 이미 실행 중인 스레드에서 새 스레드를 생성하고, 코드를 실행하는 데 사용할 수 있는 메서드를 제공합니다.

NSThread를 이용해 스레드를 생성하는 방법은 크게 두 가지가 있습니다.

  1. 새 NSThread 객체를 만들고 start 메서드를 명시적으로 호출하는 방법과
  2. detachNewThreadSelector:toTarget:withObject: 클래스 메서드를 사용하여 새 스레드를 생성하는 방법이 있습니다.
공식 문서 참조: https://developer.apple.com/documentation/foundation/thread

이 방법 중 첫 번째 방법을 사용해 NSThread 객체를 생성해 주었는데요,

self.networkRequestThread = Thread(
  target: self,
  selector: #selector(self.fetchAndPrintHTML(from:)),
  object: URL(string: "https://www.naver.com")!
)

스레드가 실행할 메서드가 속한 객체와, 실행할 메서드, 메서드를 호출할 때 필요한 객체를 지정해 주었습니다.

그다음

self.networkRequestThread?.start()

스레드의 start 메서드를 이용해 스레드를 시작해 주었습니다.

네트워크 요청을 하는 함수를 동기적으로 구성한 후, 해당 함수를 NSThread 생성자를 이용해 생성한 스레드에서 실행할 수 있게 되었는데요, 이제 작성한 코드를 다시 구성해 보도록 하겠습니다.

final class NetworkRequestManager {
  private var networkRequestThread: Thread? = nil
	
  func startRequest() {
    self.networkRequestThread = Thread(
      target: self,
      selector: #selector(self.fetchAndPrintHTML(from:)),
      object: URL(string: "https://www.naver.com")!
    )
    self.networkRequestThread?.start()
  }

  @objc private func fetchAndPrintHTML(from url: URL) {
    let responseData = try! Data(contentsOf: url)
    print(String(data: responseData, encoding: .utf8)!)
  }
	
  deinit {
    self.networkRequestThread?.cancel()
    self.networkRequestThread = nil
  }
}
#selector 에 사용되는 함수는 Objective-C에서 호출될 수 있도록 @objc 키워드가 붙어있어야 하므로 위에서 작성한 함수에 @objc 키워드를 붙여주었습니다.

작성한 코드를 실행해 볼까요?

let networkRequestManager = NetworkRequestManager()
networkRequestManager.startRequest()
print("안녕하세요")

RunLoop.main.run()

만약 startRequest함수가 스레드를 생성하지 않고 네트워크 요청을 동기적으로 처리했다면, startRequest함수 내부의 네트워크 요청이 완료된 후에 "안녕하세요"라는 문자열이 출력되었을 것입니다.

그러나, 우리는 스레드를 생성한 후에 동기적으로 구성한 네트워크 요청을 해당 스레드에서 실행하도록 코드를 작성했으며, 네트워크 요청보다 콘솔에 출력하는 작업은 상대적으로 빠르게 실행되기 때문에 콘솔에서 아래와 같은 결과를 확인할 수 있습니다.

안녕하세요
   <!doctype html> <html lang="ko" class="fzoom"> <head> <meta charset="utf-8"> <meta name="Referrer" content="origin"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=1190"> <title>NAVER</title> <meta name="apple-mobile-web-app-title" content="NAVER"/> <meta name="robots" content="index,nofollow"/> <meta name="description" content="네이버 메인에서 다양한 정보와 유용한 컨텐츠를 만나 보세요"/> <meta property="og:title" content="네이버"> <meta property="og:url" content="https://www.naver.com/"> <meta property="og:image" content="https://s.pstatic.net/static/www/mobile/edit/2016/0705/mobile_212852414260.png"> <meta property="og:description" content="네이버 메인에서 다양한 정보와 유용한 컨텐츠를 만나 보세요"/> <meta name="twitter:card" content="summary"> 
/ ... /

Swift에서 스레드를 만들어 사용하기 위해서는 위와 같은 방법을 사용하거나 조금 더 복잡하고, 세부적인 작업이 필요하다면 NSThread를 상속한 후에 코드를 작성할 수 있습니다.

방금 우리가 살펴본 방식은 개발자가 스레드를 생성하고, 어떤 스레드가 있는지 확인한 후에 특정 스레드가 해당 메서드를 실행할 수 있도록 개발자가 결정하는 방식입니다.

앞서

"프로그래밍 방식도 과도하게 스레드를 많이 만들어 스레드 스위치와 같은 오버헤드를 비용을 발생시키는 것보다 시스템이 만들어 놓은 스레드에 실행할 코드를 보내는 식으로 발전하게 됩니다."

라는 말을 했었는데요, 우리가 방금 살펴본 방식은 개발자가 스레드를 생성하고, 스레드를 실행하는 메커니즘이기 때문에 과도하게 스레드가 많이 생성될 수 있는 문제와 여러 휴먼 에러의 발생위험이 있습니다.

또한 이를 올바르게 작성했는지 검증하기 위한 디버깅, 테스팅 비용을 지불해야할 것입니다.

따라서 뒤에 등장하게 될 동시성 프로그래밍을 도와주는 도구들은 CPU 코어 개수에 맞게 스레드를 시스템이 생성하고, 시스템이 만들어놓은 스레드에 개발자가 작성한 코드를 전달하는 방식을 도입하게 됩니다.

결국 개발자가 작성한 코드의 단위는 클로져가 되고, 해당 클로져를 시스템이 관리하는 큐에 제출하고, 큐에 있는 클로저를가용한 스레드에 시스템이 분배하는 방식이 Grand Central Dispatch(GCD)입니다.

Grand Central Dispatch

GCD를 이용해, 앞서 보았던 예제인 네트워크 요청을 동기적으로 구성한 다음, 해당 코드를 다른 스레드에서 실행할 수 있도록 하는 예제를 최대한 비슷하게 다시 만들어봅시다.

먼저 시스템이 관리할 큐를 생성해 보도록 합시다.

final class NetworkRequestManager {
  private let networkRequestQueue = DispatchQueue(
    label: "com.example.networkRequestQueue"
  )
}

DispatchQueue(label:) 생성자를 이용해 private concurrent queue를 생성해 주었습니다.

다음으로 클로저 단위의 작업을 캡슐화하는 DispatchWorkItem을 선언해 주도록 하겠습니다.

final class NetworkRequestManager {
  private let networkRequestQueue = DispatchQueue(
    label: "com.example.networkRequestQueue"
  )
  private var fetchHTMLWorkItem: DispatchWorkItem? = nil
}

앞에서 살펴본 예제와 같이 네트워크 요청을 동기적으로 한 후에 응답을 콘솔에 출력하는 함수를 추가하도록 하겠습니다.

final class NetworkRequestManager {
  private let networkRequestQueue = DispatchQueue(
    label: "com.example.networkRequestQueue"
  )
  private var fetchHTMLWorkItem: DispatchWorkItem?

  private func fetchAndPrintHTML(from url: URL) {
    let responseData = try! Data(contentsOf: url)
    print(String(data: responseData, encoding: .utf8)!)
  }
}

이제 해당 함수를 실행하기 위해 큐에 작업을 제출하는 코드를 추가해 보겠습니다.

final class NetworkRequestManager {
  private let networkRequestQueue = DispatchQueue(
    label: "com.example.networkRequestQueue"
  )
  private var fetchHTMLWorkItem: DispatchWorkItem?

  private func fetchAndPrintHTML(from url: URL) {
    let responseData = try! Data(contentsOf: url)
    print(String(data: responseData, encoding: .utf8)!)
  }
	
  func startRequest() {
    self.fetchHTMLWorkItem = DispatchWorkItem {
      self.fetchAndPrintHTML(from: URL(string: "https://www.naver.com")!)
    }
    
    if let workItem = self.fetchHTMLWorkItem {
      self.networkRequestQueue.async(execute: workItem)
    }
  }

  deinit {
    self.fetchHTMLWorkItem?.cancel()
  }
}

작성한 코드 예제를 실행해 보도록 하겠습니다.

let networkRequestManager = NetworkRequestManager()
networkRequestManager.startRequest()

print("안녕하세요")

RunLoop.main.run()

결과를 확인해 봅시다.

안녕하세요
   <!doctype html> <html lang="ko" class="fzoom"> <head> <meta charset="utf-8"> <meta name="Referrer" content="origin"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=1190"> <title>NAVER</title> <meta name="apple-mobile-web-app-title" content="NAVER"/> <meta name="robots" content="index,nofollow"/> <meta name="description" content="네이버 메인에서 다양한 정보와 유용한 컨텐츠를 만나 보세요"/> <meta property="og:title" content="네이버"> <meta property="og:url" content="https://www.naver.com/"> <meta property="og:image" content="https://s.pstatic.net/static/www/mobile/edit/2016/0705/mobile_212852414260.png"> <meta property="og:description" content="네이버 메인에서 다양한 정보와 유용한 컨텐츠를 만나 보세요"/> <meta name="twitter:card" content="summary"> 
/ ... /

결과는 앞서 살펴본 예제와 동일합니다.

스레드를 생성하는 방식과 비교해, GCD는 시스템이 스레드를 관리함으로써 스레드 관리에 대한 신경을 쓰지 않을 수 있었습니다.

지금은 간단한 예제를 실행해 보았지만, 예제가 더 많이 복잡해지면 차이는 더 분명해질 것으로 생각합니다.

방금 살펴본 GCD 역시 한계를 가지고 있는데요, GCD도 concurrent dispatch queue에 의해 예약된 작업이 스레드를 차단할 경우, 시스템은 대기 중인 다른 병렬작업을 실행할 추가 스레드를 생성하게 됩니다.

위에서 작성한 코드 예제에서는 아래와 같이 custom concurrent queue를 생성해 주었는데요,

private let networkRequestQueue = DispatchQueue(
  label: "com.example.networkRequestQueue"
)

custom concurrent queue를 과도하게 생성하게 될 경우, 각각의 dispatch queue는 스레드 리소스를 사용하기 때문에 스레드를 생성하게 되어 앞서 살펴본 그래프와 같이

Screenshot 2024-02-01 at 10 31 19 PM
출처 : https://www.baeldung.com/cs/servers-threads-number

많은 스레드간 문맥 전환으로 인한 성능저하로 이어질 수 있습니다.

더 많은 스레드가 실행되면 문맥 전환이 더 자주 발생하고, 이로 인해 시스템의 부담이 더 커지게 됩니다. 이것이 바로 “thread-explosion”입니다.

thread-explosion: 시스템 내의 스레드가 많아져 스레드 스위칭 등의 오버헤드가 발생하게 되는 현상

따라서 Apple의 문서에서는 custom queue를 생성하는 대신 global concurrent dispatch queue에 작업을 제출해 스레드 수를 최소화하는 것을 가이드하고 있습니다.

또한 DispatchQueue는 First In First Out 특성을 가진 Queue이기 때문에 우선순위 역전 현상이 발생할 수 있으며, 클로저를 이용해 명시적으로 작업을 제출하는 방식이기에 작업을 기다리는 순서가 복잡해지게 되면 클로저들이 중첩되는 상황이 생기게 되어 개발자로 하여금 코드의 흐름을 따라가기 어렵게 만드는 문제가 있습니다.

이러한 문제들을 해결하기 위해 Swift Concurrency가 등장하게 되는데요,

앞서 살펴본 예제, Swift Concurrency를 이용해 다시 작성해 볼까요?

Swift Concurrency

먼저 실행되기 원하는 작업의 단위를 Task를 이용해 지정해주도록 하겠습니다.

final class NetworkRequestManager {
  private var networkRequestTask: Task<Void, Never>? = nil
}

네트워크 요청을 동기적으로 한 후에 응답을 콘솔에 출력하는 함수를 추가하도록 하겠습니다.

final class NetworkRequestManager {
  private var networkRequestTask: Task<Void, Never>? = nil
	
  private func fetchAndPrintHTML(from url: URL) {
    let responseData = try! Data(contentsOf: url)
    print(String(data: responseData, encoding: .utf8)!)
  }
}

이제 해당 함수를 실행하기 위해 task를 생성하는 함수를 추가해 보겠습니다.

final class NetworkRequestManager {
  var networkRequestTask: Task<Void, Never>? = nil
	
  func startRequest() {
    self.networkRequestTask = Task {
      self.fetchAndPrintHTML(from: URL(string: "https://www.naver.com")!)
    }
  }
	
  private func fetchAndPrintHTML(from url: URL) {
    let responseData = try! Data(contentsOf: url)
    guard Task.isCancelled == false
    else { return }

    print(String(data: responseData, encoding: .utf8)!)
  }
	
  deinit {
    self.networkRequestTask?.cancel()
    self.networkRequestTask = nil
  }
}

네트워크 요청을 한 이후에 Task가 취소되었는지 여부를 확인하고 취소가 되지 않았다면 콘솔에 응답을 출력합니다.

Swift Concurrency를 이용해 작성한 코드 예제를 실행해 보도록 하겠습니다.

let networkRequestManager = NetworkRequestManager()
networkRequestManager.startRequest()

print("안녕하세요")

RunLoop.main.run()

결과를 확인해봅시다.

안녕하세요
   <!doctype html> <html lang="ko" class="fzoom"> <head> <meta charset="utf-8"> <meta name="Referrer" content="origin"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=1190"> <title>NAVER</title> <meta name="apple-mobile-web-app-title" content="NAVER"/> <meta name="robots" content="index,nofollow"/> <meta name="description" content="네이버 메인에서 다양한 정보와 유용한 컨텐츠를 만나 보세요"/> <meta property="og:title" content="네이버"> <meta property="og:url" content="https://www.naver.com/"> <meta property="og:image" content="https://s.pstatic.net/static/www/mobile/edit/2016/0705/mobile_212852414260.png"> <meta property="og:description" content="네이버 메인에서 다양한 정보와 유용한 컨텐츠를 만나 보세요"/> <meta name="twitter:card" content="summary"> 
/ ... /

역시 결과는 앞서 살펴본 예제와 동일합니다.

GCD와 비교해 어떤가요?

코드 외적으로는 동기적인 코드를 작성하는 것과 비슷하게 코드를 작성할 수 있게 되었습니다.

또한, 컴파일 단계에서 데이터 레이스와 관련된 에러를 발생시켜 코드 작성 실수로 발생할 수 있는 문제를 해결할 수 있습니다.

이와 함께, 이전 GCD가 가지고 있던 문제인 스레드가 과도하게 생성되어 발생하는 thread-explosion 문제를 suspend point(await)에서 스레드 간 문맥 전환이 아닌, 작업의 재개 여부를 추적하는 가벼운 객체인 continuation 간 전환을 하도록 하여, 스레드를 차단하지 않고 함수 호출 정도의 비용만 지불하도록 하였습니다.

더불어, 프로세스의 스레드 수를 CPU 코어 수만큼 생성하도록 제한하여 thread-explosion 문제를 해결하였습니다.

WWDC-21 Swift concurrency: Behinde the scenes 참조

또한 GCD와 달리 작업이 실행되는 순서가 FIFO가 아니기 때문에 먼저 추가된 작업이 실행되고 있을 때 실행되고 있는 작업보다 우선순위가 더 높은 작업이 추가되면 해당 작업을 먼저 실행함으로써 우선순위 역전 문제를 해결하였습니다.

마치며

이번 글에서는 Swift Concurrency 이전에는 동시성 프로그래밍을 구현하기 위해 어떠한 도구들을 사용했는지, 어떠한 맥락으로 동시성 프로그래밍을 도와주는 도구들이 등장하게 되었는지 살펴보았습니다.

이를 통해 Swift Concurrency가 등장한 배경을 이해하는 데 여러분께 도움이 되었으면 좋겠습니다.

Swift Concurrency 역시 Swift와 함께 발전하는 기술이기에 앞으로 추가될 기능들이 더 기대가 됩니다.

긴 글 읽어주셔서 감사합니다.