Jetpack Compose - Room

2024. 3. 19. 16:03IT/Android

728x90

<미리 보기>

 

 

<소스 코드>

https://github.com/SeongHyunJeon/android-kotlin-practice/tree/55d7150808ee8865620aa189a7857daf3e76b29e/Inventory

 

 

<정리>

SQLite - 관계형 데이터 베이스로 C언어로 작성되어 빠르고 효율적으로 동작한다. 구조화된 쿼리 언어(SQL)를 사용하여 데이터를 조작할 수 있다.

 

관계형 데이터 베이스 - 테이블을 여러 개 포함하고 테이블 간의 관계를 정의하는 데이터 베이스를 의미한다. 모든 테이블 행에는 기본키라는 고유 식별자가 포함되고, 만약 다른 테이블의 기본 키를 참조한다면 이는 외래키라 부른다. 추가로 데이터 베이스의 테이블 이름은 단수형으로 짓는 것이 좋다.

 

데이터 베이스 용어

테이블 : Student, Professor같은 데이터의 상위 그룹으로 Kotlin에서 클래스를 정의하는 것과 비슷하다.

열 : id, name같은 테이블에 포함될 데이터로 Kotlin에서 프로퍼티를 정의하는 것과 비슷하다.

행 : 각 열에 해당하는 실제 데이터로 Kotlin에서 클래스의 인스턴스를 생성하는 것과 비슷하다.

 

SQLite <-> Kotlin 데이터 유형

Kotlin 데이터 유형 SQLite 데이터 유형
Int INTEGER
String VARCHAR 또는 TEXT
Boolean BOOLEAN
Float, Double REAL

 

스키마(Schema) - 데이터베이스의 테이블과 각 테이블의 열을 총칭하는 말.

 

SQL문의 대략적인 형태.

모든 SQL문은 세미 콜론으로 끝난다.

 

SELECT - 데이터를 읽을 열을 선택한다.

SELECT subject, sender FROM email; 테이블 email로부터 열 subject, sender에 해당하는 값을 출력한다.
SELECT * FROM email; 테이블 email로부터 모든 열에 해당하는 값을 출력한다.

 

집계 함수(COUNT, SUM, AVG, MIN, MAX) - 특정 열의 연산을 통해 단일 값을 반환한다.

SELECT COUNT(*) FROM email; 테이블 email로부터 모든 열에 해당하는 값의 갯수를 반환한다.
SELECT MAX(received) FROM email; 테이블 email로부터 received의 값 중에 가장 큰 값을 반환한다.

 

DISTINCT - 중복 값을 제거한다.

SELECT DISTINCT sender FROM email; email로부터 sender의 중복 제거된 값들을 반환한다.
SELECT COUNT(DISTINCT sender) FROM email; email로부터 sender의 중복 제거된 값의 갯수를 반환한다.

 

WHERE - 특정 기준에 따라 결과를 필터링한다.

SELECT * FROM email
WHERE folder = 'inbox';
folder가 inbox인 모든 행을 반환한다.
*비교 연산자는 단일 등호, 문자열은 작은 따옴표 사용.

 

WHERE + 논리 연산자(AND, OR, NOT)

SELECT * FROM email
WHERE folder = 'inbox' AND read = false;
folder가 inbox이면서 read가 false인 모든 행을 반환한다.

 

WHERE + LIKE - 특정 열의 텍스트를 검색한다.

SELECT * FROM email
WHERE name LIKE '%kim%';
name이 kim을 포함하고 있는 모든 행을 반환한다.
SELECT * FROM email
WHERE name LIKE 'park%';
name이 park으로 시작하는 모든 행을 반환한다.
SELECT * FROM email
WHERE name LIKE '%hyun';
name이 hyun으로 끝나는 모든 행을 반환한다.

 

GROUP BY - 결과를 그룹화한다.

SELECT folder, COUNT(*) FROM email
GROUP BY folder;
folder의 종류와 그에 따른 값의 행의 갯수를 반환한다.
SELECT folder, read, COUNT(*) FROM email
GROUP BY folder, read;
folder, read의 종류의 조합과 그에 따른 행의 갯수를 반환한다.

 

