Hilt - compose로 Room 사용해보기

312·2024년 4월 15일

Hilt

목록 보기
2/3

지난 글에서 만든 Hilt 프로젝트를 바탕으로 일기장 예제 앱을 만들어보겠다.

1. Room 환경 구축

App Gradle

dependencies {
	...
	// Room
    implementation("androidx.room:room-runtime:2.6.1")
    ksp("androidx.room:room-compiler:2.6.1")
    implementation("androidx.room:room-ktx:2.6.1")
    ...
    }

현재 환경에 맞는 Room 버전을 추가해준다.

2. Entity, Dao, DataBase 만들기

Diary

@Entity(tableName = "diary")
data class Diary(
    @ColumnInfo(name = "diary_title") var title: String = "",
    @ColumnInfo(name = "diary_date") var date: LocalDateTime = LocalDateTime.now(),
    @ColumnInfo(name = "diary_content") var content: String = "",
) {
    @PrimaryKey(autoGenerate = true)
    var id: Long = 0

    override fun toString(): String {
        return "id = $id, name = $title, content = $content
    }
}

@Entity 어노테이션을 통해 SQLite에서 인식할 tableName을 정해준다.
이름을 설정해주지 않으면 클래스 이름으로 table이 생성된다.

@ColumnInfo 어노테이션으로 호출할 네이밍을 설정할 수 있다.
마찬가지로 설정해주지 않으면 변수명으로 생성된다.

@PrimaryKey 어노테이션은 이 ID를 SQLite에서 고유한 id로 설정하겠다는 것을 의미한다. AutoGenerate 기능을 활용하려면 Long, Int타입으로 지정해줘야 한다.
AutoGenerate를 true로 설정하면 따로 입력해주지 않아도 자동으로 고유값을 생성한다.

DiaryDao

@Dao
interface DiaryDao {

    // 생성
    @Insert
    fun insertDiary(diary: Diary)

    // 삭제
    @Query("DELETE FROM diary where id = :id")
    fun deleteDiary(id: Long)

    @Query("DELETE FROM diary")
    fun deleteAll()

    // 업데이트
    @Query("UPDATE diary SET diary_title = :title WHERE id = :id")
    fun updateTitle(id: Long, title: String)

    @Query("UPDATE diary SET diary_content = :content WHERE id = :id")
    fun updateContent(id: Long, content: String)

    @Query("UPDATE diary SET diary_date = :date WHERE id = :id")
    fun updateDate(id: Long, date: LocalDateTime)

    // 탐색
    @Query("SELECT * FROM diary")
    fun getAll(): Flow<List<Diary>>

    @Query("SELECT * FROM diary where id = :id")
    fun getDiary(id: Long): Diary

@Dao 어노테이션으로 선언해주고 필요한 쿼리들을 생성해준다.

getAll 함수에서 Flow 타입으로 받는 이유는 많은 데이터를 송수신할때 발생할 수 있는 에러나 최신 데이터 검증 등을 안전하게 관리하게 위해서 사용해준다.

DiaryDatabase

@Database(entities = [Diary::class], version = 1, exportSchema = false)
@TypeConverters(ListConverters::class)
abstract class DiaryDatabase : RoomDatabase() {
    abstract fun diaryDao(): DiaryDao
}

@Database 어노테이션으로 Database임을 선언한다.

entites는 어떤 entity를 활용하는지 Database에 알려준다.

version은 현재 Database나 entity가 변경될때마다 버전을 1씩 올려주면 된다.
버전 기록을 유지하고 싶지 않으면 비활성화 할 수 있다.

exportSchema는 자동 마이그레이션을 활용할 때 사용한다.
구조나 Entity가 변경될 때 기존 데이터를 활용할지 모두 삭제할지 정할 수 있다.

@TypeConverters는 DB가 인식하지 못하는 타입을 변환할 클래스를 입력한다.

ListConverters

class ListConverters {
	@TypeConverter
    fun listToJson(value: List<String?>): String? {
        return Gson().toJson(value)
    }

    @TypeConverter
    fun jsonToList(value: String): List<String>? {
        return Gson().fromJson(value, Array<String>::class.java)?.toList()
    }

    @TypeConverter
    fun longToDate(value: String?): LocalDateTime? {
        return value?.let { LocalDateTime.parse(it) }
    }

