🔥 비동기 함수 정의와 호출

1112자
15분

비동기 함수(asynchronous function) 또는 비동기 메서드(asynchronous method)는 실행 중간에 일시 중단될 수 있는 특별한 종류의 함수나 메서드랍니다. 이는 실행을 완료하거나, 오류를 던지거나, 반환하지 않는 일반적인 동기 함수 및 메서드와는 대조적이에요. 비동기 함수나 메서드도 이 세 가지 중 하나를 수행하지만, 무언가를 기다리는 동안 중간에 일시 중지할 수도 있죠. 비동기 함수나 메서드의 본문 내에서는 실행이 일시 중단될 수 있는 각 지점을 표시합니다.

함수나 메서드가 비동기임을 나타내기 위해서는 선언부에서 매개변수 뒤에 async 키워드를 작성하면 됩니다. 이는 던지는(throwing) 함수를 표시하기 위해 throws를 사용하는 것과 유사해요. 함수나 메서드가 값을 반환하는 경우에는 반환 화살표(->) 앞에 async를 작성하면 되겠죠. 예를 들어, 갤러리에서 사진 이름을 가져오는 방법은 다음과 같습니다:

func listPhotos(inGallery name: String) async -> [String] {
    let result = // ... 비동기 네트워킹 코드 ...
    return result
}
swift

비동기이면서 동시에 던지는(throwing) 함수나 메서드의 경우에는 throws 앞에 async를 작성합니다. 예를 들면 다음과 같습니다:

func fetchData(from url: URL) async throws -> Data {
    let (data, response) = try await URLSession.shared.data(from: url)
 
    guard let httpResponse = response as? HTTPURLResponse,
          (200...299).contains(httpResponse.statusCode) else {
        throw DataFetchError.invalidResponse
    }
 
    return data
}
swift

비동기 메서드를 호출할 때는 해당 메서드가 반환될 때까지 실행이 일시 중단됩니다. 가능한 일시 중단 지점을 표시하기 위해 호출 앞에 await을 작성하죠. 이는 오류가 발생할 경우 프로그램의 흐름이 변경될 수 있는 가능성을 표시하기 위해 던지는(throwing) 함수를 호출할 때 try를 작성하는 것과 유사합니다. 비동기 메서드 내에서는 다른 비동기 메서드를 호출할 때에만 실행 흐름이 일시 중단됩니다. 일시 중단은 절대 암시적이거나 선점적이지 않아요. 즉, 모든 가능한 일시 중단 지점은 await으로 표시된다는 뜻이죠. 코드에서 가능한 모든 일시 중단 지점을 표시하면 동시 코드를 더 쉽게 읽고 이해할 수 있습니다.

예를 들어, 아래 코드는 갤러리에 있는 모든 사진의 이름을 가져온 다음 첫 번째 사진을 보여줍니다:

let photoNames = await listPhotos(inGallery: "Summer Vacation")
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
let photo = await downloadPhoto(named: name)
show(photo)
swift

listPhotos(inGallery:)downloadPhoto(named:) 함수는 모두 네트워크 요청을 해야 하므로 완료하는 데 상대적으로 오랜 시간이 걸릴 수 있습니다. 반환 화살표 앞에 async를 작성하여 두 함수를 모두 비동기로 만들면 이 코드가 사진을 준비하는 동안 앱의 나머지 코드는 계속 실행될 수 있죠.

위의 예제에서 동시성의 특성을 이해하기 위해, 다음은 가능한 실행 순서 중 하나입니다:

  1. 코드는 첫 번째 줄부터 실행을 시작하여 첫 번째 await까지 실행됩니다. listPhotos(inGallery:) 함수를 호출하고 해당 함수가 반환될 때까지 실행을 일시 중단하죠.
  2. 이 코드의 실행이 일시 중단되는 동안, 동일한 프로그램의 다른 동시 코드가 실행됩니다. 예를 들어, 새로운 사진 갤러리 목록을 계속 업데이트하는 장기 실행 백그라운드 작업이 있을 수 있어요. 해당 코드도 await으로 표시된 다음 일시 중단 지점까지 실행되거나 완료될 때까지 실행되겠죠.
  3. listPhotos(inGallery:)가 반환된 후에는 해당 지점부터 이 코드의 실행이 계속됩니다. 반환된 값을 photoNames에 할당하죠.
  4. sortedNamesname을 정의하는 줄은 일반적인 동기 코드입니다. 이러한 줄에는 await이 표시되지 않았으므로 가능한 일시 중단 지점이 없어요.
  5. 다음 awaitdownloadPhoto(named:) 함수 호출을 표시합니다. 해당 함수가 반환될 때까지 이 코드는 다시 실행을 일시 중지하여 다른 동시 코드가 실행될 기회를 제공하죠.
  6. downloadPhoto(named:)가 반환되면 반환 값이 photo에 할당된 다음 show(_:)를 호출할 때 인수로 전달됩니다.

코드에서 await으로 표시된 가능한 일시 중단 지점은 현재 코드 조각이 비동기 함수나 메서드가 반환되기를 기다리는 동안 실행을 일시 중지할 수 있음을 나타냅니다. 이를 스레드 양보(yielding the thread)라고도 하는데, 이는 Swift가 현재 스레드에서 코드 실행을 일시 중단하고 대신 해당 스레드에서 다른 코드를 실행하기 때문이에요. await이 있는 코드는 실행을 일시 중단할 수 있어야 하므로, 프로그램의 특정 위치에서만 비동기 함수나 메서드를 호출할 수 있습니다:

  • 비동기 함수, 메서드 또는 속성의 본문에 있는 코드.
  • @main으로 표시된 구조체, 클래스 또는 열거형의 정적 main() 메서드에 있는 코드.
  • 아래의 비구조적 동시성 에서 보여주는 것처럼 비구조적 자식 작업에 있는 코드.

