서버에서 받은 응답을 모바일 화면에서 계속 사용해야 하는 상황이라면 서버에 그 정보를 저장하고 요청해서 사용하는 게 일반적이다.
하지만 서버에 저장할 가치가 없을 경우 모바일에서 로컬에 저장하고 꺼내 사용하는 것이 더 적합하다고 생각해서 RoomDB를 사용해보고 간단한 정리를 해보았다.
사용자가 선택한 물품 정보를 수정/삭제하고 여러화면을 거쳐 최종 주문으로 연결지어야 하는 개발 요청사항이 있는 경우를 예시로 들어보았다.
SQLite를 기반으로 하며 ORM(Orbject-Relational Mapping)기능을 제공한다.
DAO(Data Access Object)와 함께 구조적이고 안전한 데이터 접근을 지원한다.(트랜잭션을 지원하여 데이터의 무결성을 보장)
RoomDB 외에 DatastorePreference가 있었지만 작지 않은 규모의 구조화된 데이터형식이 필요했기 때문에 패스했고,
SQLite도 있지만, 복잡한 쿼리가 필요없고 데이터 저장/수정 작업이 잦을 것으로 예상했기 때문에 ORM까지 있는 RoomDB를 선택했다.
Realm도 ORM이 있고 실시간 업데이트가 가능하다는 장점이 있었지만 RoomDB는 안드로이드 아키텍처 컴포넌트의 일부로, Google이 지원하는 공식 DB라이브러리 라는 점에서 선택하게 되었던 것 같다.
이와 같이 RoomDB는 객체 간 직접적인 참조를 금하고 있기 때문에 불가피하게 로컬 객체 관계가 복잡해야 한다면, RoomDB는 추천하지 않는다.
아래 내용은 안드로이드 Room 문서의 발췌내용인데, 한 마디로 직접참조를 하면 화면이 버벅거릴 수 있는 성능 문제를 야기하기 때문에 쓰지 말라는 얘기이다.
데이터베이스에서 각 객체 모델로 관계를 매핑하는 것은 일반적인 관행이며 이러한 매핑은 서버 측에서 매우 잘 작동합니다. 필드가 액세스될 때 프로그램이 필드를 로드하는 경우에도 서버는 여전히 잘 작동합니다.
클라이언트 측에서는 이 유형의 지연 로드가 일반적으로 UI 스레드에서 발생하기 때문에 실행 가능하지 않으며 UI 스레드에서 디스크에 관한 정보를 쿼리하면 상당한 성능 문제가 발생합니다. 일반적으로 UI 스레드는 활동의 업데이트된 레이아웃을 계산하고 그리는 데 약 16ms를 소요하므로 쿼리가 5ms밖에 걸리지 않은 경우에도 앱에서 프레임을 그리는 데 여전히 시간이 부족할 가능성이 크며 이에 따라 분명한 시각적 결함이 발생할 수 있습니다. 병렬로 실행 중인 별도의 트랜잭션이 있거나 기기가 다른 디스크 집약적인 작업을 실행 중이면 쿼리가 완료되는 데 훨씬 많은 시간이 걸릴 수 있습니다. 그러나 지연 로드를 사용하지 않으면 앱이 필요한 것보다 더 많은 데이터를 가져오며 이에 따라 메모리 소비 문제가 발생합니다.
그러니 상황에 맞게 선택을 잘하는 것이 중요하다고 생각한다.
그럼에도 불구하고 관계 매핑이 필요하다면 공식문서의 방식을 준수해야 한다.
Entity는 데이터베이스 테이블이다.
Room에서는 각 엔티티 클래스가 데이터베이스의 테이블과 매핑된다.
아래와 같이 @PrimarKey 어노테이션으로 id 필드를 기본 키로 지정할 수 있다.
@PrimaryKey(autoGenerate = true)를 하면 기본 키값이 자동으로 증가되기도 한다.
@Entity
data class RoomNote(
@PrimaryKey val id: String,
val content: String,
val noteName: String,
val notePhotoUrl: String
)
나의 경우 RoomNote의 종류는 9개로 고정되어 있기 때문에 자체 생성이 아니라 외부에서 id를 주입할 예정이다. 따라서 autoGenerate = true를 하지 않은 것이다. 이런 특별한 경우가 아니라면 값이 겹치지 않도록 자동생성하는 것이 좋다고 생각한다.
DAO는 데이터베이스 작업을 추상화하는 인터페이스이다.
각 메서드는 SQL 쿼리와 매핑된다.
아래와 같이 RoomNote라는 데이터에 대해서 CRUD 작업을 수행하는 메서드를 작성해보았다.
@Dao
interface NoteDao {
@Insert
suspend fun insert(note: RoomNote)
@Update
suspend fun update(note: RoomNote)
@Delete
suspend fun delete(note: RoomNote)
@Query("SELECT * FROM RoomNote")
fun getAllNotes(): List<RoomNote>
}
@Update 어노테이션을 사용하는 메서드는 엔티티를 받아들여서 기본키를 기준으로 해당 엔티티의 데이터를 업데이트한다.
@Entity
data class RoomNote(
@PrimaryKey val id: String,
val content: String,
val noteName: String,
val notePhotoUrl: String
)
이때 앞에서 이미 정의한 엔티티 데이터 클래스의 필드를 모두 불변 변수(val)로 정의했으니 업데이트가 안되는 거 아닌가 생각이 들 수도 있다.(내가 그랬다 근데 다 이유가 있었다)
Room은 내부적으로 Reflection을 사용하여 데이터를 설정하므로 'val' 필드를 수정할 수 있다. 다만 코드에서 객체를 수정할 수 없을 뿐이다. 그래서 엔티티 객체를 수정하려면 새로운 객체를 생성해서 업데이트해야 한다.(불변 객체의 특성을 유지하면서 데이터를 업데이트하는 일반적인 방법.)
Room 데이터베이스를 정의하는 추상 클래스이다.
RoomDatabase를 상속받아야 하며, 데이터베이스 인스턴스는 싱글톤으로 관리하는 것이 일반적이다.
아래와 같이 hilt와 함께 사용하면 데이터베이스와 DAO 객체의 생성을 더 간단하고 일관성있게 관리할 수 있다.
@Database(entities = [RoomNote::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
}
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext appContext: Context): AppDatabase {
return Room.databaseBuilder(
appContext, AppDatabase::class.java, "HmoaRoomDB"
).build()
}
@Provides
@Singleton
fun provideNoteDao(database: AppDatabase): NoteDao {
return database.noteDao()
}
}
위와 같이 Hilt모듈로 Room 데이터베이스와 DAO객체를 제공했다. 아래와 같이 @Inject 어노테이션을 이용해 생성자 주입이 가능한 형태가 된 것이다.
class SurveyLocalDataStoreImpl @Inject constructor(private val noteDao: NoteDao) : SurveyLocalDataStore {
...중략...
}
https://developer.android.com/training/data-storage/room?hl=ko
https://developer.android.com/training/data-storage/room/referencing-data?hl=ko#understand-no-object-references
https://velog.io/@renovatio_hyuns/RoomDB-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0