Skils/Android

[Android] DataSource 적용 및 분리

재한 2024. 5. 2. 00:05

프로젝트 관련 글

[Android] 네이버 간편 로그인(Kotlin)

[Android] 자동 로그인 with DataStore(Kotlin)

[Android] 네이버 로그인 프로필 가져오기

저번 PR에서 흥미로운 리뷰를 받았습니다.

 

사실 레포지토리 위에 하나의 계층을 두어 DataSource를 적용해 본 적이 없었습니다.

상위 계층을 하나 두면 오히려 복잡하지 않을까라는 생각으로 학습을 한 뒤 코드로 적용해 봤습니다.

 

사실 안드로이드에서 권장하는 DataLayer의 방식은 DataSource -> Respository의 흐름입니다.

하지만 굳이? Data Sources까지 만들어야 하는 이유가 뭘까에 대해서 궁금했습니다.

 

사실 이는 제가 Repository 패턴에 대해서 잘못 해석을 하고 개발을 했던 것 같습니다.

 

Repository는 직접적으로 데이터를 작성하고, 수정하는 형태가 아닌 데이터를 UI layer에 제공하고, UI에서 사용하려는 데이터로 변환하는 mapper 작업을 합니다.

Data를 직접적으로 작성, 수정, 삭제하는 CRUD 작업은 레포지토리가 아닌 DataSource 계층에서 해야 할 일이라는 뜻입니다.

 

예를 들어 API를 호출한 결과 DTO와 UI에서 사용하는 데이터의 형식은 다를 수 있습니다.

data class ArticleApiModel(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val modifications: Array<ArticleApiModel>,
    val comments: Array<CommentApiModel>,
    val lastModificationDate: Date,
    val authorId: Long,
    val authorName: String,
    val authorDateOfBirth: Date,
    val readTimeMin: Int
)

해당 데이터를 UI에서 모두 사용하지 않는다면 굳이 해당 데이터를 UI로 전달해야 할까요?

안되는 것은 아니지만 필요 이상으로 많은 정보를 전달할 필요도 없습니다.

data class Article(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val authorName: String,
    val readTimeMin: Int
)

UI에서 필요로 한 데이터를 바탕으로 클래스를 재정의하고, DataSource에서 내려받은 데이터를 변환하는 작업 즉 mapper를 

레포지토리에서 담당하고, 이를 viewModel로 전달합니다.

그 외 레포지토리에서 받아온 데이터에 대해 성공과 실패를 처리하기도 하고요.

 

하지만 이렇게 UI에서 사용할 데이터로 변환하는 mapper 작업은 부가적인 클래스를 만들어야 하기 때문에 클래스 수가 많아지는 단점이 있지만, 실제 사용하려는 데이터의 형태와 다를 경우 Model을 분리하는 것을 권장한다고 합니다.

 

많은 사람들이 그렇듯 저도 Local DataSource와 Remote DataSource로 분리를 해 데이터의 출처를 명확하게 했습니다.

Local DataSource는 room, sharedPreference, dataStore 등 안드로이드 내부 저장소를 의미합니다.

반대로 remote는 서버와 Network 통신을 통해 내려받은 데이터의 출처를 의미합니다.

LocalDataSource

interface LocalDataSource {
    suspend fun getAccessToken(): String
    suspend fun getRefreshToken(): String
    suspend fun saveAccessToken(accessToken: String)
    suspend fun saveRefreshToken(refreshToken: String)
    suspend fun deleteAccessToken()
    suspend fun deleteRefreshToken()
}

저는 DataStore를 이용해 토큰을 저장, 삭제, 조회하기 때문에 LocalDataSource를 다음과 같이 정의했습니다.

class LocalDataSourceImpl @Inject constructor(
    private val dataStore: DataStore<Preferences>,
) : LocalDataSource {
    override suspend fun getAccessToken(): String = dataStore.data.map { preferences ->
        preferences[ACCESS_TOKEN_KEY] ?: ""
    }.first()

    override suspend fun getRefreshToken(): String = dataStore.data.map { preferences ->
        preferences[REFRESH_TOKEN_KEY] ?: ""
    }.first()

    override suspend fun saveAccessToken(accessToken: String) {
        dataStore.edit { prefs ->
            prefs[ACCESS_TOKEN_KEY] = accessToken
        }
    }

    override suspend fun saveRefreshToken(refreshToken: String) {
        dataStore.edit { prefs ->
            prefs[REFRESH_TOKEN_KEY] = refreshToken
        }
    }

    override suspend fun deleteAccessToken() {
        dataStore.edit { prefs ->
            prefs.remove(ACCESS_TOKEN_KEY)
        }
    }

    override suspend fun deleteRefreshToken() {
        dataStore.edit { prefs ->
            prefs.remove(ACCESS_TOKEN_KEY)
        }
    }

    companion object {
        val ACCESS_TOKEN_KEY = stringPreferencesKey(BuildConfig.ACCESS_TOKEN_KEY)
        val REFRESH_TOKEN_KEY = stringPreferencesKey(BuildConfig.REFRESH_TOKEN_KEY)
    }
}

위에서 언급했듯이 DataSource의 책임은 데이터의 CRUD 이기 때문에 다음과 같이 작성했습니다.

그저 해당 데이터를 사용할 Repository에 데이터를 내려주기만 하면 됩니다.

 

LocalDataSource와 의존하고 있는 Repository는 다음과 같이 구현해 봤습니다.

DataStoreRepositoryImpl