ORDER BY - 결과의 순서를 정렬한다.

SELECT * FROM email
ORDER BY received DESC;
모든 행을 received의 내림차순을 기준으로  반환한다.

 

LIMIT - 특정 수의 결과만 반환한다.

SELECT * FROM email
WHERE folder = 'inbox'
ORDER BY received DESC
LIMIT 10 OFFSET 10;
모든 행의 folder가 inbox인 값을 received의 내림차순을 기준으로 반환하는데, 10개를 건너뛰고 10개를 반환한다. 

 

INSERT - 데이터베이스에 값을 삽입한다.

INSERT INTO email
VALUES(NULL, 'Hello world', 'abc123@naver.com', 'inbox', false, false, CURRENT_TIMESTAMP);
테이블 email에 해당 값을 데이터베이스 열 순서와 동일하게 삽입한다.
*CURRENT_TIMESTAMP는 쿼리 실행의 현재 시간으로 대체되는 특수 변수.
*id열에 NULL값을 제공하는 경우 자동 증가 정수에 의해 생성된다.

 

DELETE - 데이터베이스의 값을 삭제한다.

DELETE FROM email
WHERE id = 44;
id가 44인 행을 삭제한다.

 

UPDATE - 데이터베이스의 값을 수정한다.

UPDATE email
SET read = true
WHERE id = 44;
id가 44인 행의 read 값을 true로 변경한다.

Room - 데이터를 영구적으로 저장하고 관리하기 위한 Android Jetpack의 일부 라이브러리로, SQLite을 사용하여 실질적인 기능을 구현하지만 데이터 베이스 설정, 구성, 앱과의 상호작용을 간소화시켜 데이터 베이스를 편리하게 사용할 수 있도록 돕는다.

 

Room 구성 요소.

Entity : 데이터 베이스의 테이블로, 해당 클래스의 인스턴스는 데이터 베이스의 행을 나타낸다.

DAO(Data Access Object) : 앱이 데이터 베이스와 상호작용하기 위한 메서드를 정의한 인터페이스로, 해당 객체는 컴파일 타임에 생성된다.

Database : 앱과 데이터 베이스가 연결된 DAO 인스턴스를 제공하는 클래스로, 데이터 베이스의 객체 생성은 비용이 많이 들기 때문에 일반적으로 싱글톤으로 구현하여 하나의 인스턴스를 유지한다.

 

Entity를 클래스로 정의.

@Entity(tableName = "items")
data class Item(
    @PrimaryKey(autoGenerate = true) //기본 키로 지정. 각 항목은 자동으로 증분된 키를 갖는다.
    val id: Int = 0,
    val name: String,
    val price: Double,
    val quantity: Int
)

@Entity() : 해당 클래스를 테이블로 지정하는 어노테이션으로, 파라미터를 통해 테이블의 이름을 설정할 수 있다.

@PrimaryKey() : 해당 프로퍼티를 기본 키로 지정하는 어노테이션으로, 파라미터를 통해 각 항목이 자동으로 증분 되는 키를 갖게 할 수 있다.

 

DAO를 인터페이스로 정의.

@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로 지정하는 어노테이션.

@Insert() : 데이터 베이스에 데이터를 삽입하는 메서드로, 파라미터를 통해 같은 키 객체를 삽입할 때의 충돌 전략을 지정할 수 있다.

@Query() : 쿼리에서는 콜론(:)을 사용하여 함수의 인자를 참조할 수 있다.

 

Database를 추상 클래스로 정의.

@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 }
            }
        }
    }
}

@Database() : 데이터 베이스로 정의하는 어노테이션으로, 파라미터를 통해 등록할 테이블, 데이터 베이스 버전, 스키마 버전 기록 백업의 유무를 설정할 수 있다.

