ROOM DB 아키텍처

이윤설·2024년 12월 10일
0

안드로이드 연구소

목록 보기
9/33

Room DB란?

Room은 스마트폰 내장 DB에 데이터를 저장하기 위해 사용하는 ORM 라이브러리 및 데이터 소스(서버로부터 데이터베이스에 대해 연결을 구축하기 위해 사용하는 데이터 제공자)다.
쉽게 말해서, Room은 데이터베이스의 객체를 코틀린(or 자바)의 객체로 매핑해준다.

Room을 통해 아래와 같은 기능을 수행할 수 있다.

  • 앱에서 유지해야 하는 로컬 데이터, 예를 들어 노래 재생목록, 앨범, 재생한 기록, 좋아하는 아티스트, 개인 정보 기록 등을 저장할 수 있다.

  • 기기가 네트워크에 액세스 할 수 없을 때도 사용자가 오프라인 상태로 계속 콘텐츠를 탐색할 수 있도록 관련 데이터를 캐시할 수 있다. 참고로 Room에 의해 생성된 데이터베이스는 앱 내부에 저장된다.

Room의 구성요소

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

흐름

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

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

위 작업을 통해 DB에서 데이터를 가져올 수 있다.
하지만 이러한 데이터를 UI 레이어에서 사용하기 위해서는 추가 작업이 필요하다.

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

아키텍처 설명

뮤직 플레이어 앱을 주제로, 오프라인 앨범 저장 기능을 포함한 Room과 Firestore DB를 사용하는 구조라고 가정하겠다.

1. Room Entity 정의

@Entity(tableName = "albums")
data class AlbumEntity(
    @PrimaryKey val id: String,
    val title: String,
    val artist: String,
    val releaseDate: Long,  // 발매일
    val isDownloaded: Boolean = false // 오프라인 저장 여부
)

2. DAO 정의

@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에서 데이터베이스 작업(삽입, 조회, 삭제 등)을 정의한다.

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

3. Database 구현

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 } // 새로 생성한 인스턴스를 저장
            }
        }
    }
}
  1. @Volatile:

    • 여러 스레드에서 INSTANCE 변수의 변경 사항을 항상 최신 상태로 읽을 수 있도록 보장합니다.
    • 이를 통해 다중 스레드 환경에서 데이터베이스 인스턴스를 중복 생성하지 않도록 합니다.
  2. synchronized 블록:

    • 인스턴스를 생성할 때, 단일 스레드만 접근할 수 있도록 동기화합니다.
    • 이로써 데이터베이스가 여러 번 초기화되는 문제를 방지합니다.
  3. Room.databaseBuilder:

    • context.applicationContext: 애플리케이션 컨텍스트를 사용하여 메모리 누수를 방지합니다.
    • AppDatabase::class.java: 생성할 데이터베이스 클래스.
    • "app_database": 데이터베이스 파일 이름.
  4. fallbackToDestructiveMigration():

    • 스키마 버전이 변경될 때 기존 데이터를 삭제하고 새 데이터베이스를 생성합니다.
    • 데이터 손실을 방지하려면 적절한 마이그레이션 로직을 추가해야 합니다.
  5. 싱글톤 구현:

    • getDatabase 메서드를 호출할 때, 항상 같은 인스턴스를 반환합니다.
    • 이미 생성된 인스턴스가 있다면 재사용하고, 없다면 새로 생성합니다.

적용 후 장점

  1. 스레드 안전성: 여러 스레드에서 데이터베이스를 동시에 사용하더라도 충돌 없이 작동.
  2. 중앙 집중 관리: 데이터베이스 인스턴스 생성과 접근 코드가 한곳에 있어 유지보수 용이.
  3. 효율성: 인스턴스를 한 번만 생성하므로 메모리와 리소스를 절약.
  4. 간편성: AppDatabase.getDatabase(context)를 호출하여 데이터베이스를 쉽게 사용할 수 있음.

Database 클래스는 정의된 DAO의 인스턴스를 앱에 제공한다.
결과적으로 앱은 DAO를 사용하여 데이터베이스에서 데이터를 검색할 수 있다.

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

  • 사용할 Dao는 추상 메서드로 선언해줘야 한다.
  • 여러 스레드에서 getDatabase를 요청할 경우 DB가 여러 개 생성될 수 있다. 따라서 synchronized 블록을 사용하여 한 번에 한 실행 스레드만 이 코드 블록에 들어갈 수 있도록 해준다.

4. Repository 구현

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

  • viewModel와 Dao의 중간다리 역할을 한다.
  • 다양한 데이터 소스(room도 데이터 소스 중 하나이다.)로부터 데이터를 가져오며 충돌을 해결한다.
  • 단일 진입점을 제공한다.
    • ViewModel이 Firestore와 Room의 세부 구현을 몰라도 된다.
  • 충돌 관리
    • 로컬 데이터와 원격 데이터 간의 동기화를 Repository에서 통합 관리한다.
  • 필요 시 AppContainer, AppDataContainer와 같은 부가적인 클래스를 만들어서 관리해도 좋다.
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))
        )
    }
}

5. ViewModel에 적용

"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)
        }
    }
}

6. UI에서 ViewModel 사용

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

전체 구조


https://jjuunn.tistory.com/40

profile
화려한 외면이 아닌 단단한 내면

0개의 댓글

관련 채용 정보