제가 만들고 있는 앱은 MacOS와 iOS에서 둘 다 사용할 수 있는 나름의(?) 크로스 플랫폼 앱인데요. 지금 테스트를 위해서 Singleton 패턴을 사용하던 API 부분을 Dependency Injection으로 리팩토링하는 와중에 아래와 같은 에러를 발견했습니다.
일단 Dependency가 singleton으로 되어 있는데요. 나중에 수정했습니다. 에러 메시지만 봐주세요. 에러 메시지의 요지는 FirebaseApp 인스턴스가 없어서 Firebase를 사용할 수 없다는 것입니다. 그러므로 FirebaseApp.configure()를 우선 실행을 한 이후에 Firebase를 사용하라는 것이지요.
MacOS를 위한 @main 함수를 선언한 부분을 보면 분명히 실행하고 있음을 볼 수 있습니다.
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
func applicationDidFinishLaunching(_ notification: Notification) {
FirebaseApp.configure()
}
}
@main
struct JWordsApp: App {
@NSApplicationDelegateAdaptor private var delegate: AppDelegate
var body: some Scene {
WindowGroup {
ContentView()
.frame(minWidth: 800, maxWidth: .infinity, minHeight: 800, maxHeight: .infinity, alignment: .center)
}
}
}
상당히 생고생(?)을 한 결과 아래와 같은 결론을 얻을 수 있었습니다.
iOS는 같은 코드로도 이런 에러가 발생하지 않았는데요. MacOS에서만 발생하는 에러입니다. 그렇다면 제 추측으로는 iOS와 MacOS가 applicationDidFinishLaunching를 실행하는 타이밍이 다른 것으로 생각됩니다. 그렇다면 어떻게 타이밍이 다를까요?
iOS에서는 App 구조체 즉 @main 함수에 선언된 JwordsApp가 init되기 전에 applicationDidFinishLaunching가 실행이 됩니다. 반면에 MacOS에서는 applicationDidFinishLaunching가 실행이 되기 이전에 App이 init이 먼저 init이 되는 것이죠.
왜 인지는 모르겠지만 일단 각각의 OS의 특성으로 전제하고 해결 방안을 찾아야 합니다.
여기서 의문이 하나 발생하는게 DI를 적용하기 전에는 멀쩡하게 되었단 말이죠? 왜 그랬을까요? 그 이유는 type property가 메모리에 올라가는 타이밍과 관련이 있었습니다.
extension Constants {
enum Collections {
static let wordBooks =
Firestore.firestore()
.collection("develop")
.document("data")
.collection("wordBooks")
static func word(_ bookID: String) -> CollectionReference {
Firestore.firestore()
.collection("develop")
.document("data")
.collection("wordBooks")
.document(bookID)
.collection("words")
}
}
}
DI를 도입하기 이전에 코드를 보면 이렇게 각각의 Firebase reference를 Class (혹은 Enum)의 type property로 선언해서 간편하게 사용했는데요. 이 type property는 App이 실행되자마자 메모리에 로드되는 것으로 생각하기 쉽지만 사실이 변수가 메모리에 오르는 타이밍은 최초로 이 변수가 호출되는 순간입니다.
즉 이 변수가 호출되는 때는, 즉 최초로 메모리에 올라가는 순간은, 최초로 해당 reference를 활용해서 API를 호출했을 때입니다. 그 때는 이미 FirebaseApp.configure()가 실행되고도 남았을 타이밍이므로 위와 같은 에러가 발생하지 않았던 것이죠.
여기까지 왔다면 에러가 발생한 곳을 찾는 것은 어렵지 않습니다. 바로 DI를 위한 객체들이 메모리에 올라가는 과정에서 최초로 Firebase 관련 객체들을 init한 곳입니다. 그 곳은 바로 아래 두 곳이었습니다.
각각 Firebase를 한단계 감싸서 만든 객체였는데요. 이 객체들이 init이 될 때 각각 Firebase의 Firestore와 Storage를 init하도록 되어 있었습니다.
final class FirestoreDB: Database {
// Firestore singleton
let firestore = Firestore.firestore()
}
final class FirebaseIU: ImageUploader {
// Storage singleton
let store = Storage.storage()
}
그렇다면 이 버그를 해결하기 위해서는 App 객체가 init될 때 위 두 개의 Firebase와 관련된 객체가 init되어서 메모리에 올라가지 못하게 해야합니다.
제가 사용한 방법은 lazy var를 사용하는 방법입니다. lazy var는 struct에는 정의할 수 없고 class에만 정의할 수 있는 변수입니다. (struct는 self를 mutating 할 수 없기 때문에…)
final class FirestoreDB: Database {
private lazy var firestore: Firestore = {
Firestore.firestore()
}()
}
final class FirebaseIU: ImageUploader {
private lazy var store: Storage = {
Storage.storage()
}()
}
이 방법은 UIViewController를 사용할 때 self를 참조하기 위해서 많이 사용한 방법인데요.
lazy 키워드를 사용해서 선언하는 경우 Class가 init될 때 메모리에 올라오는 것이 아니고 해당 변수에 접근할 때 최초로 메모리에 올라오게 됩니다. 따라서 self는 이미 init이 끝난 상황에 메모리에 올라가는 것이기 때문에 정의하는 클로저 안에서 self를 참조할 수도 있는 것이지요.
참고로 computed property와 다른 점은 computed property의 경우 변수에 접근할 때마다 정의한 클로저가 실행이 되지만 lazy var의 경우 한번 실행한 결과가 메모리에 계속 저장이 되기 때문에 클로저 자체는 한번만 실행된다는 차이가 있습니다.
우리의 버그로 돌아와서 설명을 드리면 App 객체를 init할 때 그 안에 property로 정의된 FirestoreDB와 FirebaseIU 역시 init이 되는데 이 때 lazy로 선언되어 있기 때문에 각각의 클로저에 정의된 Firebase와 관련된 객체를 바로 메모리에 올리지 않습니다. 이전에 사용했던 static과 마찬가지로 해당 변수에 접근할 때, 즉 API를 사용할 때 비로소 메모리에 올라가므로 FirebaseApp.configure()가 선언된 이후에 메모리에 올라간다고 보장할 수 있게 되는 것이죠.