먹고 기도하고 코딩하라

[Swift] Realm (2) Configuration과 마이그레이션 본문

앱/Swift

[Swift] Realm (2) Configuration과 마이그레이션

사과먹는사람 2023. 4. 24. 22:20
728x90
728x90

 

저번에 여러 스레드에서 Realm을 써야 할 때, 어떻게 써야 앱을 살리면서 쓸 수 있는지 살펴봤다.

이번 포스팅에서는 Realm의 Configuration과 스키마 버전을 올릴 때의 마이그레이션 블록에 대해 살펴본다.

 

 

2. 마이그레이션

Realm의 스키마 버전(schemaVersion)을 올려야 할 때가 있다. 모델을 새로 생성하거나, 기존의 모델의 필드를 rename하거나 기타 수정을 가하든지 원래 있던 모델을 삭제한다든지 하는 경우이다.

column을 추가하거나 새로운 Object를 추가하는 등의 별도 수동 마이그레이션 작업이 필요없는 수정이 있는 경우에는 마이그레이션 블록을 비울 수 있고, 그렇지 않은 경우에는 마이그레이션 블록을 써야 한다.

이 과정에서 Configuration이 개입하는데, 말그대로 Realm 파일의 설정 파일격 되는 것이다. Configuration은 schemaVersion과 migrationBlock 프로퍼티를 갖고 있는데 다음과 같이 쓰면 된다.

let config = Realm.Configuration(
    schemaVersion: 2,
    migrationBlock: { migration, oldSchemaVersion in
        if oldSchemaVersion < 2 {
            // "age" 프로퍼티를 "yearsSinceBirth"로 이름 변경
            // 이름을 변경하는 작업은 enumerateObjects(ofType: _:) 바깥에서 호출해야 함
            migration.renameProperty(onType: Person.className(), from: "age", to: "yearsSinceBirth")
        }
    })

문서의 예제를 그대로 가져왔다.

현재 앱의 기존 realm 스키마 버전이 2 미만이라면, Person 클래스의 age 프로퍼티를 yearsSinceBirth로 rename하겠다는 의미를 담은 코드 블록이다.

 

이번에는 3 버전에서 Person의 firstName과 lastName을 합친 fullName 프로퍼티를 새로 만들어서 여기 값을 주입하고 싶다고 생각해보자.

그럴 때는 migrationBlock 내에서 enumerateObjects를 사용할 수 있다. enumerateObjects의 ofType 메소드에는 Realm Object 클래스의 className()을 넣어주고, 블록에는 oldObject로 이전 스키마의 해당 Object 클래스 객체, newObject로 새로운 스키마의 해당 Object 클래스 객체를 받는다. 

let config = Realm.Configuration(
    schemaVersion: 3, // 새로운 스키마 버전을 설정한다
    migrationBlock: { migration, oldSchemaVersion in
        if oldSchemaVersion < 3 {
            // enumerateObjects(ofType:_:)는 Realm 파일에 저장된 모든 Person 객체들을 순회하고 migration을 적용 
            migration.enumerateObjects(ofType: Person.className()) { oldObject, newObject in
                // firstName, lastName을 하나의 fullName으로 통합
                let firstName = oldObject!["firstName"] as? String
                let lastName = oldObject!["lastName"] as? String
                newObject!["fullName"] = "\(firstName!) \(lastName!)"
            }
        }
    }
)

이것이 migrationBlock의 일반적인 쓰임새이다.

 

 

이 글의 목적

이 글은 비일반적인... 그러니까 일반적인 상황이라면 이렇게 마개조할 정도로 모델을 잘못 설계해서는 안 됐는데, 어떻게든 마이그레이션은 해야했던 사례와 해결 방안에 대해서 소개한다.

Realm 문서를 살펴보면 알 수 있겠지만 예시에서는 Person 클래스 따로, Dog 클래스 따로 있고 둘은 별개의 객체인 그런 아름다운 형태이다. 쉽게 말해 마루강쥐처럼 개가 사람이 되는 식의 마이그레이션은 없다.

나 스키마 버전 올린다 짱이지... 이 마이그레이션 블록을 봐, 대박임

 

회사 코드의 경우, Realm 1 버전에서 설계해둔 모델을 2 버전에서 2개의 다른 모델로 분할해야 하는 상황이었다.

사실 나는 이미 앱의 스키마 버전이 2 버전일 때 입사해서 이 쪽 코드는 볼 일이 없을 줄 알았는데 웬걸... Realm의 마이그레이션에는 비밀 아닌 비밀이 숨어 있었다.

 

migration은 오직 구버전과 신버전만 있을 뿐, 중간은 존재하지 않는다

