Room을 사용하여 데이터 유지

1. 시작하기 전에

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

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

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

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

데이터 레이어에 저장소와 데이터 소스가 포함됨

기본 요건

  • Jetpack Compose를 사용하여 Android 앱의 기본 사용자 인터페이스(UI)를 빌드하는 능력
  • Text, Icon, IconButton, LazyColumn과 같은 컴포저블을 사용하는 능력
  • NavHost 컴포저블을 사용하여 앱에서 경로와 화면을 정의하는 능력
  • NavHostController를 사용하여 화면 간에 이동하는 능력
  • Android 아키텍처 구성요소 ViewModel에 관한 지식. ViewModelProvider.Factory를 사용하여 ViewModel을 인스턴스화하는 능력
  • 동시 실행 기본사항에 관한 지식
  • 장기 실행 작업에 코루틴을 사용하는 능력
  • SQLite 데이터베이스 및 SQL 언어에 관한 기본 지식

학습할 내용

  • Room 라이브러리를 사용하여 SQLite 데이터베이스를 만들고 상호작용하는 방법
  • 항목, 데이터 액세스 객체(DAO), 데이터베이스 클래스를 만드는 방법
  • DAO를 사용하여 Kotlin 함수를 SQL 쿼리에 매핑하는 방법

빌드할 항목

  • SQLite 데이터베이스에 인벤토리 항목을 저장하는 Inventory 앱을 빌드합니다.

필요한 항목

  • Inventory 앱의 시작 코드
  • Android 스튜디오가 설치된 컴퓨터
  • API 수준 26 이상을 실행하는 기기 또는 에뮬레이터

2. 앱 개요

이 Codelab에서는 Inventory 앱의 시작 코드로 작업하고 Room 라이브러리를 사용하여 데이터베이스 레이어를 앱에 추가합니다. 앱의 최종 버전에는 인벤토리 데이터베이스의 항목 목록이 표시됩니다. 사용자는 새 항목을 추가하고 기존 항목을 업데이트하며 인벤토리 데이터베이스에서 항목을 삭제할 수 있습니다. 이 Codelab에서는 항목 데이터를 Room 데이터베이스에 저장합니다. 다음 Codelab에서 앱의 나머지 기능을 완료합니다.

인벤토리 항목이 있는 휴대전화 화면

휴대전화 화면에 표시된 항목 추가 화면

항목 세부정보가 채워진 휴대전화 세부정보

3. 시작 앱 개요

이 Codelab의 시작 코드 다운로드

시작하려면 시작 코드를 다운로드하세요.

GitHub 저장소를 클론하여 코드를 가져와도 됩니다.

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-inventory-app.git
$ cd basic-android-kotlin-compose-training-inventory-app
$ git checkout starter

Inventory app GitHub 저장소에서 코드를 찾아볼 수 있습니다.

시작 코드 개요

  1. Android 스튜디오에서 시작 코드가 있는 프로젝트를 엽니다.
  2. Android 기기나 에뮬레이터에서 앱을 실행합니다. 에뮬레이터 또는 연결된 기기가 API 수준 26 이상을 사용하여 실행되는지 확인합니다. Database Inspector는 API 수준 26 이상을 실행하는 에뮬레이터/기기에서 작동합니다.
  1. 앱에 인벤토리 데이터가 표시되지 않습니다.
  2. 플로팅 작업 버튼(FAB)을 탭하면 데이터베이스에 새 항목을 추가할 수 있습니다.

앱이 새 화면으로 이동하고 여기서 새 항목의 세부정보를 입력할 수 있습니다.

인벤토리가 비어 있는 휴대전화 화면

휴대전화 화면에 표시된 항목 추가 화면

시작 코드의 문제

  1. Add Item 화면에서 항목의 이름, 가격, 수량과 같은 항목의 세부정보를 입력합니다.
  2. Save를 탭합니다. Add Item 화면이 닫히지 않지만 뒤로 키를 사용하여 뒤로 이동할 수 있습니다. 저장 기능이 구현되지 않아 항목 세부정보가 저장되지 않습니다.

앱이 완성되지 않아 Save 버튼 기능이 구현되지 않았습니다.

항목 세부정보가 채워진 휴대전화 세부정보

이 Codelab에서는 Room을 사용하여 SQLite 데이터베이스에 인벤토리 세부정보를 저장하는 코드를 추가합니다. Room 지속성 라이브러리를 사용하여 SQLite 데이터베이스와 상호작용합니다.

코드 둘러보기

다운로드한 시작 코드에는 화면 레이아웃이 미리 디자인되어 있습니다. 이 개발자 과정에서는 데이터베이스 로직을 구현하는 데 중점을 둡니다. 다음 섹션에서는 시작하는 데 도움이 되는 몇 가지 파일을 간략히 살펴봅니다.

