먹고 기도하고 코딩하라

[Swift] Realm (3) Realm의 내부 구조 본문

앱/Swift

[Swift] Realm (3) Realm의 내부 구조

사과먹는사람 2023. 4. 29. 12:27
728x90
728x90

재귀소환 및 Meeseeks() 당한 미식스들. try! Realm() 해도 이렇게 될까?

 

저번에 Realm의 마이그레이션에 대해 살펴봤다. 

Realm 파일을 열려면 다음과 같이 써주면 된다.

let realm = try! Realm()

그런데 문득 무지성으로 이렇게 많이 쓴 Realm()을 보니 이 수많은 Realm 객체들이 메모리를 심각하게 많이 차지하고, 심지어 제때 메모리에서 해제되지 않아 memory leak이 나면 어쩌지, 걱정되는 날이 올 수도 있다.

결론부터 말하자면 Realm 객체가 한 스레드에 여러 개 생기는 일은 없다. 그러므로 걱정하지 말고 Realm 접근이 필요할 때마다 Realm()으로 Realm 객체를 가져오면 된다.

그런데 어떻게 그게 가능할까? try! Realm() 을 했을 때 Realm은 과연 어떤 작업을 하게 될까?

자칫 원론적인 이야기가 될 수 있기 때문에 자제해서 핵심만 적어보겠다.

try! Realm()에서 Realm에 Jump to Definition을 해보자.

@frozen public struct Realm {
		// MARK: Internal

    internal var rlmRealm: RLMRealm

    internal init(_ rlmRealm: RLMRealm) {
        self.rlmRealm = rlmRealm
    }
}

Realm은 구조체이고, 내부에 RLMRealm을 갖고 있다.

그 위의 긴 주석도 한 번 살펴보자.

A Realm instance (also referred to as "a Realm") represents a Realm database.
Realms can either be stored on disk (see init(path:)) or in memory (see Configuration).
Realm instances are cached internally, and constructing equivalent Realm objects (for example, by using the same path or identifier) produces limited overhead.
If you specifically want to ensure a Realm instance is destroyed (for example, if you wish to open a Realm, check some property, and then possibly delete the Realm file and re-open it), place the code which uses the Realm within an autoreleasepool {} and ensure you have no other strong references to it.

  • warning Non-frozen RLMRealm instances are thread-confined and cannot be shared across threads or dispatch queues. Trying to do so will cause an exception to be thrown. You must obtain an instance of RLMRealm on each thread or queue you want to interact with the Realm on. Realms can be confined to a dispatch queue rather than the thread they are opened on by explicitly passing in the queue when obtaining the RLMRealm instance. If this is not done, trying to use the same instance in multiple blocks dispatch to the same queue may fail as queues are not always run on the same thread.

Realm 객체는 Realm 데이터베이스를 나타냅니다.
Realm은 디스크나 메모리에 저장될 수 있습니다.
Realm 객체는 내부적으로 캐시되고, 같은 경로나 identifier를 사용해서 여러 번 구성할 때의 오버헤드는 한정되어 있습니다.
만약 Realm 객체를 메모리에서 해제하길 원한다면(예를 들어 Realm을 열고 어떤 프로퍼티를 확인한 뒤 Realm 파일을 지우고 다시 열고 싶을 때), @autoreleasepool 블록 안에 Realm을 사용하는 코드를 위치시키고 강참조를 갖지 않도록 합니다.
Non-frozen RLMRealm 객체는 스레드에 묶여 있고, 다른 스레드나 디스패치 큐 간 공유가 불가능합니다. 그러려고 하면 예외가 발생될 것입니다. Realm과 상호작용하길 바라는 각 스레드에서 메소드를 호출해야 합니다.

 

 

Realm init은 3가지가 있다.

(1) 큐 등록

public init(queue: DispatchQueue? = nil) throws {
        let rlmRealm = try RLMRealm(configuration: RLMRealmConfiguration.rawDefault(), queue: queue)
        self.init(rlmRealm)
    }

Realm이 어느 디스패치 큐에 묶일지 지정하는데, 큐를 지정하지 않아도 된다.