    @TypeConverter
    fun dateToLong(date: LocalDateTime?): String? {
        return date?.toString()
    }	
}

Database에서 지원되지 않는 List나 LocatDateTime 타입 등은 Database가 인식할 수 있는 작업이 병행되어야 한다.
Database에서 데이터를 꺼내고 다시 원래 타입으로 변환하는 작업도 필요하기 때문에 변환 함수를 @TypeConveter 어노테이션으로 선언하고 만들어준다.

3. Repository 생성하기

DiaryRepository

class DiaryRepository @Inject constructor(
    private val diaryDao: DiaryDao
) {
	val diaries: Flow<List<Diary>> = diaryDao.getAll().flowOn(Dispatchers.IO).conflate()
    
    suspend fun insertDiaryDao(diary: Diary) = diaryDao.insertDiary(diary)

    suspend fun deleteDiaryDao(id: Long) = diaryDao.deleteDiary(id)

    suspend fun deleteAllDao() = diaryDao.deleteAll()
    
    suspend fun updateDiaryDao(
        id: Long,
        title: String? = null,
        content: String? = null,
        date: LocalDateTime? = null,
    ) {
        title?.let { diaryDao.updateTitle(id, it) }
        content?.let { diaryDao.updateContent(id, it) }
        date?.let { diaryDao.updateDate(id, it) }
    }
    
    suspend fun getAllDao(): Flow<List<Diary>> =
        diaryDao.getAll().flowOn(Dispatchers.IO).conflate()

    suspend fun getDiaryDao(id: Long): Diary =
        diaryDao.getDiary(id)
}

Repository에서는 @Inject로 생성자를 주입해주면 diaryDao에서 생성해준 함수들을 사용할 수 있다. suspend fun으로 함수를 생성해서 비동기 함수임을 알려준다.

4. AppModule에 주입해주기

AppModule

@Module
@InstallIn(SingletonComponent::class)
object AppModule {

	@Singleton
    @Provides
    fun provideDiaryDao(diaryDatabase: DiaryDatabase): DiaryDao = diaryDatabase.diaryDao()
    
    @Singleton
    @Provides
    fun provideDiaryRepository(diaryDao: DiaryDao): DiaryRepository = DiaryRepository(diaryDao)
}

AppModule은 앱 내의 Hilt 의존성을 관리하는 클래스로 생성했다.

@Module 어노테이션으로 Hilt 앱 내의 어떤 컴포넌트에 주입되는지 알려줄 수 있다.

@InstallIn은 Module과 함께 어디서 이 모듈안의 객체가 생성될것인지 정해줄 수 있다.

@Singleton을 통해 싱글톤임을 선언한다.

@Provides는 @Binds를 사용할 수 없는 외부 클래스 등을 주입할 때 사용한다.
Room을 사용하기 위한 예제이므로 Provides를 사용했다.
기본적으로 null을 허용하지 않으며 값을 return하지 않는다면 @Provides를 사용할 수 없다.

5. ViewModel에서 호출하기

DiaryViewModel

@HiltViewModel
class DiaryViewModel @Inject constructor(
    private val repository: DiaryRepository
) : ViewModel() {

	...

	private val _diaries = MutableStateFlow<List<Diary>>(emptyList())
	val diaries = _diaries.asStateFlow()
    
    ...

	init {
        viewModelScope.launch(Dispatchers.IO) {
            repository.getAllDao().distinctUntilChanged().collect { diaryList ->
                if (diaryList.isEmpty()) {
                    Log.d(TAG, "empty diary table")
                } else {
                    _diaries.value = diaryList
                }
            }
        }
    }
    ...

저번에 생성한 ViewModel을 커스텀해서 repository를 생성자에 넣어줬다.
ViewModel이 init될때 비동기로 repository에서 Database에 저장된 일기를 가져와 diaries StateFlow 변수에 입력해준다.

이렇게 가져온 diaries는

@Composable
fun diaryScreen(diaryViewModel: DiaryViewModel = hiltViewModel()) {

	...    
	val diaries by diaryViewModel.diaries.collectAsState()
    ...
}

이렇게 Compose UI내에서 최신 데이터를 유지하며 원하는 대로 UI를 만들어주면 된다.

// 느낀점

RoomDB 자체는 안드로이드에서 기본적으로 지원했던 만큼 레퍼런스를 찾기도 쉬웠고 Hilt와의 결합도 어렵지 않았다.

게시글을 작성하며 어노테이션을 하나하나 어떤 역할을 하는지 살펴보기 위해 내부 구조를 열심히 봤다..
Provides와 Bind등 이해하기 어려운 부분도 많았지만 (사실 아직도 잘 모르겠다..) 전반적으로 사용자가 이해하기 쉽게 주석이나 직관적인 네이밍을 많이 사용해줘서 좋았다!

다음번에는 Retrofit을 활용해보기로 하겠다.

profile
안드로이드 개발자 이상일입니다.

0개의 댓글