ui/home/HomeScreen.kt

이 파일은 홈 화면 또는 앱의 첫 번째 화면으로, 인벤토리 목록을 표시하는 컴포저블이 포함되어 있습니다. 목록에 새 항목을 추가하는 FAB +도 있습니다. 나중에 이 개발자 과정에서 목록의 항목을 표시합니다.

인벤토리 항목이 있는 휴대전화 화면

ui/item/ItemEntryScreen.kt

이 화면은 ItemEditScreen.kt와 비슷합니다. 둘 다 항목 세부정보에 관한 텍스트 필드가 있습니다. 이 화면은 홈 화면에서 FAB를 탭하면 표시됩니다. ItemEntryViewModel.kt는 이 화면의 상응하는 ViewModel입니다.

항목 세부정보가 채워진 휴대전화 세부정보

ui/navigation/InventoryNavGraph.kt

이 파일은 전체 애플리케이션의 탐색 그래프입니다.

4. Room의 기본 구성요소

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

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

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

Codelab 후반부에서 이러한 구성요소를 구현하며 자세히 알아봅니다. 다음 다이어그램은 Room 구성요소가 함께 작동하여 데이터베이스와 상호작용하는 방법을 보여줍니다.

b95ee603605526c1.png

Room 종속 항목 추가

이 작업에서는 필요한 Room 구성요소 라이브러리를 Gradle 파일에 추가합니다.

  1. 모듈 수준 gradle 파일 build.gradle.kts (Module: InventoryApp.app)을 엽니다.
  2. dependencies 블록에서 다음 코드와 같이 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"]}")

KSP는 Kotlin 주석을 파싱하는 강력하면서도 간단한 API입니다.

5. 항목 Entity 만들기

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

ee0ef2847ddcbe91.png

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

이 작업에서는 Entity 클래스를 만들고 기본 키를 저장할 Int, 항목 이름을 저장할 String, 항목 가격을 저장할 double, 재고 수량을 저장할 Int 등 각 항목의 인벤토리 정보를 저장할 필드를 정의합니다.

  1. Android 스튜디오에서 시작 코드를 엽니다.
  2. com.example.inventory 기본 패키지에서 data 패키지를 엽니다.
  3. data 패키지 내에서 앱의 데이터베이스 항목을 나타내는 Item Kotlin 클래스를 엽니다.
// No need to copy over, this is part of the starter code
class Item(
    val id: Int,
    val name: String,
    val price: Double,
    val quantity: Int
)

Data 클래스

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

예:

// Example data class with 2 properties.
data class User(val firstName: String, val lastName: String){
}

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

  • 기본 생성자에는 매개변수가 하나 이상 있어야 합니다.
  • 모든 기본 생성자 매개변수는 val 또는 var이어야 합니다.
  • 데이터 클래스는 abstractopen, sealed일 수 없습니다.

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

  1. Item 클래스 정의 앞에 data 키워드를 붙여 데이터 클래스로 변환합니다.
data class Item(
    val id: Int,
    val name: String,
    val price: Double,
    val quantity: Int
)
  1. Item 클래스 선언 위에서 데이터 클래스에 @Entity 주석을 답니다. tableName 인수를 사용하여 items을 SQLite 테이블 이름으로 설정합니다.
import androidx.room.Entity

@Entity(tableName = "items")
data class Item(
   ...
)
  1. id 속성을 @PrimaryKey로 주석 처리하여 id를 기본 키로 설정합니다. 기본 키는 Item 테이블의 모든 레코드/항목을 고유하게 식별하는 ID입니다.
import androidx.room.PrimaryKey

@Entity(tableName = "items")
data class Item(
    @PrimaryKey
    val id: Int,
    ...
)
  1. id에 기본값 0을 할당합니다. 이는 idid 값을 자동으로 생성하는 데 필요합니다.
  2. Room에서 각 항목의 증분 ID를 생성하도록 autoGenerate 매개변수를 true로 설정합니다. 이렇게 하면 각 항목에 고유한 ID가 부여됩니다.
data class Item(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    // ...
)

다음 작업에서는 데이터베이스에 액세스하기 위한 인터페이스를 정의합니다.

6. 항목 DAO 만들기

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

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

6d40734740c0cffc.png

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

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

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

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

또 다른 이점은 Android 스튜디오에서 쿼리를 작성할 때 컴파일러가 SQL 쿼리에 문법 오류가 있는지 확인한다는 것입니다.

Inventory 앱의 경우 다음 작업을 할 수 있는 기능이 필요합니다.

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

286d6a1799c173c9.png

앱에서 항목 DAO를 구현하려면 다음 단계를 완료하세요.

  1. data 패키지에서 Kotlin 인터페이스 ItemDao.kt를 만듭니다.