만약 내가 Realm 스키마 버전을 3으로 올리려고 한다. 그런데 사용자의 Realm 버전이 1이라면, 1 -> 2 -> 3 순으로 버전이 올라가는 게 아니라 1 -> 3으로 바로 올라가게 된다. 즉, migrationBlock에서는 구버전과 신버전의 객체만 있을 뿐 중간 버전이라는 건 존재하지 않는다. 안드로이드에서는 이 로직을 Room을 통해 구현하고 있다고 들었는데 Room은 루프를 돌면서 한 버전씩 올라가게 되어 있다더라. 부러웠다.

 

migrationBlock 내부에서는 Realm.objects가 아니라 enumerateObjects를 사용할 것

Realm.objects를 하면 안 되는 건 아니지만 migration 객체가 괜히 enumerateObject를 제공하는 게 아니다. enumerateObjects의 경우 구 버전의 스키마와 신 버전의 스키마를 전부 갖고 있기 때문에 Realm.select보다 활용성이 높으므로, enumerateObjects를 통해 순회를 수행하도록 한다. 

또 하나 장점은 enumerateObjects로 순회하는 동안에는 Realm.objects를 사용했을 때 접근하는 것과는 다른 로직이기 때문에 Realm이 스스로 오토 마이그레이션을 하지 않는 것이 보장된다는 것이다. 후술하겠지만 오토 마이그레이션이 이뤄지면 migration이 텅 비어서 oldValue와 newValue 자체가 없어진다. 

 

Realm.Configuration의 실행 시점은 앱에서 Realm을 처음으로 호출하게 되는 바로 그 때이다

보통 Realm의 configuration 객체를 정의하고, 그것을 Realm.defaultConfiguration에 넣게 되는 건 AppDelegate의 didFinishLaunchingWithOptions에서인데, Realm의 migration block은 그 순간 돌지 않는다. 마이그레이션 블록은 말 그대로 블록일 뿐이고, 실제로는 Realm이 앱 내에서 처음으로 접근되는 바로 그 순간 호출된다. 말인즉슨 Realm에 처음 접근하는 바로 그 순간, 메인 스레드가 아닌 다른 스레드에서 접근하고 있다면 configuration 객체 역시 메인 스레드에서 돌지 않을 수 있다는 것이다.

 

migrationBlock 내부에서 DispatchQueue.main.async는 가급적 쓰지 말 것

DispatchQueue.main.async를 쓰면 해당 블록으로 감싼 영역은 비동기 작업으로 들어가서 언제 수행이 완료될지 그 시점을 정확히 보장하기가 어렵다. 그래서 migrationBlock 자체가 나중에 실행된다고 착각할 수도 있다.

사실, 애초에 async 쓸 일을 안 만들면 된다. migrationBlock을 sync로 하면 최악의 상황으로는 죽거나, 혹은 UI 작업을 너무 오래 블락해서 UI 반응성이 현저하게 떨어지는 상황을 초래할 수 있다. 이 모든 것이 Realm 객체를 싱글턴으로 사용하려고 했기 때문에 벌어진 참사

당시에는 마이그레이션 과정에서 스레딩 문제로 죽는 건 확실하다는 분석을 하긴 했는데, 그게 마이그레이션 블록 자체가 다른 스레드에서 실행되는 건지 아니면 블록 내부에서 호출하는 함수가 다른 스레드에서 실행되는지 확실하지 않아서 DispatchQueue.main.async를 사용했다. 그런데 이렇게 하니까 먼저 수행되어야 하는 작업이 async 블록 안에 들어가 있어서 타이밍 이슈가 생기게 됐다. 이러나저러나 고통스럽기는 매한가지였다. -_-

 

migration 객체는 동적이다

이게 무슨 말인가 하면 Configuration 안의 migratrionBlock에서 쓰이는 migration 객체가 그 블록 안에서 값이 캡쳐되어 그대로 남아있는 게 아니라, 중간에 값이 바뀌기도 한다는 이야기다.

왜 이런 일이 발생할까? 내 경우에는 다음과 같은 작업을 하다가 해당 현상을 발견했다.

  1. Realm에 접근한다. Configuration의 migrationBlock이 트리거되면서 실행된다.
  2. 그런데 migrationBlock 내부에는 Realm.objects로 Realm에 한 번 싹 접근하는 동기 코드가 있었다.
  3. 그 코드로 Auto migration이 이뤄지면서, 스키마 버전이 순식간에 신버전으로 업데이트된다. (;;)
  4. 그래서 Configuration에서 수행하는 migrationBlock 내부의 migration 객체가 텅 비게 된다.

그야말로 기막히는 현상이다. 마개조가 없었다면 이렇게까지 깊게 알 일은 없었을 테다.

물론 정상적으로 쓰고 있다면 이런 일은... 발생하지 않는다... 

...

 

다음은 Realm의 best practices에 대해 알아보고, 어떻게 해야 Realm을 더 효율적으로 쓸 수 있는지 알아본다.

 

 

 

728x90
반응형
Comments