Room은 스마트폰 내장 DB에 데이터를 저장하기 위해 사용하는 ORM 라이브러리 및 데이터 소스(서버로부터 데이터베이스에 대해 연결을 구축하기 위해 사용하는 데이터 제공자)다.
쉽게 말해서, Room은 데이터베이스의 객체를 코틀린(or 자바)의 객체로 매핑해준다.
Room을 통해 아래와 같은 기능을 수행할 수 있다.
앱에서 유지해야 하는 로컬 데이터, 예를 들어 노래 재생목록, 앨범, 재생한 기록, 좋아하는 아티스트, 개인 정보 기록 등을 저장할 수 있다.
기기가 네트워크에 액세스 할 수 없을 때도 사용자가 오프라인 상태로 계속 콘텐츠를 탐색할 수 있도록 관련 데이터를 캐시할 수 있다. 참고로 Room에 의해 생성된 데이터베이스는 앱 내부에 저장된다.
우리가 해줘야 되는 작업은 다음과 같다.
위 작업을 통해 DB에서 데이터를 가져올 수 있다.
하지만 이러한 데이터를 UI 레이어에서 사용하기 위해서는 추가 작업이 필요하다.
뮤직 플레이어 앱을 주제로, 오프라인 앨범 저장 기능을 포함한 Room과 Firestore DB를 사용하는 구조라고 가정하겠다.
@Entity(tableName = "albums")
data class AlbumEntity(
@PrimaryKey val id: String,
val title: String,
val artist: String,
val releaseDate: Long, // 발매일
val isDownloaded: Boolean = false // 오프라인 저장 여부
)
@Dao
interface AlbumDao {
@Query("SELECT * FROM albums ORDER BY releaseDate DESC")
suspend fun getAllAlbums(): List<AlbumEntity>
@Query("SELECT * FROM albums WHERE isDownloaded = 1")
suspend fun getDownloadedAlbums(): List<AlbumEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAlbums(albums: List<AlbumEntity>)
}
DAO에서 데이터베이스 작업(삽입, 조회, 삭제 등)을 정의한다.
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import android.content.Context
@Database(entities = [PerformanceEntity::class, BookingEntity::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
// Dao 정의
abstract fun performanceDao(): PerformanceDao
abstract fun bookingDao(): BookingDao
companion object {
// Volatile: 여러 스레드에서 인스턴스 변수를 최신 상태로 유지
@Volatile
private var INSTANCE: AppDatabase? = null
// 데이터베이스 인스턴스를 가져오는 함수
fun getDatabase(context: Context): AppDatabase {
// 이미 생성된 인스턴스가 있다면 반환하고, 없으면 생성
return INSTANCE ?: synchronized(this) {
Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database" // 데이터베이스 이름
)
.fallbackToDestructiveMigration() // 데이터베이스 스키마 변경 시 데이터를 삭제
.build()
.also { INSTANCE = it } // 새로 생성한 인스턴스를 저장
}
}
}
}
@Volatile
:
INSTANCE
변수의 변경 사항을 항상 최신 상태로 읽을 수 있도록 보장합니다.synchronized
블록:
Room.databaseBuilder
:
context.applicationContext
: 애플리케이션 컨텍스트를 사용하여 메모리 누수를 방지합니다.AppDatabase::class.java
: 생성할 데이터베이스 클래스."app_database"
: 데이터베이스 파일 이름.fallbackToDestructiveMigration()
:
싱글톤 구현:
getDatabase
메서드를 호출할 때, 항상 같은 인스턴스를 반환합니다.AppDatabase.getDatabase(context)
를 호출하여 데이터베이스를 쉽게 사용할 수 있음.Database 클래스는 정의된 DAO의 인스턴스를 앱에 제공한다.
결과적으로 앱은 DAO를 사용하여 데이터베이스에서 데이터를 검색할 수 있다.
전체 앱에 RoomDatabase 인스턴스 하나만 있으면 되므로 RoomDatabase를 싱글톤으로 만든다.
viewModel에서 바로 Dao를 통해 데이터를 가져올 수 있지만, Dao에 있는 메서드는 단지 하나의 쿼리문이다. 따라서 이 둘 사이의 중간다리 역할(데이터를 가공하기 위한) Repository가 필요하다.
class MusicRepository(
private val albumDao: AlbumDao,
private val firestore: FirebaseFirestore
) {
// Firestore에서 앨범 데이터를 가져와 Room에 저장
suspend fun syncAlbums() {
val snapshot = firestore.collection("albums").get().await()
val albums = snapshot.documents.mapNotNull { doc ->
doc.toObject(AlbumEntity::class.java)
}
albumDao.insertAlbums(albums) // 로컬 DB에 저장
}
// 로컬 DB에서 모든 앨범 가져오기
suspend fun getLocalAlbums(): List<AlbumEntity> {
return albumDao.getAllAlbums()
}
// 오프라인 저장된 앨범만 가져오기
suspend fun getDownloadedAlbums(): List<AlbumEntity> {
return albumDao.getDownloadedAlbums()
}
// Firestore에 오프라인 저장 상태 동기화
suspend fun updateDownloadStatus(albumId: String, isDownloaded: Boolean) {
firestore.collection("albums").document(albumId).update("isDownloaded", isDownloaded).await()
albumDao.insertAlbums(
listOf(albumDao.getAllAlbums().first { it.id == albumId }.copy(isDownloaded = isDownloaded))
)
}
}
"ViewModel은 DAO를 통해 데이터베이스와 상호작용하여 UI에 데이터를 제공한다"라고 나와있지만 실질적으로는 Repository를 먼저 지난 뒤에 DAO를 통해 데이터베이스와 상호작용한다.
모든 데이터베이스 작업은 기본 UI 스레드에서 벗어나 실행되어야 한다. 코루틴과 viewModelScope를 사용하면 된다. 즉 비동기적으로 실행되어야 한다.
@HiltViewModel
class MusicViewModel @Inject constructor(
private val repository: MusicRepository
) : ViewModel() {
private val _albums = MutableLiveData<List<AlbumEntity>>()
val albums: LiveData<List<AlbumEntity>> = _albums
private val _downloadedAlbums = MutableLiveData<List<AlbumEntity>>()
val downloadedAlbums: LiveData<List<AlbumEntity>> = _downloadedAlbums
init {
loadAlbums()
}
// 모든 앨범 로드
private fun loadAlbums() {
viewModelScope.launch {
try {
repository.syncAlbums() // Firestore에서 동기화
_albums.value = repository.getLocalAlbums() // Room 데이터 가져오기
} catch (e: Exception) {
// 에러 처리
}
}
}
// 다운로드된 앨범 로드
fun loadDownloadedAlbums() {
viewModelScope.launch {
_downloadedAlbums.value = repository.getDownloadedAlbums()
}
}
// 오프라인 저장 상태 변경
fun toggleDownload(albumId: String, isDownloaded: Boolean) {
viewModelScope.launch {
repository.updateDownloadStatus(albumId, isDownloaded)
}
}
}
@Composable
fun AlbumScreen(viewModel: MusicViewModel) {
val albums by viewModel.albums.observeAsState(emptyList())
LazyColumn {
items(albums) { album ->
Row(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Column {
Text(text = album.title, style = MaterialTheme.typography.h6)
Text(text = "by ${album.artist}")
}
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = {
viewModel.toggleDownload(album.id, !album.isDownloaded)
}) {
Icon(
imageVector = if (album.isDownloaded) Icons.Default.DownloadDone else Icons.Default.Download,
contentDescription = null
)
}
}
}
}
}