@Volatile : 해당 어노테이션으로 설정된 변수는 변경되면 캐시가 아닌 메인 메모리에 쓰여지고, 읽을 때도 캐시가 아닌 메인 메모리로부터 값을 읽어 항상 최신의 상태를 유지한다.

synchronized() : 해당 파라미터로 전달된 객체가 동기화의 범위가 되어 단 하나의 스레드의 접근만 허용한다. 이를 통해 여러 개의 데이터 베이스가 생성되는 것을 방지할 수 있다.

fallbackToDestructiveMigration() : 마이그레이션 메서드로, 기존 데이터를 보존하지 않고 새로운 스키마의 데이터베이스를 재생성한다.

*마이그레이션(Migration) : 스키마가 변경되어 기존의 데이터들을 새로운 스키마에 맞춰 보존하는 방법.


Flow로부터 UI 레이어가 데이터를 수집하는 경우

Problem

1. 구성 변경과 같은 수명 주기 이벤트로 인해 액티비티가 다시 실행될 때, 데이터 베이스로부터 데이터 요청이 다시 이루어진다.

2. 수명 주기 이벤트가 발생하면 기존 데이터를 손실하지 않도록 값을 캐시에 저장하려고 하는데, 구성 변경 중 데이터의 변경이 이뤄졌다면 UI 요소가 값의 변경을 인지하지 못하고 기존의 데이터를 캐시로부터 읽어와 새로운 데이터가 아닌 이전 데이터를 출력할 수도 있다.

3. 컴포저블의 수명이 종료되어 더 이상 수집할 필요가 없음에도 작업이 완료될 때까지 실행되어 자원을 낭비할 수도 있다.

 

Solution

Flow타입을 StateFlow타입으로 변환하여 UI에 노출하면 구성 변경으로 인해 UI가 재생성되어도 상태를 저장하는 StateFlow 특성 덕분에 새로운 데이터를 데이터베이스로부터 요청하지 않고 가장 최근의 데이터를 즉시 불러올 수 있게 된다. 값이 변경되자마자 새로운 값을 방출하므로 이전의 데이터를 출력할 일도 없다. 추가적으로 UI요소에서는 StateFlow타입 객체의 value에 직접 접근이 불가능하므로 State로 변환하여 사용해야 한다.

 

Conclusion

DAO의 메서드 반환형은 Flow로, ViewModel에서는 StateFlow로, UI요소에서는 State로 변환하여 사용.

 

뷰 모델에서 Flow -> StateFlow 변환.

class ItemDetailsViewModel(
    savedStateHandle: SavedStateHandle,
    private val itemsRepository: ItemsRepository
) : ViewModel() {
    
    val uiState: StateFlow<ItemDetailsUiState> =
        itemsRepository.getItemStream(itemId)
            .filterNotNull()
            .map {
                ItemDetailsUiState(outOfStock = it.quantity <= 0 ,itemDetails = it.toItemDetails())
            }.stateIn(
                scope = viewModelScope, //데이터를 수집하는 생명주기를 뷰 모델로 설정.
                started = SharingStarted.WhileSubscribed(TIMEOUT_MILLIS), //데이터를 방출하는 생명주기를 해당 데이터를 사용하는 UI로 설정.
                initialValue = ItemDetailsUiState() //초깃값.
            )

    companion object {
        private const val TIMEOUT_MILLIS = 5_000L
    }
    
    //...생략
}

*데이터의 방출 생명주기에 지연시간을 추가하면 네트워크의 일시적인 문제로 UI 컴포저블이 소멸되었다가 다시 재생성되어 그려질 때, 방출 요청 작업이 즉시 완료되지 않아 UI에 데이터가 표시되지 않는 경우를 어느정도 방지할 수 있다.

 

UI 컴포저블에서 StateFlow -> State 변환.

@Composable
fun HomeScreen(
    navigateToItemEntry: () -> Unit,
    navigateToItemUpdate: (Int) -> Unit,
    modifier: Modifier = Modifier,
    viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory)
) {
    val homeUiState = viewModel.homeUiState.collectAsState()
    
    //...생략
}

