Grusie 안드로이드 개발 기술 블로그

[Android] 클린아키텍처에서 Room DB 사용해, 임시저장 기능 구현하기 본문

안드로이드 개발/클린아키텍처

[Android] 클린아키텍처에서 Room DB 사용해, 임시저장 기능 구현하기

grusie 2024. 5. 20. 11:18
728x90
반응형
SMALL

사이드 프로젝트를 진행하면서, 임시저장 기능을 구현해야 했다.

클린아키텍처 구조를 사용중이며, Room DB를 통해 내부저장소에 저장 할 생각을 하였다.

 

거두절미하고 코드를 보며 이해해보자

 

build.gradle.kts(Module:data)

// Room DB
kapt("androidx.room:room-compiler:2.6.1")
implementation ("androidx.room:room-ktx:2.6.1")

Room DB를 사용하기 위한 의존성을 선언해준다.

kapt는 hilt를 사용하기 위해 이미 선언 해뒀을 것이라고 생각한다.

 

Database

@Database(
    entities = [LocalSlowMailInfo::class],
    version = 1,
    exportSchema = false
)
abstract class SlowMailBoxDatabase : RoomDatabase() {
    abstract fun getSlowMailBoxDao() : SlowMailBoxDao
}

Database를 선언해주는 파일이다.

Di Module에서 의존성을 주입해서 사용할 예정이다.

Dao를 생성해서 반환해주는 abstract 함수가 있다.

Dao

@Dao
interface SlowMailBoxDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertSlowMail(slowMailInfo: LocalSlowMailInfo): Long

    @Query("SELECT * FROM slowMail WHERE spaceId = :spaceId ORDER BY createAt DESC")
    suspend fun getAllSlowMails(spaceId: Long): List<LocalSlowMailInfo>

    @Delete
    suspend fun deleteSlowMail(slowMailInfo: LocalSlowMailInfo)
}

실질적인 데이터베이스 로직을 수행하기 위한 DAO를 작성하였다.

추 후 추가/수정이 되겠지만, 초기에는 이렇게 구상하였다.

리턴값을 Flow형태Result형태로 하려고 하였으나, RoomDB에서는 Result 타입은 지원하지 않고, Flow 타입은 Select문에는 적용되나, Insert문에는 적용되지 않아, 어차피 try-catch를 사용해야 하며, 데이터를 실시간으로 방출하는 것이 아니기에, Flow를 사용하지 않고, dataSource에서 Result로 변환하여 사용하고자 했다.

Entity

@Entity(tableName = "slowMail")
data class LocalSlowMailInfo(
    @PrimaryKey(autoGenerate = true)
    val id: Long?,
    val spaceId: Long?,
    val title: String?,
    val content: String?,
    val createAt: String?,
    val paperId: Long?,
    val sendAt: String?
)

데이터베이스에서 사용할 엔티티이다.

편지를 작성하는데 필요한 데이터들을 저장한다. 추 후, 편지를 전송하는 상대방 Id 등이 추가 될 것으로 예상된다.

 

룸DB를 사용하기 위한 기본 세팅은 끝이났다.

이제, 클린아키텍처 관점으로 Di, Mapper, 로직을 위주로 살펴보자

 

DatabaseModule

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
    @Provides
    @Singleton
    fun provideSlowMailBoxDatabase(
        @ApplicationContext context: Context
    ): SlowMailBoxDatabase = Room
        .databaseBuilder(context, SlowMailBoxDatabase::class.java, "slowMail.db")
        .build()

    @Provides
    @Singleton
    fun provideSlowMailBoxDao(slowMailBoxDatabase: SlowMailBoxDatabase): SlowMailBoxDao =
        slowMailBoxDatabase.getSlowMailBoxDao()
}

Database와 관련된, Dao와 Database를 주입해주는 DI Module이다.

Singleton으로 제공한다.

Room 데이터베이스를 생성하는 과정은 ApplicationContext를 가지고, Room의 databaseBuilder 메서드를 활용하여 생성한다.

SMALL

DataSource 인터페이스

interface SlowMailBoxDataSource {
	...
    suspend fun saveTempSlowMail(
        additionalMailIfoDto: AdditionalMailIfoDto
    ): Result<Long>