이름 필드가 항목 DAO로 채워짐

  1. ItemDao 인터페이스에 @Dao 주석을 답니다.
import androidx.room.Dao

@Dao
interface ItemDao {
}
  1. 인터페이스 본문에 @Insert 주석을 추가합니다.
  2. @Insert 아래에서 Entity 클래스 item의 인스턴스를 인수로 사용하는 insert() 함수를 추가합니다.
  3. 함수를 suspend 키워드로 표시하여 별도의 스레드에서 실행되도록 합니다.

데이터베이스 작업을 실행하는 데는 시간이 오래 걸릴 수 있으므로 별도의 스레드에서 실행해야 합니다. Room은 기본 스레드에서 데이터베이스 액세스를 허용하지 않습니다.

import androidx.room.Insert

@Insert
suspend fun insert(item: Item)

데이터베이스에 항목을 삽입할 때 충돌이 발생할 수 있습니다. 예를 들어, 코드 여러 곳에서 다른 충돌하는 값(예: 같은 기본 키)을 사용하여 항목을 업데이트하려는 경우입니다. 항목은 DB의 행입니다. Inventory 앱에서는 Add Item 화면의 한 위치에만 항목을 삽입하므로 충돌이 예상되지 않으며 충돌 전략을 Ignore로 설정할 수 있습니다.

  1. onConflict 인수를 추가하고 OnConflictStrategy.IGNORE 값을 할당합니다.

onConflict 인수는 충돌이 발생하는 경우 Room에 실행할 작업을 알려줍니다. OnConflictStrategy.IGNORE 전략은 새 항목을 무시합니다.

사용 가능한 충돌 전략에 관한 자세한 내용은 OnConflictStrategy 문서를 참고하세요.

import androidx.room.OnConflictStrategy

@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(item: Item)

이제 Roomitem을 데이터베이스에 삽입하는 데 필요한 모든 코드를 생성합니다. Room 주석으로 표시된 DAO 함수를 호출하면 Room은 데이터베이스에서 상응하는 SQL 쿼리를 실행합니다. 예를 들어 Kotlin 코드에서 위 메서드 insert()를 호출하면 Room은 SQL 쿼리를 실행하여 데이터베이스에 항목을 삽입합니다.

  1. Item을 매개변수로 사용하는 @Update 주석이 있는 새 함수를 추가합니다.

업데이트된 항목에는 전달된 항목과 같은 기본 키가 있습니다. 항목의 다른 속성 일부나 전부를 업데이트할 수 있습니다.

  1. insert() 메서드와 마찬가지로 이 함수를 suspend 키워드로 표시합니다.
import androidx.room.Update

@Update
suspend fun update(item: Item)

@Delete 주석이 있는 다른 함수를 추가하여 항목을 삭제하고 정지 함수로 만듭니다.

import androidx.room.Delete

@Delete
suspend fun delete(item: Item)

나머지 기능에는 편의 주석이 없으므로 @Query 주석을 사용하여 SQLite 쿼리를 제공해야 합니다.

  1. 주어진 id에 기반하여 항목 테이블에서 특정 항목을 검색하는 SQLite 쿼리를 작성합니다. 다음 코드는 items에서 모든 열을 선택하는 샘플 쿼리를 제공합니다. 여기서 id는 특정 값과 일치하므로 id는 고유 식별자입니다.

예:

// Example, no need to copy over
SELECT * from items WHERE id = 1
  1. @Query 주석을 추가합니다.
  2. 이전 단계의 SQLite 쿼리를 @Query 주석의 문자열 매개변수로 사용합니다.
  3. String 매개변수를 SQLite 쿼리인 @Query에 추가하여 항목 테이블에서 항목을 검색합니다.

이제 쿼리는 items에서 모든 열을 선택하라고 지시합니다. 여기서 id는 :id 인수와 일치합니다. :id는 쿼리에서 콜론 표기법을 사용하여 함수의 인수를 참조합니다.

@Query("SELECT * from items WHERE id = :id")
  1. @Query 주석 뒤에 Int 인수를 사용하고 Flow<Item>을 반환하는 getItem() 함수를 추가합니다.
import androidx.room.Query
import kotlinx.coroutines.flow.Flow

@Query("SELECT * from items WHERE id = :id")
fun getItem(id: Int): Flow<Item>

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

  1. getAllItems() 함수와 함께 @Query를 추가합니다.
  2. SQLite 쿼리가 item 테이블의 모든 열을 오름차순으로 반환하도록 합니다.
  3. getAllItems()Item 항목의 목록을 Flow로 반환하도록 합니다. Room은 이 Flow를 자동으로 업데이트하므로 명시적으로 한 번만 데이터를 가져오면 됩니다.
