개발/안드로이드

Room을 사용한 데이터 유지

스몰스테핑 2024. 5. 27. 18:05

Android Jetpack 'Compose 사용 시 알아야 하는 Android 기본 사항' 강의

'단원 6: 데이터 지속성을 위해 Room 사용'의 4번 챕터 내용을 기반으로 학습 후 작성하는 글입니다.

자세한 내용은 하단 링크를 참고하세요.

 

https://developer.android.com/courses/pathways/android-basics-compose-unit-6-pathway-2?hl=ko

 

데이터 지속성을 위해 Room 사용  |  Android Basics Compose - Use Room for data persistence  |  Android Developers

Room 라이브러리를 사용하여 Android 앱에서 관계형 데이터베이스를 쉽게 만들고 사용할 수 있습니다.

developer.android.com

 


 

데이터 유지

대부분의 프로덕션 품질 앱에는 앱에서 유지해야 하는 데이터가 있다. 예를 들어 앱은 노래 재생목록, 할 일 목록의 항목, 수입 및 지출 기록, 별자리 카탈로그, 개인 정보 기록 등을 저장할 수 있다. 이러한 사용 사례에서는 이 영구 데이터를 저장하는 데 데이터베이스를 사용한다.

 

RoomAndroid Jetpack의 일부인 지속성 라이브러리이다. Room은 SQLite 데이터베이스 위에 있는 추상화 레이어이다. SQLite는 특수 언어 SQL을 사용하여 데이터베이스 작업을 실행한다. SQLite를 직접 사용하는 대신 Room은 데이터베이스 설정, 구성, 앱과의 상호작용과 같은 작업을 간소화한다. Room은 SQLite문의 컴파일 시간 확인도 제공한다.

 

추상화 레이어는 기본 구현 / 복잡성을 숨기는 함수 집합이다. 기존 기능 세트에 인터페이스를 제공한다.

 

아래 이미지는 데이터 소스로서 Room이 이 과정에서 권장하는 전체 아키텍처에 얼마나 적합한지 보여준다. Room은 데이터 소스이다.

 

UI 레이어, 도메인 레이어, 데이터 레이어가 있는 아키텍처 다이어그램

 

 

Room의 기본 구성요소

Kotlin은 데이터 클래스를 통해 데이터 작업을 쉽게 할 수 있는 방법을 제공한다. 데이터 클래스를 사용해 메모리 내 데이터로 쉽게 작업할 수 있지만 데이터 유지와 관련해서는 이 데이터를 데이터베이스 저장소와 호환되는 형식으로 변환해야 한다. 이렇게 하려면 데이터를 저장할 테이블과 데이터에 엑세스하고 데이터를 수정할 쿼리가 있어야 한다.

 

다음과 같은 세 가지 Room 구성요소를 통해 이러한 워크플로가 원활해진다.

  • Room 항목은 앱 데이터베이스의 테이블을 나타낸다. 이를 사용해 테이블의 행에 저장된 데이터를 업데이트하고 삽입할 새 행을 만든다.
  • Room DAO는 앱이 데이터베이스에서 데이터를 검색, 업데이트, 삽입, 삭제하는데 사용하는 메서드를 제공한다.
  • Room Database 클래스는 앱에 해당 데이터베이스와 연결된 DAO 인스턴스를 제공하는 데이터베이스 클래스이다.

 

Room 데이터 액세스 객체 및 항목이 앱의 나머지 부분과 상호작용하는 방식을 보여주는 다이어그램

 

 

Room 종속 항목 추가

//Room
implementation("androidx.room:room-runtime:${rootProject.extra["room_version"]}")
ksp("androidx.room:room-compiler:${rootProject.extra["room_version"]}")
implementation("androidx.room:room-ktx:${rootProject.extra["room_version"]}")

 

참고: Gradle 파일에 포함된 라이브러리 종속 항목의 경우 항상 AndroidX 출시 페이지에 표시된 최신 안정화 출시 버전 번호를 사용하는게 좋습니다.

 

 

항목 Entity 만들기

