iOS

Swift의 비동기 프로그래밍 (async/await)

미니미니니

오늘은 Swift 5.5부터 도입된 Swift Concurrency라는 동시성 프로그래밍이 지원하는 async/await 비동기 프로그래밍에 대해서 알아보겠습니다.

기존 방식의 문제점: 콜백 지옥

Swift Concurrency가 등장하기 전, 우리는 주로 클로저(Closure)를 이용한 컴플리션 핸들러(Completion Handler) 방식으로 비동기 처리를 했습니다.

func fetchUserProfile(userId: String, completion: @escaping (Result<User, Error>) -> Void) {
    // ... 네트워킹 코드 ...
    // 성공하면 completion(.success(user))
    // 실패하면 completion(.failure(error))
}

이 방식은 간단한 작업 하나에는 괜찮지만, 여러 개의 비동기 작업이 순차적으로 이어져야 할 경우 코드가 매우 복잡해집니다.

fetchUserId(for: "mini") { result in
  switch result {
  case .success(let userId):
    fetchUserProfile(userId: userId) { result in
      switch result {
      case .success(let user):
        fetchUserPosts(user: user) { result in
          switch result {
          case .success(let posts):
            // 최종 성공
            print("\n🎉 최종 결과: \(user.name)의 포스트 \(posts.count)개를 가져왔습니다.")
            posts.forEach { print("- \($0.title)") }
          case .failure(let error):
            // 포스트 가져오기 실패 처리
            print("\n❌ 에러 발생: \(error.localizedDescription)")
          }
        }
      case .failure(let error):
        // 프로필 가져오기 실패 처리
        print("\n❌ 에러 발생: \(error.localizedDescription)")
      }
    }
  case .failure(let error):
    // ID 가져오기 실패 처리
    print("\n❌ 에러 발생: \(error.localizedDescription)")
  }
}

이런 코드는 가독성이 떨어지고, 에러 처리도 중복되며, 디버깅도 매우 어려워집니다.


이런 문제를 해결하기 위해 Swift는 async/await이라는 키워드를 도입했습니다. 핵심 아이디어는 "비동기 코드를 동기 코드처럼 순서대로 작성하자!" 입니다.

위의 코드를 async/await으로 바꾸면 아래와 같이 변경됩니다.

func fetchAllUserData() async {
  print("🚀 async/await 버전으로 사용자 데이터 요청 시작")
  do {
    let userId = try await fetchUserId(for: "mini")
    print("   ✅ ID 가져오기 성공: \(userId)")
    
    let user = try await fetchUserProfile(userId: userId)
    print("   ✅ 프로필 가져오기 성공: \(user.name)")
    
    let posts = try await fetchUserPosts(user: user)
    print("   ✅ 포스트 가져오기 성공: \(posts.count)개")
    
    print("\n🎉 최종 결과: \(user.name)의 포스트 \(posts.count)개를 가져왔습니다.")
    posts.forEach { print("- \($0.title)") }
    
  } catch {
    print("\n❌ 에러 발생: \(error.localizedDescription)")
  }
}

Task {
  await fetchAllUserData()
}

코드가 위에서 아래로 자연스럽게 읽기 쉬워지고, try-catch로 에러 처리를 해줍니다. 

그럼 async/await에 대해서 차근차근 더 알아가보겠습니다

async

async는 함수 선언부에 붙여, 이 함수가 비동기적으로 동작하며 일시 중단 될 수 있음을 알립니다.

func fetchUserId(for username: String) async throws -> String {
  try await Task.sleep(nanoseconds: 1_000_000_000) // 1초 대기 (네트워크 시뮬레이션)
  if username == "mini" {
    return "user-123"
  } else {
    throw NetworkError.userNotFound
  }
}

await

await은 async 함수를 호출할 때 사용합니다. await을 만나면 프로그램은 다음과 같이 행동합니다.

  1. async 함수의 실행을 시작시킵니다.
  2. 결과가 돌아올 때까지 현재 함수의 실행을 일시 중단(suspend)합니다.
  3. 스레드를 막는 게 아니라, 스레드를 다른 작업이 쓸 수 있도록 양보합니다. 그래서 UI는 계속 부드럽게 움직일 수 있습니다.
  4. async 함수의 결과가 들어오면 중단됐던 지점부터 실행을 재개합니다.

즉, await은 비동기 작업이 끝날 때까지 스레드를 효율적으로 사용하며 기다리는 방법입니다.

 

오늘은 async/await으로 비동기 코드를 깔끔하게 만드는 법을 알아보았습니다. 하지만 여러 이미지를 동시에 다운로드 받아야 한다면 어떻게 해야 할까요? 다음 편에서는 여러 비동기 작업을 병렬로 처리하여 앱 성능을 향상시키는 방법에 대해 알아보도록 하겠습니다.