@Query("SELECT * from items ORDER BY name ASC")
fun getAllItems(): Flow<List<Item>>

완료 시간: ItemDao

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>>
}
  1. 눈에 띄는 변경사항은 표시되지 않지만 앱을 빌드하여 오류가 없는지 확인합니다.

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

이 작업에서는 이전 작업의 Entity 및 DAO를 사용하는 RoomDatabase를 만듭니다. 데이터베이스 클래스는 항목 및 DAO 목록을 정의합니다.

Database 클래스는 정의된 DAO의 인스턴스를 앱에 제공합니다. 결과적으로 앱은 DAO를 사용하여 데이터베이스의 데이터를 연결된 데이터 항목 객체의 인스턴스로 검색할 수 있습니다. 앱은 정의된 데이터 항목을 사용하여 상응하는 테이블의 행을 업데이트하거나 삽입할 새 행을 만들 수도 있습니다.

추상 RoomDatabase 클래스를 만들고 @Database 주석을 달아야 합니다. 이 클래스에는 데이터베이스가 없으면 RoomDatabase의 기존 인스턴스를 반환하는 메서드가 하나 있습니다.

다음은 RoomDatabase 인스턴스를 가져오는 일반적인 프로세스입니다.

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

데이터베이스 만들기

  1. data 패키지에서 Kotlin 클래스 InventoryDatabase.kt를 만듭니다.
  2. InventoryDatabase.kt 파일에서 InventoryDatabase 클래스를 RoomDatabase를 확장하는 abstract 클래스로 만듭니다.
  3. 클래스에 @Database 주석을 답니다. 다음 단계에서 해결하므로 누락된 매개변수 오류는 무시합니다.
import androidx.room.Database
import androidx.room.RoomDatabase

@Database
abstract class InventoryDatabase : RoomDatabase() {}

@Database 주석에는 Room이 데이터베이스를 빌드할 수 있도록 인수가 여러 개 필요합니다.

  1. Itementities 목록이 있는 유일한 클래스로 지정합니다.
  2. version1로 설정합니다. 데이터베이스 테이블의 스키마를 변경할 때마다 버전 번호를 높여야 합니다.
  3. 스키마 버전 기록 백업을 유지하지 않도록 exportSchemafalse로 설정합니다.
@Database(entities = [Item::class], version = 1, exportSchema = false)
  1. 데이터베이스가 DAO에 관해 알 수 있도록 ItemDao를 반환하는 추상 함수를 클래스 본문 내에서 선언합니다.
abstract fun itemDao(): ItemDao
  1. 추상 함수 아래에서, 데이터베이스를 만들거나 가져오는 메서드에 대한 액세스를 허용하고 클래스 이름을 한정자로 사용하는 companion object를 정의합니다.
 companion object {}
  1. companion 객체 내에서 데이터베이스에 관한 null을 허용하는 비공개 변수 Instance를 선언하고 null로 초기화합니다.

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

  1. Instance@Volatile 주석을 답니다.

휘발성 변수의 값은 캐시되지 않으며 모든 읽기와 쓰기는 기본 메모리에서 이루어집니다. 이러한 기능을 사용하면 Instance 값이 항상 최신 상태로 유지되고 모든 실행 스레드에 동일하게 유지됩니다. 즉, 한 스레드에서 Instance를 변경하면 다른 모든 스레드에 즉시 표시됩니다.

@Volatile
private var Instance: InventoryDatabase? = null
  1. Instance 아래 그리고 여전히 companion 객체 내에서 데이터베이스 빌더에 필요한 Context 매개변수를 사용하여 getDatabase() 메서드를 정의합니다.
  2. InventoryDatabase 유형을 반환합니다. getDatabase()에서 아직 아무것도 반환하지 않아 오류 메시지가 표시됩니다.
import android.content.Context

fun getDatabase(context: Context): InventoryDatabase {}

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

  1. getDatabase() 내에서 Instance 변수를 반환하거나 Instance가 null이면 synchronized{} 블록 내에서 초기화합니다. elvis 연산자(?:)를 사용하면 됩니다.
  2. 컴패니언 객체 this를 전달합니다. 이 오류는 이후 단계에서 수정합니다.
return Instance ?: synchronized(this) { }
  1. 동기화된 블록 내에서 데이터베이스 빌더를 사용하여 데이터베이스를 가져옵니다. 다음 단계에서 해결하므로 오류는 계속 무시하세요.
import androidx.room.Room

Room.databaseBuilder()
  1. synchronized 블록 내에서 데이터베이스 빌더를 사용하여 데이터베이스를 가져옵니다. 애플리케이션 컨텍스트, 데이터베이스 클래스, 데이터베이스 이름 item_databaseRoom.databaseBuilder()에 전달합니다.
