푸핫 ... 아는 개발자라면 이 제목이 한심할지도 😂
약 네 달 전에 발생했던 이슈다. 상황은 이러했다.
1. 백그라운드에 진입한 후 수행할 태스크를 BGTaskScheduler에 submit하여 주기적으로 해당 함수를 실행
2. 수행할 태스크는 파이어베이스에 진입해서 특정 도큐먼트의 값이 있는지 확인
3. 이 값이 있으면 등록되어 있는 이미지가 있고 해당 url이 반환
4. 해당 url의 이미지를 dataTask()를 통해 Data로 받아와서 UserDefaults에 저장
그런데 직면한 문제는 이러했다.
2번에서 등록되어 있는 이미지가 있는데도 3번으로 진입하지 못하고 함수를 종료한다. 웃긴게 디버깅해보면 2-30번에 한 번꼴로 성공한다. 하면서도 내가 나를 가장 의심했던 부분은 async 함수를 쓰는 점이었다. 이미지를 다운로드하는 네트워크 작업이었기 때문에 비동기로 작성해야했다. 그런데 당시 나는 백그라운드 모드의 동작을 다뤄보는 것이 처음이었고 비동기의 동작에도 제대로 이해하지 못하고 무지했다. 그런 상태에서 작성했던 코드는 아래와 같다.
func fetchImageUrl() async {
guard let partnerID = UserDefaults.shared.string(forKey: "partnerID") else { return }
db.document(partnerID).getDocument { document, _ in
// 제대로 동작하지 않기 시작하는 지점
if let document = document, let data = document.data() {
if let partnerImageURL = data["imageURL"] as? String {
guard let url = URL(string: partnerImageURL) else { return }
URLSession.shared.dataTask(with: url) { data, response, _ in
guard let data = data, error == nil else { return }
// .. 중략 .. //
}.resume()
}
}
}
}
복잡하긴한데 결국 위에서 설명한 1번~4번을 코드로 옮겨 적은 것이다. (4번의 데이터를 UserDefaults에 저장하는 부분만 생략) 주석을 달아둔 부분에서 동작이 멈추고 계속 반환되었다. 운이 좋으면 URLSession 작업 직전까지도 찍혔다.
운이 좋으면 이라는 조건이 너무 웃기고 어이없었다. 안되면 아예 안될 것이지 왜 이렇게 밀당을 하나싶었다.
한참 헤매다가 나의 iOS 선생님께 도움을 요청했다. 선생님은 스유가 아닌 유킷으로 작업하시면서도 언제나 나에게 정답을 선사해주셨다... 이번에도 그러했다 .....
코드를 다시 돌아보면 함수를 async로 작성해주었는데 그 안에서 클로저를 다시 부른다. 하지만 await은 async 함수의 실행은 기다려주지만, async 내의 closure는 기다려주지 않는다. 결국 이렇기 때문에 db에 접근은 한 후에 호출되는 클로저에서 자꾸 async 함수가 종료된 것이다. 운좋게 간혹 실행되었던 것은 기다려주지 않았지만 타이밍좋게 진입해서 종료되기 전에 수행되었던 것 같다... 그래서 기도메타임 .. ㅋㅋ
이 클로저가 동작하게 하고싶다면 해당 클로저도 async로 표시하고 내부에서 await을 사용해야 한다. 그래야 해당 클로저의 비동기 작업을 기다려준다.
withCheckedContinuation()이라는 함수로 클로저를 async 코드로 바꿔줄 수 있다. withCheckedContinuation()은 코드에 새로운 연속성을 생성하고 이를 사용해 클로저를 호출해 결과를 반환할 수 있게 된다. 공식문서와 민소네님의 글을 참고했다.
withCheckedContinuation(function:_:) | Apple Developer Documentation
Invokes the passed in closure with a checked continuation for the current task.
developer.apple.com
Swift concurrency: Update a sample app - WWDC21 - Videos - Apple Developer
Discover Swift concurrency in action: Follow along as we update an existing sample app. Get real-world experience with async/await,...
developer.apple.com
[Swift 5.7+][Concurrency] Continuations - Closure를 async 코드로 감싸 사용하기
Continuation은 프로그램 상태의 불투명한 표현입니다. 비동기 코드에서 연속(continuation)을 만들려면 withCheckedContinuation(function:_:), withCheckedThrowingContinuation(function:_:) 와 같은 코드를 호출합니다. 비동
minsone.github.io
Continuation(연속성)을 부여하여 수정된 코드는 아래와 같다.
func fetchImageUrl() async {
guard let partnerId = UserDefaults.shared.string(forKey: "partnerId") else { return }
await withCheckedContinuation { continuation in
db.document(partnerId).getDocument { document, _ in
if let document = document, let data = document.data() {
if let partnerImageUrl = data["imageUrl"] as? String {
guard let url = URL(string: partnerImageUrl) else { return }
URLSession.shared.dataTask(with: url) { data, response, _ in
guard let data = data, error == nil else { return }
// .. 중략 .. //
// resume()을 호출해주지 않으면 Task가 무기한 중단 상태를 유지하게 되므로 정확히 한 번 호출해야 함
continuation.resume()
}.resume()
}
}
}
}
}
너무 감격스럽게도 이렇게하면 매번 백그라운드 태스크가 실행될 때마다 가장 depth가 깊은 nested closure까지 잘 호출되어 이미지가 잘 저장되었다. 그런데 사실은 여기서 Continuation을 사용할 필요는 없다. withCheckedContinuation은 비동기 작업의 결과를 반환해줄 때 더 유용한 함수더라. 당시에는 그냥 되는 것만 확인하고 더 알아보지 않았었는데 나중에 자료를 더 찾아보니 그러했다.
아까 언급했듯 그저 클로저를 async로 표시하면 되고, withCheckedContinuation을 활용해 이 코드를 더 개선한다면 저 콜백 지옥을 어떻게든 처리할 수 있을 듯 하다. 이 코드로 작성해보고 싶은데 백그라운드모드가 시뮬레이터에서 돌아가지 않아서, 자고 내일 일어나서 기기연결해서 해봐야겠다. 다음 할 일은 이거일듯하니 다음 포스트에서 따로 다루면서 연속성에 대한 내용을 더 기록하면 좋을 것 같다.
백그라운드 태스크를 할당하는 방법는 아래 몽고디비의 자료를 참고했다. 가장 잘 설명되어있다. 사실 내가 겪은 문제는 단순히 내가 비동기 처리에 대해 제대로 알아보지 못했다는 점이었는데, 백그라운드 태스크마저 처음 다뤄보는 것이기 때문에 더 혼란스러웠다. async 함수가 의심되었다곤 했지만 백그라운드모드가 가장 의심스러워서 여기에서만 원인을 찾으려고 했다. signing & capability에서 백그라운드 모드를 허용하도록 추가해주고, info.plist에 태스크 식별자를 등록해 해당 식별자를 이용해 백그라운드 태스크 리퀘스트를 보내는 과정이 선행되어야 한다. 빠뜨린 것은 없는지, 오타는 없는지 등 중구난방으로 코딩하던 탓에 실제 원인을 파악하기는 더 쉽지 않았다.
Sync Data in the Background with SwiftUI - Swift SDK — Realm
To test that your background task is updating the synced realm in the background, you'll need a physical device running at minimum iOS 16. Your device must be configured to run in Developer Mode. If you get an Untrusted Developer notification, go to Settin
www.mongodb.com
당시에는 원인을 알고 너무 허무했지만 다 성장하는 과정이라고 생각하기도 한다. 몸이 고생하지 않으려면 머리를 단련하자 🏋️♀️