Entity 클래스는 테이블을 정의하고 이 클래스의 각 인스턴스는 데이터베이스 테이블의 행을 나타낸다. 항목 클래스에는 데이터베이스의 정보를 표시하고 상호작용하는 방법을 Room에 알려주는 매핑이 있다. 강의에서 기준으로 설명하는 인벤토리 앱에서 항목에는 항목 이름, 항목 가격, 사용 가능한 항목 수량 등 인벤토리 항목에 관한 정보가 포함된다.

 

항목 필드와 항목 인스턴스를 보여주는 테이블

 

@Entity 주석은 클래스를 데이터베이스 Entity 클래스로 표시한다. 각 Entity 클래스에서 앱은 항목을 보관할 데이터베이스 테이블을 만든다. Entity의 각 필드는 달리 표시되지 않는 한 데이터베이스에서 열로 표시된다. (자세한 내용은 Entity 문서 참고). 데이터베이스에 저장된 모든 항목 인스턴스에는 기본 키가 있어야한다. 기본 키는 데이터베이스 테이블의 모든 레코드/항목을 고유하게 식별하는데 사용된다. 앱이 기본 키를 할당한 후에는 수정할 수 없다. 기본 키는 데이터베이스에 존재하는 한 항목 객체를 나타낸다.

class Item(
    val id: Int = 0,
    val name: String,
    val price: Double,
    val quantity: Int
)

 

 

Data 클래스

Data 클래스는 주로 Kotlin에서 데이터를 보유하는데 사용된다. 키워드 data로 정의된다. Kotlin 데이터 클래스 객체에는 추가 이점이 있다. 예를 들어 컴파일러는 toString(), copy(), equals()와 같은 비교, 출력, 복사를 위한 유틸리티를 자동으로 생성한다.

 

다음 코드는 2개의 프로퍼티를 가진 예시로 만들어진 Data 클래스이다.

data class User(val firstName: String, val lastName: String){
	// Something here...
}

 

생성된 코드의 일관성과 의미 있는 동작을 보장하기 위해 데이터 클래스는 다음 요구사항을 충족해야 한다.

  • 기본 생성자에는 매개변수가 하나 이상 있어야 한다.
  • 모든 기본 생성자 매개변수는 val 또는 var이어야 한다.
  • 데이터 클래스는 abstract나 open, sealed일 수 없다.
경고: 모든 컴파일러는 자동으로 생성된 함수에 기본 생성자 내에서 정의된 속성만 사용한다. 컴파일러는 생성된 구현에서 클래스 본문 내에 선언된 속성을 제외한다.

 

Data 클래스에 관한 자세한 내용은 Data 클래스 문서를 참고

 

 

import androidx.room.Entity

@Entity(tableName = "items")
data class Item(
    val id: Int = 0,
    val name: String,
    val price: Double,
    val quantity: Int
)

 

기존 앱에 선언된 Item 클래스를 data 클래스로 바꾸고 @Entity 주석을 추가한다. tableName 인수를 사용해 items를 SQLite 테이블 이름으로 설정한다. table은 여러개를 만들 수 있으며, tableName은 자신의 테이블 용도에 맞게 바꾸면 된다.

 

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "items")
data class Item(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val name: String,
    val price: Double,
    val quantity: Int
)

 

id 속성을 @PrimaryKey로 주석 처리하여 id를 기본 키로 설정한다. 기본 키는 Item 테이블의 모든 레코드/항목을 고유하게 식별하는 ID이다.

 

id에 기본값 0을 할당하고, autoGenerate 매개변수를 추가하여 기본 키 열을 자동 생성시킨다.

autoGenerate가 true로 설정되면 새 항목 인스턴스가 데이터베이스에 삽입될 때 Room에서 자동으로 기본 키 열의 고유한 값을 생성한다. 이렇게 하면 기본 키 열에 값을 수동으로 할당할 필요 없이 각 항목 인스턴스가 고유 식별자를 갖게 된다.

 

 

항목 DAO 만들기

데이터 액세스 객체(DAO)는 추상 인터페이스를 제공하여 지속성 레이어를 애플리케이션의 나머지 부분과 분리하는데 사용할 수 있는 패턴이다. 이러한 분리는 단일 책임 원칙을 따른다.

 