Room.databaseBuilder(context, InventoryDatabase::class.java, "item_database")

Android 스튜디오에서는 유형 불일치 오류가 발생합니다. 이 오류를 삭제하려면 다음 단계에서 build()를 추가해야 합니다.

  1. 필요한 이전 전략을 빌더에 추가합니다. . fallbackToDestructiveMigration()을 사용하세요.
.fallbackToDestructiveMigration()
  1. 데이터베이스 인스턴스를 만들려면 .build()를 호출합니다. 이 호출을 통해 Android 스튜디오 오류가 삭제됩니다.
.build()
  1. build() 뒤에 also 블록을 추가하고 Instance = it을 할당하여 최근에 만들어진 db 인스턴스에 대한 참조를 유지합니다.
.also { Instance = it }
  1. synchronized 블록 끝에 instance를 반환합니다. 최종 코드는 다음 코드와 같습니다.
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

/**
* Database class with a singleton Instance object.
*/
@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 {
            // if the Instance is not null, return it, otherwise create a new database instance.
            return Instance ?: synchronized(this) {
                Room.databaseBuilder(context, InventoryDatabase::class.java, "item_database")
                    .build()
                    .also { Instance = it }
            }
        }
    }
}
  1. 코드를 빌드하여 오류가 없는지 확인합니다.

8. 저장소 구현

이 작업에서는 ItemsRepository 인터페이스와 OfflineItemsRepository 클래스를 구현하여 데이터베이스에서 get, insert, delete, update 항목을 제공합니다.

  1. data 패키지에서 ItemsRepository.kt 파일을 엽니다.
  2. DAO 구현에 매핑되는 다음 함수를 인터페이스에 추가합니다.
import kotlinx.coroutines.flow.Flow

/**
* Repository that provides insert, update, delete, and retrieve of [Item] from a given data source.
*/
interface ItemsRepository {
    /**
     * Retrieve all the items from the given data source.
     */
    fun getAllItemsStream(): Flow<List<Item>>

    /**
     * Retrieve an item from the given data source that matches with the [id].
     */
    fun getItemStream(id: Int): Flow<Item?>

    /**
     * Insert item in the data source
     */
    suspend fun insertItem(item: Item)

    /**
     * Delete item from the data source
     */
    suspend fun deleteItem(item: Item)

    /**
     * Update item in the data source
     */
    suspend fun updateItem(item: Item)
}
  1. data 패키지에서 OfflineItemsRepository.kt 파일을 엽니다.
  2. ItemDao 유형의 생성자 매개변수를 전달합니다.
class OfflineItemsRepository(private val itemDao: ItemDao) : ItemsRepository
  1. OfflineItemsRepository 클래스에서 ItemsRepository 인터페이스에 정의된 함수를 재정의하고 ItemDao에서 상응하는 함수를 호출합니다.
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 클래스에 전달합니다.

  1. data 패키지에서 AppContainer.kt 파일을 엽니다.
  2. ItemDao() 인스턴스를 OfflineItemsRepository 생성자에 전달합니다.
  3. 컨텍스트를 전달하는 InventoryDatabase 클래스에서 getDatabase()를 호출하여 데이터베이스 인스턴스를 인스턴스화하고 .itemDao()를 호출하여 Dao 인스턴스를 만듭니다.
override val itemsRepository: ItemsRepository by lazy {
    OfflineItemsRepository(InventoryDatabase.getDatabase(context).itemDao())
}

이제 Room을 사용하기 위한 모든 구성요소가 준비되었습니다. 이 코드는 컴파일되고 실행되지만 실제로 작동하는지는 알 수 없습니다. 따라서 지금 데이터베이스를 테스트하는 것이 좋습니다. 테스트를 완료하려면 ViewModel이 데이터베이스와 통신해야 합니다.

9. 저장 기능 추가

지금까지 데이터베이스를 만들었고 UI 클래스는 시작 코드의 일부였습니다. 앱의 일시적인 데이터를 저장하고 데이터베이스에도 액세스하려면 ViewModel을 업데이트해야 합니다. ViewModel은 DAO를 통해 데이터베이스와 상호작용하여 UI에 데이터를 제공합니다. 모든 데이터베이스 작업은 기본 UI 스레드에서 벗어나 실행되어야 합니다. 코루틴과 viewModelScope를 사용하면 됩니다.

UI 상태 클래스 둘러보기

ui/item/ItemEntryViewModel.kt 파일을 엽니다. ItemUiState 데이터 클래스는 항목의 UI 상태를 나타냅니다. ItemDetails 데이터 클래스는 단일 항목을 나타냅니다.

