Swift Concurrency의 Continuation과 Task.sleep, sleep의 차이!

오늘은 Swift Concurrency 중에서 Continuation 사용과 sleep의 차이에 대해 알아보자.

 

Continuation

먼저 Continuation은 콜백/델리게이트 기반의 비동기 API를 async/await 을 활용할 수 있도록 연결해주는 도구이다. 새로운 동시성 모델을 사용하고 싶은데 당장 구현했던 SDK 혹은 라이브러리가 콜백만 제공하는 구조라면 이럴 때 Continuation을 활용해 래핑해 사용하면 된다는 것!

 

Swift Concurrency에서 async/await는 읽기 쉽고 오류에 강한 비동기 코드를 작성할 수 있게 해준다 이러한 장점 덕분에 많은 부분에서 컨커런시로 전환하고 있지만, 실제로 라이브러리 혹은 SDK를 사용하면 completion handler나 delegate를 사용한 방식으로 구현되어있어 이를 내가 임의적으로 변경해 사용할 수 없는 경우도 있다.

 

물론 내가 직접 구현한 메서드와 같이 추가적인 Continuation 래핑 메서드 없이 해당 메서드 자체를 수정할 수 있는 형태라면 형태를 컨커런시로 리팩토링하여 사용하는 방법이 제일 낫다고 생각한다.

 

필수적으로 Continuation를 사용해야한다가 아닌, Concurrency를 활용하고 싶은데 내가 컨트롤 할 수 없을때 이 연결장치를 활용할 수 있다는 점이 핵심 개념인 것 같다.

 

Continuation은 일반적으로 두가지로 나뉘는 것 같다. CheckedContinuation과 UnsafeContinuation!

 

먼저 CheckedContinuation은 일반적으로 많이 사용하는 타입으로 Swift 런타임에 사용 여부를 체크해주는 역할을 한다.

 

예를들어 한번만 resume이 호출되어야 하는데 여러번 resume 하면 경고 및 크래시를 유발하여 알려준다.

하지만 UnsafeContinuation은 말 그대로 별로 안전하지 않은 사용 방식인데 런타임 검증이 없어서 resume을 여러번 호출 된 상황에서도 에러가 체크되지 않아 특수한 상황에서만 제한적으로 사용하는 것이 일반적이다

 

자주쓰이는 곳에는 아마도 오래된 SDK를 사용해야하는데 completionHandler 기반일때와 Delegate 패턴, 그리고 Notification 기반 이벤트에서 한번 발생하는 이벤트를 async로 기다리고 싶을떄 정도 인 것 같다 ㅎㅎ 추후에 고려해야할 상황에서 참고하도록 하자

 

예시를 한번 살펴보면

// 콜백 기반 API
func fetchUserProfile(completion: @escaping (User?, Error?) -> Void) {
    // 네트워크 요청...
}

// Continuation을 이용한 async/await 버전
func fetchUserProfile() async throws -> User {
    try await withCheckedThrowingContinuation { continuation in
        fetchUserProfile { user, error in
            if let user = user {
                continuation.resume(returning: user)
            } else if let error = error {
                continuation.resume(throwing: error)
            } else {
                continuation.resume(throwing: NSError(domain: "UnknownError", code: -1))
            }
        }
    }
}

기존에 사용했던 콜백 형태에서 let user = try await fetchUserProfile() 형태로 사용할 수 있게 된다.

sleep, Task.sleep

sleep

기존의 sleep 함수는 동기적인 성격의 함수이고 쓰레드를 점유하고, 아예 일정 시간동안 해당 쓰레드를 멈추고 동작시키지 않는 함수이다. (일정 정해진 시간 동안 DeadLock과 유사해짐)

 

전통적인 C 기반 API로 ( sleep(_:) , usleep(_:) , Thread.sleep(forTimeInterval:) 등으로 사용되고 있고, 현재 실행 중인 스레드 자체를 블록 시킨다.

 

해당 스레드는 지정된 시간 동안 아무 일도 못하고 멈춰있는 상태

print("Start")
sleep(2) // 2초 동안 스레드 전체가 멈춤
print("End")

여기서 생기는 문제점은 Swift Concurrency에서 sleep을 사용하면, 스레드 풀을 점유하게 되어서 다른 Task 들이 실행될 기회를 뺏기게 된다.

 

동시성을 활용하는 취지와 맞지 않게 된다. 여기서 말하는 동시성을 활용하는 취지는 전통적인 방식(GCD 등)은 스레드를 붙잡아 두는 방식이라 비효율적이고 Swift Concurrency는 Task를 일시정지 시키고 스레드는 다른 작업이 쓸 수 있게 해줌으로써, 적은 스레드로 많은 비동기 작업을 효율적으로 처리하자 라는 취지이다.

 

Task.sleep

비동기 함수로 중간에 멈췄다가 다시 재개 될 수 있는 함수로 Non-blocking 방식이다. 시간이 종료되기 전에 작업이 취소되면, CancellationError를 던지게 된다.

 

다시 정리하면 현재 Task의 실행을 일시 중단 시키고, 스레드를 점유하지 않고, 시스템이 해당 Task를 일시적으로 대기 큐에 넣어둔다. 그리고 지정된 시간이 지나면 Task가 다시 재개 된다 (resume)

try await Task.sleep(nanoseconds: 2_000_000_000)

이렇게 했을때 다른 Task들이 같은 스레드에서 실행될 수 있다. 위에서 말했던 동시성을 활용하는 취지와 맞게 된다. async await과 자연스럽게 통합 될 수 있다는 점도 특징

 

오늘은 기존 방식과 Swift Concurrency에서 사용하는 방식의 차이점과 기존 방식을 컨커런시에서도 자연스럽게 사용할 수 있도록 해주는 Continuation에 대해서 알아봤다 🙂

 

오늘은 여기까지!