단일 책임 원칙(SRP - Single Responsibility Principle)
클래스는 단 하나의 책임을 가지고 그에 대한 책임을 져야 한다.

 

DAO의 기능은 애플리케이션의 나머지 부분과 별도로 기본 지속성 레이어에서 데이터베이스 작업 실해오가 관련된 모든 복잡성을 숨기는 것이다. 이를 통해 데이터를 사용하는 코드와 관계없이 데이터 레이어를 변경할 수 있다.

 

 

DAO는 데이터베이스에 액세스하는 인터페이스를 정의하는 Room의 기본 구성요소입니다.

생성한 DAO는 데이터베이스 쿼리/검색, 삽입, 삭제, 업데이트를 위한 편의 메서드를 제공하는 맞춤 인터페이스다. Room은 컴파일 시간에 이 클래스의 구현을 생성한다.

 

Room 라이브러리는 SQL 문을 작성하지 않고도 간단한 삽입, 삭제, 업데이트를 실행하는 메서드를 정의할 수 있도록 @Insert, @Delete, @Update와 같은 편의성 주석을 제공한다.

 

좀 더 복잡한 삽입, 삭제, 업데이트 작업을 정의해야 하거나 데이터베이스의 데이터를 쿼리해야 하는 경우에는 @Query 주석을 사용해야한다.

 

또 다른 이점으론 Android 스튜디오에서 쿼리를 작성할 때 컴파일러가 SQL 쿼리에 문법 오류가 있는지 체크해준다.

 

 

강의에서 제공해준 Inventory 앱에서 달성해야할 목표는 다음과 같다.

  • 새 항목을 삽입하거나 추가한다.
  • 기존 항목을 업데이트하여 이름과 가격, 수량을 업데이트한다.
  • 기본 키인 id에 기반하여 특정 항목을 가져온다.
  • 모든 항목을 가져와 표시한다.
  • 데이터베이스의 항목을 삭제한다.

 

항목 DAO는 SQLite 데이터베이스와 나머지 Inventory 앱 사이의 인터페이스이다.

 

 

앱에서 항목 DAO를 구현하기 위해 다음과 같이 작성하여야 한다.

 

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow

@Dao
interface ItemDao {
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(item: Item)
    
    @Update
    suspend fun update(item: Item)
    
    @Delete
    suspend fun delete(item: Item)
    
    @Query("SELECT * from items WHERE id = :id")
    fun getItem(id: Int): Flow<Item>
    
    @Query("SELECT * from items ORDER BY name ASC")
    fun getAllItems(): Flow<List<Item>>
}

 

테이블이름 + Dao로 된 인터페이스를 하나 작성하고 @Dao 주석을 붙인다.

필요한 메서드를 순차적으로 작성한다. (삽입, 업데이트, 삭제, 항목 불러오기, 항목 전체 불러오기)

 

모든 메서드에는 각각에 맞는 Room의 주석이 붙는다.

Insert 주석의 onConflictStrategy.IGNORE는 충돌이 발생할 경우 새 항목을 무시한다는 것으로 다음과 같은 항목이 존재한다.

 

  1. OnConflictStrategy.ABORT - 충돌이 발생할 경우 처리 중단
  2. OnConflictStrategy.FAIL - 충돌이 발생할 경우 실패처리
    1. FAIL은 Room 2.5.0 버전에서 Deprecated 되어 ABORT를 사용하여야 한다.
  3. OnConflictStrategy.IGNORE - 충돌이 발생할 경우 무시
  4. OnConflictStrategy.REPLACE - 충돌이 발생할 경우 덮어쓰기
  5. OnConflictStrategy.ROLLBACK - 충돌이 발생할 경우 이전으로 되돌리기

자세한 것은 공식 문서를 참고하자.

 

 

Query문 설명은 가볍게 만든 다음 사진으로 적당히 집고 넘어가자.

더 자세하고 많은 조건, 연산자 등의 설명은 검색을 통해 찾아보자.

 

 