시작 코드는 다음과 같은 세 가지 확장 함수를 제공합니다.

  • ItemDetails.toItem() 확장 함수. ItemUiState UI 상태 객체를 Item 항목 유형으로 변환합니다.
  • Item.toItemUiState() 확장 함수. Item Room 항목 객체를 ItemUiState UI 상태 유형으로 변환합니다.
  • Item.toItemDetails() 확장 함수. Item Room 항목 객체를 ItemDetails로 변환합니다.
// No need to copy, this is part of starter code
/**
* Represents Ui State for an Item.
*/
data class ItemUiState(
    val itemDetails: ItemDetails = ItemDetails(),
    val isEntryValid: Boolean = false
)

data class ItemDetails(
    val id: Int = 0,
    val name: String = "",
    val price: String = "",
    val quantity: String = "",
)

/**
* Extension function to convert [ItemDetails] to [Item]. If the value of [ItemDetails.price] is
* not a valid [Double], then the price will be set to 0.0. Similarly if the value of
* [ItemDetails.quantity] is not a valid [Int], then the quantity will be set to 0
*/
fun ItemDetails.toItem(): Item = Item(
    id = id,
    name = name,
    price = price.toDoubleOrNull() ?: 0.0,
    quantity = quantity.toIntOrNull() ?: 0
)

fun Item.formatedPrice(): String {
    return NumberFormat.getCurrencyInstance().format(price)
}

/**
* Extension function to convert [Item] to [ItemUiState]
*/
fun Item.toItemUiState(isEntryValid: Boolean = false): ItemUiState = ItemUiState(
    itemDetails = this.toItemDetails(),
    isEntryValid = isEntryValid
)

/**
* Extension function to convert [Item] to [ItemDetails]
*/
fun Item.toItemDetails(): ItemDetails = ItemDetails(
    id = id,
    name = name,
    price = price.toString(),
    quantity = quantity.toString()
)

뷰 모델에서 위의 클래스를 사용하여 UI를 읽고 업데이트합니다.

ItemEntry ViewModel 업데이트

이 작업에서는 저장소를 ItemEntryViewModel.kt 파일에 전달합니다. Add Item 화면에 입력한 항목 세부정보도 데이터베이스에 저장합니다.

  1. ItemEntryViewModel 클래스의 validateInput() 비공개 함수를 확인하세요.
// No need to copy over, this is part of starter code
private fun validateInput(uiState: ItemDetails = itemUiState.itemDetails): Boolean {
    return with(uiState) {
        name.isNotBlank() && price.isNotBlank() && quantity.isNotBlank()
    }
}

위 함수는 name, price, quantity가 비어 있는지 확인합니다. 이 함수를 사용하여 사용자 입력을 확인한 후 데이터베이스의 항목을 추가하거나 업데이트합니다.

  1. ItemEntryViewModel 클래스를 열고 ItemsRepository 유형의 private 기본 생성자 매개변수를 추가합니다.
import com.example.inventory.data.ItemsRepository

class ItemEntryViewModel(private val itemsRepository: ItemsRepository) : ViewModel() {
}
  1. ui/AppViewModelProvider.kt에서 항목 뷰 모델의 initializer를 업데이트하고 저장소 인스턴스를 매개변수로 전달합니다.
object AppViewModelProvider {
    val Factory = viewModelFactory {
        // Other Initializers
        // Initializer for ItemEntryViewModel
        initializer {
            ItemEntryViewModel(inventoryApplication().container.itemsRepository)
        }
        //...
    }
}
  1. ItemEntryViewModel.kt 파일로 이동하고 ItemEntryViewModel 클래스 끝에 saveItem()이라는 정지 함수를 추가하여 Room 데이터베이스에 항목을 삽입합니다. 이 함수는 비차단 방식으로 데이터를 데이터베이스에 추가합니다.
suspend fun saveItem() {
}
  1. 함수 내에서 itemUiState가 유효한지 확인하고 Item 유형으로 변환하여 Room에서 데이터를 이해할 수 있도록 합니다.
  2. itemsRepository에서 insertItem()을 호출하고 데이터를 전달합니다. UI는 이 함수를 호출하여 항목 세부정보를 데이터베이스에 추가합니다.
suspend fun saveItem() {
    if (validateInput()) {
        itemsRepository.insertItem(itemUiState.itemDetails.toItem())
    }
}

항목을 데이터베이스에 추가하는 데 필요한 모든 함수를 추가했습니다. 다음 작업에서는 위 함수를 사용하도록 UI를 업데이트합니다.

ItemEntryBody() 컴포저블 둘러보기

  1. ui/item/ItemEntryScreen.kt 파일에서 ItemEntryBody() 컴포저블은 시작 코드의 일부로 부분적으로 구현됩니다. ItemEntryScreen() 함수 호출에서 ItemEntryBody() 컴포저블을 살펴보세요.
