Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ import com.threegap.bitnagil.data.report.datasource.ReportDataSource
import com.threegap.bitnagil.data.report.datasourceImpl.ReportDataSourceImpl
import com.threegap.bitnagil.data.routine.datasource.RoutineRemoteDataSource
import com.threegap.bitnagil.data.routine.datasourceImpl.RoutineRemoteDataSourceImpl
import com.threegap.bitnagil.data.user.datasource.UserDataSource
import com.threegap.bitnagil.data.user.datasourceImpl.UserDataSourceImpl
import com.threegap.bitnagil.data.user.datasource.UserLocalDataSource
import com.threegap.bitnagil.data.user.datasource.UserRemoteDataSource
import com.threegap.bitnagil.data.user.datasourceImpl.UserLocalDataSourceImpl
import com.threegap.bitnagil.data.user.datasourceImpl.UserRemoteDataSourceImpl
import com.threegap.bitnagil.data.version.datasource.VersionDataSource
import com.threegap.bitnagil.data.version.datasourceImpl.VersionDataSourceImpl
import dagger.Binds
Expand Down Expand Up @@ -56,7 +58,11 @@ abstract class DataSourceModule {

@Binds
@Singleton
abstract fun bindUserDataSource(userDataSourceImpl: UserDataSourceImpl): UserDataSource
abstract fun bindUserLocalDataSource(userLocalDataSourceImpl: UserLocalDataSourceImpl): UserLocalDataSource

@Binds
@Singleton
abstract fun bindUserRemoteDataSource(userRemoteDataSourceImpl: UserRemoteDataSourceImpl): UserRemoteDataSource

@Binds
@Singleton
Expand Down
3 changes: 3 additions & 0 deletions data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@ dependencies {
implementation(libs.bundles.retrofit)
implementation(libs.play.services.location)
implementation(libs.kotlinx.coroutines.play)

testImplementation(libs.androidx.junit)
testImplementation(libs.kotlin.coroutines.test)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.threegap.bitnagil.data.user.datasource

import com.threegap.bitnagil.domain.user.model.UserProfile
import kotlinx.coroutines.flow.StateFlow

interface UserLocalDataSource {
val userProfile: StateFlow<UserProfile?>
suspend fun saveUserProfile(userProfile: UserProfile)
fun clearCache()
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ package com.threegap.bitnagil.data.user.datasource

import com.threegap.bitnagil.data.user.model.response.UserProfileResponse

interface UserDataSource {
interface UserRemoteDataSource {
suspend fun fetchUserProfile(): Result<UserProfileResponse>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.threegap.bitnagil.data.user.datasourceImpl

import com.threegap.bitnagil.data.user.datasource.UserLocalDataSource
import com.threegap.bitnagil.domain.user.model.UserProfile
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class UserLocalDataSourceImpl @Inject constructor() : UserLocalDataSource {
private val _userProfile = MutableStateFlow<UserProfile?>(null)
override val userProfile: StateFlow<UserProfile?> = _userProfile.asStateFlow()

override suspend fun saveUserProfile(userProfile: UserProfile) {
_userProfile.update { userProfile }
}

override fun clearCache() {
_userProfile.update { null }
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
package com.threegap.bitnagil.data.user.datasourceImpl

import com.threegap.bitnagil.data.common.safeApiCall
import com.threegap.bitnagil.data.user.datasource.UserDataSource
import com.threegap.bitnagil.data.user.datasource.UserRemoteDataSource
import com.threegap.bitnagil.data.user.model.response.UserProfileResponse
import com.threegap.bitnagil.data.user.service.UserService
import javax.inject.Inject

class UserDataSourceImpl @Inject constructor(
class UserRemoteDataSourceImpl @Inject constructor(
private val userService: UserService,
) : UserDataSource {
) : UserRemoteDataSource {
override suspend fun fetchUserProfile(): Result<UserProfileResponse> =
safeApiCall {
userService.fetchUserProfile()
}
safeApiCall { userService.fetchUserProfile() }
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,59 @@
package com.threegap.bitnagil.data.user.repositoryImpl

import com.threegap.bitnagil.data.user.datasource.UserDataSource
import com.threegap.bitnagil.data.user.datasource.UserLocalDataSource
import com.threegap.bitnagil.data.user.datasource.UserRemoteDataSource
import com.threegap.bitnagil.data.user.model.response.toDomain
import com.threegap.bitnagil.domain.user.model.UserProfile
import com.threegap.bitnagil.domain.user.repository.UserRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class UserRepositoryImpl @Inject constructor(
private val userDataSource: UserDataSource,
private val userLocalDataSource: UserLocalDataSource,
private val userRemoteDataSource: UserRemoteDataSource,
) : UserRepository {
override suspend fun fetchUserProfile(): Result<UserProfile> =
userDataSource.fetchUserProfile().map { it.toDomain() }
private val fetchMutex = Mutex()

override fun observeUserProfile(): Flow<Result<UserProfile>> = flow {
fetchAndCacheIfNeeded().onFailure {
emit(Result.failure(it))
return@flow
}

emitAll(
userLocalDataSource.userProfile
.filterNotNull()
.map { Result.success(it) },
)
}

override suspend fun getUserProfile(): Result<UserProfile> {
return fetchAndCacheIfNeeded()
}

override fun clearCache() {
userLocalDataSource.clearCache()
}

private suspend fun fetchAndCacheIfNeeded(): Result<UserProfile> {
userLocalDataSource.userProfile.value?.let { return Result.success(it) }

return fetchMutex.withLock {
userLocalDataSource.userProfile.value?.let { return@withLock Result.success(it) }

userRemoteDataSource.fetchUserProfile()
.onSuccess { response ->
userLocalDataSource.saveUserProfile(response.toDomain())
}
.map { it.toDomain() }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.threegap.bitnagil.data.user.repositoryImpl

import com.threegap.bitnagil.data.user.datasource.UserLocalDataSource
import com.threegap.bitnagil.data.user.datasource.UserRemoteDataSource
import com.threegap.bitnagil.data.user.model.response.UserProfileResponse
import com.threegap.bitnagil.domain.user.model.UserProfile
import com.threegap.bitnagil.domain.user.repository.UserRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger

class UserRepositoryImplTest {

private lateinit var localDataSource: FakeUserLocalDataSource
private lateinit var remoteDataSource: FakeUserRemoteDataSource
private lateinit var userRepository: UserRepository

@Before
fun setup() {
localDataSource = FakeUserLocalDataSource()
remoteDataSource = FakeUserRemoteDataSource()
userRepository = UserRepositoryImpl(localDataSource, remoteDataSource)
}

@Test
fun `캐시가 비어있을 때 observeUserProfile을 구독하면 Remote에서 데이터를 가져와 캐시를 업데이트해야 한다`() =
runTest {
// given
val expectedProfile = UserProfile(nickname = "TestUser")
remoteDataSource.profileResponse = UserProfileResponse(nickname = "TestUser")

// when
// 구독(first)이 시작되는 순간 Fetch가 발생함
val result = userRepository.observeUserProfile().first()

// then
assertEquals(expectedProfile, result.getOrNull())
assertEquals(1, remoteDataSource.fetchCount.get())
assertEquals(expectedProfile, localDataSource.userProfile.value)
}

@Test
fun `캐시가 이미 존재할 때 observeUserProfile을 구독하면 Remote를 호출하지 않고 캐시를 반환해야 한다`() =
runTest {
// given
val cachedProfile = UserProfile(nickname = "CachedUser")
localDataSource.saveUserProfile(cachedProfile)

// when
val result = userRepository.observeUserProfile().first()

// then
assertEquals(cachedProfile, result.getOrNull())
assertEquals(0, remoteDataSource.fetchCount.get())
}

@Test
fun `여러 코루틴이 동시에 observeUserProfile을 구독해도 Remote API는 1회만 호출되어야 한다 (Race Condition 방지)`() =
runTest {
// given
remoteDataSource.profileResponse = UserProfileResponse(nickname = "RaceUser")
remoteDataSource.delayMillis = 100L // 네트워크 지연 시뮬레이션

// when
// 10개의 코루틴이 동시에 구독 시작
val jobs = List(10) {
async { userRepository.observeUserProfile().first() }
}
jobs.awaitAll()

// then
assertEquals(1, remoteDataSource.fetchCount.get())
assertEquals("RaceUser", localDataSource.userProfile.value?.nickname)
}

@Test
fun `clearCache를 호출하면 로컬 캐시가 초기화되어야 한다`() =
runTest {
// given
localDataSource.saveUserProfile(UserProfile(nickname = "ToDelete"))

// when
userRepository.clearCache()

// then
assertEquals(null, localDataSource.userProfile.value)
}

// --- Fake Objects ---

private class FakeUserLocalDataSource : UserLocalDataSource {
private val _userProfile = MutableStateFlow<UserProfile?>(null)
override val userProfile: StateFlow<UserProfile?> = _userProfile.asStateFlow()

override suspend fun saveUserProfile(userProfile: UserProfile) {
_userProfile.update { userProfile }
}

override fun clearCache() {
_userProfile.update { null }
}
}

private class FakeUserRemoteDataSource : UserRemoteDataSource {
var profileResponse: UserProfileResponse? = null
val fetchCount = AtomicInteger(0)
var delayMillis = 0L

override suspend fun fetchUserProfile(): Result<UserProfileResponse> {
if (delayMillis > 0) delay(delayMillis)
fetchCount.incrementAndGet()
return profileResponse?.let { Result.success(it) }
?: Result.failure(Exception("No profile set in fake"))
}
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package com.threegap.bitnagil.domain.auth.usecase

import com.threegap.bitnagil.domain.auth.repository.AuthRepository
import com.threegap.bitnagil.domain.user.repository.UserRepository
import javax.inject.Inject

class LogoutUseCase @Inject constructor(
private val authRepository: AuthRepository,
private val userRepository: UserRepository,
) {
suspend operator fun invoke(): Result<Unit> = authRepository.logout()
suspend operator fun invoke(): Result<Unit> =
authRepository.logout().onSuccess {
userRepository.clearCache()
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package com.threegap.bitnagil.domain.auth.usecase

import com.threegap.bitnagil.domain.auth.repository.AuthRepository
import com.threegap.bitnagil.domain.user.repository.UserRepository
import javax.inject.Inject

class WithdrawalUseCase @Inject constructor(
private val authRepository: AuthRepository,
private val userRepository: UserRepository,
) {
suspend operator fun invoke(reason: String): Result<Unit> = authRepository.withdrawal(reason)
suspend operator fun invoke(reason: String): Result<Unit> =
authRepository.withdrawal(reason).onSuccess {
userRepository.clearCache()
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.threegap.bitnagil.domain.user.repository

import com.threegap.bitnagil.domain.user.model.UserProfile
import kotlinx.coroutines.flow.Flow

interface UserRepository {
suspend fun fetchUserProfile(): Result<UserProfile>
fun observeUserProfile(): Flow<Result<UserProfile>>
suspend fun getUserProfile(): Result<UserProfile>
fun clearCache()
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@ import com.threegap.bitnagil.domain.user.model.UserProfile
import com.threegap.bitnagil.domain.user.repository.UserRepository
import javax.inject.Inject

class FetchUserProfileUseCase @Inject constructor(
class GetUserProfileUseCase @Inject constructor(
private val userRepository: UserRepository,
) {
suspend operator fun invoke(): Result<UserProfile> =
userRepository.fetchUserProfile()
suspend operator fun invoke(): Result<UserProfile> = userRepository.getUserProfile()
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[1] OnBoarindgViewModel에서는 flow를 수집하는 방식보다는 기존 일회성으로 정보를 조회하여 사용하기에, 기존 방식처럼 일회성으로 UserProfile을 조회하는 함수도 1개 필요할 것 같습니다!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요 부분은 first()를 통해 수집 후 cancel 하면 큰 문제는 없겠다 판단했었는데, 역할을 분리해서 각각 별도로 구성해보겠습니다~

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영 완료 -> fae4788

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.threegap.bitnagil.domain.user.usecase

import com.threegap.bitnagil.domain.user.model.UserProfile
import com.threegap.bitnagil.domain.user.repository.UserRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject

class ObserveUserProfileUseCase @Inject constructor(
private val userRepository: UserRepository,
) {
operator fun invoke(): Flow<Result<UserProfile>> = userRepository.observeUserProfile()
}
Loading
Loading