그리고 데이터를 가져올 때는 지속성 레이어에서 Flow를 사용하는 것이 좋다. Flow를 반환 유형으로 사용하면 데이터베이스의 데이터가 변경될 때마다 알림을 받게 되고, Room은 이 Flow를 자동으로 업데이트하므로 명시적으로 한 번만 데이터를 가져오면 된다. 이 설정은 이후 작업할 목록 업데이트에 매우 유용하다. Flow 반환 유형으로 인해 Room은 백그라운드 스레드에서도 쿼리를 실행한다. 이를 명시적으로 suspend 함수로 만들고 코루틴 범위 내에서 호출할 필요는 없다.

 

 

데이터베이스 인스턴스 만들기

Entity 및 DAO를 사용하는 RoomDatabase를 만들고, 결과적으로 앱이 DAO를 사용해 데이터베이스의 데이터를 연결된 데이터 항목 객체의 인스턴스로 검색하고, 상응하는 테이블의 행을 업데이트하거나 삽입할 새 행을 만들고, 삭제까지 이르게 할 수 있다.

 

추상 RoomDatabase 클래스를 만들고 @Database 주석을 단다. 이 클래스에는 데이터베이스가 없으면 RoomDatabase의 기존 인스턴스를 반환하는 메서드가 하나 존재한다.

 

RoomDatabase 인스턴스를 가져오는 일반적인 프로세스는 다음과 같다.

  • RoomDatabase를 확장하는 public abstract 클래스를 만든다. 정의한 새 추상 클래스는 데이터베이스 홀더 역할을 한다. 정의한 클래스는 추상 클래스이다. Room이 구현을 만들기 때문.
  • 클래스에 @Database 주석을 단다. 인수에서 데이터베이스의 항목을 나열하고 버전 번호를 설정한다.
  • ItemDao 인스턴스를 반환하는 추상 메서드나 속성을 정의하면 Room에서 구현을 생성한다.
  • 전체 앱에 RoomDatabase 인스턴스 하나만 있으면 되므로 RoomDatabase를 싱글톤으로 작성한다.
  • Room의 Room.databaseBuilder를 사용하여 item_database를 만든다.(없을 때만). 있다면 기존 데이터베이스를 반환한다.

 

 

데이터베이스 만들기

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(entities = [Item::class], version = 1, exportSchema = false)
abstract class InventoryDatabase: RoomDatabase() {
    abstract fun itemDao(): ItemDao

    companion object {
        @Volatile
        private var Instance: InventoryDatabase? = null

        fun getDatabase(context: Context): InventoryDatabase {
            return Instance ?: synchronized(this) {
                Room.databaseBuilder(context, InventoryDatabase::class.java, "item_database")
                    .fallbackToDestructiveMigration()
                    .build()
                    .also { Instance = it }
            }
        }
    }
}

 

abstract class로 InventoryDatabase를 만든 후, RoomDatabase()를 상속받게 만든다.

그리고 @Database 주석을 달며 필요한 인수를 추가한다.

 

public constructor Database(
    val entities: Array<KClass<*>> = [],
    val views: Array<KClass<*>> = [],
    val version: Int,
    val exportSchema: Boolean = true,
    val autoMigrations: Array<AutoMigration> = []
)

 

  • entities - 데이터베이스의 테이블을 제한한다, Entity와 Dao 클래스의 수에는 제한이 없지만 데이터베이스 내에서는 고유해야하기 때문.
  • version - 데이터베이스의 버전. 초기는 1단계이나, 데이터베이스 테이블의 스키마를 변경할 때마다 버전 번호를 높여야한다.
  • exportSchema - 스키마 버전 기록 백업을 유지할지 말지 선택 가능하다, 기본적으론 true이며 데이터베이스의 두 버전 간에 자동으로 마이그레이션을 생성하려면 관련 스키마 파일이 있다고 가정할 때 자동 마이그레이션 주석을 사용하는게 좋다. 데이터베이스에 자동 마이그레이션이 정의된 경우 스키마 내보내기는 true여야 한다.

 

데이터베이스가 DAO를 알 수 있도록 ItemDao를 반환하는 추상함수를 선언한다.

companion object를 정의하고 객체 내에서 데이터베이스에 관한 null을 허용하는 비공개 변수 인스턴스를 선언하고 null로 초기화 한다.

 

