확신이 없다면 Checked Continuation을!

확신이 없다면 Checked Continuation을!
Photo by Sigmund / Unsplash

URL의 미리보기 이미지(대표 이미지)를 Apple의 Link Presentation 프레임워크를 이용해 불러오도록 개발하며 특정 링크의 경우 아래와 같이 continuation misuse로 인한 leak이 발생하는 것을 확인하여 주의할 점에 대해 기록 삼아 남겨본다.

무엇이 문제였는지, 작성한 코드를 확인해 보자

Before

func requestPreviewImage() async throws -> UIImage {
  let primaryImageProvider = self.linkMetadata?.imageProvider ?? self.linkMetadata?.iconProvider
  return try await withCheckedThrowingContinuation { continuation in
    primaryImageProvider?.loadObject(ofClass: UIImage.self) { (previewImage, error) in
      guard let previewImage = previewImage as? UIImage,
      error == nil
      else {
        continuation.resume(throwing: LinkMetadataServiceError.contentsCouldNotBeLoaded)
        return
      }
		
      continuation.resume(returning: previewImage)
    }
  }
}

기존 completion Handler 기반의 코드를 Swift Concurrency로 bridge 해주기 위해 CheckedContinuation을 사용해 주었다.

CheckedContinuation을 사용해 주었기에 디버깅 콘솔에서 아래와 같은 메시지를 확인할 수 있었다.

SWIFT TASK CONTINUATION MISUSE: requestPreviewImage() leaked its continuation!

어디가 문제였을까?

imageProvider, iconProvider에 모두 nil이 할당되어 primaryImageProvider 에 nil이 들어가게 된다면?

func requestPreviewImage() async throws -> UIImage {
  let primaryImageProvider = self.linkMetadata?.imageProvider ?? self.linkMetadata?.iconProvider
  return try await withCheckedThrowingContinuation { continuation in
    primaryImageProvider?.loadObject(ofClass: UIImage.self) { (previewImage, error) in
      guard let previewImage = previewImage as? UIImage,
      error == nil
      else {
        continuation.resume(throwing: LinkMetadataServiceError.contentsCouldNotBeLoaded)
        return
      }
		
      continuation.resume(returning: previewImage)
    }

    // nil일 경우 오게되는 컨텍스트
  }
}

Optional Chaining을 이용해 메소드를 호출했기 때문에 nil로 평가되어 loadObject메소드를 호출하지 못하고 다음 컨텍스트로 넘어가게 된다.

따라서 continuation의 인터페이스를 호출하지 못하게 되어 continuation misuse로 인한 leak이 발생하게 된다.

문제를 알게 되었으니, 코드를 수정해 보자

After


func requestPreviewImage() async throws -> UIImage {
  guard let linkMetadata = self.linkMetadata,
        let primaryImageProvider = linkMetadata.imageProvider ?? linkMetadata.iconProvider,
        primaryImageProvider.canLoadObject(ofClass: UIImage.self)
  else { throw LinkMetadataServiceError.contentsCouldNotBeLoaded }
		
  return try await withCheckedThrowingContinuation { continuation in
    primaryImageProvider.loadObject(ofClass: UIImage.self) { (previewImage, error) in
      guard let previewImage = previewImage as? UIImage,
      error == nil
      else {
        continuation.resume(throwing: LinkMetadataServiceError.contentsCouldNotBeLoaded)
        return
      }
  
      continuation.resume(returning: previewImage)
    }
  }
}

guard 구문에서 Optional Binding을 해주고, NSItemProvider의 canLoadObject(ofClass:)
를 이용해 객체를 로드할 수 있는지 확인한 뒤에 조건문을 통과하지 못한다면 에러를 throw하며 early exit하도록 해주어 문제를 해결할 수 있었다.

Conclusion

continuation을 사용할 때는 코드 실행 경로에서 resume은 코드 실행 경로에서 딱 한 번 호출되어야 한다는 것에 유의해서 코드를 작성하다 보니 두 번 이상 호출하지 않도록 하는 부분은 신경을 썼었는데,

이번 경우처럼 한 번도 호출하지 못하는 경우에 대해서는 신경을 쓰지 못했던 것 같다.

그래도 다행인 부분은 withCheckedThrowingContinuation(function:_:)을 사용했기에 빠르게 문제를 확인할 수 있었다.

checkedContinuation은 런타임에 misuse에 대한 검사를 하기 때문에 unsafeContinuation보다 오버헤드가 있긴 하지만, 이처럼 개발을 하는 과정 중에 빠르게 문제를 확인할 수 있게 도와주어 misuse에 대한 확신이 없는 경우에 좋은 것 같다.