Task.yield() 메서드를 호출하여 일시 중단 지점을 명시적으로 삽입할 수 있습니다.

func generateSlideshow(forGallery gallery: String) async {
    let photos = await listPhotos(inGallery: gallery)
    for photo in photos {
        // ... 이 사진에 대해 몇 초 분량의 비디오를 렌더링 ...
        await Task.yield()
    }
}
swift

비디오를 렌더링하는 코드가 동기식이라고 가정하면, 일시 중단 지점이 포함되어 있지 않겠죠. 비디오 렌더링 작업도 오랜 시간이 걸릴 수 있습니다. 하지만 주기적으로 Task.yield()를 호출하여 명시적으로 일시 중단 지점을 추가할 수 있어요. 이런 식으로 장기 실행 코드를 구성하면 Swift가 이 작업의 진행과 프로그램의 다른 작업이 작업을 진행하는 것 사이에서 균형을 맞출 수 있습니다.

동시성이 어떻게 작동하는지 배우기 위해 간단한 코드를 작성할 때는 Task.sleep(for:tolerance:clock:) 메서드가 유용합니다. 이 메서드는 주어진 시간 동안 현재 작업을 일시 중단하죠. 다음은 sleep(for:tolerance:clock:)을 사용하여 네트워크 작업을 기다리는 것을 시뮬레이션하는 listPhotos(inGallery:) 함수의 버전입니다:

func listPhotos(inGallery name: String) async throws -> [String] {
    try await Task.sleep(for: .seconds(2))
    return ["IMG001", "IMG99", "IMG0404"]
}
swift

위 코드의 listPhotos(inGallery:) 버전은 Task.sleep(until:tolerance:clock:) 호출이 오류를 던질 수 있으므로 비동기이면서 던지는(asynchronous and throwing) 함수입니다. 이 버전의 listPhotos(inGallery:)를 호출할 때는 tryawait를 모두 작성해요:

let photos = try await listPhotos(inGallery: "A Rainy Weekend")
swift

비동기 함수는 던지는(throwing) 함수와 몇 가지 유사점이 있습니다. 비동기 함수나 던지는 함수를 정의할 때는 async 또는 throws로 표시하고, 해당 함수를 호출할 때는 await 또는 try로 표시하죠. 비동기 함수는 다른 비동기 함수를 호출할 수 있는데, 이는 던지는 함수가 다른 던지는 함수를 호출할 수 있는 것과 마찬가지입니다.

그러나 매우 중요한 차이점이 있어요. 던지는(throwing) 코드를 do-catch 블록으로 감싸서 오류를 처리하거나 Result를 사용하여 오류를 저장하여 다른 곳에서 처리할 수 있습니다. 이러한 접근 방식을 사용하면 던지지 않는(nonthrowing) 코드에서 던지는 함수를 호출할 수 있죠. 예를 들면 다음과 같습니다:

func availableRainyWeekendPhotos() -> Result<[String], Error> {
    return Result {
        try listDownloadedPhotos(inGallery: "A Rainy Weekend")
    }
}
swift

반면에 비동기 코드를 안전하게 감싸서 동기 코드에서 호출하고 결과를 기다릴 수 있는 방법은 없습니다. Swift 표준 라이브러리는 이러한 안전하지 않은 기능을 의도적으로 생략했어요. 직접 구현하려고 하면 미묘한 경쟁 상태, 스레딩 문제, 데드락과 같은 문제가 발생할 수 있거든요. 기존 프로젝트에 동시 코드를 추가할 때는 위에서부터 아래로 작업하세요. 구체적으로는 가장 상위 계층의 코드를 동시성을 사용하도록 변환하는 것부터 시작하여, 프로젝트의 아키텍처를 한 계층씩 거쳐가면서 호출하는 함수와 메서드를 변환하기 시작하면 됩니다. 동기 코드는 후반부에 설명하는 Task 등 추가 도구 없이 절대 비동기 코드를 호출할 수 없으므로 아래에서 위로 접근하는 방법은 없어요.

graph TD
    A[동기 코드] --> B[비동기 코드]
    B --> C[비동기 코드]
    C --> D[비동기 코드]

위의 다이어그램은 동기 코드에서 비동기 코드로의 일방향 호출 흐름을 보여줍니다. 비동기 코드는 동기 코드를 호출할 수 있지만, 그 반대는 불가능해요. 따라서 기존 프로젝트에 동시성을 도입할 때는 최상위 계층부터 시작하여 한 계층씩 아래로 내려가면서 작업해야 합니다.

비동기 프로그래밍은 복잡한 주제이지만, Swift의 동시성 모델은 가능한 한 쉽고 안전하게 만들어졌어요. asyncawait 키워드를 사용하여 비동기 코드를 명확하게 표시하고, 컴파일러가 잠재적인 문제를 잡아낼 수 있도록 돕죠. 또한 Task와 같은 추상화를 제공하여 낮은 수준의 스레딩 세부 사항을 직접 다룰 필요 없이 동시 코드를 작성할 수 있게 해줍니다.

비동기 프로그래밍을 시작할 때는 먼저 간단한 예제로 시작하는 것이 좋아요. 예를 들어, 네트워크 호출을 시뮬레이션하기 위해 sleep(for:tolerance:clock:)을 사용하는 작은 프로그램을 작성해 볼 수 있죠. 그런 다음 더 복잡한 시나리오로 점진적으로 발전시켜 나가면서 Swift의 동시성 기능을 익혀갈 수 있습니다.

또한 Apple의 공식 문서와 WWDC 세션을 통해 더 많은 것을 배울 수 있어요. 특히 다음 자료들이 도움이 될 거예요: