본문 바로가기
Jetpack Compose

Jetpack Compose에서 Room 사용 | DB 사용

by junjunjun 2023. 7. 18.
반응형

본 글은 정확하지 않을 수 있습니다. 참고용으로만 봐주시면 감사하겠습니다.

 

공식 Room 사용 예제가이드를 참고하여 정리하였습니다.

코드는 공식 Room 사용 예제에 있는 코드를 가져왔습니다.

 

 

 

처음에 Room라이브러리를 접했을 때 Database는 백엔드에서 관리를 해주는데 왜 앱 개발에서도 Database가 쓰이는지 의아하였다.

하지만 각 Database의 쓰임새는 다르며 Room이 언제 쓰이는지 살펴보면 그 차이점을 알 수 있다.

  • 앱에서 유지해야 하는 로컬 데이터, 예를 들어 노래 재생목록이나 할 일 목록의 항목, 수입 및 지출 기록, 별자리 카탈로그, 개인 정보 기록 등을 저장할 수 있다.
  • 기기가 네트워크에 액세스 할 수 없을 때도 사용자가 오프라인 상태로 계속 콘텐츠를 탐색할 수 있도록 관련 데이터를 캐시할 수 있다.

참고로 Room에 의해 생성된 데이터베이스는 앱 내부에 저장된다.

 

Room 이란

Room은 Android Jetpack의 일부인 지속성(Persistence) 라이브러리이다. Room은 SQLite 데이터베이스 위에 있는 추상화 레이어이다. 따라서 Room은 데이터베이스 설정, 구성, 앱과의 상호작용과 같은 작업을 간소화한다.

개인적으로 느끼기에 Spring의 JPA와 사용 방법이 비슷하였다.

 

+ Room은 데이터 소스이다

layer 구조

 

Room의 기본 구성요소

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

쉽게 말해 Database 클래스는 DAO를 얻어오고 DAO를 통해 crud작업을 수행하며 엔티티 얻거나 수정할 수 있다.

 

우리가 해줘야 되는 작업은 다음과 같다.

  1.  room 항목(엔티티=table) 만들기
  2.  DAO 만들기
  3.  Database 클래스 만들기

위 작업으로 db에서 데이터를 가져올 수 있다. 하지만 이러한 데이터를 UI에서 사용하기 위해서는 추가 작업이 필요하다.

  1.  Repository 만들기
  2.  viewModel에 적용
  3.  UI에서 사용

전체 흐름

 

이제 실제로 구현을 해보자.

코드 중간중간 난해한 문법이나 처음 보는 클래스들이 보이는데 이를 모두 이해하기보다는 전체적인 흐름을 이해하는 것을 권장드립니다.

 

0. 종속성 설정

plugins {
	...
    id 'kotlin-kapt' // 추가
}
dependencies {
	...
    def room_version = '2.5.1'
    implementation "androidx.room:room-runtime:$room_version"
    implementation "androidx.room:room-ktx:$room_version" 
    kapt "androidx.room:room-compiler:$room_version"
}

 

1. 엔티티 만들기

자바의 엔티티 생성과 유사하다.

역할도 자바의 엔티티와 동일하다. db의 테이블 설정이다.

@Entity(tableName = "items")
data class Item(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val name: String,
    val price: Double,
    val quantity: Int
)
  • 엔티티 어노테이션과 기본키 설정은 필수이다.
  • data 클래스를 사용한다.

 

2. DAO 생성

DAO는 추상 인터페이스를 제공하여 지속성 레이어를 애플리케이션의 나머지 부분과 분리하는 데 사용할 수 있는 패턴이다.

 

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

 

즉 데이터 엔티티에 대한 작업을 정의하는 메서드의 집합으로 구성된다.