만약 큐가 주어진다면 Realm 객체는 현재 스레드뿐 아니라 주어진 디스패치 큐에 할당된 블럭에서 사용 가능하다.

 

(2) configuration, 큐 등록

public init(configuration: Configuration, queue: DispatchQueue? = nil) throws {
        let rlmRealm = try RLMRealm(configuration: configuration.rlmConfiguration, queue: queue)
        self.init(rlmRealm)
    }

Realm 생성에 쓰일 configuration을 전달한다. 큐의 용도는 앞서 설명한 것과 동일하다.

 

(3) fileURL 등록

public init(fileURL: URL) throws {
        var configuration = Configuration.defaultConfiguration
        configuration.fileURL = fileURL
        try self.init(configuration: configuration)
    }

// 위에서 부르는 self.init이 바로 이거
public init(configuration: Configuration, queue: DispatchQueue? = nil) throws {
        let rlmRealm = try RLMRealm(configuration: configuration.rlmConfiguration, queue: queue)
        self.init(rlmRealm)
    }

특정 파일 URL로 저장된 Realm을  가져온다. Realm이 저장되어 있을 로컬 URL을 지정해서 가져온다.

 

(1), (2)번 방식은 메모리에 Realm 객체를 저장하는 것이고 (3)번 방식은 디스크에 저장하는 방식이다.

이걸 사용하면 Ouf of memory 이슈를 해결할 수 있지 않을까 하는 그런 생각이 든다. 이미 너무 멀리 왔지만

(1), (2)번 방식의 공통점은 try RLMRealm을 가져와서 그걸로 init을 한다는 점이다. 그러니까, Realm 구조체는 내부적으로 RLMRealm 타입을 변수로 갖고 있다. (3) 방식조차도 RLMRealm을 가져오는 방식이다.

 

RLMRealm은 뭘까? 이번에는 RLMRealm.h 파일로 가본다.

@interface RLMRealm : NSObject

RLMRealm은 NSObjet 타입이다. 참고로 NSObject는 구조체가 아니라 추상 클래스다.

그 위에 달린 주석을 잠깐 살펴보면…

An RLMRealm instance (also referred to as "a Realm") represents a Realm database.
Realms can either be stored on disk (see +[RLMRealm realmWithURL:]) or in memory (see RLMRealmConfiguration).
RLMRealm instances are cached internally, and constructing equivalent RLMRealm objects (for example, by using the same path or identifier) multiple times on a single thread within a single iteration of the run loop will normally return the same RLMRealm object.
If you specifically want to ensure an RLMRealm instance is destroyed (for example, if you wish to open a Realm, check some property, and then possibly delete the Realm file and re-open it), place the code which uses the Realm within an @autoreleasepool {} and ensure you have no other strong references to it.
@warning Non-frozen RLMRealm instances are thread-confined and cannot be shared across threads or dispatch queues. Trying to do so will cause an exception to be thrown. You must call this method on each thread you want to interact with the Realm on. For dispatch queues, this means that you must call it in each block which is dispatched, as a queue is not guaranteed to run all of its blocks on the same thread.

RLMRealm은 Realm 데이터베이스를 나타냅니다.

Realm은 디스크나 메모리에 저장될 수 있습니다.

RLMRealm 객체는 내부적으로 캐시되고, 런루프의 반복 중 단일 스레드에서 동일한 RLMRealm 개체를 동일 경로, identifier를 사용해서 여러 번 구성하면 일반적으로 동일한 RLMRealm 객체가 반환됩니다.

RLMRealm 객체는 내부적으로 캐시되고, 한 스레드에서는 일반적으로 동일한 RLMRealm 객체를 반환합니다.

만약 RLMRealm 객체를 메모리에서 해제하길 원한다면(예를 들어 Realm을 열고 어떤 프로퍼티를 확인한 뒤 Realm 파일을 지우고 다시 열고 싶을 때), @autoreleasepool 블록 안에 Realm을 사용하는 코드를 위치시키고 강참조를 갖지 않도록 합니다.