    suspend fun getAllTempSavedMails(spaceId: Long): Result<LocalSlowMailInfoList>
}

repository 전달할 내용들을 담은 인터페이스이다.

실제 작동시킬 함수를 선언해두었다. 리턴값을 보면 Result로 리턴하도록 선언하였다.

 

RemoteSource 구현체

class SlowMailBoxRemoteSource @Inject constructor(
    private val slowMailBoxService: SlowMailBoxService,
    private val slowMailBoxDao: SlowMailBoxDao
) : SlowMailBoxDataSource {
	...
    
    override suspend fun saveTempSlowMail(additionalMailIfoDto: AdditionalMailIfoDto): Result<Long> {
        return try {
            Result.success(slowMailBoxDao.insertSlowMail(additionalMailIfoDto.toLocalModel()))
        }catch (e:Exception) {
            Result.failure(e)
        }
    }

    override suspend fun getAllTempSavedMails(spaceId: Long): Result<LocalSlowMailInfoList> {
        return try {
            Result.success(LocalSlowMailInfoList(slowMailBoxDao.getAllSlowMails(spaceId)))
        }catch (e:Exception) {
            Result.failure(e)
        }
    }
}

이전에 선언한 DataSource를 구현한 RemoteSource이다.

추가적인 내용은 없으며, dao를 주입받아 사용하며, 원하는 타입으로 변환하기 위한 매핑을 진행한다.

ex) try - catch를 활용하여 Result 타입으로 리턴하기

ex2) .toLocalModel()을 활용하여 repository에서 넘어온 데이터를 로컬DB에 저장할 타입으로 매핑

 

Mapper

...
fun AdditionalMailIfoDto.toLocalModel(): LocalSlowMailInfo {
    return LocalSlowMailInfo(
        id = null,
        spaceId = this.spaceId,
        title = this.title,
        content = this.content,
        createAt = LocalDateTime.now().toString(),
        paperId = this.paperId,
        sendAt = this.sendAt
    )
}

fun LocalSlowMailInfo.toAdditionalMailInfo(): AdditionalMailInfo {
    return AdditionalMailInfo(
        spaceId = this.spaceId ?: 0,
        title = this.title ?: "",
        content = this.content ?: "",
        paperId = this.paperId ?: 0,
        sendAt = this.sendAt ?: ""
    )
}

fun LocalSlowMailInfoList.toAdditionalMailInfoList(): List<AdditionalMailInfo> {
    return this.localSlowMailInfoList.map { it.toAdditionalMailInfo() }
}
...

모든 데이터 타입과 매퍼를 보여주는 것은 시간 낭비이니, 이렇게만 보여주겠다.

 

Repository 인터페이스

interface SlowBoxRepository {
    ...
    
    suspend fun saveTempSlowMail(additionalMailInfo: AdditionalMailInfo): Result<Long>

    suspend fun getAllTempSavedMails(spaceId: Long): Result<List<AdditionalMailInfo>>
}

DataSource에서 받아온 데이터들을 Domain계층에서 사용하기 위한 Repository 인터페이스이다.

 

Repository 구현체

class DefaultSlowBoxRepository @Inject constructor(private val slowMailBoxDataSource: SlowMailBoxDataSource) :
    SlowBoxRepository {
    ...
    override suspend fun saveTempSlowMail(additionalMailInfo: AdditionalMailInfo): Result<Long> {
        return slowMailBoxDataSource.saveTempSlowMail(additionalMailInfo.toDto())
    }

    override suspend fun getAllTempSavedMails(spaceId: Long): Result<List<AdditionalMailInfo>> {
        return slowMailBoxDataSource.getAllTempSavedMails(spaceId).map{it.toAdditionalMailInfoList()}
    }
}

repository 구현체이다.

dataSource를 주입받아 사용하며, 리턴 받은 값들을, useCase에서 사용할 수 있는, 형태로 매핑하여 사용

 

UseCase

class GetAllTempSavedMailsUseCase(private val slowBoxRepository: SlowBoxRepository) {
    suspend operator fun invoke(spaceId: Long): Result<List<AdditionalMailInfo>> {
        return slowBoxRepository.getAllTempSavedMails(spaceId)
    }
}

