From 13f3fc459c31fa9120301121f26607f47cdd1825 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 15:07:18 +0900 Subject: [PATCH 01/21] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=20=EC=BA=90=EC=8B=B1?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `CachedFeedLikeState.kt`: 피드의 좋아요 여부(`isLiked`)와 좋아요 수(`likeCount`)를 관리하는 `CachedFeedLikeState` internal 데이터 클래스 추가 --- .../data/feed/repository/model/CachedFeedLikeState.kt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 data/feed/src/main/java/com/into/websoso/data/feed/repository/model/CachedFeedLikeState.kt diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/CachedFeedLikeState.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/CachedFeedLikeState.kt new file mode 100644 index 000000000..5864dd34e --- /dev/null +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/CachedFeedLikeState.kt @@ -0,0 +1,6 @@ +package com.into.websoso.data.feed.repository.model + +internal data class CachedFeedLikeState( + val isLiked: Boolean, + val likeCount: Int, +) From 6df2b10129aca420d2bfd0827a113638d01a320b Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 15:08:27 +0900 Subject: [PATCH 02/21] =?UTF-8?q?fix:=20=ED=94=BC=EB=93=9C=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=B0=8F=20=EC=BA=90=EC=8B=B1=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `feedDetailStates` (ConcurrentHashMap)를 추가하여 피드 상세 정보의 좋아요 상태(`isLiked`, `likeCount`)를 별도로 관리하도록 개선 - `saveLikeState` 시 메인 피드 리스트(Flow)에 해당 데이터가 없는 경우에도 상세 캐시 상태를 업데이트하도록 로직 수정 - `fetchFeed` 수행 시 서버에서 조회한 최신 좋아요 상태를 `feedDetailStates` 캐시에 반영하는 로직 추가 - 캐시된 피드 존재 여부를 확인하기 위한 `findCachedFeed` 메서드 및 상세 상태 업데이트를 위한 `updateFeedDetailState` 메서드 구현 --- .../feed/repository/UpdatedFeedRepository.kt | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index dcdfe4953..765eba96d 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -15,6 +15,7 @@ import com.into.websoso.data.feed.model.CommentsEntity import com.into.websoso.data.feed.model.FeedDetailEntity import com.into.websoso.data.feed.model.FeedEntity import com.into.websoso.data.feed.model.FeedsEntity +import com.into.websoso.data.feed.repository.model.CachedFeedLikeState import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -54,6 +55,7 @@ class UpdatedFeedRepository private val dirtyFeedStates = ConcurrentHashMap() private val originalFeedStates = ConcurrentHashMap() + private val feedDetailStates = ConcurrentHashMap() // ============================================================================================ // Feed Creation & Modification @@ -238,6 +240,10 @@ class UpdatedFeedRepository updateFeedInFlow(_sosoAllFeeds, feedId) updateFeedInFlow(_sosoRecommendedFeeds, feedId) updateFeedInFlow(_myFeeds, feedId) + + if (findCachedFeed(feedId) == null) { + updateFeedDetailState(feedId) + } } private fun updateFeedInFlow( @@ -260,6 +266,23 @@ class UpdatedFeedRepository } } + private fun updateFeedDetailState(feedId: Long) { + val target = feedDetailStates[feedId] ?: return + val newLiked = !target.isLiked + val newCount = if (newLiked) target.likeCount + 1 else target.likeCount - 1 + + trackDirtyState(feedId, target.isLiked, newLiked) + feedDetailStates[feedId] = CachedFeedLikeState( + isLiked = newLiked, + likeCount = newCount.coerceAtLeast(0), + ) + } + + private fun findCachedFeed(feedId: Long): FeedEntity? = + _sosoAllFeeds.value.find { it.id == feedId } + ?: _sosoRecommendedFeeds.value.find { it.id == feedId } + ?: _myFeeds.value.find { it.id == feedId } + /** * 변경된 좋아요 상태를 추적합니다. */ @@ -365,7 +388,12 @@ class UpdatedFeedRepository */ suspend fun fetchFeed(feedId: Long): FeedDetailEntity { val rawDetail = feedApi.getFeed(feedId).toData() - return applyDirtyStateToDetail(rawDetail) + val mergedDetail = applyDirtyStateToDetail(rawDetail) + feedDetailStates[feedId] = CachedFeedLikeState( + isLiked = mergedDetail.isLiked, + likeCount = mergedDetail.likeCount, + ) + return mergedDetail } private fun applyDirtyStateToDetail(feed: FeedDetailEntity): FeedDetailEntity { From 3198b31bd40deb697407eaff450e6a589175ba2e Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 15:51:01 +0900 Subject: [PATCH 03/21] =?UTF-8?q?fix:=20UpdatedFeedRepository=20=EB=82=B4?= =?UTF-8?q?=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=B3=80=EC=88=98=20=EB=B0=8F=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EB=AA=85=EC=B9=AD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 미동기화된 상태를 의미하는 `dirty` 관련 명칭을 사용자 의도가 반영된 대기 상태를 의미하는 `pendingLike`로 변경 - `dirtyFeedStates`, `originalFeedStates`를 각각 `pendingLikeStates`, `originalLikeStates`로 변경 - `applyDirtyState`, `trackDirtyState` 등의 메서드를 `applyPendingLikeState`, `trackPendingLikeState`로 변경하여 가독성 개선 --- .../feed/repository/UpdatedFeedRepository.kt | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 765eba96d..c5a2241c2 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -53,8 +53,8 @@ class UpdatedFeedRepository private val _myFeeds = MutableStateFlow>(emptyList()) val myFeeds = _myFeeds.asStateFlow() - private val dirtyFeedStates = ConcurrentHashMap() - private val originalFeedStates = ConcurrentHashMap() + private val pendingLikeStates = ConcurrentHashMap() + private val originalLikeStates = ConcurrentHashMap() private val feedDetailStates = ConcurrentHashMap() // ============================================================================================ @@ -168,7 +168,7 @@ class UpdatedFeedRepository // ============================================================================================ /** - * 서버에서 피드 리스트를 조회하고, 로컬의 미동기화된 좋아요 상태(Dirty)를 병합하여 캐시(Flow)를 갱신합니다. + * 서버에서 피드 리스트를 조회하고, 로컬의 미동기화된 좋아요 상태를 병합하여 캐시(Flow)를 갱신합니다. */ suspend fun fetchFeeds( lastFeedId: Long, @@ -182,7 +182,7 @@ class UpdatedFeedRepository size = size, ).toData() - val mergedFeeds = result.feeds.map { feed -> applyDirtyState(feed) } + val mergedFeeds = result.feeds.map { feed -> applyPendingLikeState(feed) } val isRecommended = feedsOption == "RECOMMENDED" val targetFlow = if (isRecommended) _sosoRecommendedFeeds else _sosoAllFeeds @@ -206,7 +206,7 @@ class UpdatedFeedRepository feeds: List, isRefreshed: Boolean, ) { - val mergedFeeds = feeds.map { feed -> applyDirtyState(feed) } + val mergedFeeds = feeds.map { feed -> applyPendingLikeState(feed) } _myFeeds.update { current -> if (isRefreshed) mergedFeeds else (current + mergedFeeds).distinctBy { it.id } @@ -216,8 +216,8 @@ class UpdatedFeedRepository /** * 서버 데이터보다 로컬의 변경사항(좋아요)을 우선 적용하여 반환합니다. */ - private fun applyDirtyState(feed: FeedEntity): FeedEntity { - val localIsLiked = dirtyFeedStates[feed.id] ?: return feed + private fun applyPendingLikeState(feed: FeedEntity): FeedEntity { + val localIsLiked = pendingLikeStates[feed.id] ?: return feed if (feed.isLiked != localIsLiked) { val adjustedCount = if (localIsLiked) feed.likeCount + 1 else feed.likeCount - 1 @@ -258,7 +258,7 @@ class UpdatedFeedRepository val newLiked = !target.isLiked val newCount = if (newLiked) target.likeCount + 1 else target.likeCount - 1 - trackDirtyState(feedId, target.isLiked, newLiked) + trackPendingLikeState(feedId, target.isLiked, newLiked) val newList = list.toMutableList() newList[index] = target.copy(isLiked = newLiked, likeCount = newCount) @@ -271,7 +271,7 @@ class UpdatedFeedRepository val newLiked = !target.isLiked val newCount = if (newLiked) target.likeCount + 1 else target.likeCount - 1 - trackDirtyState(feedId, target.isLiked, newLiked) + trackPendingLikeState(feedId, target.isLiked, newLiked) feedDetailStates[feedId] = CachedFeedLikeState( isLiked = newLiked, likeCount = newCount.coerceAtLeast(0), @@ -284,18 +284,18 @@ class UpdatedFeedRepository ?: _myFeeds.value.find { it.id == feedId } /** - * 변경된 좋아요 상태를 추적합니다. + * 서버에 아직 반영되지 않은 마지막 좋아요 상태를 추적합니다. */ - private fun trackDirtyState( + private fun trackPendingLikeState( feedId: Long, original: Boolean, new: Boolean, ) { - originalFeedStates.putIfAbsent(feedId, original) - if (originalFeedStates[feedId] == new) { - dirtyFeedStates.remove(feedId) + originalLikeStates.putIfAbsent(feedId, original) + if (originalLikeStates[feedId] == new) { + pendingLikeStates.remove(feedId) } else { - dirtyFeedStates[feedId] = new + pendingLikeStates[feedId] = new } } @@ -303,11 +303,11 @@ class UpdatedFeedRepository * 기록된 변경 사항들을 서버와 동기화합니다. */ fun syncDirtyFeeds() { - if (dirtyFeedStates.isEmpty()) return + if (pendingLikeStates.isEmpty()) return - val syncMap = dirtyFeedStates.toMap() - dirtyFeedStates.clear() - originalFeedStates.clear() + val syncMap = pendingLikeStates.toMap() + pendingLikeStates.clear() + originalLikeStates.clear() scope.launch { syncMap.forEach { (id, isLiked) -> @@ -388,7 +388,7 @@ class UpdatedFeedRepository */ suspend fun fetchFeed(feedId: Long): FeedDetailEntity { val rawDetail = feedApi.getFeed(feedId).toData() - val mergedDetail = applyDirtyStateToDetail(rawDetail) + val mergedDetail = applyPendingLikeStateToDetail(rawDetail) feedDetailStates[feedId] = CachedFeedLikeState( isLiked = mergedDetail.isLiked, likeCount = mergedDetail.likeCount, @@ -396,8 +396,8 @@ class UpdatedFeedRepository return mergedDetail } - private fun applyDirtyStateToDetail(feed: FeedDetailEntity): FeedDetailEntity { - val localIsLiked = dirtyFeedStates[feed.id] ?: return feed + private fun applyPendingLikeStateToDetail(feed: FeedDetailEntity): FeedDetailEntity { + val localIsLiked = pendingLikeStates[feed.id] ?: return feed if (feed.isLiked != localIsLiked) { val adjustedCount = if (localIsLiked) feed.likeCount + 1 else feed.likeCount - 1 From 77a2704441ee89810493c97fbbc3fc77208ce8c1 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 16:16:57 +0900 Subject: [PATCH 04/21] =?UTF-8?q?refactor:=20=ED=94=BC=EB=93=9C=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=AA=85?= =?UTF-8?q?=EC=B9=AD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepository.kt`: 메서드명을 `syncDirtyFeeds`에서 `syncPendingLikes`로 변경하고, 좋아요 상태를 동기화한다는 점을 명확히 하도록 주석 수정 - `UpdatedFeedDetailViewModel.kt`, `FeedViewModel.kt`: ViewModel이 해제되는 시점(`onCleared`)에 호출되는 메서드명을 변경된 명칭(`syncPendingLikes`)에 맞춰 수정 --- .../into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt | 2 +- .../websoso/data/feed/repository/UpdatedFeedRepository.kt | 4 ++-- .../main/java/com/into/websoso/feature/feed/FeedViewModel.kt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt b/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt index 0b353148a..eeff5bb93 100644 --- a/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt @@ -145,7 +145,7 @@ class UpdatedFeedDetailViewModel */ override fun onCleared() { super.onCleared() - feedRepository.syncDirtyFeeds() + feedRepository.syncPendingLikes() } /** diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index c5a2241c2..128c48cee 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -300,9 +300,9 @@ class UpdatedFeedRepository } /** - * 기록된 변경 사항들을 서버와 동기화합니다. + * 서버에 아직 반영되지 않은 좋아요 상태들을 동기화합니다. */ - fun syncDirtyFeeds() { + fun syncPendingLikes() { if (pendingLikeStates.isEmpty()) return val syncMap = pendingLikeStates.toMap() diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt index 00fcaeaa2..56a0add78 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt @@ -202,7 +202,7 @@ class FeedViewModel override fun onCleared() { super.onCleared() - feedRepository.syncDirtyFeeds() + feedRepository.syncPendingLikes() } // --- 기타 탭/필터 로직 --- From efbaa6e7340687dbdd3747ef447fe3573ca88224 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 16:53:47 +0900 Subject: [PATCH 05/21] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EB=B3=B4=EB=A5=98=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=9D=84=20=EC=9C=84=ED=95=9C=20`PendingFeed?= =?UTF-8?q?LikeStore`=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 피드별 좋아요 상태(`isLiked`)를 관리하기 위한 `pendingLikes` Flow 및 CRUD 메서드 정의 - 서버 반영 전 로컬의 임시 상태를 관리하고, 상태 일치 시 삭제하는 기능을 포함하는 `PendingFeedLikeStore` 인터페이스 구현 --- .../data/feed/store/PendingFeedLikeStore.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt new file mode 100644 index 000000000..092f154c9 --- /dev/null +++ b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt @@ -0,0 +1,19 @@ +package com.into.websoso.data.feed.store + +import kotlinx.coroutines.flow.Flow + +interface PendingFeedLikeStore { + val pendingLikes: Flow> + + suspend fun getPendingLikes(): Map + + suspend fun updatePendingLike( + feedId: Long, + isLiked: Boolean, + ) + + suspend fun deletePendingLikeIfMatched( + feedId: Long, + isLiked: Boolean, + ): Boolean +} From ecd3b39db89a676a8db3115610ffc4b3d5023d6e Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 17:33:17 +0900 Subject: [PATCH 06/21] =?UTF-8?q?feat:=20PendingFeedLikeDataStore=EB=A5=BC?= =?UTF-8?q?=20=EC=9C=84=ED=95=9C=20Dagger=20Qualifier=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `DataStoreQualifier.kt`: 좋아요 요청 보류 상태를 관리하는 DataStore를 구분하기 위한 `@PendingFeedLikeDataStore` 한정자(Qualifier) 추가 --- .../com/into/websoso/core/datastore/di/DataStoreQualifier.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreQualifier.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreQualifier.kt index 318c474ea..4369aa82d 100644 --- a/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreQualifier.kt +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreQualifier.kt @@ -13,3 +13,7 @@ internal annotation class LibraryFilterDataStore @Qualifier @Retention(AnnotationRetention.BINARY) internal annotation class UserDataStore + +@Qualifier +@Retention(AnnotationRetention.BINARY) +internal annotation class PendingFeedLikeDataStore From 92956a8dc9d7145634762c0a40f8e3c4ca75072c Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 17:33:19 +0900 Subject: [PATCH 07/21] =?UTF-8?q?feat:=20PendingFeedLikeDataStore=EB=A5=BC?= =?UTF-8?q?=20=EC=9C=84=ED=95=9C=20Dagger=20Qualifier=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `DataStoreQualifier.kt`: 좋아요 요청 보류 상태를 관리하는 DataStore를 구분하기 위한 `@PendingFeedLikeDataStore` 한정자(Qualifier) 추가 --- .../websoso/core/datastore/di/DataStoreModule.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt index 6d14fbfe0..7c533b837 100644 --- a/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt @@ -27,6 +27,11 @@ internal object DataStoreModule { name = USER_DATASTORE, ) + private const val PENDING_FEED_LIKE_DATASTORE = "PENDING_FEED_LIKE_DATASTORE" + private val Context.pendingFeedLikeDataStore: DataStore by preferencesDataStore( + name = PENDING_FEED_LIKE_DATASTORE, + ) + @Provides @Singleton @AccountDataStore @@ -47,4 +52,11 @@ internal object DataStoreModule { fun provideUserDataStore( @ApplicationContext context: Context, ): DataStore = context.userDataStore + + @Provides + @Singleton + @PendingFeedLikeDataStore + fun providePendingFeedLikeDataStore( + @ApplicationContext context: Context, + ): DataStore = context.pendingFeedLikeDataStore } From 88fcbd57c57df1594265834000ff67ac29e327cf Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 17:34:42 +0900 Subject: [PATCH 08/21] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EB=B3=B4=EB=A5=98=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `PendingFeedLikesPreferences.kt`: 피드 ID(`feedId`)와 좋아요 여부(`isLiked`)를 저장하는 `PendingFeedLikesPreferences` 및 `PendingFeedLikePreferences` 데이터 클래스 정의 --- .../feed/model/PendingFeedLikesPreferences.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/model/PendingFeedLikesPreferences.kt diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/model/PendingFeedLikesPreferences.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/model/PendingFeedLikesPreferences.kt new file mode 100644 index 000000000..ade4a6ac1 --- /dev/null +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/model/PendingFeedLikesPreferences.kt @@ -0,0 +1,14 @@ +package com.into.websoso.core.datastore.datasource.feed.model + +import kotlinx.serialization.Serializable + +@Serializable +internal data class PendingFeedLikesPreferences( + val likes: List, +) + +@Serializable +internal data class PendingFeedLikePreferences( + val feedId: Long, + val isLiked: Boolean, +) From d324628de6123ebdddc87b6b34f5c720fdb86428 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 17:34:50 +0900 Subject: [PATCH 09/21] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EB=B3=B4=EB=A5=98=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=9D=84=20=EC=9C=84=ED=95=9C=20DataStore=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `DefaultPendingFeedLikeStore`: DataStore를 사용하여 피드 ID별 좋아요 보류 상태(`isLiked`)를 관리하는 로직 구현 - `PendingFeedLikesPreferences` 모델을 JSON 문자열로 인코딩/디코딩하여 저장하는 직렬화 로직 추가 - 보류 중인 좋아요 업데이트(`updatePendingLike`) 및 상태 일치 시 삭제(`deletePendingLikeIfMatched`) 기능 구현 - `PendingFeedLikeStoreModule`: Hilt를 이용한 `PendingFeedLikeStore` 의존성 주입 설정 추가 --- .../feed/DefaultPendingFeedLikeStore.kt | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt new file mode 100644 index 000000000..0f06475a9 --- /dev/null +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt @@ -0,0 +1,114 @@ +package com.into.websoso.core.datastore.datasource.feed + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.into.websoso.core.common.dispatchers.Dispatcher +import com.into.websoso.core.common.dispatchers.WebsosoDispatchers +import com.into.websoso.core.datastore.datasource.feed.model.PendingFeedLikePreferences +import com.into.websoso.core.datastore.datasource.feed.model.PendingFeedLikesPreferences +import com.into.websoso.core.datastore.di.PendingFeedLikeDataStore +import com.into.websoso.data.feed.store.PendingFeedLikeStore +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +internal class DefaultPendingFeedLikeStore + @Inject + constructor( + @param:PendingFeedLikeDataStore private val pendingFeedLikeDataStore: DataStore, + @param:Dispatcher(WebsosoDispatchers.DEFAULT) private val dispatcher: CoroutineDispatcher, + ) : PendingFeedLikeStore { + override val pendingLikes: Flow> + get() = pendingFeedLikeDataStore.data + .map { preferences -> + withContext(dispatcher) { + decodePendingLikes(preferences[PENDING_FEED_LIKES_KEY]) + } + }.distinctUntilChanged() + + override suspend fun getPendingLikes(): Map = pendingLikes.first() + + override suspend fun updatePendingLike( + feedId: Long, + isLiked: Boolean, + ) { + pendingFeedLikeDataStore.edit { preferences -> + val pendingLikes: MutableMap = decodePendingLikes( + preferences[PENDING_FEED_LIKES_KEY], + ).toMutableMap() + pendingLikes.put(feedId, isLiked) + preferences[PENDING_FEED_LIKES_KEY] = encodePendingLikes(pendingLikes) + } + } + + override suspend fun deletePendingLikeIfMatched( + feedId: Long, + isLiked: Boolean, + ): Boolean { + var deleted = false + + pendingFeedLikeDataStore.edit { preferences -> + val pendingLikes: MutableMap = decodePendingLikes( + preferences[PENDING_FEED_LIKES_KEY], + ).toMutableMap() + if (pendingLikes[feedId] != isLiked) return@edit + + pendingLikes.remove(feedId) + if (pendingLikes.isEmpty()) { + preferences.remove(PENDING_FEED_LIKES_KEY) + } else { + preferences[PENDING_FEED_LIKES_KEY] = encodePendingLikes(pendingLikes) + } + deleted = true + } + + return deleted + } + + private fun decodePendingLikes(jsonString: String?): Map { + if (jsonString == null) return emptyMap() + + return runCatching { + Json + .decodeFromString(jsonString) + .likes + .associate { pendingLike -> pendingLike.feedId to pendingLike.isLiked } + }.getOrDefault(emptyMap()) + } + + private fun encodePendingLikes(pendingLikes: Map): String = + Json.encodeToString( + PendingFeedLikesPreferences( + likes = pendingLikes.entries.map { pendingLike -> + PendingFeedLikePreferences( + feedId = pendingLike.key, + isLiked = pendingLike.value, + ) + }, + ), + ) + + companion object { + private val PENDING_FEED_LIKES_KEY = stringPreferencesKey("PENDING_FEED_LIKES_KEY") + } + } + +@Module +@InstallIn(SingletonComponent::class) +internal interface PendingFeedLikeStoreModule { + @Binds + @Singleton + fun bindPendingFeedLikeStore(defaultPendingFeedLikeStore: DefaultPendingFeedLikeStore): PendingFeedLikeStore +} From 7f2ef9326dc608cf88305a2e23103e12143b4df7 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 17:34:53 +0900 Subject: [PATCH 10/21] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EB=B3=B4=EB=A5=98=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=9D=84=20=EC=9C=84=ED=95=9C=20DataStore=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `DefaultPendingFeedLikeStore`: DataStore를 사용하여 피드 ID별 좋아요 보류 상태(`isLiked`)를 관리하는 로직 구현 - `PendingFeedLikesPreferences` 모델을 JSON 문자열로 인코딩/디코딩하여 저장하는 직렬화 로직 추가 - 보류 중인 좋아요 업데이트(`updatePendingLike`) 및 상태 일치 시 삭제(`deletePendingLikeIfMatched`) 기능 구현 - `PendingFeedLikeStoreModule`: Hilt를 이용한 `PendingFeedLikeStore` 의존성 주입 설정 추가 --- core/datastore/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 3e1e3df38..537747926 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -12,9 +12,9 @@ android { dependencies { implementation(projects.core.common) implementation(projects.data.account) + implementation(projects.data.feed) implementation(projects.data.library) implementation(projects.data.user) - implementation(libs.datastore.preferences) implementation(libs.serialization.json) } From 47ffc9594a8b918cd26494c6bd4c7a407b70778c Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 18:04:05 +0900 Subject: [PATCH 11/21] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EB=B3=B4=EB=A5=98=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `PendingFeedLikeStore`: 특정 피드 ID의 보류된 좋아요 상태를 삭제하는 `deletePendingLike` 인터페이스 메서드 추가 - `DefaultPendingFeedLikeStore`: `deletePendingLike` 구현을 통해 DataStore에서 특정 피드 ID의 데이터를 삭제하고, 전체 데이터가 비어있을 경우 키 자체를 제거하도록 로직 반영 --- .../feed/DefaultPendingFeedLikeStore.kt | 15 +++++++++++++++ .../data/feed/store/PendingFeedLikeStore.kt | 2 ++ 2 files changed, 17 insertions(+) diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt index 0f06475a9..3036f2bf5 100644 --- a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt @@ -53,6 +53,21 @@ internal class DefaultPendingFeedLikeStore } } + override suspend fun deletePendingLike(feedId: Long) { + pendingFeedLikeDataStore.edit { preferences -> + val pendingLikes: MutableMap = decodePendingLikes( + preferences[PENDING_FEED_LIKES_KEY], + ).toMutableMap() + + pendingLikes.remove(feedId) + if (pendingLikes.isEmpty()) { + preferences.remove(PENDING_FEED_LIKES_KEY) + } else { + preferences[PENDING_FEED_LIKES_KEY] = encodePendingLikes(pendingLikes) + } + } + } + override suspend fun deletePendingLikeIfMatched( feedId: Long, isLiked: Boolean, diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt index 092f154c9..0e0a026e2 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt @@ -12,6 +12,8 @@ interface PendingFeedLikeStore { isLiked: Boolean, ) + suspend fun deletePendingLike(feedId: Long) + suspend fun deletePendingLikeIfMatched( feedId: Long, isLiked: Boolean, From 65b4e634e75b02a5fc92945dfe34b12e52662707 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 18:19:35 +0900 Subject: [PATCH 12/21] =?UTF-8?q?feat:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=BB=AC=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepository`에 `PendingFeedLikeStore`를 주입하여 앱 종료 후에도 좋아요 상태를 유지할 수 있도록 개선 - 좋아요 상태가 변경될 때 호출되는 `updatePendingLike` 및 `deletePendingLike` 메서드 구현 - 변경된 좋아요 상태가 원본 상태와 다를 경우 로컬에 저장하고, 원본과 일치할 경우 로컬 데이터를 삭제하도록 로직 수정 --- .../feed/repository/UpdatedFeedRepository.kt | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 128c48cee..454e0f445 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -16,6 +16,7 @@ import com.into.websoso.data.feed.model.FeedDetailEntity import com.into.websoso.data.feed.model.FeedEntity import com.into.websoso.data.feed.model.FeedsEntity import com.into.websoso.data.feed.repository.model.CachedFeedLikeState +import com.into.websoso.data.feed.store.PendingFeedLikeStore import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -34,6 +35,7 @@ class UpdatedFeedRepository @Inject constructor( private val feedApi: FeedApi, + private val pendingFeedLikeStore: PendingFeedLikeStore, private val multiPartMapper: MultiPartMapper, private val imageDownloader: ImageDownloader, private val imageCompressor: ImageCompressor, @@ -294,8 +296,33 @@ class UpdatedFeedRepository originalLikeStates.putIfAbsent(feedId, original) if (originalLikeStates[feedId] == new) { pendingLikeStates.remove(feedId) + deletePendingLike(feedId) } else { pendingLikeStates[feedId] = new + updatePendingLike(feedId, new) + } + } + + private fun updatePendingLike( + feedId: Long, + isLiked: Boolean, + ) { + scope.launch { + runCatching { + pendingFeedLikeStore.updatePendingLike(feedId, isLiked) + }.onFailure { + Log.e("UpdatedFeedRepository", "Failed to save pending feed like $feedId", it) + } + } + } + + private fun deletePendingLike(feedId: Long) { + scope.launch { + runCatching { + pendingFeedLikeStore.deletePendingLike(feedId) + }.onFailure { + Log.e("UpdatedFeedRepository", "Failed to delete pending feed like $feedId", it) + } } } From e90acebcac21145c13e60fcceb719b4dcf9b1841 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 18:30:35 +0900 Subject: [PATCH 13/21] =?UTF-8?q?fix:=20=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=EB=90=9C=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=EC=9D=98=20DataStore=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepository`: 서버와 좋아요 상태 동기화 성공 시, `pendingFeedLikeStore`에 저장된 임시 데이터를 삭제하는 `deleteSyncedPendingLike` 로직 구현 및 적용 --- .../data/feed/repository/UpdatedFeedRepository.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 454e0f445..91ecc8725 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -340,6 +340,8 @@ class UpdatedFeedRepository syncMap.forEach { (id, isLiked) -> runCatching { if (isLiked) feedApi.postLikes(id) else feedApi.deleteLikes(id) + }.onSuccess { + deleteSyncedPendingLike(id, isLiked) }.onFailure { Log.e("UpdatedFeedRepository", "Failed to sync feed $id", it) } @@ -347,6 +349,17 @@ class UpdatedFeedRepository } } + private suspend fun deleteSyncedPendingLike( + feedId: Long, + isLiked: Boolean, + ) { + runCatching { + pendingFeedLikeStore.deletePendingLikeIfMatched(feedId, isLiked) + }.onFailure { + Log.e("UpdatedFeedRepository", "Failed to delete synced feed like $feedId", it) + } + } + // ============================================================================================ // Feed Actions (Remove, Report) // ============================================================================================ From 808fd0a5ce86333d6855a3dea534b9ffa4223bdd Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 18:43:49 +0900 Subject: [PATCH 14/21] =?UTF-8?q?fix:=20UpdatedFeedRepository=20=EB=82=B4?= =?UTF-8?q?=20=ED=8E=9C=EB=94=A9=EB=90=9C=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `observePendingLikes` 메서드를 통해 `pendingFeedLikeStore`의 변경 사항을 구독하고, `pendingLikeStates`를 실시간으로 업데이트하도록 수정 - 펜딩된 좋아요 상태를 모든 피드 리스트(`_sosoAllFeeds`, `_sosoRecommendedFeeds`, `_myFeeds`)에 즉각 반영하는 `applyPendingLikeStatesToCachedFeeds` 로직 구현 --- .../feed/repository/UpdatedFeedRepository.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 91ecc8725..d555644e4 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -59,6 +59,20 @@ class UpdatedFeedRepository private val originalLikeStates = ConcurrentHashMap() private val feedDetailStates = ConcurrentHashMap() + init { + observePendingLikes() + } + + private fun observePendingLikes() { + scope.launch { + pendingFeedLikeStore.pendingLikes.collect { pendingLikes -> + pendingLikeStates.clear() + pendingLikeStates.putAll(pendingLikes) + applyPendingLikeStatesToCachedFeeds() + } + } + } + // ============================================================================================ // Feed Creation & Modification // ============================================================================================ @@ -231,6 +245,16 @@ class UpdatedFeedRepository return feed } + private fun applyPendingLikeStatesToCachedFeeds() { + val updateAction: (List) -> List = { list -> + list.map { feed -> applyPendingLikeState(feed) } + } + + _sosoAllFeeds.update(updateAction) + _sosoRecommendedFeeds.update(updateAction) + _myFeeds.update(updateAction) + } + // ============================================================================================ // Interaction (Like, Sync) // ============================================================================================ From 9bd112252c60e13b5fd6794c10b95b96a057953d Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 19:09:24 +0900 Subject: [PATCH 15/21] =?UTF-8?q?fix:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B3=B4=EC=99=84=20=EB=B0=8F=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=EB=A6=AC/=EB=A1=9C=EC=BB=AC=20=EC=A0=80=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `syncPendingLikes`: 동기화 시 메모리(`pendingLikeStates`)뿐만 아니라 로컬 저장소(`pendingFeedLikeStore`)의 데이터도 함께 처리하도록 수정 - `deleteSyncedPendingLike`: 동기화 완료 후 데이터를 삭제하는 로직을 메모리(`FromMemory`)와 로컬 저장소(`FromStore`)용으로 분리하여 관리 최적화 - 동기화 시작 전 빈 맵 확인 로직을 코루틴 내부로 이동하여 데이터 유실 방지 및 안정성 향상 --- .../feed/repository/UpdatedFeedRepository.kt | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index d555644e4..430b2dbe0 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -354,18 +354,16 @@ class UpdatedFeedRepository * 서버에 아직 반영되지 않은 좋아요 상태들을 동기화합니다. */ fun syncPendingLikes() { - if (pendingLikeStates.isEmpty()) return - - val syncMap = pendingLikeStates.toMap() - pendingLikeStates.clear() - originalLikeStates.clear() - scope.launch { + val syncMap = pendingFeedLikeStore.getPendingLikes() + pendingLikeStates.toMap() + if (syncMap.isEmpty()) return@launch + syncMap.forEach { (id, isLiked) -> runCatching { if (isLiked) feedApi.postLikes(id) else feedApi.deleteLikes(id) }.onSuccess { - deleteSyncedPendingLike(id, isLiked) + deleteSyncedPendingLikeFromStore(id, isLiked) + deleteSyncedPendingLikeFromMemory(id, isLiked) }.onFailure { Log.e("UpdatedFeedRepository", "Failed to sync feed $id", it) } @@ -373,7 +371,17 @@ class UpdatedFeedRepository } } - private suspend fun deleteSyncedPendingLike( + private fun deleteSyncedPendingLikeFromMemory( + feedId: Long, + isLiked: Boolean, + ) { + pendingLikeStates.remove(feedId, isLiked) + if (!pendingLikeStates.containsKey(feedId)) { + originalLikeStates.remove(feedId) + } + } + + private suspend fun deleteSyncedPendingLikeFromStore( feedId: Long, isLiked: Boolean, ) { From 40a43ea056feef7066a6990f558ac34cf600ae7b Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 19:30:40 +0900 Subject: [PATCH 16/21] =?UTF-8?q?fix:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=98=20=EC=88=9C=EC=B0=A8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B3=B4=EC=9E=A5=20=EB=B0=8F=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=B0=A9=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepository`: `pendingFeedLikeStore`에 대한 쓰기 작업의 순서 보장을 위해 `enqueuePendingLikeStoreWrite` 메서드 구현 및 `Job.join()`을 이용한 직렬화 로직 적용 - 초기화 시 `Flow.collect`를 통한 관찰 방식 대신 `getPendingLikes`를 호출하여 일회성으로 데이터를 복구하도록 `restorePendingLikes` 로직 수정 - `savePendingLike` 및 `deletePendingLike` 호출 시 새로운 쓰기 큐 로직을 적용하여 데이터 정합성 강화 --- .../feed/repository/UpdatedFeedRepository.kt | 52 ++++++++++++------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 430b2dbe0..8484b413c 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -19,6 +19,7 @@ import com.into.websoso.data.feed.repository.model.CachedFeedLikeState import com.into.websoso.data.feed.store.PendingFeedLikeStore import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -58,18 +59,19 @@ class UpdatedFeedRepository private val pendingLikeStates = ConcurrentHashMap() private val originalLikeStates = ConcurrentHashMap() private val feedDetailStates = ConcurrentHashMap() + private val pendingLikeStoreWriteLock = Any() + private var pendingLikeStoreWriteJob: Job? = null init { - observePendingLikes() + restorePendingLikes() } - private fun observePendingLikes() { + private fun restorePendingLikes() { scope.launch { - pendingFeedLikeStore.pendingLikes.collect { pendingLikes -> - pendingLikeStates.clear() - pendingLikeStates.putAll(pendingLikes) - applyPendingLikeStatesToCachedFeeds() - } + val pendingLikes = pendingFeedLikeStore.getPendingLikes() + pendingLikeStates.clear() + pendingLikeStates.putAll(pendingLikes) + applyPendingLikeStatesToCachedFeeds() } } @@ -331,21 +333,35 @@ class UpdatedFeedRepository feedId: Long, isLiked: Boolean, ) { - scope.launch { - runCatching { - pendingFeedLikeStore.updatePendingLike(feedId, isLiked) - }.onFailure { - Log.e("UpdatedFeedRepository", "Failed to save pending feed like $feedId", it) - } + enqueuePendingLikeStoreWrite(feedId, "save") { + pendingFeedLikeStore.updatePendingLike(feedId, isLiked) } } private fun deletePendingLike(feedId: Long) { - scope.launch { - runCatching { - pendingFeedLikeStore.deletePendingLike(feedId) - }.onFailure { - Log.e("UpdatedFeedRepository", "Failed to delete pending feed like $feedId", it) + enqueuePendingLikeStoreWrite(feedId, "delete") { + pendingFeedLikeStore.deletePendingLike(feedId) + } + } + + private fun enqueuePendingLikeStoreWrite( + feedId: Long, + actionName: String, + action: suspend () -> Unit, + ) { + synchronized(pendingLikeStoreWriteLock) { + val previousJob = pendingLikeStoreWriteJob + pendingLikeStoreWriteJob = scope.launch { + previousJob?.join() + runCatching { + action() + }.onFailure { + Log.e( + "UpdatedFeedRepository", + "Failed to $actionName pending feed like $feedId", + it, + ) + } } } } From 6ae731a57bfef31ef6ccc6a088c2095e96aa07b2 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 23 Jun 2026 16:51:40 +0900 Subject: [PATCH 17/21] =?UTF-8?q?fix:=20`UpdatedFeedRepository`=20?= =?UTF-8?q?=EB=82=B4=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=A1=9C=EC=A7=81=EC=9D=98=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=EC=A0=9C=EC=96=B4=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `pendingLikeStoreWriteLock` (Any) 및 `Job`을 이용한 순차 처리 로직을 `Mutex`와 `withLock`을 사용하는 방식으로 변경하여 코루틴 환경에서의 안정성 및 가독성 향상 - `enqueuePendingLikeStoreWrite` 메서드를 `writePendingLikeStore`로 네이밍 변경 및 로직 최적화 --- .../feed/repository/UpdatedFeedRepository.kt | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 8484b413c..640cf8df9 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -27,6 +27,8 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -59,8 +61,7 @@ class UpdatedFeedRepository private val pendingLikeStates = ConcurrentHashMap() private val originalLikeStates = ConcurrentHashMap() private val feedDetailStates = ConcurrentHashMap() - private val pendingLikeStoreWriteLock = Any() - private var pendingLikeStoreWriteJob: Job? = null + private val pendingLikeStoreWriteMutex = Mutex() init { restorePendingLikes() @@ -333,26 +334,24 @@ class UpdatedFeedRepository feedId: Long, isLiked: Boolean, ) { - enqueuePendingLikeStoreWrite(feedId, "save") { + writePendingLikeStore(feedId, "save") { pendingFeedLikeStore.updatePendingLike(feedId, isLiked) } } private fun deletePendingLike(feedId: Long) { - enqueuePendingLikeStoreWrite(feedId, "delete") { + writePendingLikeStore(feedId, "delete") { pendingFeedLikeStore.deletePendingLike(feedId) } } - private fun enqueuePendingLikeStoreWrite( + private fun writePendingLikeStore( feedId: Long, actionName: String, action: suspend () -> Unit, ) { - synchronized(pendingLikeStoreWriteLock) { - val previousJob = pendingLikeStoreWriteJob - pendingLikeStoreWriteJob = scope.launch { - previousJob?.join() + scope.launch { + pendingLikeStoreWriteMutex.withLock { runCatching { action() }.onFailure { From d77e967a9662b8581f48eb242a3a39d787c4c78b Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Wed, 24 Jun 2026 16:12:41 +0900 Subject: [PATCH 18/21] =?UTF-8?q?refactor:=20=ED=94=BC=EB=93=9C=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이미 나간 sync 요청이 성공했을 때 최신 로컬 상태와 다시 맞춰보는 보정 로직 - `UpdatedFeedRepository.kt`: 좋아요 동기화 성공 시 현재 상태를 재확인하여 처리하는 `handleSyncedPendingLike` 메서드 추가 - 동기화 중 사용자가 좋아요 상태를 변경한 경우, 해당 변경 사항이 유실되지 않도록 `pendingLikeStates`를 유지하고 업데이트하는 로직 적용 - 피드 캐시 또는 상세 상태에서 현재 좋아요 상태를 조회하는 `findCurrentLikeState` 메서드 추가로 데이터 정합성 강화 --- .../feed/repository/UpdatedFeedRepository.kt | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 640cf8df9..576e47aa1 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -377,8 +377,7 @@ class UpdatedFeedRepository runCatching { if (isLiked) feedApi.postLikes(id) else feedApi.deleteLikes(id) }.onSuccess { - deleteSyncedPendingLikeFromStore(id, isLiked) - deleteSyncedPendingLikeFromMemory(id, isLiked) + handleSyncedPendingLike(id, isLiked) }.onFailure { Log.e("UpdatedFeedRepository", "Failed to sync feed $id", it) } @@ -386,6 +385,24 @@ class UpdatedFeedRepository } } + private suspend fun handleSyncedPendingLike( + feedId: Long, + syncedIsLiked: Boolean, + ) { + val currentIsLiked = findCurrentLikeState(feedId) + if (currentIsLiked != null && currentIsLiked != syncedIsLiked) { + originalLikeStates[feedId] = syncedIsLiked + pendingLikeStates[feedId] = currentIsLiked + updatePendingLike(feedId, currentIsLiked) + return + } + + deleteSyncedPendingLikeFromStore(feedId, syncedIsLiked) + deleteSyncedPendingLikeFromMemory(feedId, syncedIsLiked) + } + + private fun findCurrentLikeState(feedId: Long): Boolean? = findCachedFeed(feedId)?.isLiked ?: feedDetailStates[feedId]?.isLiked + private fun deleteSyncedPendingLikeFromMemory( feedId: Long, isLiked: Boolean, From 6d8fa9dcebfa54746c43feeead720c31a8cacbd7 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Wed, 24 Jun 2026 16:23:01 +0900 Subject: [PATCH 19/21] =?UTF-8?q?fix:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=EC=9D=98=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B8=20=EA=B0=92=20=EC=BA=90=EC=8B=B1=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 앱 재시작 후 pending 병합 시, 서버 원본 isLiked를 originalLikeStates에 기록해서 pending 제거 판단 기준을 복구 - `UpdatedFeedRepository` 내 `applyPendingLikeState` 및 `applyPendingLikeStateToDetail` 함수에서 좋아요 상태 변경 전 원본 값을 `originalLikeStates`에 저장하도록 수정하여 데이터 일관성 보장 기반 마련 --- .../into/websoso/data/feed/repository/UpdatedFeedRepository.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 576e47aa1..e9aeeeb35 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -237,6 +237,7 @@ class UpdatedFeedRepository */ private fun applyPendingLikeState(feed: FeedEntity): FeedEntity { val localIsLiked = pendingLikeStates[feed.id] ?: return feed + originalLikeStates.putIfAbsent(feed.id, feed.isLiked) if (feed.isLiked != localIsLiked) { val adjustedCount = if (localIsLiked) feed.likeCount + 1 else feed.likeCount - 1 @@ -502,6 +503,7 @@ class UpdatedFeedRepository private fun applyPendingLikeStateToDetail(feed: FeedDetailEntity): FeedDetailEntity { val localIsLiked = pendingLikeStates[feed.id] ?: return feed + originalLikeStates.putIfAbsent(feed.id, feed.isLiked) if (feed.isLiked != localIsLiked) { val adjustedCount = if (localIsLiked) feed.likeCount + 1 else feed.likeCount - 1 From faaa6e74eaafb5849c0281926cce9f43a005a034 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Wed, 24 Jun 2026 16:28:18 +0900 Subject: [PATCH 20/21] =?UTF-8?q?refactor:=20DefaultPendingFeedLikeStore?= =?UTF-8?q?=20=EB=82=B4=20MutableMap=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `DefaultPendingFeedLikeStore.kt`: `pendingLikes.put(feedId, isLiked)` 호출 방식을 코틀린 관용구인 인덱스 연산자(`[]`) 사용으로 변경 --- .../datastore/datasource/feed/DefaultPendingFeedLikeStore.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt index 3036f2bf5..07af7e3d9 100644 --- a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt @@ -48,7 +48,7 @@ internal class DefaultPendingFeedLikeStore val pendingLikes: MutableMap = decodePendingLikes( preferences[PENDING_FEED_LIKES_KEY], ).toMutableMap() - pendingLikes.put(feedId, isLiked) + pendingLikes[feedId] = isLiked preferences[PENDING_FEED_LIKES_KEY] = encodePendingLikes(pendingLikes) } } From 407e53465b7ebb73e03b9babd6e7c8328895cca0 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Wed, 24 Jun 2026 16:38:59 +0900 Subject: [PATCH 21/21] =?UTF-8?q?docs:=20PendingFeedLikeStore=20=EB=82=B4?= =?UTF-8?q?=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=20=EC=9D=BC?= =?UTF-8?q?=EC=B9=98=20=EC=97=AC=EB=B6=80=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `PendingFeedLikeStore.kt`: `deletePendingLikeIfMatched` 메서드에 파라미터 설명 및 반환값 정의를 포함한 KDoc 주석 추가 --- .../com/into/websoso/data/feed/store/PendingFeedLikeStore.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt index 0e0a026e2..7bc64175a 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt @@ -14,6 +14,11 @@ interface PendingFeedLikeStore { suspend fun deletePendingLike(feedId: Long) + /** + * 저장된 좋아요 상태가 [isLiked]와 일치하는 경우에만 삭제합니다. + * + * `@return` 매칭되어 삭제된 경우 true, 일치하지 않아 삭제하지 않은 경우 false + * */ suspend fun deletePendingLikeIfMatched( feedId: Long, isLiked: Boolean,