4T2F / ThinkBig

🌟씽크빅 스터디🌟
5 stars 1 forks source link

Swift Concurrency의 도입이 기존의 비동기 프로그래밍 접근 방식에 어떤 영향을 미쳤다고 생각하시나요? #3

Open Hsungjin opened 11 months ago

Hsungjin commented 11 months ago

Swift Concurrency의 도입이 기존의 비동기 프로그래밍 접근 방식에 어떤 영향을 미쳤다고 생각하시나요?

Hsungjin commented 10 months ago

Swift Coucurreny는 2021년 WWDC에서 기존 GCD (Grand Central Dispatch) 의 문제를 해결하기 위해 등장했습니다. GCD정리글 다음 링크는 제가 이해한 내용을 정리한 내용입니다. GCD에 대해 미리 알고 보시면 이해하시는데 도움이 됩니다!

Swift Concurrency

WWDC 2021에서 새로 소개된 동시성 프로그래밍 API 입니다. Swift Concurrency는 동시성 프로그래밍을 가독성이 좋은 깔끔한 코드로 작성하고자 도입된 개념입니다. async와 await 키워드를 이용해 비동기 태스크 종료 후 코드를 작성할 수 있습니다. await 키워드로 인해 중지되면 이후에 사용해야 하는 데이터를 힙(heap) 영역에 저장해 두고, 이후에 다시 힙 영역에서 해당 데이터를 가져와 사용합니다.

GCD vs SwiftConcurrency

가독성 (Pyramid of Doom)

아래의 그림은 Pyramid of Doom의 단편적인 예시 입니다. 여러 구문의 들여쓰기가 중첩되면 다음과 같이 가독성이 떨어지는 코드가 생기게 됩니다.

기존의 동시성 프로그래밍을 할때 completion handler를 많이 사용했고, 그로 인해 너무 많은 콜백이 발생하면서 점점 들여쓰기가 많아져 가독성이 저하됐습니다.

기존 강의에서의 예제보다 다른 예제로 이해하기 쉽게 표현했습니다.

func recruitmentProcess(completion: @escaping ((Result) -> Void)) {
    documentTest { documentTestResult in // 1. 서류
        self.codingTest(documentTest: documentTest) { codingTestResult in // 2. 코딩테스트
            self.firstInterView(codingtest: codingTestResult) { firstInterViewResult in // 3. 1차 면접
                self.liveCoding(firstInterView: firstInterViewResult) { result // 4. 라이브코딩
                    completion(result)
                }
            }
        }
    }
}

위의 코드는 채용프로세스에 관혀여 비동기 코드를 GCD와 CompletionHandler를 사용하여 작성한 코드입니다. 서류시험, 코딩테스트, 1차 면접, 라이브코딩 순으로 코드가 동작하는 모습입니다. 반면 같은 코드를 Swift Concurrency로 작성하면 아래와 같이 작성할 수 있습니다. 들여쓰기가 대폭 줄어들면서 가독성이 좋은 코드가 되었습니다.

func recruitmentProcess() async throws -> Result {
    let documentTestResult = try await documentTest() // 1. 서류
    let codingTestResult = try await codingTest(documentTest: documentTestResult) // 2. 코딩테스트
    let firstInterViewResult = try await firstInterView(codingTest: codingTestResult) // 3. 1차 인터뷰
    let result = try await liveCoding(cookie: firstInterViewResult) // 4. 라이브 코딩

    return result
}

Async 키워드를 통하여 비동기 함수임을 나타내고, Await 키워드를 통하여 메서드의 리턴을 기다려 순차적으로 실행할 수 있습니다. Completion Handler가 사라지면서, 비동기 함수를 한 줄로 처리할 수 있어서 훨씬 간결해진 모습입니다. 덕분에 가독성적인 측면에서 많이 개선이 되었으며 동시에 순환참조 문제 또한 해결된 모습입니다 try await 키워드를 빼먹을 경우 컴파일 시점에서 오류가 발생하기 때문에 개발자 입장에서는 조금 더 안정적으로 에러 핸들링이 가능해진 모습입니다.

에러 핸들링의 안정성

다음으로 살펴볼 차이점은 에러 핸들링 방식입니다. 아래 코드는 제가 네트워킹 샘플 앱에서 URLSession을 이용해 이미지 하나를 다운로드하는 기능을 구현한 함수입니다. Swift Concurrency가 아닌 기존의 방식으로 작성한 경우입니다.

