IOS

[IOS] Realm 사용해서 데이터 저장하기

출시 프로젝트 중 API 통신으로 받아온 데이터를 앱 내부에 저장해놓고 사용해야 했는데

 

Realm SDK를 사용해서 데이터 모델을 설계했던 경험을 정리한 글이다.


1. 클래스

Realm은 DB에 저장할 타입으로 클래스를 사용한다.

구조체를 사용하지 않는 가장 큰 이유는 "live"에 포커스를 맞춰서 그렇다고 한다. 

 

Realm DB에 저장하려는 클래스는 Object 클래스를 상속해야하고

내부 프로퍼티에 @Persisted 라는 property wrapper 속성을 사용한다.

class Place: Object {
    
    @Persisted(primaryKey: true) var contentId: Int
    @Persisted var image: String

}

위의 Place 클래스는 DB에서 하나의 테이블처럼 취급되고 자동으로 Realm Scheme에 등록된다.

이 클래스의 인스턴스는 하나의 레코드이며 내부 프로퍼티들은 테이블의 컬럼에 해당된다.

 

기본키로 등록된 컬럼(contentId)은 테이블 내에서 중복된 값을 가질 수 없기 때문에

해당 테이블의 레코드를 조회할 때 주로 사용한다.

 

2. EmbeddedObject

EmbeddedObject를 상속한 클래스는 별도의 테이블로 등록되지 않고

다른 Object를 상속한 클래스의 프로퍼티로서 사용된다.

 

따라서 EmbeddedObject를 상속한 클래스는 독립적으로 존재할 수 없고

자신을 프로퍼티로 갖는 다른 클래스와 같은 생명주기를 갖는다.

class Place: Object {
    
    @Persisted(primaryKey: true) var contentId: Int
    @Persisted var image: String
    @Persisted var intro: Intro?

}

class Intro: EmbeddedObject {
    
    @Persisted var zipcode: Int
    @Persisted var tel: String
 
}

Place 클래스가 Intro 클래스를 내부 프로퍼티로 갖고 있으므로 구조상 테이블이 중첩된 형태이지만

Place 객체가 사라질 때 Intro 객체도 자동으로 함께 삭제된다.

 

그리고 EmbeddedObject는 반드시 테이블 내에서 Optional 타입으로 선언되어야 한다.

그렇지 않으면 오류가 발생한다.

 

3. Codable

위에서 살펴본 Realm Object 객체를 생성할 때 편의 초기자를 사용해서 원하는 값으로 커스텀할 수 있다.

class Place: Object {
    
    @Persisted(primaryKey: true) var contentId: Int
    @Persisted var image: String
    
    convenience init(id: Int, url: String){
    	self.init()
    	contentId = id
        image = url
    }
	
}

만약 네트워크 통신으로 받아온 데이터(ex. json)를 Codable을 사용해서 바로 Realm Object로

Decoding하고 싶다면 아래처럼 init(from decoder:) 초기자를 구현해주면 된다.

class Place: Object, Codable {
    
    @Persisted(primaryKey: true) var contentId: Int
    @Persisted var image: String
    
    enum CodingKeys: String, CodingKey {
    
        case contentId = "contentid"
        case image = "firstimage"
        
    }
    
    required convenience init(from decoder: Decoder) throws {
        self.init()
        let container = try decoder.container(keyedBy: CodingKeys.self)
      
        contentId = Int(try container.decode(String.self, forKey: .contentId)) ?? 0
        image = try container.decode(String.self, forKey: .image)
        
    }
    
}

Place 타입으로 Decoding 할 때 init(from decoder:) 초기자가 호출되고 내부에서 1단계 초기화가 끝나면

CodingKeys에 설정된 키를 사용해 Decoding이 과정이 이뤄지면서 각 프로퍼티에 값이 저장된다. 

 

이 초기자에서 응답으로 받아온 데이터와 다른 타입으로 변환할 수도 있다.

위 예시에서 "contentid" 키에 해당하는 값이 String 타입이지만 변환되는 클래스에는 Int 타입으로 저장했다.

 

4. 배열

테이블에 배열을 저장하려면 List라는 Realm에서 정의된 타입을 사용해야 한다.

 

List는 Swift의 Array처럼 제네릭 타입으로 Int, Bool, String 등 여러 기본 타입을 원소로 가질 수 있고