Room은 기본 스레드에서 데이터 베이스의 접근을 허용하지 않는다. 기본 스레드를 사용하는 viewModelScope가 Room의 DAO 정지 함수를 호출할 시, 데이터 베이스 작업은 내부적으로 백그라운드 스레드에서 실행된다. +정지 함수가 아닌 Flow타입의 반환형 함수도 백그라운드 스레드에서 실행된다.

class ItemDetailsViewModel(
    savedStateHandle: SavedStateHandle,
    private val itemsRepository: ItemsRepository
) : ViewModel() {
    //...생략

    fun reduceQuantityByOne() {
        viewModelScope.launch {
            val currentItem = uiState.value.itemDetails.toItem()
            if(currentItem.quantity > 0) {
                itemsRepository.updateItem(currentItem.copy(quantity = currentItem.quantity - 1))
            }
        }
    }
}

다양한 뷰 모델 객체 생성을 위한 ViewModelProvider.Factory 정의.

object AppViewModelProvider {
    val Factory = viewModelFactory {
        // Initializer for ItemEditViewModel
        initializer {
            ItemEditViewModel(
                this.createSavedStateHandle(),
                inventoryApplication().container.itemsRepository
            )
        }
        // Initializer for ItemEntryViewModel
        initializer {
            ItemEntryViewModel(inventoryApplication().container.itemsRepository)
        }

        // Initializer for ItemDetailsViewModel
        initializer {
            ItemDetailsViewModel(
                this.createSavedStateHandle(),
                inventoryApplication().container.itemsRepository
            )
        }

        // Initializer for HomeViewModel
        initializer {
            HomeViewModel(inventoryApplication().container.itemsRepository)
        }
    }
}

fun CreationExtras.inventoryApplication(): InventoryApplication =
    (this[AndroidViewModelFactory.APPLICATION_KEY] as InventoryApplication)

createSavedStateHandle() : UI 상태를 저장하고 이전 상태를 복원하는 SavedStateHandle객체를 생성하는 메서드로, 이는 해당 뷰 모델의 객체를 생성한 UI 컴포저블의 동적 경로를 가져오는데 사용된다.


동적 경로를 이용한 화면 전환.

NavHost(
        navController = navController,
        startDestination = HomeDestination.route,
        modifier = modifier
    ) {
        //..홈 화면과 항목 추가 컴포저블 생략
        
        composable(
            route = ItemDetailsDestination.routeWithArgs,
            arguments = listOf(navArgument(ItemDetailsDestination.itemIdArg) { 
                type = NavType.IntType
            })
        ) {
            ItemDetailsScreen(
            	//1. 세부사항 화면에서 편집 버튼에 대한 이벤트를 이용하여 키를 동적으로 전달. 기본 경로는 베이스로 적어두었다.
                navigateToEditItem = { navController.navigate("${ItemEditDestination.route}/$it") },
                navigateBack = { navController.navigateUp() }
            )
        }
        composable(
            route = ItemEditDestination.routeWithArgs, //3. 비로소 경로가 완성되어 1번의 경로와 일치하게 된다.
            arguments = listOf(navArgument(ItemEditDestination.itemIdArg) { //2. 해당 인자로 키가 전달되는데 타입을 지정할 수 있다.
                type = NavType.IntType
            })
        ) {
            ItemEditScreen(
                navigateBack = { navController.popBackStack() },
                onNavigateUp = { navController.navigateUp() }
            )
        }
    }