(Spring Data JPA의 Repository 포지션)

 

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 item WHERE id = :id") // 복잡하면 쿼리문은 @Query 사용
	fun getItem(id: Int): Flow<Item>   

	@Query("SELECT * from item ORDER BY name ASC")
	fun getAllItems(): Flow<List<Item>>
}
  • 코드 여러 곳에서 같은 기본키를 업데이트하면 충돌이 발생할 수 있다. 이를 방지하기 onConflict으로 충돌 전략을 정할 수 있다.
  • Flow를 반환 유형으로 사용하면 데이터베이스의 데이터가 변경될 때마다 알림을 받게 된다. Flow는 비동기적으로 데이터를 제공하는데, 데이터베이스에서 변경 사항이 발생하면 Flow는 새로운 데이터로 자동으로 업데이트된다.
    이를 통해 명시적으로 한 번만 데이터를 가져오고, 데이터베이스의 변경 사항을 실시간으로 반영할 수 있게 된다.
  • Room에서 제공하는 기본적인 DAO 함수는 비동기적으로 실행된다. 따라서 suspend를 붙여야 한다.
    하지만 반환 타입이 Flow일 경우에는 Room이 자동으로 설정해 주기 때문에 suspend가 없어도 된다.

 

3. 데이터베이스 클래스 만들기

Database 클래스는 정의된 DAO의 인스턴스를 앱에 제공한다.

결과적으로 앱은 DAO를 사용하여 데이터베이스에서 데이터를 검색할 수 있다.

 

전체 앱에 RoomDatabase 인스턴스 하나만 있으면 되므로 RoomDatabase를 싱글톤으로 만든다.

 

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

// @Database 어노테이션 사용하면 인자값으로는 [적용할 엔티티 목록, 버전, 백업 유지 여부] 이다.
// 추상클래스이며 RoomDatabase상속받음
@Database(entities = [Item::class], version = 1, exportSchema = false)
abstract class InventoryDatabase : RoomDatabase() {

   abstract fun itemDao(): ItemDao    // 사용할 Dao

   companion object {
   
        // 여러 스레드에서 동시에 인스턴스에 접근할 수 있는 경우, 
        // @Volatile을 사용하여 해당 변수의 값을 최신으로 유지합니다.
       @Volatile 
       private var Instance: InventoryDatabase? = null

        // db 인스턴스 얻는 메서드
       fun getDatabase(context: Context): InventoryDatabase {
           // 인스턴스가 null이 아니면 리턴하고, 아닐 경우 새로운 인스턴스를 생성 [싱글톤!!]
           return Instance ?: synchronized(this) {
               Room.databaseBuilder(context, InventoryDatabase::class.java, "item_database")
                   .build()
                   .also { Instance = it }
           }
       }
   }
}
  • 사용할 Dao는 추상 메서드로 선언해줘야 한다.
  • 여러 스레드에서 getDatabase를 요청할 경우 DB가 여러 개 생성될 수 있다. 따라서 synchronized 블록을 사용하여 한 번에 한 실행 스레드만 이 코드 블록에 들어갈 수 있도록 해준다.

 

 

지금까지 엔티티, Dao, 데이터베이스를 구현하는 방법에 대해 알아보았다.

이제부터는 이를 사용하는 방법에 대해 알아보겠다.

 

4. Repository 만들기

viewModel에서 바로 Dao를 통해 데이터를 가져올 수 있지만, Dao에 있는 메서드는 단지 하나의 쿼리문이다. 따라서 이 둘 사이의 중간다리 역할(데이터를 가공하기 위한) Repository가 필요하다.

  • viewModel와 Dao의 중간다리 역할을 한다.
  • 다양한 데이터 소스(room도 데이터 소스 중 하나이다.)로부터 데이터를 가져오며 충돌을 해결한다.
  • 데이터 액세스를 위한 깔끔한 API를 제공한다.

계층 구조

 

// ItemsRepository.kt 
// 앱이 간단하면 인터페이스 생략 가능
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)
}

// OfflineItemsRepository.kt
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)
}
// 위 코드의 요구사항은 간단하기 때문에 Dao의 메서드를 그대로 가져와서 반환하고 있다.
// 본인은 이거 때문에 Repository의 역할에 대한 이해가 잘 되지 않았었다.

 

 

spring에서는 어노테이션만 붙여주면 알아서 Container에 넣어주며 의존성 주입까지 처리해 준다. 하지만 해당 예제에서는 우리가 직접 구현해야 한다.

위 코드에서 OfflineItemsRepository를 컨테이너에 추가하면서 itemDao을 의존성 주입해줘야 한다.

 

1. 컨테이너에 인스턴스 넣어주기

