[feat] #59, #64, #69 아카이브 API 연동 및 페이징 구현#73
Conversation
Kotlin 라이브러리(`neki.kotlin.library`)에서 Android 라이브러리(`neki.android.library`)로 모듈 타입을 변경하고, 이에 따라 `namespace`를 설정합니다.
[refactor] 업로드 티켓 발급 API 분리 - 기존 `getUploadTicket`을 `getMultipleUploadTicket`으로 변경합니다. - 단일 파일 업로드를 위한 `getSingleUploadTicket` 메서드를 추가합니다. [feat] 이미지 업로드 Repository 기능 추가 - `Uri` 또는 `String` URL을 통해 이미지를 직접 업로드할 수 있는 `uploadImageFromUri`와 `uploadImageFromUrl` 메서드를 `MediaUploadRepository`에 추가합니다.
`UploadMultiplePhotoUseCase`를 구현하여 여러 장의 사진을 한 번에 업로드하는 기능을 추가합니다. 이 UseCase는 다음의 순서로 동작합니다. 1. 업로드할 이미지 개수만큼 업로드 티켓을 한 번에 발급받습니다. 2. 각 이미지를 발급받은 Presigned URL을 통해 업로드합니다. 3. 업로드가 완료된 모든 사진을 서버에 등록합니다.
`MediaUploadRepository`에 Uri 또는 Url로부터 이미지를 업로드하는 기능을 추가합니다. - `uploadImageFromUri`: `Uri`를 `ByteArray`로 변환하여 업로드합니다. - `uploadImageFromUrl`: 이미지 `Url`을 `ByteArray`로 변환하여 업로드합니다. - `ContentType`을 `Bitmap.CompressFormat`으로 변환하는 확장 함수를 추가합니다.
`UploadSinglePhotoUseCase`에서 이미지를 업로드할 때 `ByteArray`를 직접 전달하는 대신 이미지 `URL`을 사용하도록 수정합니다. 이에 따라 `getUploadTicket`은 `getSingleUploadTicket`으로, `uploadImage`는 `uploadImageFromUrl`로 변경되었습니다.
`UploadMultiplePhotoUseCase`를 추가하고, 여러 장의 이미지 Uri를 서버에 업로드하는 기능을 구현합니다. 기존 `UploadType`을 `SINGLE`과 `MULTIPLE`로 변경하여, 단일/다중 이미지 업로드 분기 처리를 명확하게 합니다.
폴더 목록을 조회하고, 새로운 폴더를 생성하는 기능을 위한 API 연동 코드를 추가합니다. - `FolderService`에 폴더 목록 조회(`getFolders`) 및 폴더 생성(`createFolder`) API 함수를 정의합니다. - `FolderRepository` 인터페이스와 그 구현체인 `FolderRepositoryImpl`을 추가하여 데이터 계층을 정의합니다. - 폴더 생성 요청(`CreateFolderRequest`), 폴더 생성 응답(`CreateFolderResponse`), 폴더 목록 응답(`FolderResponse`)에 사용될 데이터 모델을 추가합니다.
보관함 화면 진입 시 즐겨찾기, 전체 사진과 함께 앨범 목록을 조회하도록 `fetchFolders` 함수를 추가하고 `init` 블록에서 호출합니다.
`folderRepository.createFolder()`를 호출하여 사용자가 입력한 이름으로 새 앨범(폴더)을 생성하는 기능을 구현합니다. 앨범 생성이 성공하면 폴더 목록을 다시 불러와 화면을 갱신합니다. 생성 실패 시에는 에러 로그를 기록합니다.
기존에 사용하던 더미 데이터를 제거하고, `FolderRepository`와 `PhotoRepository`를 사용하여 실제 앨범 및 즐겨찾는 사진 요약 정보를 가져오도록 `AllAlbumViewModel`을 수정합니다.
- `PhotoRepository`와 `FolderRepository`를 사용하여 즐겨찾기 앨범 정보와 전체 앨범 목록을 가져오도록 구현합니다. - 로딩 상태를 관리하기 위해 `isLoading` 상태를 추가하고, 데이터 fetch 시 `isLoading`을 true/false로 업데이트합니다. - `Album` 모델을 `AlbumPreview` 모델로 변경하여 화면에 필요한 최소한의 데이터만 사용하도록 수정합니다.
[refactor] ViewModel에서 더미 데이터 제거 및 API 연동 로직 추가 기존에 사용하던 `dummyPhotos`를 제거하고, `photoRepository.getPhotos()`를 호출하여 실제 사진 목록을 가져오도록 수정합니다.
Paging3 라이브러리를 사용하여 사진 목록을 불러오는 `PagingSource`를 구현합니다. - `PhotoPagingSource`: 폴더 ID를 기반으로 사진 목록을 페이징합니다. - `FavoritePhotoPagingSource`: 즐겨찾기한 사진 목록을 정렬 순서에 따라 페이징합니다.
Paging의 `initialLoadSize`를 `PAGE_SIZE`로 설정하여 초기 로딩 성능을 개선합니다.
앨범 상세 화면으로 이동할 때 `albumId`만 전달하던 방식에서 `AlbumPreview` 객체 전체를 전달하도록 수정합니다. 이를 통해 앨범 제목(`title`)을 상세 화면 ViewModel에 직접 주입할 수 있게 되어, 불필요한 데이터 로딩 과정을 제거하고 화면 간 데이터 전달 방식을 일관성 있게 개선합니다. - `navigateToAlbumDetail` 함수의 파라미터에 `title`을 추가합니다. - 앨범 아이템 클릭 시 `albumId` 대신 `AlbumPreview` 객체를 전달하도록 `ClickAlbumItem` 인텐트를 수정합니다. - `AlbumDetailViewModel` 생성 시 `title`을 주입받아 초기 상태를 설정합니다.
- `folderRepository.createFolder` API를 호출하여 새로운 앨범을 생성하는 기능을 구현합니다. - API 호출 성공 시, 앨범 목록을 갱신하고 "새로운 앨범을 추가했어요"라는 토스트 메시지를 표시합니다. - API 호출 실패 시, "앨범 추가에 실패했어요"라는 토스트 메시지를 표시하고 에러 로그를 기록합니다.
폴더 생성 API의 응답 타입이 `BasicResponse`에서 `BasicNullableResponse`로 변경되었습니다. 이에 따라 `FolderRepository`의 `createFolder` 함수의 반환 타입을 `Result<Long>`에서 `Result<Unit>`으로 수정하여, 더 이상 폴더 ID를 반환하지 않도록 변경했습니다.
선택한 앨범을 삭제하는 기능을 구현합니다. - `FolderService`에 `deleteFolder` API 함수를 추가합니다. - `FolderRepository` 및 `FolderRepositoryImpl`에 `deleteFolder` 함수를 추가하고, `DeleteFolderRequest`를 사용하여 API를 호출하도록 구현합니다. - `AllAlbumViewModel`에서 선택된 앨범들의 ID를 사용하여 `folderRepository.deleteFolder`를 호출하고, 성공 시 앨범 목록을 다시 불러오도록 수정합니다.
사진 그리드 레이아웃에 사용되는 상수의 이름을 더 명확하게 변경하고 이를 각 화면에 적용합니다. - `ARCHIVE_LAYOUT_HORIZONTAL_PADDING` -> `PHOTO_GRID_LAYOUT_HORIZONTAL_PADDING` - `ARCHIVE_LAYOUT_BOTTOM_PADDING` -> `PHOTO_GRAY_LAYOUT_BOTTOM_PADDING` - `PHOTO_GRID_LAYOUT_TOP_PADDING` 상수 추가
전체 사진 화면에 정렬 순서(최신순, 오래된순) 및 즐겨찾기 필터링 기능을 추가합니다. - `PhotoService`와 `PhotoPagingSource`에 `sortOrder` 파라미터를 추가하여 API 요청 시 정렬 순서를 전달합니다. - `AllPhotoViewModel`에서 `flatMapLatest`를 사용해 필터(`_photoFilter`, `_isFavoriteOnly`) 변경 시 `photoPagingData`가 새로 발행되도록 수정했습니다. - 필터가 변경되면 화면이 최상단으로 스크롤되도록 `ScrollToTop` 사이드 이펙트를 처리합니다. - 스크롤 로직을 개선하여, 페이징 데이터 로드가 완료된 후 스크롤이 실행되도록 보장합니다.
사진 삭제 API 호출에 실패했을 경우, 로딩 상태만 `false`로 변경되고 확인 다이얼로그나 바텀시트가 열린 채로 유지되던 문제를 수정합니다. 삭제 실패 시 `isShowDeleteDialog`와 `isShowDeleteBottomSheet` 상태를 `false`로 변경하여 정상적으로 닫히도록 수정했습니다.
Walkthrough페이징 추가, 폴더 CRUD API/모델/리포지토/DI 추가, 미디어 업로드 API 확장(단/다중 티켓, URI/URL 업로드) 및 다중 사진 업로드 유스케이스 도입, UI 전반 Album→AlbumPreview 전환 및 네비게이션에 앨범 제목 추가, ArchiveResult로 결과 전파. Changes
Sequence Diagram(s)sequenceDiagram
participant User as 사용자
participant VM as ArchiveMainViewModel
participant UC as UploadMultiplePhotoUseCase
participant MediaRepo as MediaUploadRepository
participant UploadService as UploadService
participant PhotoRepo as PhotoRepository
participant API as 원격API
User->>VM: 여러 사진 선택 후 업로드 요청
VM->>UC: invoke(imageUris, contentType, folderId)
UC->>MediaRepo: getMultipleUploadTicket(count,fileName,...)
MediaRepo->>UploadService: 요청 (티켓 생성)
UploadService->>API: POST /api/upload/tickets
API-->>UploadService: presigned URL 목록
UploadService-->>MediaRepo: 티켓 반환
par 병렬 업로드
UC->>MediaRepo: uploadImageFromUri(presignedUrl, uri)
MediaRepo->>API: PUT presigned-url (병렬)
API-->>MediaRepo: 업로드 응답
end
UC->>PhotoRepo: registerPhotos(mediaIds, folderId)
PhotoRepo->>API: POST /api/photos/register
API-->>PhotoRepo: 등록 완료
PhotoRepo-->>UC: 등록된 Media 리스트
UC-->>VM: Result<List<Media>>
sequenceDiagram
participant User as 사용자
participant UI as AllPhotoScreen
participant VM as AllPhotoViewModel
participant Repo as PhotoRepository
participant Pager as Pager/PhotoPagingSource
participant Service as PhotoService
participant API as 원격API
UI->>VM: 화면 진입 -> getPhotosFlow()
VM->>Repo: getPhotosFlow()
Repo->>Pager: Pager 생성
UI->>Pager: 스크롤 -> 다음 페이지 요청
Pager->>Service: getPhotos(page=N, sortOrder)
Service->>API: GET /api/photos?page=N&sortOrder=...
API-->>Service: PhotoResponse
Service-->>Pager: 응답 전달
Pager-->>UI: PagingData 방출
UI->>User: 그리드 업데이트
🎯 Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes 📌 Possibly related PRs
🏷️ Suggested labels
👥 Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt (1)
171-205: 빈 선택 상태에서 삭제 API 호출 가능선택이 비어 있는 상태로 확인 버튼이 눌리면 빈 리스트로 삭제 호출이 나갈 수 있습니다. 방어 로직을 추가해 주세요.
🛡️ 방어 로직 추가 제안
private fun handleFavoriteDelete( state: AlbumDetailState, reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, postSideEffect: (AlbumDetailSideEffect) -> Unit, ) { val selectedPhotoIds = state.selectedPhotos.map { it.id } + if (selectedPhotoIds.isEmpty()) { + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 선택해주세요.")) + reduce { copy(isShowDeleteDialog = false) } + return + } viewModelScope.launch { reduce { copy(isLoading = true) } photoRepository.deletePhoto(photoIds = selectedPhotoIds) .onSuccess { ... } .onFailure { error -> ... } } }private fun handleAlbumPhotoDelete( state: AlbumDetailState, reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, postSideEffect: (AlbumDetailSideEffect) -> Unit, ) { val selectedPhotoIds = state.selectedPhotos.map { it.id } + if (selectedPhotoIds.isEmpty()) { + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 선택해주세요.")) + reduce { copy(isShowDeleteBottomSheet = false) } + return + } viewModelScope.launch { reduce { copy(isLoading = true) } photoRepository.deletePhoto(photoIds = selectedPhotoIds) .onSuccess { ... } .onFailure { error -> ... } } }Also applies to: 208-242
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumScreen.kt (1)
117-145:existingAlbumNames가uiState.albums변경 시 업데이트되지 않을 수 있습니다.
remember에 key가 없어서uiState.albums가 변경되어도existingAlbumNames가 갱신되지 않습니다. 새 앨범이 추가된 후 bottom sheet가 다시 열리면 중복 검증이 실패할 수 있습니다.🐛 수정 제안
- val existingAlbumNames = remember { uiState.albums.map { it.title } } + val existingAlbumNames = remember(uiState.albums) { uiState.albums.map { it.title } }feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt (1)
60-70: 앨범 선택 해제 로직에서 객체 동등성 불일치로 인한 버그
AlbumPreview는 Kotlin data class이므로 자동 생성된equals()메서드가 모든 속성(id, title, thumbnailUrl, photoCount)을 비교합니다. 그러나 Line 63에서는it.id == intent.album.id로 id만 비교하고, Line 64의selectedAlbums.remove(intent.album)는 전체 속성이 일치해야 작동합니다. 같은 album id를 가진 객체라도 다른 API 응답에서 온 경우 다른 메타데이터를 가질 수 있어, id는 일치하지만 객체는 다르게 인식되어 제거되지 않을 수 있습니다.🐛 id 기반 필터링을 사용한 수정 제안
is UploadAlbumIntent.ClickAlbumItem -> { reduce { copy( - selectedAlbums = if (state.selectedAlbums.any { it.id == intent.album.id }) { - selectedAlbums.remove(intent.album) + selectedAlbums = if (selectedAlbums.any { it.id == intent.album.id }) { + selectedAlbums.filter { it.id != intent.album.id } } else { selectedAlbums.add(intent.album) }.toPersistentList(), ) } }
🤖 Fix all issues with AI agents
In
`@core/data/src/main/java/com/neki/android/core/data/paging/PhotoPagingSource.kt`:
- Around line 38-40: PhotoPagingSource의 load(...)에서 모든 Exception을 잡는 catch 블록이
CancellationException까지 잡아 코루틴 취소가 전파되지 않으니, catch (e: Exception) 내부에서
CancellationException을 재전파하거나 별도 catch (e: CancellationException)로 먼저 처리하도록
변경하세요; 구체적으로 PhotoPagingSource의 load 함수에서 현재 catch (e: Exception) {
LoadResult.Error(e) }를 수정해 CancellationException인 경우 throw e를 수행하고 그 외에는 기존대로
LoadResult.Error(e)를 반환하도록 하세요.
In
`@core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadMultiplePhotoUseCase.kt`:
- Around line 40-51: The code in UploadMultiplePhotoUseCase uses tickets[index]
inside the coroutineScope imageUris.mapIndexed block which can throw
IndexOutOfBoundsException if tickets is shorter than imageUris; fix by using
safe access (tickets.getOrNull(index)) and handle the missing ticket
explicitly—either fail fast with a clear exception (e.g., throw
IllegalStateException("Missing upload ticket for index $index")) or filter/zip
imageUris with tickets before launching coroutines (e.g., zip imageUris with
tickets to produce pairs) and then call mediaUploadRepository.uploadImageFromUri
for each pair so you never directly index into tickets.
In
`@feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveNavKey.kt`:
- Around line 20-24: AlbumDetail 데이터 클래스의 새 필드 title에 기본값을 추가해 역직렬화 호환성을 보장하세요:
AlbumDetail 클래스(구체적으로 title 프로퍼티)에 기본값(예: 빈 문자열) 을 설정해 이전에 저장된 NavKey를 복원할 때
MissingFieldException이 발생하지 않도록 수정하고, 필요한 경우 ArchiveNavKey를 사용하는 곳에서 생성 호출이 기본값과
호환되는지 확인하세요.
In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumViewModel.kt`:
- Around line 154-156: The local declaration val album in the
SelectMode.SELECTING branch shadows the function parameter album: AlbumPreview;
rename the local variable returned by state.albums.find (e.g., foundAlbum or
existingAlbum) and update all subsequent uses in that branch (for example change
state.albums.find { it.id == album.id } to state.albums.find { it.id == album.id
} -> val foundAlbum = ... and then use foundAlbum.id when computing isSelected
and other places) so the parameter and local variable are distinct and no longer
shadow each other.
In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainContract.kt`:
- Around line 22-24: 현재 uploadType 프로퍼티는 selectedUris.size == 1이면 SINGLE로 판정해
갤러리 URI 선택 시 uploadWithoutAlbum에서 state.scannedImageUrl ?: return으로 조용히 종료되는 문제가
있습니다; 고치려면 uploadType의 SINGLE 분기를 scannedImageUrl != null으로 제한하거나(즉 val
uploadType: UploadType get() = if (scannedImageUrl != null) UploadType.SINGLE
else UploadType.MULTIPLE), 아니면 uploadWithoutAlbum 함수 내부에서 uploadType 대신
state.scannedImageUrl 여부로 분기하여 scannedImageUrl가 null인 경우에는 selectedUris 단일 항목을
업로드하도록 처리하세요; 관련 식별자: uploadType 프로퍼티, uploadWithoutAlbum, uploadWithAlbumRow,
state.scannedImageUrl, selectedUris.
In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoScreen.kt`:
- Around line 66-67: Remove the debug log call Timber.d(pagingItems.toString())
from AllPhotoScreen (where pagingItems is assigned via
viewModel.photoPagingData.collectAsLazyPagingItems()); simply delete that
Timber.d(...) line so no debug-only logging remains in the production UI code.
In
`@feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt`:
- Line 113: The upload handler in UploadAlbumViewModel currently returns
silently when state.selectedAlbums is empty (val firstAlbum =
state.selectedAlbums.firstOrNull() ?: return), causing no user feedback; change
this to emit a user-facing event instead of returning silently—for example,
replace the early return with code that posts a Toast/ Snackbar UI event (via
the ViewModel's existing UI event channel or LiveData) like
emit(UiEvent.ShowToast("Please select an album")) or alternatively ensure the
upload button is disabled in the UI until state.selectedAlbums is non-empty;
update the upload method that contains the firstAlbum line and any related UI
event types so the view can display the message.
In `@gradle/libs.versions.toml`:
- Line 37: Update the paging library version from 3.3.2 to the latest stable
3.3.6 by changing the paging version entry (paging = "3.3.2") to paging =
"3.3.6" in libs.versions.toml, and verify that any module references using that
version (e.g., dependencies referencing paging-runtime and paging-compose)
continue to use the updated version variable so both paging-runtime and
paging-compose are bumped to 3.3.6.
🧹 Nitpick comments (15)
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailContract.kt (1)
8-18:deletedPhotoIds도 ImmutableSet으로 고정해 상태 불변성을 유지하세요.
현재Set<Long>는 가변 구현이 섞일 여지가 있어 MVI 상태 일관성이 깨질 수 있습니다. 다른 상태 필드와 동일하게 immutable 컬렉션으로 맞추는 편이 안전합니다.♻️ 제안 수정
import com.neki.android.core.model.Photo import com.neki.android.feature.archive.impl.model.SelectMode import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf data class AlbumDetailState( val isLoading: Boolean = false, val title: String = "", val isFavoriteAlbum: Boolean = false, val selectMode: SelectMode = SelectMode.DEFAULT, val selectedPhotos: ImmutableList<Photo> = persistentListOf(), - val deletedPhotoIds: Set<Long> = emptySet(), + val deletedPhotoIds: ImmutableSet<Long> = persistentSetOf(), val isShowDeleteDialog: Boolean = false, val isShowDeleteBottomSheet: Boolean = false, val selectedDeleteOption: PhotoDeleteOption = PhotoDeleteOption.REMOVE_FROM_ALBUM, )feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailScreen.kt (2)
152-165: Staggered grid에서 로딩 인디케이터가 한 컬럼에만 표시됩니다.
LazyVerticalStaggeredGrid에서item { }블록은 단일 셀만 차지합니다.StaggeredGridCells.Fixed(2)설정으로 2열 그리드이므로, 로딩 인디케이터가 한쪽 컬럼에만 표시되어 시각적으로 중앙 정렬되지 않습니다.♻️ 전체 너비에 로딩 인디케이터를 표시하는 방법
fullSpan을 사용하여 두 컬럼을 모두 차지하도록 수정:if (pagingItems.loadState.append is LoadState.Loading) { - item { + item(span = StaggeredGridItemSpan.FullLine) { Box( modifier = Modifier .fillMaxWidth() .padding(16.dp), contentAlignment = Alignment.Center, ) { CircularProgressIndicator( color = NekiTheme.colorScheme.primary500, ) } } }추가로 import 필요:
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
96-97: 페이징 에러 상태에 대한 처리가 누락되었습니다.
isRefreshing과isEmpty는 처리되지만,LoadState.Error상태에 대한 UI 피드백이 없습니다. 네트워크 오류 등 발생 시 사용자에게 적절한 안내가 필요합니다.♻️ 에러 상태 처리 추가 제안
val isRefreshing = pagingItems.loadState.refresh is LoadState.Loading val isEmpty = pagingItems.itemCount == 0 && pagingItems.loadState.refresh is LoadState.NotLoading +val refreshError = pagingItems.loadState.refresh as? LoadState.Error그리고 UI에서 에러 상태를 표시하거나, 토스트 메시지를 보여주는 방식으로 처리할 수 있습니다:
refreshError?.let { error -> // 에러 UI 표시 또는 재시도 버튼 제공 // nekiToast.showToast(text = "데이터를 불러오지 못했어요") }feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveNavKey.kt (1)
42-43:AlbumDetail생성 시 named args 사용 권장Line 43에서 위치 인자 사용은 필드 추가/순서 변경 시 실수 가능성이 있어요. 가독성과 안전성을 위해 named args로 명시해 주세요.
🔧 제안 수정안
fun Navigator.navigateToAlbumDetail(id: Long, title: String = "", isFavorite: Boolean = false) { - navigate(ArchiveNavKey.AlbumDetail(isFavorite, title, id)) + navigate( + ArchiveNavKey.AlbumDetail( + isFavorite = isFavorite, + title = title, + albumId = id, + ) + ) }core/data/src/main/java/com/neki/android/core/data/remote/api/PhotoService.kt (1)
58-67:sortOrder기본값 일관성 검토.
getPhotos는sortOrder에 기본값"DESC"가 있지만,getFavoritePhotos는 기본값이 없습니다. 서비스 레이어에서 일관성을 위해 동일한 패턴을 적용하는 것을 고려해 보세요.♻️ 제안된 수정
suspend fun getFavoritePhotos( page: Int = 0, size: Int = 20, - sortOrder: String, + sortOrder: String = "DESC", ): BasicResponse<FavoritePhotoResponse> {core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt (2)
22-38:first()호출 시 빈 리스트 처리 확인.서버 응답이 빈 리스트를 반환할 경우
first()가NoSuchElementException을 던집니다.runSuspendCatching이 이를 처리하겠지만, 보다 명확한 에러 메시지를 위해firstOrNull()과 명시적 에러 처리를 고려해 보세요.♻️ 제안된 수정
- ).data.toModels().first() + ).data.toModels().firstOrNull() + ?: error("No upload ticket received from server") }
71-88:Dispatchers.Default사용에 대한 검토.
uri.toByteArray()는ContentResolver를 통한 I/O 읽기와 비트맵 압축(CPU 작업)을 모두 포함합니다. 현재Dispatchers.Default는 CPU 집약적 작업에 적합하지만, I/O 작업도 포함되어 있어Dispatchers.IO가 더 적절할 수 있습니다.♻️ 제안된 수정
- val imageBytes = withContext(Dispatchers.Default) { + val imageBytes = withContext(Dispatchers.IO) { uri.toByteArray(core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt (1)
5-8: 매개변수 명칭을folderIds로 통일해 명확성 개선 제안
리스트 타입인데id는 단수로 보입니다.DeleteFolderRequest.folderIds와도 용어가 불일치합니다.♻️ 제안 변경
- suspend fun deleteFolder(id: List<Long>): Result<Unit> + suspend fun deleteFolder(folderIds: List<Long>): Result<Unit>core/data/src/main/java/com/neki/android/core/data/remote/model/response/CreateFolderResponse.kt (1)
6-6: Line 6 TODO 추적 필요
릴리즈 전에 제거될 계획이라면 이슈/티켓으로 추적해 누락되지 않도록 해주세요.원하시면 TODO 추적용 이슈 템플릿/초안을 만들어 드릴까요?
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumViewModel.kt (2)
95-113: 코드 중복: fetchFavoriteSummary/fetchFolders
fetchFavoriteSummary와fetchFolders함수가ArchiveMainViewModel및UploadAlbumViewModel과 동일한 패턴으로 중복되어 있습니다. 이 로직을 공통 유틸리티나 base class로 추출하면 유지보수성이 향상될 수 있습니다.
194-194: TODO 코멘트: 삭제 타입 핸들링 미구현삭제 타입에 따른 핸들링이 TODO로 남아 있습니다. 이 기능 구현이 필요한 경우 별도 이슈로 트래킹하시겠습니까?
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt (1)
158-166: 코드 중복:fetchFolders함수가 여러 ViewModel에서 반복됩니다.
fetchFolders구현이AllAlbumViewModel,UploadAlbumViewModel, 그리고 이 파일에서 거의 동일하게 반복됩니다. 공통 유틸리티나 base class로 추출하는 것을 고려해 보세요.feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoViewModel.kt (1)
54-59:photoPagingData에cachedIn이 적용되지 않았습니다.
originalPagingData는cachedIn(viewModelScope)이 적용되어 있지만,combine으로 생성된photoPagingData는 캐싱되지 않습니다. 여러 collector가 있거나 configuration change 시 불필요한 재조회가 발생할 수 있습니다.♻️ 수정 제안
val photoPagingData: Flow<PagingData<Photo>> = combine( originalPagingData, deletedPhotoIds, ) { pagingData, deletedIds -> pagingData.filter { photo -> photo.id !in deletedIds } -} +}.cachedIn(viewModelScope)feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoScreen.kt (1)
233-239: Preview가 비어있습니다.Paging 기반 화면이라 Preview 구현이 어렵지만,
flowOf(PagingData.from(listOf(...)))를 사용하여 mock 데이터로 Preview를 제공할 수 있습니다. 현재 상태로도 기능에는 문제없습니다.feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt (1)
88-106: 에러 발생 시 사용자 피드백 고려
fetchFavoriteSummary와fetchFolders모두 실패 시 Timber 로그만 남기고 사용자에게는 피드백이 없습니다. 데이터 로딩 실패 시 사용자가 상황을 인지할 수 있도록 토스트 메시지나 에러 상태 표시를 고려해 보세요.
포토카드 상세 화면(`PhotoDetailScreen`)에서 변경 사항이 발생했을 때 상위 화면으로 전달하는 결과 타입을 `Boolean`에서 `ArchiveResult`로 변경합니다. `ArchiveResult`는 `PhotoDeleted`와 `FavoriteChanged` 두 가지 상태를 가지며, 각각 삭제된 사진의 ID와 즐겨찾기 상태가 변경된 사진의 ID 및 상태를 포함합니다. 이를 통해 아카이브 메인, 전체 사진, 앨범 상세 화면 등에서 개별 아이템의 상태를 더 명확하게 갱신할 수 있도록 수정했습니다.
앨범 추가 바텀시트에서 기존 앨범 이름 목록을 가져오는 `remember`에 `uiState.albums`를 키로 추가합니다. 이를 통해 앨범 목록이 변경될 때마다 중복 검사를 위한 이름 목록이 올바르게 갱신되도록 수정합니다.
QR 코드 스캔을 통한 업로드와 갤러리 선택을 통한 업로드를 명확하게 구분하기 위해 `UploadType`의 로직을 수정합니다. - `UploadType.SINGLE`과 `UploadType.MULTIPLE`을 `UploadType.QR_CODE`와 `UploadType.GALLERY`로 변경하여 업로드 출처를 명확히 합니다. - `scannedImageUrl`이 있을 경우 `QR_CODE`로, 그렇지 않으면 `GALLERY`로 업로드 타입을 결정하도록 수정했습니다.
- AllPhoto/AlbumDetail에 PhotoDeleted, FavoriteChanged Intent 추가 - PagingData에 updatedFavorites 적용하여 즐겨찾기 상태 반영 - ResultEffect로 ArchiveResult 수신 후 ViewModel Intent 전달
QR 코드 스캔을 통한 업로드와 갤러리 선택을 통한 업로드를 명확하게 구분하기 위해 `UploadType`의 로직을 수정합니다. - `UploadType.SINGLE`과 `UploadType.MULTIPLE`을 `UploadType.QR_CODE`와 `UploadType.GALLERY`로 변경하여 업로드 출처를 명확히 합니다. - `scannedImageUrl`이 있을 경우 `QR_CODE`로, 그렇지 않으면 `GALLERY`로 업로드 타입을 결정하도록 수정했습니다.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailContract.kt`:
- Line 14: AlbumDetailState.deletedPhotoIds가 현재 실제로 동기화되지 않는 데드 코드이므로, 삭제된 ID를
관리하는 ViewModel의 deletedPhotoIds MutableStateFlow와 일치시키거나 필드를 제거하세요; 구체적으로는
AlbumDetailState.deletedPhotoIds 선언을 제거하거나, 상태 업데이트가 발생하는 reduce() 블록(예: 이벤트 처리용
reduce()/reduceState() 등)에서 ViewModel의 deletedPhotoIds 값을 반영하도록 상태를 복제하여
deletedPhotoIds를 갱신하도록 추가하고 photoPagingData 필터링 로직과 일관되게 유지하세요.
🧹 Nitpick comments (4)
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt (1)
225-259: 삭제 처리 로직 중복은 공통화 고려해볼 만합니다.
handleFavoriteDelete와 로직이 거의 동일하니 공통 함수로 추출하면 유지보수성이 좋아집니다.feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt (1)
244-255: 앨범 추가 후 bottom sheet 닫기 타이밍을 확인해 주세요.
reduce { copy(isShowAddAlbumBottomSheet = false) }가onSuccess/onFailure블록 밖에서 호출되어 API 호출 완료 전에 bottom sheet가 닫힐 수 있습니다. 의도된 UX라면 괜찮지만, 실패 시에도 즉시 닫히는 것이 사용자에게 혼란을 줄 수 있습니다.♻️ 성공/실패 후 닫기로 변경하는 제안
private fun handleAddAlbum( albumName: String, reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit, postSideEffect: (ArchiveMainSideEffect) -> Unit, ) { viewModelScope.launch { folderRepository.createFolder(name = albumName) .onSuccess { fetchFolders(reduce) postSideEffect(ArchiveMainSideEffect.ShowToastMessage("새로운 앨범을 추가했어요")) + reduce { copy(isShowAddAlbumBottomSheet = false) } } .onFailure { error -> postSideEffect(ArchiveMainSideEffect.ShowToastMessage("앨범 추가에 실패했어요")) Timber.e(error) + reduce { copy(isShowAddAlbumBottomSheet = false) } } - reduce { copy(isShowAddAlbumBottomSheet = false) } } }feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoScreen.kt (2)
166-166: 선택된 사진 검색 최적화를 고려해 보세요.
uiState.selectedPhotos.any { it.id == photo.id }는 각 아이템마다 O(n) 검색을 수행합니다. 선택된 사진이 많아지면Set<Long>으로 변환하여 O(1) 검색으로 최적화할 수 있습니다.♻️ Set을 사용한 최적화 제안
+ val selectedPhotoIds = remember(uiState.selectedPhotos) { + uiState.selectedPhotos.map { it.id }.toSet() + } + // items 블록 내에서: - val isSelected = uiState.selectedPhotos.any { it.id == photo.id } + val isSelected = selectedPhotoIds.contains(photo.id)
233-236: Preview가 비어있는 상태입니다.Paging 없이 Preview를 구현하기 어렵다는 점은 이해하지만, 최소한의 placeholder UI나 주석을 추가하면 Preview의 의도가 더 명확해집니다.
There was a problem hiding this comment.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt (1)
60-70: 앨범 토글 로직에서 remove 동작이 실패할 수 있습니다.ID 비교로 존재 여부를 확인하지만(
state.selectedAlbums.any { it.id == intent.album.id }), 제거 시에는 객체 동등성(remove(intent.album))에 의존합니다.AlbumPreview가 data class라도 다른 필드 값이 다르면 제거가 실패할 수 있습니다.🐛 ID 기반 필터링으로 수정 제안
is UploadAlbumIntent.ClickAlbumItem -> { reduce { copy( - selectedAlbums = if (state.selectedAlbums.any { it.id == intent.album.id }) { - selectedAlbums.remove(intent.album) - } else { - selectedAlbums.add(intent.album) - }.toPersistentList(), + selectedAlbums = if (state.selectedAlbums.any { it.id == intent.album.id }) { + selectedAlbums.filter { it.id != intent.album.id }.toPersistentList() + } else { + selectedAlbums.add(intent.album) + }, ) } }
`ArchiveEntryProvider` 내에 개별적으로 존재하던 `archiveMainEntry`, `allPhotoEntry`, `allAlbumEntry`, `albumDetailEntry`, `photoDetailEntry` 함수들을 단일 `archiveEntry` 함수로 통합합니다.
- DELETE /api/folders에 deletePhotos 쿼리 파라미터 추가
- DELETE /api/folders/{folderId}/photos 폴더에서 사진 제거 API 추가
- AlbumDeleteOption에 따라 사진 포함/미포함 삭제 분기
- PhotoDeleteOption에 따라 앨범에서만 제거/완전 삭제 분기
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt`:
- Around line 110-115: The favorite-album navigation can produce a null title
(navigateToFavoriteAlbum -> navigator.navigateToAlbumDetail called without
title) which makes key.title nullable and can crash code that expects non-null
in the Factory; make this null-safe by either (A) supplying a non-null title
when calling navigateToAlbumDetail from navigateToFavoriteAlbum (e.g., use a
default or fetch the album title before navigation) or (B) update the Factory
and any consumers that read key.title to accept a nullable String and handle
null cases safely (e.g., fallback UI/text or guard clauses). Locate references
to navigateToFavoriteAlbum, navigateToAlbumDetail, key.title and the Factory to
implement the chosen fix so no non-null assertion on title remains.
🧹 Nitpick comments (4)
core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt (1)
19-23: 생성된 폴더 ID 반환 고려
createFolder가Result<Unit>을 반환하지만,CreateFolderResponse에 생성된 폴더의 ID가 포함되어 있다면 이를 반환하는 것이 향후 유용할 수 있습니다. 현재 사용처에서는 폴더 목록을 다시 조회하므로 당장 문제는 없지만, 추후 개선 시 참고해 주세요.feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumViewModel.kt (1)
175-186: Bottom sheet 닫기 타이밍 개선 고려현재 네트워크 호출 완료 후 bottom sheet를 닫고 있어, 느린 네트워크에서 사용자가 대기해야 합니다. 버튼 클릭 즉시 bottom sheet를 닫고 로딩 인디케이터를 표시하는 방식을 고려해 보세요.
♻️ 개선 제안
private fun handleAddAlbum( albumName: String, reduce: (AllAlbumState.() -> AllAlbumState) -> Unit, postSideEffect: (AllAlbumSideEffect) -> Unit, ) { viewModelScope.launch { + reduce { copy(isShowAddAlbumBottomSheet = false, isLoading = true) } folderRepository.createFolder(name = albumName) .onSuccess { fetchFolders(reduce) postSideEffect(AllAlbumSideEffect.ShowToastMessage("새로운 앨범을 추가했어요")) } .onFailure { error -> postSideEffect(AllAlbumSideEffect.ShowToastMessage("앨범 추가에 실패했어요")) Timber.e(error) } - reduce { copy(isShowAddAlbumBottomSheet = false) } + reduce { copy(isLoading = false) } } }feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt (2)
82-84:EnterAlbumDetailScreenIntent 사용 여부 검토가 필요합니다.페이징으로 전환 후 이 Intent가 no-op이 되었습니다. 화면 진입 시 별도 로직이 필요 없다면 Intent 자체를 제거하거나, 향후 필요한 초기화 로직(예: 분석 이벤트 전송)이 있다면 명시적으로 추가하는 것을 고려해 주세요.
190-267:handleFavoriteDelete와handleAlbumPhotoDelete중복 로직 정리 고려.두 함수의 구조가 매우 유사합니다 (로딩 상태 설정 → 저장소 호출 → 성공 시 상태 초기화 → 실패 시 에러 처리). 공통 로직을 추출하여 코드 중복을 줄일 수 있습니다.
♻️ 공통 삭제 로직 추출 예시
+ private fun executePhotoDelete( + selectedPhotoIds: List<Long>, + deleteOperation: suspend () -> Result<Unit>, + reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, + postSideEffect: (AlbumDetailSideEffect) -> Unit, + dismissDialog: AlbumDetailState.() -> AlbumDetailState, + ) { + viewModelScope.launch { + reduce { copy(isLoading = true) } + + deleteOperation() + .onSuccess { + Timber.d("삭제 성공") + deletedPhotoIds.update { it + selectedPhotoIds.toSet() } + reduce { + copy( + selectedPhotos = persistentListOf(), + selectMode = SelectMode.DEFAULT, + isLoading = false, + ).dismissDialog() + } + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 삭제했어요")) + } + .onFailure { error -> + Timber.e(error) + reduce { copy(isLoading = false).dismissDialog() } + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진 삭제에 실패했어요")) + } + } + }
Ojongseok
left a comment
There was a problem hiding this comment.
정말 고생 많으셨습니다.
즐겨찾기와 사진삭제를 제외하고 모든 부분에서 일관되게 API 핸들링하고 있어 읽어나가기 수월했던 것 같습니다. 즐겨찾기/사진삭제 이벤트가 발생했을 때에도 ResultBus에 태워보내 PagingData를 갱신하는 것도 잘 보았습니다.
현재 API 스펙에 folder로 되어있는데 저희도 맞출까요?
UI에서 앨범이라는 워딩을 많이 사용하고 있고, API 명세에서는 폴더로 내려주지만 저희는 현행대로 앨범이라는 워딩을 그대로 사용해도 괜찮지 않을까 싶습니다.
Q1. 현재 domain 모듈에 data, data-api 두 모듈에 대한 참조를 갖고 있는데 문제 없을까요?
domain에서 data를 참조하는 이유가 runSuspendCatching을 사용하기 위함이라면 해당 함수의 경로를 바꾸는게 좋을까요?
Q2. 이번 PR에서 Album -> AlbumPreview data class로 많은 부분에서 변경해주셨던데 Album에서 thumbnailUrl을 포함할 수 없어서 별도로 분리하신건가요?
| override suspend fun uploadImageFromUri( | ||
| uploadUrl: String, | ||
| uri: Uri, | ||
| contentType: ContentType, | ||
| ) = runSuspendCatching { | ||
| val imageBytes = withContext(Dispatchers.Default) { | ||
| uri.toByteArray( | ||
| context = context, | ||
| format = contentType.toCompressFormat(), | ||
| ) ?: error("Failed to convert uri to byte array") | ||
| } | ||
|
|
||
| uploadService.uploadImage( | ||
| presignedUrl = uploadUrl, | ||
| imageBytes = imageBytes, | ||
| contentType = contentType.label, | ||
| ) | ||
| } |
There was a problem hiding this comment.
Uri 업로드 함수를 추가해주셔
suspend fun uploadImage(
uploadUrl: String,
imageBytes: ByteArray,
contentType: ContentType,
): Result<Unit>
MediaUploadRepository 내 해당 인터페이스는 제거해도 되지 않을까요?
🔗 관련 이슈
📙 작업 설명
페이징 (Paging3)
사진 업로드 (#64)
앨범/폴더 API (#69)
필터링 기능
📸 스크린샷 또는 시연 영상 (선택)
default.mp4
default.mp4
default.mp4
default.mp4
default.mp4
default.mp4
default.mp4
default.mp4
default.mp4
default.mp4
💬 추가 설명 or 리뷰 포인트 (선택)
폴더 삭제 및 사진 삭제 옵션 분기
DELETE /api/folders에deletePhotos쿼리 파라미터 추가AlbumDeleteOption.DELETE_WITH_PHOTOS→deletePhotos=true(사진까지 함께 삭제)AlbumDeleteOption.DELETE_ALBUM_ONLY→deletePhotos=false(앨범만 삭제)DELETE /api/folders/{folderId}/photosAPI 추가PhotoDeleteOption.REMOVE_FROM_ALBUM→ 폴더에서 사진 연관관계만 해제PhotoDeleteOption.REMOVE_FROM_ALL→DELETE /api/photos로 사진 완전 삭제모든 사진에서 ScrollToTop 이슈
정렬 변경 -> ScrollToTop -> 최상단으로 이동 -> 새로운 Flow 수신 -> 최하단으로 스크롤 이동됨.ArchiveResult
PagingData 한계
Q. 네이밍
고봉밥 죄송합니다... 여행 가서도 짬내서 코멘트 확인해보겠습니다!
Summary by CodeRabbit
릴리스 노트
새로운 기능
개선
✏️ Tip: You can customize this high-level summary in your review settings.