func downloadImageWithURL(url: URL,
                          session: URLSession,
                          completionHandler: @escaping (UIImage?, Error?) -> Void) {
    session.dataTask(with: url) { data, response, error in
        guard let imageData = data else {
            completionHandler(nil, DownloadManagerError.invalidData)
            return
        }

        guard let urlResponse = response as? HTTPURLResponse,
              urlResponse.statusCode == 200 else {
            completionHandler(nil, DownloadManagerError.networkFail)
            return
        }

        let image = UIImage(data: imageData)
        completionHandler(image, nil)
    }.resume()
}

기존 방식의 코드에서는 completion handler에 데이터와 에러 정보를 같이 보내주는 방법을 많이 선택합니다. 위 코드에서 ompletion handler의 경우 에러 처리를 위해 필요한 부분이지만, 작성하지 않아도 컴파일 에러가 발생하지는 않습니다. 따라서 코드를 작성할 때 에러 핸들링을 빼먹지 않았는지 주의할 필요가 있습니다. 이는 코드를 작성하는 과정에서 꼼꼼하게 확인한다면 예방할 수 있는 문제점이긴 하지만, 수동으로 일일이 확인해야 한다는 단점이 있습니다. 또한, 발생할 수 있는 에러 혹은 올바른 결과마다 핸들러를 작성해야 하는 번거로움이 있습니다.

위 함수를 Swift Concurrency로 재작성하면 아래와 같이 작성할 수 있습니다.

func downloadImageWithURLSwitfConcurrency(url: URL, session: URLSession) async throws -> UIImage {
    let (data, response) = try await session.data(from: url)
    guard let urlResponse = response as? HTTPURLResponse,
          urlResponse.statusCode == 200 else {
        throw DownloadManagerError.networkFail
    }

    guard let image = UIImage(data: data) else {
        throw DownloadManagerError.invalidData
    }

    return image
}

Swift Concurrency로 작성하면 에러 핸들링은 throw로, 데이터 전달은 return으로 분리할 수 있습니다. 이런 식으로 작성하면 guard let - else 구문에서 completion handler를 누락하는 실수를 방지할 수 있습니다. 함수에서 에러를 throw하거나 이미지를 return해야 하기 때문입니다. 또한 completion handler를 사용하지 않아 콜백이 없어 가독성이 좋아집니다.

시스템이 스레드를 관리

일반적으로 함수는 정해진 스레드에서 처리해야 합니다. 즉, 처리해야 할 작업이 많아지면 생성되는 스레드도 증가하게 됩니다. 그렇다면 스레드의 수가 많아지면 더 많이 처리할 수 있으니까 더 좋은 거 아닐까요?

예상외로 그렇지는 않습니다. 스레드의 수가 지나치게 많이 생성되는 현상(thread explosion)이 발생합니다. 스레드의 수가 디바이스의 CPU 코어 수보다 많아지면 실제 용량을 초과해 요구하는 셈(overcommit)이 되어버립니다.

코어의 수가 제한된 디바이스에서 스레드가 과도하게 생성될 경우 빈번한 컨텍스트 스위칭(context switching)으로 인해 스케줄링 오버헤드가 발생하거나, block된 스레드가 다시 실행되기를 기다리면서 가지고 있는 메모리 및 리소스 때문에 메모리 오버헤드가 발생해 성능을 저하시킬 가능성이 있습니다.

또, 엄청난 양의 스레드를 개발자들이 직접 관리하는 것은 꽤나 복잡한 작업을 요구하게 될 것입니다.

GCD를 사용할 때는 너무 많은 스레드들이 block 되지 않도록 제어하거나, 세마포어(semaphore)를 사용해 할당되는 스레드의 수를 제한하는 방법을 사용해야 했습니다. 이 점 또한 앞에서 언급한 ‘개발자의 실수로 인해 발생할 수 있는 오류’를 발생시킬 가능성이 있기 때문에 언어적인 측면에서 안전하다고 판단되는 것은 아니었습니다.

WWDC 2021에서 발표된 내용에 따르면 Concurrency 도입 이후 스레드 관리를 개발자가 하기보다는 시스템 자체에서 처리하는 방향으로 채택했습니다.

요약

  • Swift Concurrency에 적용된 변화
  • 🌱 언어적 개선 ∙ completion handler 제거로 인한 가독성 증가 ∙ self 참조 사이클이 생길 우려 제거 ∙ 에러 핸들링의 용이
  • 🌱 성능적 개선 ∙ 시스템이 스레드 관리 ∙ 컨텍스트 스위칭을 함수 호출로 대체