Non-frozen RLMRealm 객체는 스레드에 묶여 있고, 다른 스레드나 디스패치 큐 간 공유가 불가능합니다. 그러려고 하면 예외가 발생될 것입니다. Realm과 상호작용하길 바라는 각 스레드에서 메소드를 호출해야 합니다. 디스패치 큐의 경우에는 큐가 항상 동일한 스레드에서 모든 블럭을 실행할 수 있는 게 아니기 때문에 각 블럭에서 호출해야 합니다.

그러니까, try! Realm()을 할 때마다 Realm 구조체가 새로 생성되기는 한다.

그런데 Realm은 내부적으로 RLMRealm 인스턴스를 갖고 있다.

이 때 궁금한 점이 Realm 객체가 내부적으로 캐싱된다는 건 어떻게 받아들여야 할까 하는 것이었다.

구조체는 ARC 방식으로 관리될 필요가 없고, 스코프를 벗어나면 메모리에서 해제되는 방식이다. 그런데 내부적으로 캐싱이 된다는 게 무슨 말일까? 궁금해서 더 찾아봤다.

 

Realm에는 iOS 13.0 이상부터 가능한 extension이 붙어 있는데, 이 extension 안에 Realm의 생성자가 또 있다. 여기서 RLMGetCachedRealm이라는 걸로 캐시된 Realm이 있는지 확인해서 가져오는 역할을 한다.

@MainActor
    public init(configuration: Realm.Configuration = .defaultConfiguration,
                downloadBeforeOpen: OpenBehavior = .never) async throws {
        let scheduler = RLMScheduler.dispatchQueue(.main)
        let rlmConfiguration = configuration.rlmConfiguration

        // 만약 이 actor를 위해 캐시된 Realm이 있다면, 그냥 갖다쓴다
        // Realm이 열린 상태지만 다른 스케줄러에 있다면, 동기적으로 연다
        // An async open would just dispatch to the background and then back to
        // perform the final synchronous open.
        var realm = RLMGetCachedRealm(rlmConfiguration, scheduler)
        if realm == nil, let cachedRealm = RLMGetAnyCachedRealm(rlmConfiguration) {
            realm = try withExtendedLifetime(cachedRealm) {
                try RLMRealm(configuration: rlmConfiguration, confinedTo: scheduler)
            }
        }
        if let realm = realm {
            // This can't be hit on the first open so .once == .never
            if downloadBeforeOpen == .always {
                try await realm.waitForDownloadCompletion()
            }
            self = Realm(realm)
            return
        }

        // We're doing the first open and hitting the expensive path, so do an async
        // open on a background thread
        let task = RLMAsyncOpenTask(configuration: rlmConfiguration, confinedTo: scheduler,
                                    download: shouldAsyncOpen(configuration, downloadBeforeOpen))
        do {
            try await withTaskCancellationHandler {
                // Work around <https://github.com/apple/swift/issues/61119> by smuggling
                // the Realm out via a property on task rather than returning it
                task.localRealm = try await task.waitForOpen()
            } onCancel: {
                task.cancel()
            }
            self = Realm(task.localRealm!)
            task.localRealm = nil
        } catch {
            // Check if the task was cancelled and if so replace the error
            // with reporting cancellation
            try Task.checkCancellation()
            throw error
        }
    }

RLMGetCachedRealm은 내부적으로 캐시된 RLMCacheRealm을 반환하게 된다.

 

만약, 다음의 두 가지 경우라면 에러가 나타날 수는 있다.

(1) 사용자가 특정 큐에서 다른 큐에 묶인 Realm을 열려고 시도

(2) 더이상 존재하지 않는 스레드에 묶인 무효한 캐시 Realm에 접근

첫 번째 경우라면 에러를 throw하게 되고, 두 번째 경우라면 새로운 RLMRealm을 생성하고 캐시 엔트리를 현재 존재하는 스레드로 변경한다.

 

Realm은 이렇게 내부적으로 캐시를 유지하고 있다. 그러므로 try! Realm() 할 때마다 너무 많은 메모리를 소비하게 되는 건 아닌지 걱정할 것 없다. 

다음은 realm의 best practices에 대해 적어보고자 한다. 거기까지만 쓰고 이제 Realm 글은 그만 쓰려 한다. 

728x90
반응형
Comments