// No need to copy over, part of the starter code
ItemEntryBody(
    itemUiState = viewModel.itemUiState,
    onItemValueChange = viewModel::updateUiState,
    onSaveClick = { },
    modifier = Modifier
        .padding(innerPadding)
        .verticalScroll(rememberScrollState())
        .fillMaxWidth()
)
  1. UI 상태와 updateUiState 람다는 함수 매개변수로 전달됩니다. UI 상태가 업데이트되는 방식은 함수 정의에서 확인하세요.
// No need to copy over, part of the starter code
@Composable
fun ItemEntryBody(
    itemUiState: ItemUiState,
    onItemValueChange: (ItemUiState) -> Unit,
    onSaveClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        // ...
    ) {
        ItemInputForm(
             itemDetails = itemUiState.itemDetails,
             onValueChange = onItemValueChange,
             modifier = Modifier.fillMaxWidth()
         )
        Button(
             onClick = onSaveClick,
             enabled = itemUiState.isEntryValid,
             shape = MaterialTheme.shapes.small,
             modifier = Modifier.fillMaxWidth()
         ) {
             Text(text = stringResource(R.string.save_action))
         }
    }
}

이 컴포저블에서는 ItemInputFormSave 버튼이 표시됩니다. ItemInputForm() 컴포저블에서는 텍스트 필드 3개를 표시합니다. Save는 텍스트 필드에 텍스트를 입력한 경우에만 사용 설정됩니다. isEntryValid 값은 모든 텍스트 필드의 텍스트가 비어 있지 않은 경우 true입니다.

항목 세부정보가 일부만 채워지고 저장 버튼이 사용 중지된 휴대전화 화면

항목 세부정보가 채워지고 저장 버튼이 사용 설정된 휴대전화 화면

  1. 구성 가능한 ItemInputForm() 함수 구현을 살펴보고 onValueChange 함수 매개변수를 확인합니다. itemDetails 값을 사용자가 텍스트 필드에 입력한 값으로 업데이트합니다. Save 버튼이 사용 설정될 때까지 itemUiState.itemDetails에는 저장해야 하는 값이 있습니다.
// No need to copy over, part of the starter code
@Composable
fun ItemEntryBody(
    //...
) {
    Column(
        // ...
    ) {
        ItemInputForm(
             itemDetails = itemUiState.itemDetails,
             //...
         )
        //...
    }
}
// No need to copy over, part of the starter code
@Composable
fun ItemInputForm(
    itemDetails: ItemDetails,
    modifier: Modifier = Modifier,
    onValueChange: (ItemUiState) -> Unit = {},
    enabled: Boolean = true
) {
    Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
        OutlinedTextField(
            value = itemUiState.name,
            onValueChange = { onValueChange(itemDetails.copy(name = it)) },
            //...
        )
        OutlinedTextField(
            value = itemUiState.price,
            onValueChange = { onValueChange(itemDetails.copy(price = it)) },
            //...
        )
        OutlinedTextField(
            value = itemUiState.quantity,
            onValueChange = { onValueChange(itemDetails.copy(quantity = it)) },
            //...
        )
    }
}

Save 버튼에 클릭 리스너 추가

모든 것을 연결하려면 클릭 핸들러를 Save 버튼에 추가합니다. 클릭 핸들러 내에서 코루틴을 실행하고 saveItem()을 호출하여 Room 데이터베이스에 데이터를 저장합니다.

  1. ItemEntryScreen.kt의 구성 가능한 ItemEntryScreen 함수 내에서, 구성 가능한 rememberCoroutineScope() 함수를 사용하여 coroutineScope라는 val을 만듭니다.
import androidx.compose.runtime.rememberCoroutineScope

val coroutineScope = rememberCoroutineScope()
  1. ItemEntryBody() 함수 호출을 업데이트하고 onSaveClick 람다 내에서 코루틴을 실행합니다.
ItemEntryBody(
   // ...
    onSaveClick = {
        coroutineScope.launch {
        }
    },
    modifier = modifier.padding(innerPadding)
)
  1. ItemEntryViewModel.kt 파일에서 saveItem() 함수 구현을 살펴보고 itemUiState가 유효한지 확인하여 itemUiStateItem 유형으로 변환하고 itemsRepository.insertItem()을 사용하여 데이터베이스에 삽입합니다.
// No need to copy over, you have already implemented this as part of the Room implementation

suspend fun saveItem() {
    if (validateInput()) {
        itemsRepository.insertItem(itemUiState.itemDetails.toItem())
    }
}
  1. ItemEntryScreen.kt의 구성 가능한 ItemEntryScreen 함수 내 코루틴에서 viewModel.saveItem()을 호출하여 항목을 데이터베이스에 저장합니다.
