Skip to content

Commit 64eb15e

Browse files
authored
Merge pull request #195 from YAPP-Github/refactor/#194-userprofile-flow
2 parents 55c8f13 + fae4788 commit 64eb15e

16 files changed

Lines changed: 295 additions & 57 deletions

File tree

app/src/main/java/com/threegap/bitnagil/di/data/DataSourceModule.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import com.threegap.bitnagil.data.report.datasource.ReportDataSource
2020
import com.threegap.bitnagil.data.report.datasourceImpl.ReportDataSourceImpl
2121
import com.threegap.bitnagil.data.routine.datasource.RoutineRemoteDataSource
2222
import com.threegap.bitnagil.data.routine.datasourceImpl.RoutineRemoteDataSourceImpl
23-
import com.threegap.bitnagil.data.user.datasource.UserDataSource
24-
import com.threegap.bitnagil.data.user.datasourceImpl.UserDataSourceImpl
23+
import com.threegap.bitnagil.data.user.datasource.UserLocalDataSource
24+
import com.threegap.bitnagil.data.user.datasource.UserRemoteDataSource
25+
import com.threegap.bitnagil.data.user.datasourceImpl.UserLocalDataSourceImpl
26+
import com.threegap.bitnagil.data.user.datasourceImpl.UserRemoteDataSourceImpl
2527
import com.threegap.bitnagil.data.version.datasource.VersionDataSource
2628
import com.threegap.bitnagil.data.version.datasourceImpl.VersionDataSourceImpl
2729
import dagger.Binds
@@ -56,7 +58,11 @@ abstract class DataSourceModule {
5658

5759
@Binds
5860
@Singleton
59-
abstract fun bindUserDataSource(userDataSourceImpl: UserDataSourceImpl): UserDataSource
61+
abstract fun bindUserLocalDataSource(userLocalDataSourceImpl: UserLocalDataSourceImpl): UserLocalDataSource
62+
63+
@Binds
64+
@Singleton
65+
abstract fun bindUserRemoteDataSource(userRemoteDataSourceImpl: UserRemoteDataSourceImpl): UserRemoteDataSource
6066

6167
@Binds
6268
@Singleton

data/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,7 @@ dependencies {
2020
implementation(libs.bundles.retrofit)
2121
implementation(libs.play.services.location)
2222
implementation(libs.kotlinx.coroutines.play)
23+
24+
testImplementation(libs.androidx.junit)
25+
testImplementation(libs.kotlin.coroutines.test)
2326
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.threegap.bitnagil.data.user.datasource
2+
3+
import com.threegap.bitnagil.domain.user.model.UserProfile
4+
import kotlinx.coroutines.flow.StateFlow
5+
6+
interface UserLocalDataSource {
7+
val userProfile: StateFlow<UserProfile?>
8+
suspend fun saveUserProfile(userProfile: UserProfile)
9+
fun clearCache()
10+
}

data/src/main/java/com/threegap/bitnagil/data/user/datasource/UserDataSource.kt renamed to data/src/main/java/com/threegap/bitnagil/data/user/datasource/UserRemoteDataSource.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@ package com.threegap.bitnagil.data.user.datasource
22

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

5-
interface UserDataSource {
5+
interface UserRemoteDataSource {
66
suspend fun fetchUserProfile(): Result<UserProfileResponse>
77
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.threegap.bitnagil.data.user.datasourceImpl
2+
3+
import com.threegap.bitnagil.data.user.datasource.UserLocalDataSource
4+
import com.threegap.bitnagil.domain.user.model.UserProfile
5+
import kotlinx.coroutines.flow.MutableStateFlow
6+
import kotlinx.coroutines.flow.StateFlow
7+
import kotlinx.coroutines.flow.asStateFlow
8+
import kotlinx.coroutines.flow.update
9+
import javax.inject.Inject
10+
import javax.inject.Singleton
11+
12+
@Singleton
13+
class UserLocalDataSourceImpl @Inject constructor() : UserLocalDataSource {
14+
private val _userProfile = MutableStateFlow<UserProfile?>(null)
15+
override val userProfile: StateFlow<UserProfile?> = _userProfile.asStateFlow()
16+
17+
override suspend fun saveUserProfile(userProfile: UserProfile) {
18+
_userProfile.update { userProfile }
19+
}
20+
21+
override fun clearCache() {
22+
_userProfile.update { null }
23+
}
24+
}

data/src/main/java/com/threegap/bitnagil/data/user/datasourceImpl/UserDataSourceImpl.kt renamed to data/src/main/java/com/threegap/bitnagil/data/user/datasourceImpl/UserRemoteDataSourceImpl.kt

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
package com.threegap.bitnagil.data.user.datasourceImpl
22

33
import com.threegap.bitnagil.data.common.safeApiCall
4-
import com.threegap.bitnagil.data.user.datasource.UserDataSource
4+
import com.threegap.bitnagil.data.user.datasource.UserRemoteDataSource
55
import com.threegap.bitnagil.data.user.model.response.UserProfileResponse
66
import com.threegap.bitnagil.data.user.service.UserService
77
import javax.inject.Inject
88

9-
class UserDataSourceImpl @Inject constructor(
9+
class UserRemoteDataSourceImpl @Inject constructor(
1010
private val userService: UserService,
11-
) : UserDataSource {
11+
) : UserRemoteDataSource {
1212
override suspend fun fetchUserProfile(): Result<UserProfileResponse> =
13-
safeApiCall {
14-
userService.fetchUserProfile()
15-
}
13+
safeApiCall { userService.fetchUserProfile() }
1614
}
Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,59 @@
11
package com.threegap.bitnagil.data.user.repositoryImpl
22

3-
import com.threegap.bitnagil.data.user.datasource.UserDataSource
3+
import com.threegap.bitnagil.data.user.datasource.UserLocalDataSource
4+
import com.threegap.bitnagil.data.user.datasource.UserRemoteDataSource
45
import com.threegap.bitnagil.data.user.model.response.toDomain
56
import com.threegap.bitnagil.domain.user.model.UserProfile
67
import com.threegap.bitnagil.domain.user.repository.UserRepository
8+
import kotlinx.coroutines.flow.Flow
9+
import kotlinx.coroutines.flow.emitAll
10+
import kotlinx.coroutines.flow.filterNotNull
11+
import kotlinx.coroutines.flow.flow
12+
import kotlinx.coroutines.flow.map
13+
import kotlinx.coroutines.sync.Mutex
14+
import kotlinx.coroutines.sync.withLock
715
import javax.inject.Inject
16+
import javax.inject.Singleton
817

18+
@Singleton
919
class UserRepositoryImpl @Inject constructor(
10-
private val userDataSource: UserDataSource,
20+
private val userLocalDataSource: UserLocalDataSource,
21+
private val userRemoteDataSource: UserRemoteDataSource,
1122
) : UserRepository {
12-
override suspend fun fetchUserProfile(): Result<UserProfile> =
13-
userDataSource.fetchUserProfile().map { it.toDomain() }
23+
private val fetchMutex = Mutex()
24+
25+
override fun observeUserProfile(): Flow<Result<UserProfile>> = flow {
26+
fetchAndCacheIfNeeded().onFailure {
27+
emit(Result.failure(it))
28+
return@flow
29+
}
30+
31+
emitAll(
32+
userLocalDataSource.userProfile
33+
.filterNotNull()
34+
.map { Result.success(it) },
35+
)
36+
}
37+
38+
override suspend fun getUserProfile(): Result<UserProfile> {
39+
return fetchAndCacheIfNeeded()
40+
}
41+
42+
override fun clearCache() {
43+
userLocalDataSource.clearCache()
44+
}
45+
46+
private suspend fun fetchAndCacheIfNeeded(): Result<UserProfile> {
47+
userLocalDataSource.userProfile.value?.let { return Result.success(it) }
48+
49+
return fetchMutex.withLock {
50+
userLocalDataSource.userProfile.value?.let { return@withLock Result.success(it) }
51+
52+
userRemoteDataSource.fetchUserProfile()
53+
.onSuccess { response ->
54+
userLocalDataSource.saveUserProfile(response.toDomain())
55+
}
56+
.map { it.toDomain() }
57+
}
58+
}
1459
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package com.threegap.bitnagil.data.user.repositoryImpl
2+
3+
import com.threegap.bitnagil.data.user.datasource.UserLocalDataSource
4+
import com.threegap.bitnagil.data.user.datasource.UserRemoteDataSource
5+
import com.threegap.bitnagil.data.user.model.response.UserProfileResponse
6+
import com.threegap.bitnagil.domain.user.model.UserProfile
7+
import com.threegap.bitnagil.domain.user.repository.UserRepository
8+
import kotlinx.coroutines.async
9+
import kotlinx.coroutines.awaitAll
10+
import kotlinx.coroutines.delay
11+
import kotlinx.coroutines.flow.MutableStateFlow
12+
import kotlinx.coroutines.flow.StateFlow
13+
import kotlinx.coroutines.flow.asStateFlow
14+
import kotlinx.coroutines.flow.first
15+
import kotlinx.coroutines.flow.update
16+
import kotlinx.coroutines.test.runTest
17+
import org.junit.Assert.assertEquals
18+
import org.junit.Before
19+
import org.junit.Test
20+
import java.util.concurrent.atomic.AtomicInteger
21+
22+
class UserRepositoryImplTest {
23+
24+
private lateinit var localDataSource: FakeUserLocalDataSource
25+
private lateinit var remoteDataSource: FakeUserRemoteDataSource
26+
private lateinit var userRepository: UserRepository
27+
28+
@Before
29+
fun setup() {
30+
localDataSource = FakeUserLocalDataSource()
31+
remoteDataSource = FakeUserRemoteDataSource()
32+
userRepository = UserRepositoryImpl(localDataSource, remoteDataSource)
33+
}
34+
35+
@Test
36+
fun `캐시가 비어있을 때 observeUserProfile을 구독하면 Remote에서 데이터를 가져와 캐시를 업데이트해야 한다`() =
37+
runTest {
38+
// given
39+
val expectedProfile = UserProfile(nickname = "TestUser")
40+
remoteDataSource.profileResponse = UserProfileResponse(nickname = "TestUser")
41+
42+
// when
43+
// 구독(first)이 시작되는 순간 Fetch가 발생함
44+
val result = userRepository.observeUserProfile().first()
45+
46+
// then
47+
assertEquals(expectedProfile, result.getOrNull())
48+
assertEquals(1, remoteDataSource.fetchCount.get())
49+
assertEquals(expectedProfile, localDataSource.userProfile.value)
50+
}
51+
52+
@Test
53+
fun `캐시가 이미 존재할 때 observeUserProfile을 구독하면 Remote를 호출하지 않고 캐시를 반환해야 한다`() =
54+
runTest {
55+
// given
56+
val cachedProfile = UserProfile(nickname = "CachedUser")
57+
localDataSource.saveUserProfile(cachedProfile)
58+
59+
// when
60+
val result = userRepository.observeUserProfile().first()
61+
62+
// then
63+
assertEquals(cachedProfile, result.getOrNull())
64+
assertEquals(0, remoteDataSource.fetchCount.get())
65+
}
66+
67+
@Test
68+
fun `여러 코루틴이 동시에 observeUserProfile을 구독해도 Remote API는 1회만 호출되어야 한다 (Race Condition 방지)`() =
69+
runTest {
70+
// given
71+
remoteDataSource.profileResponse = UserProfileResponse(nickname = "RaceUser")
72+
remoteDataSource.delayMillis = 100L // 네트워크 지연 시뮬레이션
73+
74+
// when
75+
// 10개의 코루틴이 동시에 구독 시작
76+
val jobs = List(10) {
77+
async { userRepository.observeUserProfile().first() }
78+
}
79+
jobs.awaitAll()
80+
81+
// then
82+
assertEquals(1, remoteDataSource.fetchCount.get())
83+
assertEquals("RaceUser", localDataSource.userProfile.value?.nickname)
84+
}
85+
86+
@Test
87+
fun `clearCache를 호출하면 로컬 캐시가 초기화되어야 한다`() =
88+
runTest {
89+
// given
90+
localDataSource.saveUserProfile(UserProfile(nickname = "ToDelete"))
91+
92+
// when
93+
userRepository.clearCache()
94+
95+
// then
96+
assertEquals(null, localDataSource.userProfile.value)
97+
}
98+
99+
// --- Fake Objects ---
100+
101+
private class FakeUserLocalDataSource : UserLocalDataSource {
102+
private val _userProfile = MutableStateFlow<UserProfile?>(null)
103+
override val userProfile: StateFlow<UserProfile?> = _userProfile.asStateFlow()
104+
105+
override suspend fun saveUserProfile(userProfile: UserProfile) {
106+
_userProfile.update { userProfile }
107+
}
108+
109+
override fun clearCache() {
110+
_userProfile.update { null }
111+
}
112+
}
113+
114+
private class FakeUserRemoteDataSource : UserRemoteDataSource {
115+
var profileResponse: UserProfileResponse? = null
116+
val fetchCount = AtomicInteger(0)
117+
var delayMillis = 0L
118+
119+
override suspend fun fetchUserProfile(): Result<UserProfileResponse> {
120+
if (delayMillis > 0) delay(delayMillis)
121+
fetchCount.incrementAndGet()
122+
return profileResponse?.let { Result.success(it) }
123+
?: Result.failure(Exception("No profile set in fake"))
124+
}
125+
}
126+
}
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
package com.threegap.bitnagil.domain.auth.usecase
22

33
import com.threegap.bitnagil.domain.auth.repository.AuthRepository
4+
import com.threegap.bitnagil.domain.user.repository.UserRepository
45
import javax.inject.Inject
56

67
class LogoutUseCase @Inject constructor(
78
private val authRepository: AuthRepository,
9+
private val userRepository: UserRepository,
810
) {
9-
suspend operator fun invoke(): Result<Unit> = authRepository.logout()
11+
suspend operator fun invoke(): Result<Unit> =
12+
authRepository.logout().onSuccess {
13+
userRepository.clearCache()
14+
}
1015
}
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
package com.threegap.bitnagil.domain.auth.usecase
22

33
import com.threegap.bitnagil.domain.auth.repository.AuthRepository
4+
import com.threegap.bitnagil.domain.user.repository.UserRepository
45
import javax.inject.Inject
56

67
class WithdrawalUseCase @Inject constructor(
78
private val authRepository: AuthRepository,
9+
private val userRepository: UserRepository,
810
) {
9-
suspend operator fun invoke(reason: String): Result<Unit> = authRepository.withdrawal(reason)
11+
suspend operator fun invoke(reason: String): Result<Unit> =
12+
authRepository.withdrawal(reason).onSuccess {
13+
userRepository.clearCache()
14+
}
1015
}

0 commit comments

Comments
 (0)