class SaveTempSlowMailUseCase(private val slowBoxRepository: SlowBoxRepository) {
    suspend operator fun invoke(additionalMailInfo: AdditionalMailInfo): Result<Long> {
        return slowBoxRepository.saveTempSlowMail(additionalMailInfo)
    }
}

repository를 주입받아 사용하는 UseCase

해당하는 로직이 있으면 추가 구현해야하나, 현재는 그냥 넘겨주는 형태이다.

 

ViewModel

fun saveTempMailInfo(title: String, content: String) {
    setEditSlowMailBoxUiState(EditSlowMailBoxUiState.Loading)

    viewModelScope.launch {
        delay(500)
        slowMailBoxUseCases.saveTempSlowMailUseCase(
            getAdditionalMailInfoUiModel(
                title,
                content
            ).toDomainModel()
        ).onSuccess {
            setEditSlowMailBoxUiState(EditSlowMailBoxUiState.SuccessTempSave)
        }.onFailure {
            Log.e(
                "${this::class.simpleName}",
                (it as RemoteError).toStringForLog()
            )
            setEditSlowMailBoxUiState(EditSlowMailBoxUiState.Error((it).toStringForUser()))
        }
    }
}

useCase를 사용하는 viewModel로서, 임시저장의 경우, title과 content를 가지고 domain에 해당하는 모델로 만들어서 useCase로 전달한다.

이 외에 정보들은, viewModel에서 관리하여, 파라미터로 전달 받지 않고 변환한다.

result를 가지고 Success/Failure에 따라 해당 데이터를 처리한다.

리스트를 불러오는 함수의 경우 Success일 때, UiModel로 매핑하는 과정이 추가로 필요 할 것이다.

 

delay(500)을 준 이유는, RoomDB에 저장하는 과정이 너무 빨라, Loading Progress가 사용자에게 보이지 않을 정도의 속도라서, 앱이 강제종료 되는 것 처럼 보이기에, 딜레이를 주어 progress bar를 보여주도록 구현하고, 완료되었을 시, 액티비티 종료와 함께, Toast를 띄우도록 구현하였다.

 

Activity

private fun confirmTempSave() {
    ConfirmTempSaveDialog.newInstance(
        positiveCallback = {
            viewModel.saveTempMailInfo(
                binding.etTitle.text.toString(),
                binding.viewRecordCard.etContent.text.toString()
            )
        }
    ).apply {
        show(supportFragmentManager, ConfirmTempSaveDialog.CONFIRM_TEMP_SAVE_DIALOG)
    }
}

임시 저장을 진행할 액티비티이다.

다이얼로그를 띄우고, 저장 버튼을 클릭 시, title과 content를 viewModel에 보내도록 한다.

다이얼로그에서 버튼을 선택했을 때, 인터페이스를 통해 사용하는 방식도 있으나, 컴포즈를 사용하면서 활용한 () -> Unit 형태의 리스너가 편하여 이렇게 작성하였다.

 

결과

RoomDB를 활용한 임시저장 기능 결과물

 

Database Inspector

원하는 내용이 잘 저장된 것을 볼 수 있다.

 

후기

RoomDB를 활용하여 데이터 저장을 여러번 해본 적이 있어서 금방 했던 것 같다. 총 소요시간 4시간 정도?

Result타입이 되지 않는다는 점과, List타입은 되나, List타입이 들어있는 객체는 안 된다는 것을 깨닫는데에 오래 걸렸던 것 같다. DI를 사용하니.. Impl이 제대로 생성되지 않았다는 에러 메세지만 보였기 때문이었다.

클린아키텍처 구조에서도 사용해 본적이 있으나, 확실히 하고 넘어 간 것이 아닌 것 같아 RoomDB에 대해서 안 그래도 정리하는 게시글을 작성해야 겠다고 생각하였었다.

기회가 되어 이렇게 작성하게 되었으며, 다음 업데이트를 진행할 때 migration이 필요할 것 같으니, 추 후 그것도 포함하여 업로드 할 예정이다.

반응형
LIST