@Volatile
private var Instance: InventoryDatabase? = null

 

Instance 변수는 데이터베이스가 만들어지면 데이터베이스 참조를 유지한다. 이를 통해 주어진 시점에 열린 데이터베이스의 단일 인스턴스를 유지한다. 데이터베이스는 만들고 유지하는데 비용이 많이 들어간다.

 

Instance에 @Volatile 주석을 단다. 이 주석은 휘발성 변수를 지정하는 것으로, 휘발성 변수의 값은 캐시되지 않으며 모든 읽기와 쓰기는 기본 메모리에서 이뤄진다. 이러한 기능을 사용해 Instanace의 값이 항상 최신 상태로 유지되고 모든 실행 스레드에 동일하게 유지될 수 있다. 즉, 한 스레드에서 Instance를 변경하면 다른 모든 스레드에서도 즉시 변경된 값이 표시된다는 것이다.

 

 

이후 데이터베이스를 반환할 함수를 정의한다.

fun getDatabase(context: Context): InventoryDatabase {
    return Instance ?: synchronized(this) {
        Room.databaseBuilder(context, InventoryDatabase::class.java, "item_database")
            .fallbackToDestructiveMigration()
            .build()
            .also { Instance = it }
    }
}

 

여러 스레드에서 동시에 데이터베이스 인스턴스를 요청할 수 있어 하나가 아닌 두 개의 데이터베이스가 생성된다. 이 문제는 경합 상태라고 하며, 코드를 래핑하여 synchronized 블록 내에 데이터베이스를 가져오면 한 번에 한 실행 스레드만 이 코드 블록에 들어갈 수 있으므로 데이터베이스가 한 번만 초기화된다. 경합 상태를 방지하려면 synchronzied{} 블록을 사용한다.

 

안드로이드 스튜디오에서는 유형 불일치 오류가 발생한다. 이 오류를 삭제하기 위해 fallbackToDestructiveMigration()을 추가한다.

 

참고: 일반적으로 스키마가 변경될 때를 위한 이전 전략과 함께 이전 객체를 제공한다. 이전 객체는 데이터가 손실되지 않도록 이전 스키마의 모든 행을 가져와 새 스키마의 행으로 변환하는 방법을 정의하는 객체이다. 이전은 이 튜토리얼에서 다루지 않는다.

하지만 이 용어는 스키마가 변경되어 데이터를 손실하지 않고 날짜를 이동해야 하는 경우를 의미한다. 이 앱은 샘플 앱이므로 데이터베이스를 삭제하고 다시 빌드하는 간단한 대안이 있다. 즉, 인벤토리 데이터가 손실된다. 예를 들어 항목 클래스에서 무언가를 변경하면(예: 새 매개변수 추가) 앱이 데이터베이스를 삭제하고 다시 초기화하도록 허용할 수 있다.

 

 

저장소 구현

import kotlinx.coroutines.flow.Flow

interface ItemsRepository {
    fun getAllItemsStream(): Flow<List<Item>>

    fun getItemStream(id: Int): Flow<Item?>

    suspend fun insertItem(item: Item)

    suspend fun deleteItem(item: Item)

    suspend fun updateItem(item: Item)
}
import kotlinx.coroutines.flow.Flow

class OfflineItemsRepository(private val itemDao: ItemDao) : ItemsRepository {
    override fun getAllItemsStream(): Flow<List<Item>> = itemDao.getAllItems()

    override fun getItemStream(id: Int): Flow<Item?> = itemDao.getItem(id)

    override suspend fun insertItem(item: Item) = itemDao.insert(item)

    override suspend fun deleteItem(item: Item) = itemDao.delete(item)

    override suspend fun updateItem(item: Item) = itemDao.update(item)
}

 

 

AppContainer 클래스 구현

데이터베이스를 인스턴스화하고 DAO 인스턴스를 OfflineItemsRepository 클래스에 전달한다.

import android.content.Context

interface AppContainer {
    val itemsRepository: ItemsRepository
}