ItemEntryBody(
    // ...
    onSaveClick = {
        coroutineScope.launch {
            viewModel.saveItem()
        }
    },
    //...
)

ItemEntryViewModel.kt 파일에서 saveItem()viewModelScope.launch()를 사용하지 않았지만 이는 저장소 메서드를 호출할 때 ItemEntryBody()에는 필요합니다. 코루틴이나 다른 정지 함수에서만 정지 함수를 호출할 수 있습니다. viewModel.saveItem() 함수는 정지 함수입니다.

  1. 앱을 빌드하고 실행합니다.
  2. + FAB를 탭합니다.
  3. Add Item 화면에서 항목 세부정보를 추가하고 Save를 탭합니다. Save 버튼을 탭해도 Add Item 화면이 닫히지 않습니다.

항목 세부정보가 채워지고 저장 버튼이 사용 설정된 휴대전화 화면

  1. onSaveClick 람다에서 viewModel.saveItem() 호출 뒤에 navigateBack() 호출을 추가하여 이전 화면으로 다시 돌아갑니다. ItemEntryBody() 함수는 다음 코드와 같습니다.
ItemEntryBody(
    itemUiState = viewModel.itemUiState,
    onItemValueChange = viewModel::updateUiState,
    onSaveClick = {
        coroutineScope.launch {
            viewModel.saveItem()
            navigateBack()
        }
    },
    modifier = modifier.padding(innerPadding)
)
  1. 앱을 다시 실행하고, 데이터를 입력하고 저장하는 동일한 단계를 실행합니다. 이번에는 앱이 Inventory 화면으로 다시 이동합니다.

이 작업으로 데이터가 저장되지만 앱에서 인벤토리 데이터를 확인할 수는 없습니다. 다음 작업에서는 저장한 데이터를 확인하는 데 Database Inspector를 사용합니다.

인벤토리 목록이 비어 있는 앱 화면

10. Database Inspector를 사용하여 데이터베이스 콘텐츠 보기

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

  1. API 수준 26 이상을 실행하는 에뮬레이터 또는 연결된 기기에서 앱을 실행합니다(아직 실행하지 않은 경우).
  2. Android 스튜디오의 메뉴 바에서 View > Tool Windows > App Inspection을 선택합니다.
  3. Database Inspector 탭을 선택합니다.
  4. Database Inspector 창의 드롭다운 메뉴에서 com.example.inventory를 선택합니다(아직 선택하지 않은 경우). Inventory 앱의 item_databaseDatabases 창에 표시됩니다.

6876a506d634ca2a.png

  1. Databases 창에서 item_database 노드를 펼치고 검사할 Item을 선택합니다. Databases 창이 비어 있으면 에뮬레이터에서 Add Item 화면을 사용하여 항목을 데이터베이스에 추가합니다.
  2. Database Inspector에서 Live updates 체크박스를 선택하여 에뮬레이터나 기기에서 실행 중인 앱과 상호작용할 때 표시되는 데이터를 자동으로 업데이트합니다.

ffd820637ed70b89.png

축하합니다. Room을 사용하여 데이터를 유지할 수 있는 앱을 만들었습니다. 다음 Codelab에서는 lazyColumn을 앱에 추가하여 데이터베이스의 항목을 표시하고 새로운 기능(예: 항목을 삭제하고 업데이트하는 기능)을 앱에 추가합니다. 다음 Codelab에서 뵙겠습니다!

11. 솔루션 코드 가져오기

이 Codelab의 솔루션 코드는 GitHub 저장소에 있습니다. 완료된 Codelab의 코드를 다운로드하려면 다음 git 명령어를 사용하세요.

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-inventory-app.git
$ cd basic-android-kotlin-compose-training-inventory-app
$ git checkout room

또는 ZIP 파일로 저장소를 다운로드한 다음 압축을 풀고 Android 스튜디오에서 열어도 됩니다.

이 Codelab의 솔루션 코드는 GitHub에서 확인하세요.

12. 요약

  • 테이블을 @Entity 주석이 달린 데이터 클래스로 정의합니다. @ColumnInfo 주석이 달린 속성을 테이블의 열로 정의합니다.
  • 데이터 액세스 객체(DAO)를 @Dao 주석이 달린 인터페이스로 정의합니다. DAO는 Kotlin 함수를 데이터베이스 쿼리에 매핑합니다.
  • 주석을 사용하여 @Insert, @Delete, @Update 함수를 정의합니다.
  • SQLite 쿼리 문자열과 함께 @Query 주석을 다른 쿼리의 매개변수로 사용합니다.
  • Database Inspector를 사용하여 Android SQLite 데이터베이스에 저장된 데이터를 확인합니다.

13. 자세히 알아보기

Android 개발자 문서

블로그 게시물

동영상

기타 문서 및 도움말