// AppContainer.kt 의존성 주입을 위한 앱 컨테이너
interface AppContainer {
    val itemsRepository: ItemsRepository
}

// AppContainer를 구현하여 OfflineItemsRepository인스턴스를 제공한다.
class AppDataContainer(private val context: Context) : AppContainer {

    override val itemsRepository: ItemsRepository by lazy {
        OfflineItemsRepository(InventoryDatabase.getDatabase(context).itemDao())
    }
}

 

구현을 완료해 주었다. 이제 구현된 컨테이너를 앱 실행 시 생성해주어야 한다.

 

2. 앱 실행 시 설정한 앱 컨테이너 생성

class InventoryApplication : Application() {

    // container 를 통해 의존성 주입받은 OfflineItemsRepository 인스턴스를 사용할 수 있게 된다.
    lateinit var container: AppContainer
    
    // 앱 실행시 실행되는 메서드
    override fun onCreate() {
        super.onCreate()
        container = AppDataContainer(this)
    }
}

// Android 시스템이 애플리케이션을 시작할 때 InventoryApplication클래스를
// 인식하고 애플리케이션 수명주기를 관리할 수 있도록 설정해줘야 한다.
// +++ AndroidManifest.xml에 추가시켜줘야 함
<application
	android:name=".InventoryApplication" // 클래스 추가
>
	
</application>

 

꽤 복잡하게 느껴진다.

흐름을 보면,

  1.  앱이 실행하면 onCreate를 통해 container에 AppDataContainer를 담는다.
  2. AppDataContainer는 Dao를 의존성 주입받은 Repository 인스턴스를 가지고 있다. 참고로 Dao는 Database클래스를 통해 얻을 수 있다.
  3. 결론적으로 container.itemsRepository로 Repository 인스턴스를 얻을 수 있다.

 

5. ViewModel에 적용

"ViewModel은 DAO를 통해 데이터베이스와 상호작용하여 UI에 데이터를 제공한다"라고 나와있지만 실질적으로는 Repository를 먼저 지난 뒤에 DAO를 통해 데이터베이스와 상호작용한다. 

 

모든 데이터베이스 작업은 기본 UI 스레드에서 벗어나 실행되어야 한다. 코루틴과 viewModelScope를 사용하면 된다.

즉 비동기적으로 실행되어야 한다.

 

class ItemEntryViewModel(private val itemsRepository: ItemsRepository) : ViewModel() {
		
    // 사용법
    suspend fun saveItem() {
       if (itemUiState.isValid()) {
           itemsRepository.insertItem(itemUiState.toItem())
       }
    }
}

 

마찬가지로 의존성주입을 해줘야 한다.

// ui/AppViewModelProvider.kt
object AppViewModelProvider {
   val Factory = viewModelFactory {
       
       // ItemEntryViewModel 초기화
       initializer {
           ItemEntryViewModel(inventoryApplication().container.itemsRepository)
       }
       //...
   }
}
fun CreationExtras.inventoryApplication(): InventoryApplication =
    (this[AndroidViewModelFactory.APPLICATION_KEY] as InventoryApplication)

이곳에서 모든 viewModel을 초기화해준다.

 

6. UI에서 사용

import androidx.compose.runtime.rememberCoroutineScope
...

@Composable
fun ItemEntryScreen(
    ...
     // 이렇게 의존성 주입받은 viewModel 사용 가능
    viewModel: ItemEntryViewModel = viewModel(factory = AppViewModelProvider.Factory) 
) {

    val coroutineScope = rememberCoroutineScope() // 비동기 작업을 위해 코루틴 사용
    Scaffold(
		...
    ) { innerPadding ->
        ItemEntryBody(
            onSaveClick = {
                coroutineScope.launch { // 코루틴이나 다른 정지 함수에서만 정지 함수를 호출할 수 있습니다.
                    viewModel.saveItem() // saveItem은 정지 함수임
                }
            },
            modifier = Modifier
        )
    }
}

 

 

정리

생각보다 추가된 파일이 많으며 각각의 관계도 복잡하다.

위 예제에 대한 전체적인 흐름을 보며 다시 한번 정리해 보자.

전체 흐름

 

 

이해가 안 가는 부분은 공식 Room 사용 예제를 참고하시면 됩니다.

반응형

댓글