class AppDataContainer(private val context: Context) : AppContainer {
    override val itemsRepository: ItemsRepository by lazy {
        OfflineItemsRepository(InventoryDatabase.getDatabase(context).itemDao())
    }
}

 

 

저장 기능 추가

데이터베이스를 완성했다. UI 클래스는 해당 강의 시작시 주어졌다. 모든 것이 준비되어 있기 때문에 이제 데이터베이스와 ViewModel을 연결시켜 작동하도록 하게 해주면 된다.

 

앱의 일시적인 데이터를 저장하고 데이터베이스에도 액세스하려면 ViewModel을 업데이트해야한다. ViewModel은 DAO를 통해 데이터베이스와 상호작용하여 UI에 데이터를 제공한다. 모든 데이터베이스 작업은 기본 UI 스레드에서 벗어나 실행되어야 한다. 코루틴과 viewModelScope를 사용하면 된다.

 

Item을 추가하는 화면을 관리하는 ViewModel을 수정한다.

// 아이템 추가 화면 ViewModel
class ItemEntryViewModel(private val itemsRepository: ItemsRepository) : ViewModel() {
    
    ...
    
    // 아이템 추가 함수
    suspend fun saveItem() {
        if (validateInput()) {
            itemsRepository.insertItem(itemUiState.itemDetails.toItem())
        }
    }
}
// App의 ViewModelProvider 내부에 아이템 추가 화면 ViewModel을
// 수정된 프로퍼티에 맞게 저장소를 추가하는 코드
initializer {
    ItemEntryViewModel(inventoryApplication().container.itemsRepository)
}
// 아이템 추가 화면 Compose의 세이브 버튼 클릭 리스너 수정
onSaveClick = {
    coroutineScope.launch {
        viewModel.saveItem()
        navigateBack()
    }
},

 

ViewModel에서 매개변수로 Repository를 요구하고, 그에 따라 오류가 생기는 ViewModel 초기화 부분에 Repository를 추가해준다.

 

이후, ViewModel로 다시 가서 아이템을 추가(데이터베이스로)하는 함수를 만들고, Compose의 세이브 버튼 클릭 리스너에 추가한다.

 

그럼 데이터가 추가 되지만, 전체 목록은 업데이트를 연결하지 않았기에 볼 수 없다. 이는 다음 강의에서 다루는 예정이어서 대신 Database Inspector를 사용해 데이터베이스 콘텐츠를 보는 방법을 소개하고 있다.

 

 

Database Inspector를 사용해 데이터베이스 콘텐츠 보기

Database Inspector를 사용하면 앱 실행 중에 앱의 데이터베이스를 검사하고 쿼리하고 수정할 수 있다. 이 기능은 데이터베이스 디버깅에 특히 유용하며 SQLite 기반으로 빌드된 라이브러리 및 일반 SQLite를 사용한 작업을 지원한다. API 수준 26을 실행하는 에뮬레이터/기기에서 가장 잘 작동한다.

 

참고: API 수준 26 이상에서 Android 운영체제에 포함된 SQLite 라이브러리에서만 작동하며, 앱과 함께 제공되는 다른 SQLite 라이브러리와는 함께 작동하지 않는다.

 

4번 항목
6번 항목

  1. API 수준 26 이상을 실행하는 에뮬레이터 또는 연결된 기기에서 앱을 실행합니다(아직 실행하지 않은 경우).
  2. Android 스튜디오의 메뉴 바에서 View > Tool Windows > App Inspection을 선택합니다.
  3. Database Inspector 탭을 선택합니다.
  4. Database Inspector 창의 드롭다운 메뉴에서 com.example.inventory를 선택합니다(아직 선택하지 않은 우). Inventory
  5. 앱의 item_database가 Databases 창에 표시됩니다.
  6. Databases 창에서 item_database 노드를 펼치고 검사할 Item을 선택합니다. Databases 창이 비어 있으면 에뮬레이
  7. 터에서 Add Item 화면을 사용하여 항목을 데이터베이스에 추가합니다.
  8. Database Inspector에서 Live updates 체크박스를 선택하여 에뮬레이터나 기기에서 실행 중인 앱과 상호작용할 때 표시되는 데이터를 자동으로 업데이트합니다.