Skip to content

Commit 836b342

Browse files
committed
Refactor: 펜딩 변경사항 동기화 및 동시성 제어 개선
1 parent d4abc0e commit 836b342

2 files changed

Lines changed: 71 additions & 41 deletions

File tree

data/src/main/java/com/threegap/bitnagil/data/routine/repositoryImpl/RoutineRepositoryImpl.kt

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import kotlinx.coroutines.flow.filterNotNull
2525
import kotlinx.coroutines.flow.flow
2626
import kotlinx.coroutines.flow.onEach
2727
import kotlinx.coroutines.launch
28+
import kotlinx.coroutines.sync.Mutex
29+
import kotlinx.coroutines.sync.withLock
2830
import javax.inject.Inject
2931
import javax.inject.Singleton
3032

@@ -36,9 +38,10 @@ class RoutineRepositoryImpl @Inject constructor(
3638
) : RoutineRepository {
3739

3840
private val repositoryScope = CoroutineScope(SupervisorJob() + dispatcher)
41+
private val mutex = Mutex()
3942
private val pendingChangesByDate = mutableMapOf<String, MutableMap<String, RoutineCompletionInfo>>()
4043
private val originalStatesByDate = mutableMapOf<String, MutableMap<String, RoutineCompletionInfo>>()
41-
private val syncTrigger = MutableSharedFlow<String>(
44+
private val syncTrigger = MutableSharedFlow<Unit>(
4245
extraBufferCapacity = 1,
4346
onBufferOverflow = BufferOverflow.DROP_OLDEST,
4447
)
@@ -48,7 +51,7 @@ class RoutineRepositoryImpl @Inject constructor(
4851
repositoryScope.launch {
4952
syncTrigger
5053
.debounce(500L)
51-
.collect { dateKey -> flushPendingChanges(dateKey) }
54+
.collect { flushAllPendingChanges() }
5255
}
5356
}
5457

@@ -71,28 +74,41 @@ class RoutineRepositoryImpl @Inject constructor(
7174
}
7275

7376
override suspend fun applyRoutineToggle(dateKey: String, routineId: String, completionInfo: RoutineCompletionInfo) {
74-
if (originalStatesByDate[dateKey]?.containsKey(routineId) != true) {
75-
routineLocalDataSource.getCompletionInfo(dateKey, routineId)?.let {
76-
originalStatesByDate.getOrPut(dateKey) { mutableMapOf() }[routineId] = it
77+
mutex.withLock {
78+
if (originalStatesByDate[dateKey]?.containsKey(routineId) != true) {
79+
routineLocalDataSource.getCompletionInfo(dateKey, routineId)?.let {
80+
originalStatesByDate.getOrPut(dateKey) { mutableMapOf() }[routineId] = it
81+
}
7782
}
83+
pendingChangesByDate.getOrPut(dateKey) { mutableMapOf() }[routineId] = completionInfo
7884
}
7985
routineLocalDataSource.applyOptimisticToggle(dateKey, routineId, completionInfo)
80-
pendingChangesByDate.getOrPut(dateKey) { mutableMapOf() }[routineId] = completionInfo
81-
syncTrigger.emit(dateKey)
86+
syncTrigger.emit(Unit)
8287
}
8388

84-
private suspend fun flushPendingChanges(dateKey: String) {
85-
val snapshot = pendingChangesByDate.remove(dateKey)
86-
val originals = originalStatesByDate.remove(dateKey)
87-
val actualChanges = snapshot?.filter { (routineId, pending) -> originals?.get(routineId) != pending }
88-
if (actualChanges.isNullOrEmpty()) return
89-
90-
val syncRequest = RoutineCompletionInfos(routineCompletionInfos = actualChanges.values.toList())
91-
routineRemoteDataSource.syncRoutineCompletion(syncRequest.toDto())
92-
.onFailure {
93-
val range = routineLocalDataSource.lastFetchRange ?: return
94-
fetchAndSave(range.first, range.second)
89+
private suspend fun flushAllPendingChanges() {
90+
val snapshot: Map<String, Map<String, RoutineCompletionInfo>>
91+
val originals: Map<String, Map<String, RoutineCompletionInfo>>
92+
mutex.withLock {
93+
snapshot = pendingChangesByDate.mapValues { it.value.toMap() }
94+
originals = originalStatesByDate.mapValues { it.value.toMap() }
95+
pendingChangesByDate.clear()
96+
originalStatesByDate.clear()
97+
}
98+
99+
for ((dateKey, pendingForDate) in snapshot) {
100+
val actualChanges = pendingForDate.filter { (routineId, pending) ->
101+
originals[dateKey]?.get(routineId) != pending
95102
}
103+
if (actualChanges.isEmpty()) continue
104+
105+
val syncRequest = RoutineCompletionInfos(routineCompletionInfos = actualChanges.values.toList())
106+
routineRemoteDataSource.syncRoutineCompletion(syncRequest.toDto())
107+
.onFailure {
108+
val range = routineLocalDataSource.lastFetchRange ?: return@onFailure
109+
fetchAndSave(range.first, range.second)
110+
}
111+
}
96112
}
97113

98114
private suspend fun refreshCache() {

data/src/test/java/com/threegap/bitnagil/data/routine/repositoryImpl/RoutineRepositoryImplTest.kt

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,20 @@ class RoutineRepositoryImplTest {
114114
assertEquals(1, remoteDataSource.syncCount.get())
115115
}
116116

117+
@Test
118+
fun `서로 다른 날짜에 토글 시 debounce 후 두 날짜 모두 sync되어야 한다`() = runTest {
119+
repository = createRepository(testScheduler)
120+
runCurrent()
121+
val (dateKey1, routineId1) = setupCacheWithRoutineOnDate("2024-01-01", "routine1", isCompleted = false)
122+
val (dateKey2, routineId2) = setupCacheWithRoutineOnDate("2024-01-02", "routine2", isCompleted = false)
123+
124+
repository.applyRoutineToggle(dateKey1, routineId1, RoutineCompletionInfo(routineId1, routineCompleteYn = true, subRoutineCompleteYn = emptyList()))
125+
repository.applyRoutineToggle(dateKey2, routineId2, RoutineCompletionInfo(routineId2, routineCompleteYn = true, subRoutineCompleteYn = emptyList()))
126+
127+
advanceTimeBy(501L)
128+
assertEquals(2, remoteDataSource.syncCount.get())
129+
}
130+
117131
@Test
118132
fun `A→B→A 토글 시 최종 상태가 원래와 동일하면 API를 호출하지 않아야 한다`() = runTest {
119133
repository = createRepository(testScheduler)
@@ -175,33 +189,33 @@ class RoutineRepositoryImplTest {
175189

176190
// --- Helpers ---
177191

178-
private fun setupCacheWithRoutine(isCompleted: Boolean): Pair<String, String> {
179-
val dateKey = "2024-01-01"
180-
val routineId = "routine1"
181-
val schedule = RoutineSchedule(
182-
dailyRoutines = mapOf(
183-
dateKey to DailyRoutines(
184-
routines = listOf(
185-
Routine(
186-
id = routineId,
187-
name = "테스트 루틴",
188-
repeatDays = listOf(DayOfWeek.MONDAY),
189-
executionTime = "08:00",
190-
startDate = dateKey,
191-
endDate = dateKey,
192-
routineDate = dateKey,
193-
isCompleted = isCompleted,
194-
isDeleted = false,
195-
subRoutineNames = emptyList(),
196-
subRoutineCompletionStates = emptyList(),
197-
recommendedRoutineType = null,
198-
),
192+
private fun setupCacheWithRoutine(isCompleted: Boolean): Pair<String, String> =
193+
setupCacheWithRoutineOnDate("2024-01-01", "routine1", isCompleted)
194+
195+
private fun setupCacheWithRoutineOnDate(dateKey: String, routineId: String, isCompleted: Boolean): Pair<String, String> {
196+
val existing = localDataSource.routineSchedule.value?.dailyRoutines ?: emptyMap()
197+
val newDailyRoutines = existing + mapOf(
198+
dateKey to DailyRoutines(
199+
routines = listOf(
200+
Routine(
201+
id = routineId,
202+
name = "테스트 루틴",
203+
repeatDays = listOf(DayOfWeek.MONDAY),
204+
executionTime = "08:00",
205+
startDate = dateKey,
206+
endDate = dateKey,
207+
routineDate = dateKey,
208+
isCompleted = isCompleted,
209+
isDeleted = false,
210+
subRoutineNames = emptyList(),
211+
subRoutineCompletionStates = emptyList(),
212+
recommendedRoutineType = null,
199213
),
200-
isAllCompleted = isCompleted,
201214
),
215+
isAllCompleted = isCompleted,
202216
),
203217
)
204-
localDataSource.saveSchedule(schedule, dateKey, dateKey)
218+
localDataSource.saveSchedule(RoutineSchedule(newDailyRoutines), dateKey, dateKey)
205219
return dateKey to routineId
206220
}
207221

0 commit comments

Comments
 (0)