object ItemEditDestination : NavigationDestination {
    override val route = "item_edit"
    override val titleRes = R.string.edit_item_title
    const val itemIdArg = "itemId"
    val routeWithArgs = "$route/{$itemIdArg}" //기본 경로와 동적 경로를 통해 비로소 경로가 완성되는 것을 볼 수 있다.
} //ItemEditDestination의 기본 경로.
@Composable
fun ItemDetailsScreen(
    navigateToEditItem: (Int) -> Unit,
    navigateBack: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: ItemDetailsViewModel = viewModel(factory = AppViewModelProvider.Factory)
) {
    val uiState = viewModel.uiState.collectAsState()
    val coroutineScope = rememberCoroutineScope()

    Scaffold(
        topBar = {
            //..생략
        }, 
        floatingActionButton = {
            FloatingActionButton(
            	//편집 버튼에 대한 이벤트로 현재 항목에 대한 id를 전달하는 것을 볼 수 있다. 이로써 해당 키를 포함한 편집 화면으로 이동한다.
                onClick = { navigateToEditItem(uiState.value.itemDetails.id) },
                shape = MaterialTheme.shapes.medium,
                modifier = Modifier.padding(dimensionResource(id = R.dimen.padding_large))
            ) {
                Icon(
                    imageVector = Icons.Default.Edit,
                    contentDescription = stringResource(R.string.edit_item_title),
                )
            }
        }, modifier = modifier
    ) { ...생략
    }
}
class ItemEditViewModel(
    savedStateHandle: SavedStateHandle,
    private val itemsRepository: ItemsRepository
) : ViewModel() {

    var itemUiState by mutableStateOf(ItemUiState())
        private set

    //ItemEditScreen이 뷰 모델 객체를 생성하면, SavedStateHandle을 이용하여 해당 컴포저블의 동적 키를 가져올 수 있게 된다.
    private val itemId: Int = checkNotNull(savedStateHandle[ItemEditDestination.itemIdArg])

    init {
        viewModelScope.launch {
            //가져온 키를 활용하여 데이터베이스로부터 해당 항목에 대한 정보를 가져와 UI상태로 저장한다.
            itemUiState = itemsRepository.getItemStream(itemId)
                .filterNotNull()
                .first()
                .toItemUiState(true)
        }
    }

    //..생략
}

FloatingActionButton() : Scaffold의 프로퍼티를 사용하여 보통 오른쪽 하단의 버튼을 출력할 수 있는 컴포저블.


rememberCoroutineScope() - 호출되는 컴포지션에 바인딩된(의존적인) CoroutineScope를 반환하는 구성 가능한 함수로, UI요소에서 비동기적인 작업을 수행할 때 사용된다.

@Composable
fun ItemEntryScreen(
    navigateBack: () -> Unit,
    onNavigateUp: () -> Unit,
    canNavigateBack: Boolean = true,
    viewModel: ItemEntryViewModel = viewModel(factory = AppViewModelProvider.Factory)
) {
    val coroutineScope = rememberCoroutineScope() //CoroutineScope 생성.

    Scaffold(
        topBar = {
            //..생략
        }
    ) { innerPadding ->
        ItemEntryBody(
            itemUiState = viewModel.itemUiState,
            onItemValueChange = viewModel::updateUiState,
            onSaveClick = {
                          coroutineScope.launch { //비동기적으로 내부에 선언된 작업 실행.
                              viewModel.saveItem()
                              navigateBack()
                          }
            }
            //..생략
        )
    }
}

Room을 이용한 데이터베이스의 테스트 코드.

@RunWith(AndroidJUnit4::class) //안드로이드 프레임워크와의 통합된 테스트 환경을 사용할 수 있다. Room을 이용한 데이터베이스의 조작은 안드로이드 프레임워크의 서비스.
class ItemDaoTest {
    private lateinit var itemDao: ItemDao
    private lateinit var inventoryDatabase: InventoryDatabase

    @Before
    fun createDb() {
        val context: Context = ApplicationProvider.getApplicationContext()

        //해당 빌더를 사용하여 생성된 데이터베이스는 RAM에서만 존재하므로, 테스트가 종료되자마자 데이터베이스는 물론이거니와 저장된 데이터들도 삭제된다.
        inventoryDatabase = Room.inMemoryDatabaseBuilder(context, InventoryDatabase::class.java)
            .allowMainThreadQueries() //기본 스레드에서 쿼리를 실행할 수 있도록 허용.
            .build()
        itemDao = inventoryDatabase.itemDao()
    }
    
    //..생략
}
728x90