class DataStoreRepositoryImpl @Inject constructor(
    private val localDataSource: LocalDataSource,
) : DataStoreRepository {
    override suspend fun getUserToken(): Flow<ApiResponse<LoginResponse>> = flow {
        val accessToken = localDataSource.getAccessToken()
        val refreshToken = localDataSource.getRefreshToken()
        if (accessToken.isBlank().not() && refreshToken.isBlank().not()) {
            emit(
                ApiResponse.Success(
                    LoginResponse(
                        accessToken = accessToken,
                        refreshToken = refreshToken,
                    ),
                ),
            )
        } else if (accessToken.isBlank()) {
            emit(
                ApiResponse.Error(errorMessage = ErrorMessage.NO_ACCESS_TOKEN_MESSAGE),
            )
        } else {
            emit(
                ApiResponse.Error(errorMessage = ErrorMessage.NO_REFRESH_TOKEN_MESSAGE),
            )
        }
    }

    override suspend fun saveAccessToken(accessToken: String) {
        localDataSource.saveAccessToken(accessToken)
    }

    override suspend fun saveRefreshToken(refreshToken: String) {
        localDataSource.saveRefreshToken(refreshToken)
    }

    override suspend fun deleteAccessToken() {
        localDataSource.deleteAccessToken()
    }

    override suspend fun deleteRefreshToken() {
        localDataSource.deleteRefreshToken()
    }
}

DataSource로부터 얻어온 토큰을 통해 viewModel에게 전달한 결괏값을 핸들링하고, 사용될 정보만을 담는 객체로 변환합니다.

여기에선 토큰이 있는지 없는지만을 검사하기 때문에 isBlank.not을 통해서 비어있는지 검사하고, 그에 따른 결과를 전달합니다.

 

ViewModel의 코드는 변하지 않습니다.

실질적인 비즈니스 로직은 변화하지 않았기 때문에 Repository에서 내려주는 결괏값에 따라 UI를 업데이트합니다.

 

RemoteDataSource

기존에는 UserRepository가 api와 의존해서 서버 호출에 대한 데이터를 내려받고, 그 과정에서 에러 핸들링과 매퍼를 담당했습니다.

개선된 구조에서는 UserRepository가 api와 의존하는 것이 아닌 RemoteDataSource와 의존하고, 거기서 데이터를 내려받고, 핸들리오가 매퍼를 담당합니다.

 

UserRemoteDataSource

interface UserRemoteDataSource {
    suspend fun signUp(userRequest: UserRequest): Response<UserSignUpResponse>
}

UserRemoteDataSourceImpl

class UserRemoteDataSourceImpl @Inject constructor(
    private val userApi: UserApi,
) : UserRemoteDataSource {
    override suspend fun signUp(userRequest: UserRequest): Response<UserSignUpResponse> {
        return userApi.signUp(userRequest)
    }
}

RemoteDataSource가 서버와 직접적으로 맞닿아 통신을 합니다.

 

UserRepositoryImpl

class UserRepositoryImpl @Inject constructor(
    private val userRemoteDataSource: UserRemoteDataSource,
    private val localDataSource: LocalDataSource,
) : UserRepository {
    override fun signUp(userRequest: UserRequest): Flow<ApiResponse<Boolean>> = flow {
        runCatching {
            userRemoteDataSource.signUp(userRequest)
        }.onSuccess { response ->
            response.body()?.let {
                it.data?.let {
                    localDataSource.saveAccessToken(userRequest.accessToken)
                    localDataSource.saveRefreshToken(userRequest.refreshToken)
                    emit(ApiResponse.Success(data = it))
                } ?: run {
                    emit(
                        ApiResponse.Error(
                            errorCode = it.statusCode ?: ErrorCode.NONE,
                            errorMessage = it.message ?: "",
                        ),
                    )
                }
            }
        }.onFailure { // 다른 예외가 발생한 경우 -> 서버가 닫힌 경우
            emit(ApiResponse.Error(errorMessage = it.message ?: ""))
        }
    }
}

UserRepository는 LocalDataSource와 RemoteDataSource 모두 필요합니다.

회원가입 요청에 대한 결과를 위한 Remote, 성공했다면 Local에 저장하기 위한 LocalDataSource에 접근해야 하기 때문입니다.

 

기존의 코드는 UserRepository가 다른 레포지토리(DataStoreRepository)와 의존하는 구조였는데, DataSource라는 상위 계층을 의존하는 방향으로 변경됐고, 꽤나 직관적인 구조를 따르고 있습니다.

 

최종적인 구조는 아래와 같습니다.

 

UI에서 직접적으로 DataSource 클래스를 직접 참조하지 못하도록 중간 계층인 Repository를 이용하고,

Repository 클래스는 DataSource와 UI 사이에 위치해, mapper와 결과에 대한 에러 핸들링을 담당합니다.

 

위 그림에서 혼동이 오는 곳이 있는데, LoginViewModel이 DataStoreRepository를 의존하는 것이 아닌

User Repository가 RemoteDataSource와 LocalDataSource에 의존하고 있는 것입니다.

 

과거에는 레포지토리가 다른 레포지토리에 의존하지 않고, 데이터의 출처가 하나라면 레포지토리가 DataSource의 역할을 대신해도 되지 않을까?라는 생각으로 두 계층을 하나로 묶어서 사용했었습니다.

하지만 UserRepository처럼 데이터의 출처가 여러 곳이라면 위 방법은 조금 위험하며,

의존하는 Repository가 변경된다면, 변경되어야 할 코드가 굉장히 많아지고, 분리하는 것이 안전한 방식이라는 것을 느낀 것 같습니다.

이후 domain Layer 까지 생각한다면 더더욱 그렇게 해야하구요,,