다른 Realm Object를 담는 것도 가능하지만 Swift와 다르게 참조 타입이다.

 

편의를 위해 클래스에 연산 프로퍼티를 정의하여 Swift의 Array처럼 사용할 수도 있다.

class Place: Object {
    
    @Persisted(primaryKey: true) var contentId: Int
    @Persisted var imageList: List<String>
    
    var images: [String] {
        get { imageList.map { $0 } }
        set {
            imageList.removeAll()
            imageList.append(objectsIn: newValue)
        }
    }
}

5. Realm 파일

앱에서 Realm을 사용하려면 먼저 Realm 파일을 열어줘야 한다.

이 때 Configuration 값을 변경해서 기본 경로가 아닌 특정 경로에 있는 Realm 파일을 열거나
Realm Scheme에 직접 클래스를 추가할 수도 있다.

private let localRealm: Realm = {
        var config = Realm.Configuration.defaultConfiguration
        config.deleteRealmIfMigrationNeeded = true
        return try! Realm(configuration: config)
    }()

deleteRealmIfMigrationNeededtrue일 경우 Realm 파일에 저장된 테이블과 현재 테이블이 다를 때

즉, 스키마가 다를 때 기존의 Realm 파일을 삭제하고 다시 만들어준다.

 

개발 중에 스키마가 달라질 때 마다 앱을 삭제하고 다시 설치하거나 마이그레이션할 필요가 없어서 편하지만

반드시 디버깅 모드에서만 활성화 하는 것을 권장하고 있다.

NOTE
이 설정을 활성화한 상태이고 Realm Studio가 열려 있다면 Realm 파일이 삭제되는 과정에서 오류가 발생한다.

6. 이슈

1) 제네릭 Object 클래스

테이블을 만들다 보니 하나의 클래스 구조를 여러 Object 클래스에 사용할 수 있을 것 같아서

제네릭 타입의 Object 클래스를 선언하고 여러 타입의 테이블을 만들었다.

class SomeClass<T: EmbeddedObject>: Object {
    @Persisted var key: String?
    @Persisted var type: T?
}

그런데 Realm에서 테이블을 인식하지 못하는 문제가 발생했다.

찾아본 글에 따르면 Realm은 Objective-C와 호환 가능한 멤버만 노출시키는데

제네릭은 호환되지 않아서 그렇다고 한다.

2) 백그라운드 스레드 트랜잭션

네트워크 응답을 백그라운드 스레드에서 처리할 때 동시에 Realm 트랜잭션을 수행했는데

아래와 같은 런타임 오류 메시지가 출력되며 앱이 강제 종료됐다.

cannot schedule async transaction. make sure you are running from inside a run loop

Realm 문서에나와있는 writeAsync 메서드를 사용해봤지만 효과가 없었다.

공식 문서에는 백그라운드 스레드에서 쓰기가 가능하다고 돼있는데 구글에 검색해보니

나와 같은 문제를 겪고 있는 사람들이 꽤 있었다.

 

원인을 찾지 못했지만 최근에 추가된 기능이라서 오류가 있을 수 있다고 생각했다.

결국 모든 트랜잭션을 메인 스레드에서 수행했고 이후 오류는 발생하지 않았다.


참고 문서 

https://github.com/realm/realm-swift/issues/7812

 

Exception with error: "Cannot schedule async transaction. Make sure you are running from inside a run loop." · Issue #7812 · r

Problem When trying to perform an async write from within a thread without a run-loop, which is a common situation when performing an async write in a Task, an exception happens with the following ...

github.com

https://stackoverflow.com/questions/42099847/crash-when-using-deleterealmifmigrationneeded-with-realm-in-swift

 

Crash when using deleteRealmIfMigrationNeeded with Realm in Swift

I'm doing the following inside of didFinishLaunchingWithOptions: let config = Realm.Configuration( schemaVersion: 0, deleteRealmIfMigrationNeeded: true ) Realm.Configuration.defaultConfigurati...

stackoverflow.com

https://stackoverflow.com/questions/54892943/can-not-add-class-with-generic-type-to-swift-realm

 

Can not add class with generic type to swift realm

I need to save a class in realm, this class contains a generic type as the following:- @objcMembers class ClassA<T: Object & Codable>: Object, Codable { dynamic var key: String?

stackoverflow.com