Skip to content

Conversation

@starshape7
Copy link
Collaborator

@starshape7 starshape7 commented Dec 22, 2025

Related issue 🛠

Work Description 📝

Screenshot 📸

image image image image image image image image

Uncompleted Tasks 😅

  • 탭바 간격 조정 필요

PR Point 📌

트러블 슈팅 💥

Summary by CodeRabbit

  • 새로운 기능

    • 게시물 목록: 최신/추천/댓글순 필터, 페이지네이션, 좋아요 및 상세 이동 지원
    • 게시물 상세: 본문·이미지·댓글 보기/작성, 좋아요/언좋기, 삭제·편집·신고 인터랙션
    • 게시물 작성/수정: 이미지 첨부(로컬/URI/URL), 미리보기 및 제출 흐름
    • 보드 전용 목록 화면 및 내비게이션 추가
  • 스타일

    • 디자인 시스템 확장: 프로필 이미지 컴포넌트, Lottie 스피너, 댓글 입력 필드, 버튼·모양 개선 및 삭제 아이콘 리소스 추가
  • 기타

    • 날짜·숫자 표시 포맷 개선 (가독성 향상)

✏️ Tip: You can customize this high-level summary in your review settings.

# Conflicts:
#	app/build.gradle.kts
#	core/designsystem/build.gradle.kts
#	core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/Calender.kt
#	core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/DateHeader.kt
#	core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/appBar/AppBar.kt
#	core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/ImageButton.kt
#	core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/ImageTextButton.kt
#	core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/SocialLoginButton.kt
#	core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/AcceptModal.kt
#	core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/RejectModal.kt
#	core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/EnrollStudyItem.kt
#	core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/StudyItem.kt
#	core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/weather/Weather.kt
#	core/designsystem/src/main/java/com/umcspot/spot/designsystem/theme/TypoGraphy.kt
#	data/board/src/main/java/com/umcspot/spot/board/repositoryimpl/BoardRepositoryImpl.kt
#	feature/board/src/main/java/com/umcspot/spot/feature/board/main/BoardScreen.kt
#	feature/home/src/main/java/com/umcspot/spot/home/HomeScreen.kt
#	feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt
#	feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt
#	feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt
#	feature/main/src/main/java/com/umcspot/spot/main/component/MainBottomBar.kt
#	feature/study/src/main/java/com/umcspot/spot/study/preferLocation/PreferLocationBottomSheet.kt
#	feature/study/src/main/java/com/umcspot/spot/study/preferLocation/PreferLocationStudyScreen.kt
#	feature/study/src/main/java/com/umcspot/spot/study/recruiting/RecruitingStudyFilterScreen.kt
#	feature/study/src/main/java/com/umcspot/spot/study/recruiting/RecruitingStudyScreen.kt
# Conflicts:
#	feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt
#	feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt
#	feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt
#	feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListScreen.kt
#	feature/signup/src/main/java/com/umcspot/spot/signup/SignUpScreen.kt
#	feature/signup/src/main/java/com/umcspot/spot/signup/component/AgreementModal.kt
@starshape7 starshape7 linked an issue Dec 22, 2025 that may be closed by this pull request
4 tasks
@coderabbitai
Copy link

coderabbitai bot commented Dec 22, 2025

Walkthrough

포스트 중심 도메인·데이터·서비스·리포지토리·매퍼와 게시글 목록/상세/작성 UI 및 네비게이션을 대거 추가·재구성하고, 디자인시스템에 이미지/스피너/입력 컴포넌트와 TextButton API 변경 및 모델(ImageRef·Global 등)·빌드 설정(모듈 추가, lottie 업그레이드)을 반영했습니다.

Changes

Cohort / File(s) 요약
빌드·설정
app/build.gradle.kts, settings.gradle.kts, gradle/libs.versions.toml, feature/main/build.gradle.kts, core/designsystem/build.gradle.kts, data/post/build.gradle.kts, domain/post/build.gradle.kts
모듈 포함(:data:post, :domain:post), 의존성 추가(예: projects.domain.post), lottie 버전 업데이트
디자인시스템 — 컴포넌트 변경/추가
core/designsystem/src/main/java/.../ProfileImage.kt, Spinner.kt, button/ImageTextButton.kt, button/TextButton.kt, button/TextButtonPreview.kt, button/ImageButton.kt, comment/CommentField.kt, post/CommentUserInfoItem.kt, post/PostUserInfoItem.kt, post/CountsView.kt, post/PostItem.kt, empty/EmptyAlert.kt, modal/*.kt, shapes/Shapes.kt, effect/dropShadow/DropShadowPreview.kt
ProfileImage, Lottie 스피너, CommentField 등 신규 컴포넌트 추가. TextButton API(상태/shape/ClickSurface) 대규모 변경 및 프리뷰/shape 상수 조정
리소스
core/designsystem/src/main/res/drawable/delete.xml
삭제 아이콘 벡터 리소스 추가
코어 모델/유틸
core/model/src/main/java/com/umcspot/spot/model/ImageRef.kt, Global.kt, DataFormatter.kt
ImageRef variants 변경(LocalName→Name, LocalPath→LocalUri), PostType/ActivityType 등 enum 추가, createdAt 포맷·cap 유틸 추가
네트워크 유틸
core/network/src/main/java/.../MultipartFactory.kt
Uri → MultipartBody.Part 변환 유틸(콘텐츠 리졸버 사용) 추가
데이터 계층 — Board 리팩터
data/board/src/main/java/.../BoardDataSource.kt, datasourceimpl/BoardDataSourceImpl.kt, service/BoardService.kt, dto/response/* (추가/삭제)
기존 tag/ranked/labeled 엔드포인트 제거, recent/best/filtered 엔드포인트 및 like API 추가, DTO 교체
데이터 계층 — Post 신규 모듈
data/post/src/main/java/.../service/PostService.kt, datasource/*, datasourceimpl/*, repositoryimpl/PostRepositoryImpl.kt, di/*, dto/*, mapper/PostMapper.kt
Post REST 서비스, DTO, 매퍼, 리포지토리 구현 및 Hilt DI 바인딩 추가; 업로드·multipart 처리 포함
도메인 — Board 재구성
domain/board/src/main/java/.../board/*, (삭제) BoardResult.kt, Board.kt, repository/BoardRepository.kt
ranked/labeled 모델 삭제, recent/best/postList 모델 추가, 리포지토리 시그니처 변경
도메인 — Post 신규 모듈
domain/post/src/main/java/.../postDetail/*, posting/*, repository/PostRepository.kt
Post 도메인 모델과 리포지토리 인터페이스 추가
기능 — Board 기능 재구성·추가
feature/board/src/main/java/.../main/*, boardList/*, post/content/*, post/posting/*, navigation/*
기존 통합 Board 삭제/분리 후 게시글 목록(BoardList), 상세(PostContent), 작성(Posting) 화면·ViewModel·네비게이션 추가
앱 네비게이션·UI 통합
feature/main/src/main/java/.../MainNavHost.kt, MainNavigator.kt, MainScreen.kt
게시글 목록/상세/작성 라우트 통합, 작성 중 뒤로가기 확인 다이얼로그, FAB/상태 로직 확장
기타
data/login/*, .gitignore, AndroidManifest.xml, proguard 등
불필요 import 제거, 새 모듈용 매니페스트/프로가드/깃이그노어 추가 및 정리

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as PostContentScreen
    participant VM as PostViewModel
    participant Repo as PostRepositoryImpl
    participant Service as PostService
    participant API as Server

    User->>UI: 게시글 상세 열기(postId)
    UI->>VM: load(postId)
    VM->>Repo: getPostDetail(postId)
    Repo->>Service: getPostDetail(postId)
    Service->>API: GET /api/posts/{postId}
    API-->>Service: PostDetailResponseDto
    Service-->>Repo: BaseResponse<PostDetailResponseDto>
    Repo->>Repo: DTO → Domain 매핑 (toDomain)
    Repo-->>VM: Result<PostDetailResult>
    VM-->>UI: uiState 업데이트(성공)

    User->>UI: 좋아요 클릭
    UI->>VM: toggleLike()
    VM->>VM: 낙관적 UI 업데이트 (applyLocalLike)
    VM->>Repo: postPostLike(postId)
    Repo->>Service: POST /api/posts/{postId}/like
    Service->>API: POST ...
    API-->>Service: NullResultResponse
    Service-->>Repo: Success/Failure
    Repo-->>VM: Result
    VM->>VM: 성공 유지 또는 롤백
    VM-->>UI: 상태 반영

    User->>UI: 코멘트 전송
    UI->>VM: sendComment(content)
    VM->>Repo: sendComment(postId, CommentRequestDto)
    Repo->>Service: POST /api/posts/{postId}/comments
    API-->>Service: SendCommentResponseDto
    Repo-->>VM: Result
    VM->>VM: 상세 재요청(getPostDetail) 및 UI 갱신
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

🍒 [FEAT]

Suggested reviewers

  • fredleeJH

Poem

🐰 새 포스트가 폴짝 왔네,
이미지 단장하고 스피너 빙글빙글,
좋아요 툭 누르면 반짝 반응,
댓글 한 줄이면 꽃이 피고,
토끼가 축하해, 훌륭한 한 번의 커밋! 🥕🐇

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description check ⚠️ Warning PR 설명이 매우 불완전합니다. 필수 섹션인 'Work Description'이 비어 있으며, 'PR Point'와 '트러블 슈팅'도 작성되지 않았습니다. Work Description 섹션을 채워 변경 사항의 세부 내용을 설명하고, PR Point와 트러블 슈팅 섹션도 완성하시기 바랍니다.
Docstring Coverage ⚠️ Warning Docstring coverage is 1.97% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 주요 기능 구현(Bulletin Board)을 명확히 설명하고 있으며, 변경사항과 일치합니다.
✨ Finishing touches
  • 📝 Generate docstrings

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 12

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (17)
feature/board/src/main/java/com/umcspot/spot/feature/board/boardList/BoardListScreen.kt-214-214 (1)

214-214: 사용자에게 노출되는 에러 메시지 개선 필요

원시 에러 메시지(ui.msg)를 직접 표시하면 기술적인 내용이나 민감한 정보가 노출될 수 있습니다. 사용자 친화적인 메시지로 변환하거나, 개발 모드에서만 상세 메시지를 표시하도록 개선해주세요.

예시:

text = "게시글을 불러오는 중 문제가 발생했습니다."
feature/board/src/main/java/com/umcspot/spot/feature/board/boardList/BoardListScreen.kt-312-321 (1)

312-321: 그라디언트 방향 주석 수정 필요

오른쪽 그라디언트도 주석과 코드가 일치하지 않습니다. 주석은 "내부(투명) → 바깥(흰색)"이지만 실제로는 투명에서 흰색으로 진행합니다.

🔎 주석 수정 제안
-                // 오른쪽: 내부(투명) → 바깥(흰색)
+                // 오른쪽 페이드: 내부(투명) → 바깥(흰색)
                 drawRect(
                     brush = Brush.horizontalGradient(
                         colors = listOf(Color.Transparent, bg),
                         startX = size.width - w,
                         endX = size.width
                     ),

현재 코드는 의도한 대로 동작하는 것으로 보이며, 주석만 명확하게 수정하면 됩니다.

feature/board/src/main/java/com/umcspot/spot/feature/board/boardList/BoardListScreen.kt-302-311 (1)

302-311: 그라디언트 방향 주석 수정 필요

주석에는 "왼쪽: 내부(흰색) → 바깥(투명)"이라고 되어 있지만, 실제 코드는 colors = listOf(Color.Transparent, bg)로 투명에서 흰색으로 진행됩니다. 주석과 코드가 일치하지 않습니다.

🔎 주석 수정 제안
                 val w = scrimWidth.toPx()
-                // 왼쪽: 내부(흰색) → 바깥(투명)
+                // 왼쪽 페이드: 바깥(투명) → 내부(흰색)
                 drawRect(
                     brush = Brush.horizontalGradient(
                         colors = listOf(Color.Transparent, bg),
                         startX = w,
                         endX = 0f
                     ),

또는 의도가 "왼쪽 끝을 흰색, 안쪽을 투명"이었다면 코드를 수정해야 합니다:

                         colors = listOf(bg, Color.Transparent),
core/designsystem/build.gradle.kts-17-18 (1)

17-18: domain:post 의존성 제거 필요

core:designsystem 모듈의 domain:post 의존성은 실제로 사용되지 않으므로 제거하세요. 반면 domain:board 의존성은 PostItem.ktCountsView.kt에서 PostResult 모델을 사용하므로 필요합니다.

// 다음 라인 제거
implementation(projects.domain.post)
feature/board/src/main/java/com/umcspot/spot/feature/board/post/content/navigation/PostContentNavigation.kt-36-37 (1)

36-37: @Serializable 어노테이션이 const val에는 효과가 없습니다.

@Serializable은 클래스나 data class에 적용되는 어노테이션입니다. const val은 컴파일 타임 상수이므로 이 어노테이션은 무의미합니다. 제거하거나, BoardNavigation.kt처럼 route를 data object로 정의하는 것을 고려하세요.

🔎 제안된 수정 - 어노테이션 제거
-@Serializable
 const val POST_CONTENT_ROUTE = "post/{postId}"

또는 type-safe navigation으로 통일:

@Serializable
data class PostContent(val postId: Long) : Route
feature/board/src/main/java/com/umcspot/spot/feature/board/post/content/PostViewmodel.kt-51-54 (1)

51-54: 에러 발생 시 UiState.Empty 대신 UiState.Failure 사용 권장

로드 실패 시 UiState.Empty로 설정하면 에러 정보가 손실됩니다. 사용자에게 적절한 에러 메시지를 표시하려면 UiState.Failure를 사용하는 것이 좋습니다.

🔎 제안된 수정
             }.onFailure { e ->
                 Log.e("PostViewModel", "load error", e)
-                _uiState.update { it.copy(data = UiState.Empty) }
+                _uiState.update { it.copy(data = UiState.Failure(e.message ?: "알 수 없는 오류")) }
             }
feature/board/src/main/java/com/umcspot/spot/feature/board/post/content/PostViewmodel.kt-122-138 (1)

122-138: deletePost 함수의 중복 중첩 및 빈 성공 블록

runCatchingonSuccess 내에서 다시 result.onSuccess/onFailure를 처리하는 구조가 중복됩니다. 또한 내부 onSuccess 블록이 비어있어 의도가 불분명합니다. 삭제 성공 시 UI 상태 업데이트나 네비게이션 처리가 필요할 수 있습니다.

🔎 제안된 수정
     fun deletePost() {
         val current = (_uiState.value.data as? UiState.Success)?.data ?: return
         val postId = current.postId
 
         viewModelScope.launch {
-            runCatching {
-                postRepository.deletePost(postId = postId)
-            }.onSuccess { result ->
-                result.onSuccess {
-                }.onFailure { e ->
-                    Log.e("PostViewModel", "deletePost failure", e)
-                }
-            }.onFailure { e ->
-                Log.e("PostViewModel", "deletePost error", e)
-            }
+            postRepository.deletePost(postId = postId)
+                .onSuccess {
+                    // TODO: 삭제 성공 후 처리 (예: 이전 화면으로 네비게이션)
+                    Log.d("PostViewModel", "deletePost success")
+                }
+                .onFailure { e ->
+                    Log.e("PostViewModel", "deletePost error", e)
+                }
         }
     }
feature/board/src/main/java/com/umcspot/spot/feature/board/post/posting/PostingViewModel.kt-27-51 (1)

27-51: clear() 함수의 PostType 기본값 불일치를 수정하세요.

Line 33에서 _postType의 초기값은 PostType.FREE_TALK인데, Line 49의 clear() 함수에서는 PostType.PASS_EXPERIENCE로 설정하고 있습니다. 이는 일관성이 없는 동작을 야기할 수 있습니다.

🔎 수정 제안
 fun clear() {
     _editingPostId.value = null
     _title.value = ""
     _body.value = ""
-    _postType.value = PostType.PASS_EXPERIENCE
+    _postType.value = PostType.FREE_TALK
     _image.value = ImageRef.None
 }
feature/board/src/main/java/com/umcspot/spot/feature/board/post/content/PostContentScreen.kt-419-421 (1)

419-421: Null 처리 필요 - comment.content.toString()

CommentResult.contentString?(nullable)로 정의되어 있어, comment.content.toString()은 null일 때 "null" 문자열을 표시합니다.

🔎 제안된 수정
         Text(
-            text = comment.content.toString(),
+            text = comment.content ?: "",
             style = SpotTheme.typography.medium_400,
domain/post/src/main/java/com/umcspot/spot/post/model/postDetail/PostDetailResult.kt-50-50 (1)

50-50: 더미 데이터의 잘못된 날짜 형식

createdAt = "25.44.44 44:44"는 유효하지 않은 날짜/시간 형식입니다 (월 44, 일 44). 더미 데이터라도 실제와 유사한 형식을 사용하는 것이 디버깅 시 혼란을 줄일 수 있습니다.

🔎 제안된 수정
-                createdAt = "25.44.44 44:44",
+                createdAt = "25.12.08 14:30",
domain/post/src/main/java/com/umcspot/spot/post/model/postDetail/PostDetailResult.kt-29-30 (1)

29-30: PostType.values() 사용 - Kotlin 1.9+ 에서 권장되지 않음

Kotlin 1.9부터 enum의 values() 메서드는 entries 프로퍼티로 대체하는 것이 권장됩니다. Enum.entries를 사용하면 성능이 개선됩니다.

-            val labels = PostType.values()
-            val postType = labels.random()
+            val postType = PostType.entries.random()
feature/board/src/main/java/com/umcspot/spot/feature/board/main/BoardViewmodel.kt-49-50 (1)

49-50: 로그 태그가 잘못되었습니다

ViewModel에서 "BoardRepositoryImpl" 태그를 사용하고 있습니다. "BoardViewModel"로 수정하세요.

🔎 수정 제안
-                Log.d("BoardRepositoryImpl", "getRecentBoard: $recents")
-                Log.d("BoardRepositoryImpl", "getBestBoard: $bests")
+                Log.d("BoardViewModel", "getRecentBoard: $recents")
+                Log.d("BoardViewModel", "getBestBoard: $bests")
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/comment/CommentField.kt-164-190 (1)

164-190: Preview 함수 이름과 내용이 불일치하며, 두 프리뷰가 동일합니다

CommentFieldPreview_CanWritecanWrite = false로 설정되어 있어 이름과 다릅니다. 또한 두 프리뷰 함수가 완전히 동일한 코드입니다.

🔎 수정 제안
 @Preview(showBackground = true)
 @Composable
 private fun CommentFieldPreview_CanWrite() {
     SpotTheme {
         CommentField(
             onSendComment = {},
             comment = "",
             onCommentChange = {},
-            canWrite = false,
+            canWrite = true,
             modifier = Modifier.padding(16.dp)
         )
     }
 }

 @Preview(showBackground = true)
 @Composable
 private fun CommentFieldPreview_CantWrite() {
     SpotTheme {
         CommentField(
             onSendComment = {},
             comment = "",
             onCommentChange = {},
             canWrite = false,
             modifier = Modifier.padding(16.dp)
         )
     }
 }
data/post/src/main/java/com/umcspot/spot/post/repositoryimpl/PostRepositoryImpl.kt-53-55 (1)

53-55: 로그 메시지 오타

"senedComment"는 "sendComment"의 오타입니다.

🔎 수정 제안
-            Log.d("PostRepository", "senedComment res = $res")
+            Log.d("PostRepository", "sendComment res = $res")
             val domain = res.result.toDomain()
-            Log.d("PostRepository", "senedComment mapped = $res")
+            Log.d("PostRepository", "sendComment mapped = $domain")
             domain

참고: 55번 줄에서 $res 대신 $domain을 출력해야 합니다.

data/post/src/main/java/com/umcspot/spot/post/repositoryimpl/PostRepositoryImpl.kt-36-38 (1)

36-38: 로그 메시지가 실제 메서드와 불일치

getPostDetail 메서드인데 로그에는 "getRecentBoard"로 표시됩니다. 디버깅 시 혼란을 야기할 수 있습니다.

🔎 수정 제안
-            Log.d("PostRepository", "getRecentBoard res = $res")
+            Log.d("PostRepository", "getPostDetail res = $res")
             val domain = res.result.toDomain()
-            Log.d("PostRepository", "getRecentBoard mapped = $domain")
+            Log.d("PostRepository", "getPostDetail mapped = $domain")
             domain
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/TextButton.kt-257-257 (1)

257-257: content 파라미터가 선언되었지만 사용되지 않습니다.

content: (@Composable () -> Unit)? = null 파라미터가 함수 본문에서 사용되지 않습니다. 사용하지 않을 경우 제거하거나, 향후 확장을 위한 것이라면 구현을 추가하세요.

core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/RejectModal.kt-71-75 (1)

71-75: 닫기 아이콘에 접근성 속성이 누락되었습니다.

contentDescriptionnull이고 클릭 가능한 요소에 시맨틱 역할이 없습니다. 스크린 리더 사용자가 이 버튼의 용도를 알 수 없습니다.

🔎 접근성 개선 제안
 Image(
     painter = painterResource(R.drawable.dismiss),
-    contentDescription = null,
-    modifier = Modifier.clickable { onDismiss() }
+    contentDescription = "닫기",
+    modifier = Modifier
+        .semantics { role = Role.Button }
+        .clickable { onDismiss() }
 )
🧹 Nitpick comments (62)
feature/board/src/main/java/com/umcspot/spot/feature/board/post/posting/VerifyImageBrightness.kt (2)

46-47: 매직 넘버를 상수로 추출하는 것을 고려하세요.

샘플 영역 크기(0.20f)와 스텝 제수(30)가 하드코딩되어 있습니다. 이들을 명명된 상수로 추출하면 코드 가독성과 유지보수성이 향상됩니다.

🔎 상수 추출 제안
+private const val SAMPLE_REGION_RATIO = 0.20f  // 샘플링할 코너 영역 비율
+private const val SAMPLE_STEP_DIVISOR = 30     // 성능을 위한 스텝 간격 계산용
+
 private fun sampleCornerLuma(bitmap: Bitmap, corner: Corner): Float {
     // HARDWARE bitmap이면 픽셀 접근 불가 -> ARGB_8888로 복사
     val safeBitmap = if (bitmap.config == Bitmap.Config.HARDWARE) {
         bitmap.copy(Bitmap.Config.ARGB_8888, /* mutable = */ false)
     } else {
         bitmap
     }
 
     val w = safeBitmap.width
     val h = safeBitmap.height
-    val sw = max(1, (w * 0.20f).toInt())
-    val sh = max(1, (h * 0.20f).toInt())
+    val sw = max(1, (w * SAMPLE_REGION_RATIO).toInt())
+    val sh = max(1, (h * SAMPLE_REGION_RATIO).toInt())
 
     val (startX, startY) = when (corner) {
         Corner.TopStart -> 0 to 0
         Corner.TopEnd -> (w - sw) to 0
         Corner.BottomStart -> 0 to (h - sh)
         Corner.BottomEnd -> (w - sw) to (h - sh)
     }
 
     var sum = 0f
     var count = 0
 
-    val stepX = max(1, sw / 30)
-    val stepY = max(1, sh / 30)
+    val stepX = max(1, sw / SAMPLE_STEP_DIVISOR)
+    val stepY = max(1, sh / SAMPLE_STEP_DIVISOR)
 
     for (y in startY until (startY + sh) step stepY) {
         for (x in startX until (startX + sw) step stepX) {

Also applies to: 59-60


74-78: 비트맵 재활용 로직이 올바르게 구현되었습니다.

복사된 비트맵을 적절히 재활용하여 메모리 누수를 방지하고 있습니다. 다만, 예외 상황 처리를 추가하면 더 안정적인 코드가 됩니다.

🔎 방어적 에러 처리 추가 제안
 private fun sampleCornerLuma(bitmap: Bitmap, corner: Corner): Float {
+    // 비정상 크기 체크
+    if (bitmap.width <= 0 || bitmap.height <= 0) return 1f
+
     // HARDWARE bitmap이면 픽셀 접근 불가 -> ARGB_8888로 복사
     val safeBitmap = if (bitmap.config == Bitmap.Config.HARDWARE) {
-        bitmap.copy(Bitmap.Config.ARGB_8888, /* mutable = */ false)
+        bitmap.copy(Bitmap.Config.ARGB_8888, /* mutable = */ false) ?: return 1f
     } else {
         bitmap
     }
 
+    return try {
+        computeLuma(safeBitmap, corner)
+    } finally {
+        // copy로 만든 비트맵이면 해제(메모리)
+        if (safeBitmap !== bitmap) safeBitmap.recycle()
+    }
+}
+
+private fun computeLuma(safeBitmap: Bitmap, corner: Corner): Float {
     val w = safeBitmap.width
     val h = safeBitmap.height
     val sw = max(1, (w * 0.20f).toInt())
     val sh = max(1, (h * 0.20f).toInt())
 
     val (startX, startY) = when (corner) {
         Corner.TopStart -> 0 to 0
         Corner.TopEnd -> (w - sw) to 0
         Corner.BottomStart -> 0 to (h - sh)
         Corner.BottomEnd -> (w - sw) to (h - sh)
     }
 
     var sum = 0f
     var count = 0
 
     val stepX = max(1, sw / 30)
     val stepY = max(1, sh / 30)
 
     for (y in startY until (startY + sh) step stepY) {
         for (x in startX until (startX + sw) step stepX) {
             val c = safeBitmap.getPixel(min(x, w - 1), min(y, h - 1))
             val r = ((c shr 16) and 0xFF) / 255f
             val g = ((c shr 8) and 0xFF) / 255f
             val b = (c and 0xFF) / 255f
             val luma = 0.2126f * r + 0.7152f * g + 0.0722f * b
             sum += luma
             count++
         }
     }
 
-    // copy로 만든 비트맵이면 해제(메모리)
-    if (safeBitmap !== bitmap) safeBitmap.recycle()
-
     return if (count == 0) 1f else sum / count
 }

이 방식은 예외 발생 시에도 비트맵이 확실히 재활용되도록 보장합니다.

feature/board/src/main/java/com/umcspot/spot/feature/board/boardList/BoardListScreen.kt (7)

69-69: 파라미터 네이밍 컨벤션 수정 필요

파라미터 이름 viewmodelviewModel로 변경하여 Kotlin 네이밍 컨벤션(camelCase)을 따라야 합니다.

🔎 제안하는 수정
 fun BoardListScreen(
-    viewmodel : BoardListViewModel = hiltViewModel(),
+    viewModel: BoardListViewModel = hiltViewModel(),
     contentPadding: PaddingValues,
     onRegisterScrollToTop: ((() -> Unit)?) -> Unit,
     onPostClicked: (Long) -> Unit
 ) {
-    val state by viewmodel.uiState.collectAsStateWithLifecycle()
+    val state by viewModel.uiState.collectAsStateWithLifecycle()
     // ... 나머지 viewmodel 참조들도 모두 viewModel로 변경

82-84: 불필요한 재계산 방지

tabItems 리스트가 매 recomposition마다 재생성됩니다. 이미 remember로 감싸져 있지만, 리스트 생성 로직 자체가 블록 안에 있어 최적화가 필요합니다.

🔎 제안하는 수정
-    val tabItems = remember {
-        listOf<PostType?>(null) + PostType.values().toList()
-    }
+    val tabItems = remember { listOf<PostType?>(null) + PostType.entries }

참고: values()는 deprecated되었으므로 entries를 사용하는 것이 좋습니다.


106-121: DisposableEffect 의존성 최적화 필요

selectedTab을 의존성에 포함하면 탭 변경 시마다 lifecycle observer가 제거/재등록됩니다. Lifecycle observer는 한 번만 등록하고, observer 내부에서 현재 selectedTab을 참조하도록 변경하는 것이 효율적입니다.

🔎 제안하는 수정
-    DisposableEffect(lifecycleOwner, selectedTab) {
+    DisposableEffect(lifecycleOwner) {
         val lifecycle = lifecycleOwner.lifecycle
 
         val observer = LifecycleEventObserver { _, event ->
             if (event == Lifecycle.Event.ON_RESUME) {
                 val selectedType = tabItems[selectedTab]
                 viewmodel.selectType(selectedType)
             }
         }

이렇게 하면 탭 변경 시 불필요한 observer 재등록이 발생하지 않습니다.


135-151: LaunchedEffect 책임 분리 고려

하나의 LaunchedEffect에서 스크롤 위치 복원과 저장을 모두 처리하고 있습니다. 복원은 한 번만 실행되어야 하지만, 저장은 지속적으로 발생해야 하므로 분리하는 것이 명확합니다.

🔎 제안하는 수정
-    LaunchedEffect(ui is UiState.Success) {
-        if (ui is UiState.Success) {
-
-            val pos = viewmodel.scrollPosition
-            if (pos.index != 0 || pos.offset != 0) {
-                listState.scrollToItem(pos.index, pos.offset)
-            }
-
-            snapshotFlow {
+    // 스크롤 위치 복원 (한 번만)
+    LaunchedEffect(ui is UiState.Success) {
+        if (ui is UiState.Success) {
+            val pos = viewmodel.scrollPosition
+            if (pos.index != 0 || pos.offset != 0) {
+                listState.scrollToItem(pos.index, pos.offset)
+            }
+        }
+    }
+
+    // 스크롤 위치 저장 (지속적)
+    LaunchedEffect(listState) {
+        snapshotFlow {
                 listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
             }
                 .distinctUntilChanged()
                 .collectLatest { (index, offset) ->
                     viewmodel.saveScrollPosition(index, offset)
                 }
-        }
     }

196-229: UI 상태별 렌더링 최적화 고려

Loading, Error, Empty 상태에서도 BoardListScreenContent가 하위에 렌더링됩니다. 상태에 따라 컨텐츠를 조건부로 렌더링하면 불필요한 composition을 줄일 수 있습니다.

🔎 제안하는 수정
         Box(
             modifier = Modifier
                 .fillMaxSize()
         ) {
-            BoardListScreenContent(
-                listState = listState,
-                itemList = itemList,
-                isLoadingMore = isLoadingMore,
-                onLikeClick = {
-                    viewmodel.toggleLike(it)
-                },
-                onPostClicked = {
-                    viewmodel.setPostInfo(it)
-                    onPostClicked(it.postId)
-                }
-            )
-
             when (ui) {
                 is UiState.Loading -> {
                     // ... 로딩 UI
                 }
                 is UiState.Failure -> {
                     // ... 에러 UI
                 }
                 is UiState.Empty -> {
                     // ... 빈 상태 UI
                 }
-                is UiState.Success -> Unit
+                is UiState.Success -> {
+                    BoardListScreenContent(
+                        listState = listState,
+                        itemList = itemList,
+                        isLoadingMore = isLoadingMore,
+                        onLikeClick = { viewmodel.toggleLike(it) },
+                        onPostClicked = {
+                            viewmodel.setPostInfo(it)
+                            onPostClicked(it.postId)
+                        }
+                    )
+                }
             }
         }

222-222: 하드코딩된 문자열 리소스화 필요

"데이터가 없습니다." 문자열이 하드코딩되어 있습니다. 다국어 지원 및 유지보수를 위해 string resource로 이동하는 것을 권장합니다.


259-267: 반복되는 Spacer 높이값 상수화 고려

동일한 높이값(screenHeightDp(5.dp))이 여러 곳에서 반복됩니다. 상수로 추출하면 유지보수성이 향상됩니다.

🔎 제안하는 수정
+    val itemSpacing = screenHeightDp(5.dp)
+
     LazyColumn(
         // ...
     ) {
         items(
             items = itemList,
             key = { it.postId }
         ) { item ->
             PostListItem(
                 item = item,
                 onLikeClick = { onLikeClick(item) },
                 onClick = { onPostClicked(item) }
             )
 
-            Spacer(modifier = Modifier.height(screenHeightDp(5.dp)))
+            Spacer(modifier = Modifier.height(itemSpacing))
             HorizontalDivider(
                 modifier = Modifier
                     .fillMaxWidth(),
                 thickness = 1.dp,
                 color = SpotTheme.colors.gray200
             )
-            Spacer(modifier = Modifier.height(screenHeightDp(5.dp)))
+            Spacer(modifier = Modifier.height(itemSpacing))
         }
data/post/src/main/java/com/umcspot/spot/post/dto/request/CommentRequestDto.kt (1)

7-7: 불필요한 suppress 어노테이션 가능성

@SuppressLint("UnsafeOptInUsageError")가 사용되고 있는데, @Serializable@SerialName은 kotlinx.serialization의 안정화된 API입니다. 이 suppress 어노테이션이 실제로 필요한지 확인해주세요.

🔎 제안하는 수정
-@SuppressLint("UnsafeOptInUsageError")
 @Serializable
 data class CommentRequestDto(
     @SerialName("content")
     val content : String
 )
feature/main/build.gradle.kts (1)

18-20: 빈 줄 제거를 고려하세요.

Line 19의 빈 줄이 의도적인 그룹화가 아니라면 제거하여 일관성을 유지하는 것이 좋습니다.

🔎 제안하는 수정
 implementation(projects.feature.board)
-
 implementation(projects.feature.alert)
feature/board/src/main/java/com/umcspot/spot/feature/board/boardList/navigation/BoardListNavigation.kt (1)

38-38: 람다 표현식 간소화 가능

현재 람다가 단순히 파라미터를 전달만 하므로, 메서드 레퍼런스로 간소화할 수 있습니다.

🔎 제안된 수정 사항
-            onPostClicked = { onPostClick(it) }
+            onPostClicked = onPostClick
domain/board/src/main/java/com/umcspot/spot/domain/board/model/postList/PostResult.kt (2)

32-48: PostType.values()는 Kotlin 1.9+에서 deprecated 되었습니다.

values() 대신 entries를 사용하는 것이 권장됩니다. 또한 더미 데이터의 날짜 형식("25.44.44 44:44")이 유효하지 않은 값입니다. 테스트/프리뷰 시 혼란을 줄 수 있으므로 실제 형식에 맞는 값을 사용하는 것이 좋습니다.

🔎 수정 제안
 fun dummyPost(index: Int, total: Int): PostResult {
-    val labels = PostType.values()
+    val labels = PostType.entries
     val postType = labels.random()
     val title = "Lorem ipsum dolor sit amet consectetur Lorem ipsum dolor sit amet consectetur Lorem ipsum dolor sit amet consectetur"

     return PostResult(
         postId = index.toLong(),
         postType = postType,
         title = title,
         content = title,
         likeNum = (0..2000).random().toLong(),
         commentNum = (0..2000).random().toLong(),
         viewNum = (0..2000).random().toLong(),
-        createdAt = "25.44.44 44:44",
-        isLiked = index%2==0
+        createdAt = "25.01.15 14:30",
+        isLiked = index % 2 == 0
     )
 }

10-15: @JvmStatic 어노테이션 필요성 검토

이 함수가 Kotlin 코드에서만 호출된다면 @JvmStatic 어노테이션은 불필요합니다. Java interop이 필요한 경우에만 유지하세요.

domain/post/src/main/java/com/umcspot/spot/post/model/postDetail/SendComment.kt (1)

3-5: 빈 댓글 전송 방지를 위한 검증 고려

현재 content 필드에 대한 검증이 없어 빈 문자열이나 공백만 있는 댓글이 전송될 수 있습니다. UI나 repository 레이어에서 검증을 수행하는 경우라면 문제없지만, 도메인 모델 수준에서의 검증도 고려해보세요.

🔎 선택적 개선: 검증 로직 추가
 data class SendComment (
     val content : String
-)
+) {
+    init {
+        require(content.isNotBlank()) { "댓글 내용은 비어있을 수 없습니다" }
+    }
+}
domain/post/src/main/java/com/umcspot/spot/post/model/posting/Posting.kt (1)

6-11: 필드 유효성 검증 고려

title과 content 필드가 빈 문자열이거나 공백만 포함할 수 있습니다. 필요한 경우 UsCase 레이어나 ViewModel에서 유효성 검증을 추가하는 것을 권장합니다.

core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/PostUserInfoItem.kt (1)

19-51: LGTM!

사용자 정보를 표시하는 Composable이 깔끔하게 구현되었습니다. ProfileImage를 재사용하고 SpotTheme을 활용하여 일관된 디자인 시스템을 따르고 있습니다.

참고: CommentUserInfoItem.ktCommentUserInfo와 유사한 패턴을 공유합니다. 필요한 경우 공통 로직을 추출하여 재사용성을 높일 수 있지만, 현재 구현도 충분히 명확합니다.

core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/ProfileImage.kt (3)

41-46: Coil AsyncImagePainter에 placeholder와 error 처리 추가를 권장합니다.

원격 URL이나 로컬 URI 로딩 시 네트워크 지연이나 로드 실패에 대한 시각적 피드백이 없습니다. 사용자 경험 향상을 위해 placeholder와 error drawable을 지정하는 것이 좋습니다.

🔎 제안된 수정
         is ImageRef.Url -> {
-            rememberAsyncImagePainter(model = imageRef.url)
+            rememberAsyncImagePainter(
+                model = imageRef.url,
+                placeholder = painterResource(R.drawable.spot_logo),
+                error = painterResource(R.drawable.spot_logo)
+            )
         }

         is ImageRef.LocalUri ->
-            rememberAsyncImagePainter(model = imageRef.uri)
+            rememberAsyncImagePainter(
+                model = imageRef.uri,
+                placeholder = painterResource(R.drawable.spot_logo),
+                error = painterResource(R.drawable.spot_logo)
+            )

50-57: contentScale 및 접근성 개선을 권장합니다.

프로필 이미지의 경우 ContentScale.Crop을 사용하여 원형 클리핑 시 이미지가 올바르게 채워지도록 하고, 접근성을 위해 의미 있는 contentDescription을 제공하는 것이 좋습니다.

🔎 제안된 수정
+import androidx.compose.ui.layout.ContentScale
+
     if (painter != null) {
         Image(
             painter = painter,
-            contentDescription = null,
+            contentDescription = "프로필 이미지",
+            contentScale = ContentScale.Crop,
             modifier = modifier
                 .clip(CircleShape)
         )
     }

62-62: Preview 함수 명명 규칙을 PascalCase로 변경하세요.

Composable 함수는 PascalCase 명명 규칙을 따라야 합니다.

🔎 제안된 수정
-private fun preview() {
+private fun ProfileImagePreview() {
data/post/src/main/java/com/umcspot/spot/post/dto/response/SendCommentResponseDto.kt (1)

7-12: @SuppressLint 대신 적절한 @OptIn 사용을 권장합니다.

@SuppressLint("UnsafeOptInUsageError")는 kotlinx.serialization의 experimental API 경고를 억제하는 임시 방편입니다. 가능하다면 프로젝트 전체에서 @OptIn(ExperimentalSerializationApi::class)를 적절히 설정하거나, Gradle 설정에서 opt-in을 활성화하는 것이 더 안전합니다.

feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt (1)

99-106: when 블록 내 빈 분기가 있습니다.

BoardList 케이스 이후 빈 블록이 있습니다. 다른 화면에서의 FAB 동작이 추가될 예정이라면 TODO 주석을 남기거나, 불필요하다면 제거하는 것이 좋습니다.

🔎 제안된 수정
                 onClickMultiple = {
                     when {
                         dest?.hasRoute(BoardList::class) == true -> {
                             navigator.navController.navigateToPostingNew()
                         }
-
+                        // TODO: 다른 화면에서의 FAB 동작 추가
                     }
                 },
data/board/src/main/java/com/umcspot/spot/board/dto/response/RecentBoardResponseDto.kt (2)

8-9: @SuppressLint("UnsafeOptInUsageError") 대신 적절한 @OptIn 사용을 권장합니다.

@SuppressLintUnsafeOptInUsageError를 억제하는 것은 실험적 API 사용에 대한 경고를 숨기는 방식입니다. kotlinx.serialization의 실험적 기능을 사용하는 경우, 명시적으로 @OptIn(ExperimentalSerializationApi::class)를 사용하는 것이 더 명확합니다.

만약 실험적 API를 사용하지 않는다면 이 어노테이션 자체가 불필요할 수 있습니다.

🔎 제안된 수정
-@SuppressLint("UnsafeOptInUsageError")
 @Serializable
 data class RecentBoardResponseDto(

15-16: 동일하게 @SuppressLint 제거를 권장합니다.

위와 동일한 이유로, 실험적 API를 명시적으로 사용하지 않는다면 제거하는 것이 좋습니다.

data/board/src/main/java/com/umcspot/spot/board/dto/response/PostListResponseDto.kt (1)

8-17: @SuppressLint("UnsafeOptInUsageError") 사용에 대한 동일한 권장 사항입니다.

이전 파일과 동일하게, 실험적 API를 명시적으로 opt-in하거나 불필요하다면 제거하는 것을 권장합니다. 이 패턴이 여러 DTO에서 반복되고 있으므로 프로젝트 전체적으로 일관된 접근 방식을 적용하는 것이 좋습니다.

data/board/src/main/java/com/umcspot/spot/board/dto/response/BestBoardResponseDto.kt (1)

8-9: 다른 DTO 파일과 동일한 @SuppressLint 패턴입니다.

RecentBoardResponseDto.kt, PostListResponseDto.kt에서 언급한 것과 동일하게, 이 어노테이션의 필요성을 검토하고 일관된 처리 방식을 적용해 주세요.

feature/board/src/main/java/com/umcspot/spot/feature/board/main/navigation/BoardNavigation.kt (1)

38-38: 불필요한 람다 래핑을 제거하세요.

{onMoveToPostContent(it)}onMoveToPostContent로 직접 전달할 수 있습니다.

🔎 제안된 수정
-            onMoveToPostContent = {onMoveToPostContent(it)}
+            onMoveToPostContent = onMoveToPostContent
feature/board/src/main/java/com/umcspot/spot/feature/board/post/content/navigation/PostContentNavigation.kt (1)

24-25: postId 추출 실패 시 조용히 반환하면 디버깅이 어려울 수 있습니다.

postId가 없을 때 return@composable로 조용히 종료하면 문제 원인을 파악하기 어렵습니다. 로깅을 추가하거나 에러 화면을 표시하는 것을 고려하세요.

🔎 제안된 수정
-        val postId = backStackEntry.arguments?.getLong("postId") ?: return@composable
+        val postId = backStackEntry.arguments?.getLong("postId") ?: run {
+            Log.e("PostContentNavigation", "postId argument is missing")
+            return@composable
+        }
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/TextButtonPreview.kt (2)

83-95: Toggle 프리뷰에서 빈 텍스트 및 파라미터 순서 불일치

  1. Line 92에서 text = ""로 빈 문자열이 설정되어 있습니다. 이것이 의도된 것인지 확인이 필요합니다.
  2. Line 88은 text 파라미터가 state보다 먼저 오지만, 다른 라인들과 일관성이 없습니다.
🔎 제안된 수정
 @Composable
 fun TextButtonToggle_AssetsPreview() {
     SpotTheme {
         Row(Modifier.padding(12.dp)) {
-            TextButton(text = "Btn",  state = TextButtonState.Toggle, onClick = {})
+            TextButton(text = "Btn", onClick = {}, state = TextButtonState.Toggle)
             Spacer(Modifier.width(8.dp))
             TextButton(text = "Btn", onClick = {}, state = TextButtonState.Toggle, enabled = false)
             Spacer(Modifier.width(8.dp))
-            TextButton(text = "", onClick = {}, state = TextButtonState.Toggle, checked = true)
+            TextButton(text = "Btn", onClick = {}, state = TextButtonState.Toggle, checked = true)
         }
     }
 }

15-67: 프리뷰 함수명과 실제 컴포넌트 불일치 고려

TextButtonXL_AssetsPreview, TextButtonL_AssetsPreview 등의 함수명이 기존 래퍼 컴포넌트(TextButtonXL, TextButtonL 등)를 암시하지만, 실제로는 모두 동일한 TextButton을 사용하고 있습니다. 향후 유지보수 시 혼란을 줄 수 있으니, 프리뷰 목적에 맞게 함수명을 변경하거나 주석을 추가하는 것을 고려해 보세요.

예: TextButton_B500State_AssetsPreview, TextButton_R500State_AssetsPreview

domain/post/src/main/java/com/umcspot/spot/post/repository/PostRepository.kt (1)

3-4: 사용되지 않는 import 제거

ImageRefPostType이 import되어 있지만, 인터페이스 메서드 시그니처에서 직접 사용되지 않습니다. 이 타입들은 Posting 모델 내부에서 사용되므로 여기서는 제거해도 됩니다.

🔎 제안된 수정
 package com.umcspot.spot.post.repository
 
-import com.umcspot.spot.model.ImageRef
-import com.umcspot.spot.model.PostType
 import com.umcspot.spot.post.model.postDetail.PostDetailResult
 import com.umcspot.spot.post.model.postDetail.SendComment
feature/board/src/main/java/com/umcspot/spot/feature/board/post/posting/PostingScreen.kt (1)

91-100: submit 실패 시 사용자 피드백 부재

현재 LaunchedEffect에서 UiState.Success만 처리하고 있습니다. 에러 상태(UiState.Error)에 대한 처리가 없어서 사용자가 제출 실패 시 어떤 피드백도 받지 못합니다. 토스트 메시지나 에러 다이얼로그를 추가하는 것을 고려해 보세요.

🔎 제안된 수정
     LaunchedEffect(submitState) {
         when (submitState) {
             is UiState.Success -> {
                 postingViewModel.consumeSubmitResult()
                 onSubmitSuccess()
             }
-
-            else -> Unit
+            is UiState.Error -> {
+                // 에러 메시지 표시 (예: Toast 또는 Snackbar)
+                postingViewModel.consumeSubmitResult()
+            }
+            else -> Unit
         }
     }
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/DeleteModal.kt (2)

34-34: 사용되지 않는 import

TextButtonSize가 import되어 있지만 사용되지 않습니다.

🔎 제안된 수정
 import com.umcspot.spot.designsystem.component.button.TextButton
-import com.umcspot.spot.designsystem.component.button.TextButtonSize
 import com.umcspot.spot.designsystem.component.button.TextButtonState

50-58: 고정 높이로 인한 콘텐츠 오버플로우 가능성

Card에 height(screenHeightDp(227.dp))로 고정 높이가 설정되어 있습니다. modalTitle이나 modalDes가 길어질 경우 콘텐츠가 잘릴 수 있습니다. wrapContentHeight()heightIn(min = ..., max = ...)를 사용하여 유연하게 처리하는 것을 고려해 보세요.

🔎 제안된 수정
     Card(
         modifier = modifier
             .width(screenWidthDp(326.dp))
-            .height(screenHeightDp(227.dp)),
+            .wrapContentHeight()
+            .heightIn(min = screenHeightDp(200.dp)),
         shape = SpotShapes.Hard,
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/CountsView.kt (3)

82-83: cap 함수 중복 - 공유 유틸리티 사용 권장

cap(n: Long) 함수가 Stat 컴포저블 내부에 로컬로 정의되어 있습니다. 관련 코드 스니펫에서 확인되듯이 core/model/DataFormatter.ktcap(n: Int) 함수가 이미 존재하며, StudyItem.ktCountsView.kt에도 유사한 함수가 중복 정의되어 있습니다.

Long 타입을 지원하는 오버로드를 DataFormatter.kt에 추가하고 공유하는 것을 권장합니다.


75-81: 미사용 파라미터 count1 제거 검토

count1 파라미터가 기본값 0으로 선언되어 있지만, 현재 모든 호출처에서 사용되지 않습니다. 향후 확장을 위한 것이라면 주석으로 의도를 명시하거나, 현재 불필요하다면 제거하여 API를 단순화하는 것이 좋습니다.


95-106: 아이콘 크기에 반응형 사이징 미적용

파일 전반에서 screenWidthDp(), screenHeightDp()를 사용하여 반응형 크기 조정을 적용하고 있으나, Line 100의 아이콘 크기는 고정값 14.dp를 사용하고 있습니다. 일관성을 위해 screenWidthDp(14.dp)로 변경하는 것을 권장합니다.

🔎 제안된 수정
         Icon(
             painter = painterResource(iconRes),
             contentDescription = null,
             tint = if (likeChecked) SpotTheme.colors.B500 else Color.Unspecified,
             modifier = Modifier
-                .size(14.dp)
+                .size(screenWidthDp(14.dp))
                 .clickable(
                     indication = null,
                     onClick = onLikeClick,
                     interactionSource = interaction
                 )
         )
data/post/src/main/java/com/umcspot/spot/post/service/PostService.kt (2)

6-6: 미사용 import 확인 필요

PostingRequestDto가 import되어 있지만 이 인터페이스 내에서 직접 사용되지 않습니다. RequestBody로 직렬화되어 전달되는 경우라면 이 import는 불필요할 수 있습니다.


44-57: postPosteditPost 반환 타입 불일치

postPostretrofit2.Response<BaseResponse<FinishPostResponseDto>>를 반환하고, editPostNullResultResponse를 직접 반환합니다. 이러한 불일치는 호출처에서 다른 에러 처리 로직이 필요하게 됩니다.

의도적인 설계라면 주석으로 이유를 명시하는 것이 좋고, 그렇지 않다면 일관된 반환 타입 사용을 권장합니다.

feature/board/src/main/java/com/umcspot/spot/feature/board/post/content/PostViewmodel.kt (1)

16-16: 미사용 import 제거

asSharedFlow가 import되어 있지만 사용되지 않습니다.

feature/board/src/main/java/com/umcspot/spot/feature/board/main/BoardScreen.kt (2)

314-337: BestCardList에서 forEachIndexed 대신 lazy 컴포지션 고려

현재 forEachIndexed를 사용하여 모든 아이템을 한 번에 컴포즈하고 있습니다. 리스트가 커질 경우 성능에 영향을 줄 수 있습니다. 단, Best 인기글이 항상 소수(예: 5개 이하)로 제한된다면 현재 구현도 괜찮습니다.


182-192: 에러/Empty 상태 오버레이 - 기존 콘텐츠와 겹침

UiState.FailureUiState.Empty 상태에서 오버레이 메시지가 LazyColumn 위에 표시되지만, 기존 콘텐츠가 뒤에 남아있을 수 있습니다. 상태에 따라 LazyColumn을 조건부로 렌더링하거나, 오버레이에 불투명 배경을 추가하는 것을 고려해 주세요.

🔎 제안된 수정
         when (ui) {
-            is UiState.Failure -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+            is UiState.Failure -> Box(
+                Modifier.fillMaxSize().background(Color.White),
+                contentAlignment = Alignment.Center
+            ) {
                 Text(text = "에러: ${ui.msg}", color = Color.Red)
             }
 
-            is UiState.Empty -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+            is UiState.Empty -> Box(
+                Modifier.fillMaxSize().background(Color.White),
+                contentAlignment = Alignment.Center
+            ) {
                 Text(text = "데이터가 없습니다.", color = Color.Gray)
             }
domain/board/src/main/java/com/umcspot/spot/domain/board/model/board/RecentBoardResult.kt (1)

27-38: 더미 데이터 생성의 일관성 개선을 고려하세요.

현재 구현에서 두 가지 개선 사항을 고려할 수 있습니다:

  1. Line 36: (0..2000).random()은 호출할 때마다 다른 값을 생성합니다. 더미 데이터의 일관성을 위해 index 기반의 결정적 값을 사용하는 것이 좋습니다.
  2. Line 30: 매우 긴 Lorem ipsum 텍스트는 UI에서 오버플로우나 잘림 현상을 일으킬 수 있습니다.
🔎 개선 제안
 internal fun dummyRecentPost(index: Int, total : Int): RecentPostResult {
     val labels = PostType.entries.toTypedArray()                 // 5개 모두 사용
     val label = labels[index % labels.size]
-    val title = "Lorem ipsum dolor sit amet consectetur Lorem ipsum dolor sit amet consectetur Lorem ipsum dolor sit amet consectetur"
+    val title = "더미 게시글 제목 ${index + 1}"

     return RecentPostResult(
         postId = index.toLong(),
         postType = label,
         title = title,
-        commentCount =(0..2000).random(),
+        commentCount = (index * 17) % 2001,  // 결정적이면서도 다양한 값
     )
 }
domain/board/src/main/java/com/umcspot/spot/domain/board/model/board/BestBoardResult.kt (1)

29-43: RecentBoardResult.kt와 동일한 개선 사항을 적용하세요.

이 파일도 RecentBoardResult.kt와 동일한 패턴을 사용하고 있어, 같은 개선 사항이 적용됩니다:

  1. Line 40: (0..2000).random()은 비결정적 값을 생성합니다.
  2. Line 32: 긴 Lorem ipsum 텍스트는 UI 문제를 일으킬 수 있습니다.
  3. Lines 34-35: 불필요한 빈 줄이 있습니다.
🔎 개선 제안
 internal fun dummyBestPost(index: Int, total : Int): BestPostResult {
     val labels = PostType.entries.toTypedArray()                 // 5개 모두 사용
     val label = labels[index% labels.size]
-    val title = "Lorem ipsum dolor sit amet consectetur Lorem ipsum dolor sit amet consectetur Lorem ipsum dolor sit amet consectetur"
-
-
+    val title = "인기 게시글 제목 ${index + 1}"

     return BestPostResult(
         postId = index.toLong(),
         postType = label,
         title = title,
-        commentCount =(0..2000).random(),
+        commentCount = (index * 23) % 2001,  // 결정적이면서도 다양한 값
         content = title,
     )
 }
feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt (1)

101-128: 네비게이션 그래프 통합이 올바르게 구현되었습니다.

게시판, 게시글 목록, 게시글 작성, 게시글 상세 화면의 네비게이션이 적절히 연결되어 있습니다. 제공된 relevant code snippets와 비교했을 때 콜백 시그니처와 파라미터 전달이 정확합니다.

Line 125의 디버그 로깅은 개발 중에는 유용하지만, 프로덕션 환경에서는 구조화된 로깅 시스템 사용을 고려해보세요.

feature/board/src/main/java/com/umcspot/spot/feature/board/post/posting/PostingViewModel.kt (1)

77-116: 게시글 제출 전 유효성 검사 추가를 고려하세요.

현재 submit() 함수는 제목과 본문의 trim()만 수행하고 빈 값 검사는 하지 않습니다. 사용자가 빈 제목이나 본문으로 게시글을 제출할 경우 서버 에러나 의미 없는 게시글이 생성될 수 있습니다.

🔎 유효성 검사 추가 제안
 fun submit() {
     val title = _title.value.trim()
     val body = _body.value.trim()
     val type = _postType.value
     val image = _image.value
     val targetId = _editingPostId.value
+
+    // 유효성 검사
+    if (title.isBlank()) {
+        _submitState.value = UiState.Failure("제목을 입력해주세요")
+        return
+    }
+    if (body.isBlank()) {
+        _submitState.value = UiState.Failure("내용을 입력해주세요")
+        return
+    }

     viewModelScope.launch {
         runCatching {
core/network/src/main/java/com/umcspot/spot/network/multipart/MultipartFactory.kt (1)

46-74: Multipart 생성 로직이 견고하나 소폭 최적화 가능합니다.

전반적인 구현은 훌륭하며, 특히:

  • Line 50-51: URI 유효성 사전 검증
  • Line 54: MIME 타입 fallback 처리
  • Line 66-70: contentLength의 안전한 처리

다만 Line 50의 스트림을 열고 닫는 검증 로직이 Line 60-63에서 다시 스트림을 여는 것과 중복됩니다. 실제로는 문제가 없지만 약간의 성능 오버헤드가 있습니다.

🔎 선택적 최적화 제안

스트림을 두 번 여는 대신, 첫 번째 writeTo 호출에서 예외를 처리하도록 할 수도 있습니다. 하지만 현재 구현도 안전하고 명확하므로 이는 선택사항입니다.

// 현재 방식도 충분히 좋지만, 원한다면 사전 검증을 제거하고
// writeTo에서만 처리할 수 있습니다
data/board/src/main/java/com/umcspot/spot/board/service/BoardService.kt (2)

10-10: 사용되지 않는 import

kotlinx.serialization.Polymorphic이 import되었지만 이 파일에서 사용되지 않습니다. 제거를 고려해주세요.

🔎 제안된 수정
-import kotlinx.serialization.Polymorphic

40-43: REST API 설계 - unlike 엔드포인트

DELETE /api/posts/{postId}/unlike보다는 DELETE /api/posts/{postId}/like가 RESTful 관례에 더 부합합니다. 좋아요 리소스를 생성(POST)하고 삭제(DELETE)하는 개념입니다. 백엔드 API가 이미 정의되어 있다면 현재 구현을 유지하셔도 됩니다.

feature/board/src/main/java/com/umcspot/spot/feature/board/post/content/PostContentScreen.kt (3)

55-55: 사용되지 않는 import

RejectDialog가 import되었지만 이 파일에서 사용되지 않습니다.

🔎 제안된 수정
-import com.umcspot.spot.designsystem.component.modal.RejectDialog

143-151: 자동 스크롤 로직의 매직 넘버와 인덱스 계산

lastIndex = 2 + post.comments.size - 1에서 2가 무엇을 의미하는지 명확하지 않습니다. LazyColumn의 item 순서(post_header, divider, comments)를 고려한 것으로 보이나, 코드 가독성을 위해 상수로 추출하거나 주석을 추가하는 것이 좋습니다.

또한 delay(300)도 상수로 추출하면 유지보수성이 향상됩니다.

🔎 제안된 수정
+private const val HEADER_ITEM_COUNT = 2  // post_header + divider
+private const val KEYBOARD_ANIMATION_DELAY_MS = 300L

 LaunchedEffect(isCommentFocused, post.comments.size) {
     if (isCommentFocused) {
-        val lastIndex = 2 + post.comments.size - 1
+        // LazyColumn items: post_header(0), divider(1), comments(2...)
+        val lastIndex = HEADER_ITEM_COUNT + post.comments.size - 1
         val target = lastIndex.coerceAtLeast(0)
 
-        delay(300)
+        delay(KEYBOARD_ANIMATION_DELAY_MS)
 
         listState.animateScrollToItem(target)
     }
 }

507-509: Preview 함수 네이밍 컨벤션

Preview 함수 이름 preview()는 Compose 네이밍 컨벤션상 PreviewPostContentScreen 또는 PostContentScreenPreview 형태가 더 명확합니다.

feature/board/src/main/java/com/umcspot/spot/feature/board/boardList/BoardListViewmodel.kt (1)

32-32: scrollPosition을 공개 var로 노출하면 캡슐화가 깨집니다

외부에서 직접 수정 가능한 공개 mutable 프로퍼티 대신, private backing field와 public getter를 사용하거나 StateFlow로 관리하는 것이 좋습니다.

🔎 수정 제안
-    var scrollPosition: ScrollPosition = ScrollPosition()
+    private var _scrollPosition: ScrollPosition = ScrollPosition()
+    val scrollPosition: ScrollPosition get() = _scrollPosition

saveScrollPositionresetScrollPosition 함수에서 _scrollPosition을 수정하도록 변경하세요.

data/post/src/main/java/com/umcspot/spot/post/datasource/PostDataSource.kt (1)

8-8: 사용되지 않는 import

SendComment import가 이 인터페이스에서 사용되지 않습니다. 인터페이스 메서드는 CommentRequestDto를 직접 받고 있습니다.

🔎 수정 제안
-import com.umcspot.spot.post.model.postDetail.SendComment
data/post/src/main/java/com/umcspot/spot/post/repositoryimpl/PostRepositoryImpl.kt (3)

3-4: 사용되지 않는 import 정리 필요

Context, Uri, ApplicationContext, toMediaTypeOrNull, MultipartBody, buffer, source 등의 import가 사용되지 않습니다.

Also applies to: 18-18, 21-22, 25-26


139-141: getPostDetailDummy()private으로 변경

내부 폴백용 함수이므로 외부에 노출할 필요가 없습니다.

🔎 수정 제안
-    fun getPostDetailDummy(): PostDetailResult {
+    private fun getPostDetailDummy(): PostDetailResult {
         return PostDetailResult.dummyPostDetail(5, 5)
     }

146-149: 확장 함수 가시성 제한 고려

toJsonRequestBody()는 이 파일 내에서만 사용되므로 internal 또는 private로 제한하는 것이 좋습니다.

🔎 수정 제안
-fun RequestPosting.toJsonRequestBody(): RequestBody {
+internal fun RequestPosting.toJsonRequestBody(): RequestBody {
     val jsonString = json.encodeToString(RequestPosting.serializer(), this)
     return jsonString.toRequestBody("application/json; charset=utf-8".toMediaType())
 }
feature/board/src/main/java/com/umcspot/spot/feature/board/main/BoardViewmodel.kt (1)

60-63: 실패 시 사용자에게 오류 상태 전달 필요

현재 실패 시 UiState.Empty로 설정하여 사용자에게 오류가 발생했다는 피드백이 없습니다. UiState.Error 상태를 추가하여 사용자에게 재시도 옵션을 제공하는 것을 고려하세요.

core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/RejectModal.kt (1)

49-56: 고정 높이로 인한 콘텐츠 오버플로우 가능성이 있습니다.

modalTitle이나 modalDes가 길 경우 height(screenHeightDp(227.dp))로 고정된 Card 내에서 콘텐츠가 잘릴 수 있습니다. wrapContentHeight()heightIn(min = ...) 사용을 고려해 보세요.

data/board/src/main/java/com/umcspot/spot/board/repositoryimpl/BoardRepositoryImpl.kt (1)

51-60: postPostLike와 deletePostLike는 에러 로깅이 없습니다.

다른 메서드들과 달리 onFailure 로깅이 없어 디버깅이 어려울 수 있습니다. 일관성을 위해 에러 로깅을 추가하는 것을 고려해 보세요.

🔎 로깅 추가 제안
 override suspend fun postPostLike(postId: Long): Result<Unit> =
     runCatching {
         boardService.postPostLike(postId)
+    }.onFailure { e ->
+        Log.e("BoardRepository", "postPostLike failed", e)
     }

 override suspend fun deletePostLike(postId: Long): Result<Unit> =
     runCatching {
         boardService.deletePostLike(postId)
+    }.onFailure { e ->
+        Log.e("BoardRepository", "deletePostLike failed", e)
     }
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/TextButton.kt (2)

154-175: Click 상태의 들여쓰기가 일관되지 않습니다.

다른 상태들과 달리 TextButtonColors 내부 속성들의 들여쓰기가 맞지 않습니다.

🔎 들여쓰기 수정
     Click(
         normal = TextButtonColors(
-        bg = White,
-        text = Black,
-        border = White
+            bg = White,
+            text = Black,
+            border = White
         ),
         disabled = TextButtonColors(
-        bg = White,
-        text = G400,
-        border = White
+            bg = White,
+            text = G400,
+            border = White
         ),
         pressed = TextButtonColors(
-        bg = B100,
-        text = Black,
-        border = B100
+            bg = B100,
+            text = Black,
+            border = B100
         ),
         selected = TextButtonColors(
-        bg = B100,
-        text = B500,
-        border = B100
+            bg = B100,
+            text = B500,
+            border = B100
         )
     )

228-235: 0.5.dp 테두리는 일부 디스플레이에서 제대로 렌더링되지 않을 수 있습니다.

서브 픽셀 값(0.5.dp)은 저해상도 디스플레이에서 보이지 않거나 일관되지 않게 표시될 수 있습니다. 1.dp 사용을 고려해 보세요.

Comment on lines +302 to +453
//// ---------- Size Convenience ----------
//
//@Composable
//fun TextButtonXL(
// text: String,
// style : TextStyle,
// onClick: () -> Unit,
// modifier: Modifier = Modifier,
// shape : Shape = SpotShapes.Soft,
// width: Dp = 200.dp,
// enabled: Boolean = true,
// checked : Boolean = false,
// state: TextButtonState = TextButtonState.B400State,
// content: @Composable () -> Unit = {}
//) = TextButton(
// text = text,
// style = style,
// onClick = onClick,
// modifier = modifier,
// shape = shape,
// size = TextButtonSize.XL,
// width = width,
// enabled = enabled,
// checked = checked,
// state = state,
// content = content
//)
//
//@Composable
//fun TextButtonL(
// text: String,
// style : TextStyle,
// onClick: () -> Unit,
// modifier: Modifier = Modifier,
// shape : Shape = SpotShapes.Soft,
// width: Dp = 180.dp,
// enabled: Boolean = true,
// checked : Boolean = false,
// state: TextButtonState = TextButtonState.B400State,
// content: @Composable () -> Unit = {}
//) = TextButton(
// text = text,
// onClick = onClick,
// modifier = modifier,
// shape = shape,
// size = TextButtonSize.L,
// width = width,
// enabled = enabled,
// checked = checked,
// state = state,
// content = content
//)
//
//@Composable
//fun TextButtonM(
// text: String,
// style : TextStyle,
// onClick: () -> Unit,
// modifier: Modifier = Modifier,
// width: Dp = 160.dp,
// shape : Shape = SpotShapes.Soft,
// enabled: Boolean = true,
// checked : Boolean = false,
// state: TextButtonState = TextButtonState.B400State,
// content: @Composable () -> Unit = {}
//) = TextButton(
// text = text,
// onClick = onClick,
// modifier = modifier,
// size = TextButtonSize.M,
// shape = shape,
// width = width,
// enabled = enabled,
// checked = checked,
// state = state,
// content = content
//)
//
//@Composable
//fun TextButtonS(
// text: String,
// style : TextStyle,
// onClick: () -> Unit,
// modifier: Modifier = Modifier,
// width: Dp = 140.dp,
// shape : Shape = SpotShapes.Soft,
// enabled: Boolean = true,
// checked : Boolean = false,
// state: TextButtonState = TextButtonState.B400State,
// content: @Composable () -> Unit = {}
//) = TextButton(
// text = text,
// onClick = onClick,
// modifier = modifier,
// size = TextButtonSize.S,
// shape = shape,
// width = width,
// enabled = enabled,
// checked = checked,
// state = state,
// content = content
//)
//
//@Composable
//fun TextButtonXS(
// text: String,
// style : TextStyle,
// onClick: () -> Unit,
// modifier: Modifier = Modifier,
// shape : Shape = SpotShapes.Soft,
// width: Dp = 70.dp,
// enabled: Boolean = true,
// checked : Boolean = false,
// state: TextButtonState = TextButtonState.B400State,
// content: @Composable () -> Unit = {}
//) = TextButton(
// text = text,
// onClick = onClick,
// modifier = modifier,
// size = TextButtonSize.XS,
// shape = shape,
// width = width,
// enabled = enabled,
// checked = checked,
// state = state,
// content = content
//)
//
//@Composable
//fun TextToggleButton(
// text: String,
// onClick: () -> Unit,
// modifier: Modifier = Modifier,
// shape : Shape = SpotShapes.Soft,
// width: Dp = 126.dp,
// enabled: Boolean = true,
// checked : Boolean = false,
// size: TextButtonSize = TextButtonSize.M, // ← 기본값을 M로
// state: TextButtonState = TextButtonState.Toggle,
// content: @Composable () -> Unit = {}
//) = TextButton(
// text = text,
// onClick = onClick,
// modifier = modifier,
// size = size,
// shape = shape,
// width = width,
// enabled = enabled,
// checked = checked,
// state = state,
// content = content
//)
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

주석 처리된 코드 블록을 제거해 주세요.

약 150줄의 주석 처리된 코드가 있습니다. 이는 코드 가독성을 저하시키고 유지보수를 어렵게 합니다. 필요하다면 Git 히스토리에서 복구할 수 있으므로 삭제하는 것이 좋습니다.

🤖 Prompt for AI Agents
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/TextButton.kt
lines 302-453: 이 구간에 약 150줄에 달하는 전부 주석 처리된 대체 크기 콤포저블들이 남아 있어 가독성과 유지보수성을 해치므로
해당 주석 블록 전체를 파일에서 삭제하세요; 필요시 Git 히스토리로 복구할 수 있다는 점을 참고하고, 삭제 후 빌드·테스트가 통과하는지 확인한
다음 커밋 메시에 "Remove commented size convenience composables"와 같이 설명을 붙여 푸시하세요.

Comment on lines +13 to +26
suspend fun getRecentBoard(): BaseResponse<RecentBoardResponseDto>
suspend fun getBestBoard(
sortBy: SortType
): BaseResponse<BestBoardResponseDto>

suspend fun getFilteredPosts(
cursor : Long,
postType: PostType,
size : Int
): BaseResponse<PostListResponseDto>

suspend fun postPostLike(postId : Long) : NullResultResponse

suspend fun deletePostLike(postId : Long) : NullResultResponse
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for postPostLike and deletePostLike method definitions
echo "=== Searching for postPostLike and deletePostLike methods ==="
rg -n "fun (postPostLike|deletePostLike)" --type=kt -B 1 -A 3

Repository: SPOTeam/Android-V2

Length of output: 167


🏁 Script executed:

#!/bin/bash
# First, find all Kotlin files related to datasources
echo "=== Finding datasource files ==="
find . -name "*DataSource.kt" -type f

echo ""
echo "=== Searching for postPostLike and deletePostLike in Kotlin files ==="
rg -n "postPostLike|deletePostLike" . --glob="*.kt" -B 1 -A 3

Repository: SPOTeam/Android-V2

Length of output: 12317


좋아요 API가 BoardDataSource와 PostDataSource 양쪽에 중복 정의되어 있습니다.

postPostLikedeletePostLike 메서드가 BoardDataSource와 PostDataSource 양쪽에서 동일하게 정의되어 있으며, 두 경우 모두 동일한 엔드포인트(POST /api/posts/{postId}/like, DELETE /api/posts/{postId}/unlike)를 호출합니다. 이러한 중복을 제거하고 한 곳에서만 정의하여 재사용하는 것이 유지보수성을 높일 수 있습니다.

Comment on lines 18 to 25
override suspend fun getRecentBoard(): Result<RecentPostResultList> =
runCatching {
boardService.getRecentBoard().result.toDomainList()
}.onFailure { e ->
Log.e("BoardRepository", "getRecentBoard failed", e)
}.recoverCatching { e ->
recentPostDummies(3)
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

에러 복구 시 더미 데이터 반환은 실제 오류를 숨깁니다.

recoverCatching으로 항상 더미 데이터를 반환하면 호출자가 실제 데이터와 더미 데이터를 구분할 수 없고, 네트워크 오류가 사용자에게 전달되지 않습니다. 개발/디버그 환경에서만 더미 데이터를 사용하거나, 오류를 그대로 전파하는 것을 고려해 보세요.

🔎 오류 전파 방식 제안
 override suspend fun getRecentBoard(): Result<RecentPostResultList> =
     runCatching {
         boardService.getRecentBoard().result.toDomainList()
     }.onFailure { e ->
         Log.e("BoardRepository", "getRecentBoard failed", e)
-    }.recoverCatching { e ->
-        recentPostDummies(3)
     }

또는 디버그 빌드에서만 더미 데이터를 사용하도록 조건부 처리를 추가하세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
override suspend fun getRecentBoard(): Result<RecentPostResultList> =
runCatching {
boardService.getRecentBoard().result.toDomainList()
}.onFailure { e ->
Log.e("BoardRepository", "getRecentBoard failed", e)
}.recoverCatching { e ->
recentPostDummies(3)
}
override suspend fun getRecentBoard(): Result<RecentPostResultList> =
runCatching {
boardService.getRecentBoard().result.toDomainList()
}.onFailure { e ->
Log.e("BoardRepository", "getRecentBoard failed", e)
}
🤖 Prompt for AI Agents
In
data/board/src/main/java/com/umcspot/spot/board/repositoryimpl/BoardRepositoryImpl.kt
around lines 18–25, do not always swallow errors by unconditionally returning
dummy data in recoverCatching; instead either rethrow/propagate the original
exception (so the caller receives a failed Result) or return dummy data only in
non-production (e.g., guard with BuildConfig.DEBUG or an injected feature flag).
Concretely, replace recoverCatching with logic that returns Result.failure(e)
(or simply rethrows) after logging, or wrap the dummy-return branch behind a
debug/dev flag so production errors are propagated to the caller.

abstract class PostRepositoryModule {
@Binds
@Singleton
abstract fun bindsDummyRepository(dummyRepositoryImpl: PostRepositoryImpl): PostRepository
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

함수명이 실제 용도와 불일치합니다.

함수명이 bindsDummyRepository로 되어 있지만, 실제로는 프로덕션 PostRepository를 바인딩하고 있습니다. "Dummy"라는 이름은 테스트용이나 임시 코드를 암시하여 혼란을 줄 수 있습니다.

🔎 제안: 명확한 함수명으로 변경
-    abstract fun bindsDummyRepository(dummyRepositoryImpl: PostRepositoryImpl): PostRepository
+    abstract fun bindsPostRepository(postRepositoryImpl: PostRepositoryImpl): PostRepository
🤖 Prompt for AI Agents
In data/post/src/main/java/com/umcspot/spot/post/di/PostRepositoryModule.kt
around line 17, the function is named bindsDummyRepository but actually binds
the production PostRepository; rename it to a clear, accurate name such as
bindsPostRepository (or bindPostRepository) to reflect its real purpose, update
the function signature accordingly, and update any references/usages or tests
and KDoc/comments to match the new name so the module no longer implies a
dummy/test binding.

val isLoadingMore: StateFlow<Boolean> = _isLoadingMore.asStateFlow()


private val inFlightLikes = mutableSetOf<Long>()
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

inFlightLikes의 스레드 안전성 문제

mutableSetOf<Long>()은 스레드 안전하지 않습니다. toggleLike가 여러 코루틴에서 동시에 호출될 경우 데이터 경합이 발생할 수 있습니다.

🔎 수정 제안
-    private val inFlightLikes = mutableSetOf<Long>()
+    private val inFlightLikes = java.util.Collections.synchronizedSet(mutableSetOf<Long>())

또는 ConcurrentHashMap.newKeySet()을 사용할 수 있습니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private val inFlightLikes = mutableSetOf<Long>()
private val inFlightLikes = java.util.Collections.synchronizedSet(mutableSetOf<Long>())
🤖 Prompt for AI Agents
In
feature/board/src/main/java/com/umcspot/spot/feature/board/boardList/BoardListViewmodel.kt
around line 48, the current inFlightLikes = mutableSetOf<Long>() is not
thread-safe and can cause data races when toggleLike is called from multiple
coroutines; replace it with a thread-safe concurrent set such as
ConcurrentHashMap.newKeySet<Long>() (or
Collections.newSetFromMap(ConcurrentHashMap<Long, Boolean>())), update imports,
and keep all add/remove/check operations the same so they operate atomically;
alternatively protect accesses with a Mutex or synchronized block if you prefer
explicit coroutine-safe locking.

Comment on lines +62 to +63
Log.d("BoardListViewModel", "load posts: $firstPage")
Log.d("BoardListViewModel", "dataformat on First Index: ${firstPage.posts[0].createdAt}")
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

빈 리스트 접근 시 IndexOutOfBoundsException 발생 가능

firstPage.posts[0]에 직접 접근하기 전에 리스트가 비어있지 않은지 확인해야 합니다. 게시글이 없는 경우 앱이 크래시됩니다.

🔎 수정 제안
             }.onSuccess { firstPage ->
                 Log.d("BoardListViewModel", "load posts: $firstPage")
-                Log.d("BoardListViewModel", "dataformat on First Index: ${firstPage.posts[0].createdAt}")
+                Log.d("BoardListViewModel", "dataformat on First Index: ${firstPage.posts.firstOrNull()?.createdAt}")
                 _uiState.update { it.copy(data = UiState.Success(firstPage)) }
🤖 Prompt for AI Agents
In
feature/board/src/main/java/com/umcspot/spot/feature/board/boardList/BoardListViewmodel.kt
around lines 62-63, the code accesses firstPage.posts[0] without checking for an
empty list which can throw IndexOutOfBoundsException; guard the access by
checking if firstPage.posts.isNotEmpty() (or use firstOrNull()) before reading
createdAt, and change the log to conditionally log the first post’s createdAt
only when present (otherwise log that the list is empty).

private val _likeSelected = MutableStateFlow(false)
val likeSelected: StateFlow<Boolean> = _likeSelected.asStateFlow()

private val inFlightLikes = mutableSetOf<Long>()
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

inFlightLikes의 스레드 안전성 문제

mutableSetOf<Long>()은 스레드 안전하지 않습니다. toggleLike()가 여러 코루틴에서 동시에 호출될 경우 add()remove() 연산에서 경쟁 조건이 발생할 수 있습니다.

🔎 제안된 수정
-    private val inFlightLikes = mutableSetOf<Long>()
+    private val inFlightLikes = java.util.Collections.synchronizedSet(mutableSetOf<Long>())

또는 ConcurrentHashMap.newKeySet()을 사용하거나, 단일 Dispatcher에서만 접근하도록 보장하는 방법도 있습니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private val inFlightLikes = mutableSetOf<Long>()
private val inFlightLikes = java.util.Collections.synchronizedSet(mutableSetOf<Long>())
🤖 Prompt for AI Agents
In
feature/board/src/main/java/com/umcspot/spot/feature/board/post/content/PostViewmodel.kt
around line 37, inFlightLikes is declared as mutableSetOf<Long>() which is not
thread-safe; replace it with a concurrent set (e.g.,
ConcurrentHashMap.newKeySet<Long>()) or otherwise ensure all accesses happen on
a single coroutine dispatcher (e.g., confine to viewModelScope with a
single-threaded dispatcher) and update add()/remove() calls accordingly to avoid
race conditions. Ensure imports and nullability match Kotlin usage and keep all
mutations to the chosen thread-safe structure or confined dispatcher.

Comment on lines 20 to 29
LaunchedEffect(painterState) {
val success = painterState as? AsyncImagePainter.State.Success ?: return@LaunchedEffect
val drawable = success.result.drawable

val bitmap = (drawable as? BitmapDrawable)?.bitmap ?: return@LaunchedEffect
val luma = sampleCornerLuma(bitmap, sampleCorner)

// 어두우면 흰색 아이콘, 밝으면 검은색 아이콘
tint = if (luma < threshold) Color.White else Color.Black
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

이미지 분석을 백그라운드 스레드에서 실행하세요.

LaunchedEffect는 기본적으로 메인 스레드에서 실행되며, sampleCornerLuma 내부의 비트맵 픽셀 접근(getPixel())은 특히 큰 이미지의 경우 느릴 수 있습니다. 이는 UI 지연(jank)이나 ANR을 유발할 수 있습니다.

🔎 백그라운드 디스패처로 이동하는 수정 제안
     LaunchedEffect(painterState) {
-        val success = painterState as? AsyncImagePainter.State.Success ?: return@LaunchedEffect
-        val drawable = success.result.drawable
-
-        val bitmap = (drawable as? BitmapDrawable)?.bitmap ?: return@LaunchedEffect
-        val luma = sampleCornerLuma(bitmap, sampleCorner)
-
-        // 어두우면 흰색 아이콘, 밝으면 검은색 아이콘
-        tint = if (luma < threshold) Color.White else Color.Black
+        withContext(Dispatchers.Default) {
+            val success = painterState as? AsyncImagePainter.State.Success ?: return@withContext
+            val drawable = success.result.drawable
+
+            val bitmap = (drawable as? BitmapDrawable)?.bitmap ?: return@withContext
+            val luma = sampleCornerLuma(bitmap, sampleCorner)
+
+            // 어두우면 흰색 아이콘, 밝으면 검은색 아이콘
+            tint = if (luma < threshold) Color.White else Color.Black
+        }
     }

kotlinx.coroutines.Dispatchers를 import해야 합니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
LaunchedEffect(painterState) {
val success = painterState as? AsyncImagePainter.State.Success ?: return@LaunchedEffect
val drawable = success.result.drawable
val bitmap = (drawable as? BitmapDrawable)?.bitmap ?: return@LaunchedEffect
val luma = sampleCornerLuma(bitmap, sampleCorner)
// 어두우면 흰색 아이콘, 밝으면 검은색 아이콘
tint = if (luma < threshold) Color.White else Color.Black
}
LaunchedEffect(painterState) {
withContext(Dispatchers.Default) {
val success = painterState as? AsyncImagePainter.State.Success ?: return@withContext
val drawable = success.result.drawable
val bitmap = (drawable as? BitmapDrawable)?.bitmap ?: return@withContext
val luma = sampleCornerLuma(bitmap, sampleCorner)
// 어두우면 흰색 아이콘, 밝으면 검은색 아이콘
tint = if (luma < threshold) Color.White else Color.Black
}
}
🤖 Prompt for AI Agents
In
feature/board/src/main/java/com/umcspot/spot/feature/board/post/posting/VerifyImageBrightness.kt
around lines 20-29, the image brightness sampling runs on the main thread inside
LaunchedEffect which can cause UI jank; move the heavy bitmap pixel access off
the main thread by importing kotlinx.coroutines.Dispatchers and wrapping the
call to sampleCornerLuma(bitmap, sampleCorner) in a
withContext(Dispatchers.Default) block (or Dispatchers.IO if preferred) so the
bitmap sampling executes on a background dispatcher and then assign tint on the
main thread after awaiting the result.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 12

♻️ Duplicate comments (2)
feature/board/src/main/java/com/umcspot/spot/feature/board/post/posting/VerifyImageBrightness.kt (1)

19-27: 이미지 분석을 백그라운드 스레드로 이동하세요.

기존 리뷰에서 이미 지적된 것처럼, 비트맵 픽셀 접근(getPixel())이 메인 스레드에서 실행되어 큰 이미지의 경우 UI jank나 ANR을 유발할 수 있습니다. withContext(Dispatchers.Default)로 감싸서 백그라운드에서 실행하도록 수정이 필요합니다.

feature/board/src/main/java/com/umcspot/spot/feature/board/post/content/PostViewmodel.kt (1)

37-37: inFlightLikes의 스레드 안전성 문제

이전 리뷰에서 이미 지적된 사항입니다. mutableSetOf<Long>()은 스레드 안전하지 않으므로 Collections.synchronizedSet() 또는 ConcurrentHashMap.newKeySet()을 사용해야 합니다.

🧹 Nitpick comments (18)
feature/board/src/main/java/com/umcspot/spot/feature/board/post/posting/VerifyImageBrightness.kt (1)

12-16: 함수 이름을 더 명확하게 개선할 수 있습니다.

rememberDismissTintFromPainter에서 "Dismiss"가 특정 사용 사례를 암시하지만, 이 함수는 이미지 위 오버레이 틴트를 계산하는 범용적인 유틸리티입니다. rememberOverlayTintFromPainter 또는 rememberAdaptiveTintFromImage 같은 이름이 재사용성 측면에서 더 명확할 수 있습니다.

feature/board/src/main/java/com/umcspot/spot/feature/board/main/BoardViewmodel.kt (2)

57-59: 실패 시 에러 정보 손실

onFailure에서 UiState.Empty로 설정하면 에러 정보가 손실됩니다. 사용자에게 에러 상태를 표시하거나 재시도 옵션을 제공하려면 UiState.Error 상태 사용을 고려해 보세요.

🔎 수정 제안
-            }.onFailure {
+            }.onFailure { e ->
+                Log.e("BoardViewModel", "load failed", e)
                 _uiState.update { it.copy(user = UiState.Empty) }
             }

또는 UiState에 Error 상태가 있다면:

.onFailure { e ->
    _uiState.update { it.copy(user = UiState.Error(e)) }
}

74-79: 변수명과 실제 데이터 불일치

변수명이 newTagBoards이지만 실제로는 getBestBoard를 호출하여 best boards를 가져오고 있습니다. 코드 가독성을 위해 변수명을 수정하세요.

🔎 수정 제안
-            runCatching { boardRepository.getBestBoard(type).getOrThrow() }
-                .onSuccess { newTagBoards ->
+            runCatching { boardRepository.getBestBoard(type).getOrThrow() }
+                .onSuccess { newBestBoards ->
                     _uiState.update {
                         it.copy(user = UiState.Success(
                             cur.copy(
                                 selected = type,
-                                bestBoards = newTagBoards
+                                bestBoards = newBestBoards
                             )
                         ))
                     }
                 }
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/DeleteModal.kt (3)

65-69: 접근성 개선: dismiss 아이콘에 contentDescription 추가 필요

스크린 리더 사용자를 위해 클릭 가능한 dismiss 아이콘에 의미 있는 contentDescription을 제공해야 합니다.

🔎 제안된 수정
 Image(
     painter = painterResource(R.drawable.dismiss),
-    contentDescription = null,
+    contentDescription = "닫기",
     modifier = Modifier.clickable { onDismiss() }
 )

72-77: 접근성 개선: delete 아이콘에 contentDescription 추가 권장

모달의 목적을 명확히 전달하기 위해 delete 아이콘에도 설명을 추가하는 것이 좋습니다.

🔎 제안된 수정
 Image(
     painter = painterResource(R.drawable.delete),
-    contentDescription = null,
+    contentDescription = "삭제",
     modifier = Modifier
         .size(screenWidthDp(33.dp))
 )

43-51: 고정 높이 사용 시 콘텐츠 오버플로우 가능성

height(screenHeightDp(227.dp))로 고정 높이를 사용하면 긴 텍스트가 잘릴 수 있습니다. 콘텐츠에 따라 유동적인 높이가 필요할 경우 wrapContentHeight()heightIn(min = ...)을 고려해 보세요.

core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/RejectModal.kt (3)

36-44: 콜백 네이밍 명확성 개선 권장

세 개의 콜백(onClick, onCancel, onDismiss)의 역할이 파라미터 이름만으로는 명확하지 않습니다:

  • onClick: "네" 버튼 클릭?
  • onCancel: "아니요" 버튼 클릭?
  • onDismiss: X 아이콘 클릭?

의도가 더 명확하도록 onConfirm, onReject, onDismiss 같은 네이밍을 고려해 보세요.


68-72: 접근성 개선: dismiss 아이콘에 contentDescription 추가 필요

DeleteModal과 동일하게 스크린 리더를 위한 설명이 필요합니다.

🔎 제안된 수정
 Image(
     painter = painterResource(R.drawable.dismiss),
-    contentDescription = null,
+    contentDescription = "닫기",
     modifier = Modifier.clickable { onDismiss() }
 )

46-126: 향후 리팩토링 고려: DeleteModal과 공통 구조 추출

DeleteModalRejectModal이 많은 레이아웃 코드를 공유합니다(Card, dismiss 아이콘, Column 구조, 스페이싱 등). 향후 모달이 추가될 경우 공통 베이스 컴포넌트나 슬롯 기반 패턴을 고려해 보세요.

feature/board/src/main/java/com/umcspot/spot/feature/board/post/posting/PostingViewModel.kt (1)

53-68: 로깅 프레임워크 사용을 권장합니다.

현재 Log.dLog.e를 직접 사용하고 있습니다. 프로덕션 환경에서는 Timber와 같은 로깅 프레임워크를 사용하는 것이 로그 레벨 관리와 릴리스 빌드에서의 로그 제거 측면에서 더 좋습니다.

Also applies to: 77-116

core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/CommentUserInfoItem.kt (1)

20-25: 파라미터 선언 스타일을 일관되게 수정하세요.

Kotlin 코딩 컨벤션에 따르면 콜론 앞에는 공백이 없어야 합니다. 현재 modifier : Modifier처럼 콜론 앞에 공백이 있습니다.

🔎 제안하는 수정
 @Composable
 fun CommentUserInfo(
-    modifier : Modifier = Modifier,
-    commentWriterName : String,
-    commentWriterImage : ImageRef,
+    modifier: Modifier = Modifier,
+    commentWriterName: String,
+    commentWriterImage: ImageRef,
 ) {
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/CountsView.kt (2)

30-68: 코드 중복을 제거하여 유지보수성을 개선하세요.

CountView 오버로드가 거의 동일한 구조를 가지고 있습니다. 공통 인터페이스를 도입하거나 통합 타입을 활용하여 중복을 제거하는 것을 권장합니다.

💡 리팩토링 제안

공통 인터페이스를 정의하여 중복을 줄일 수 있습니다:

interface PostCountable {
    val likeCount: Long
    val commentCount: Long
    val viewCount: Long
    val isLiked: Boolean
}

@Composable
fun <T : PostCountable> CountView(
    item: T,
    onLikeClick: (T) -> Unit = {},
) {
    Row(
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(screenWidthDp(3.dp))
    ) {
        Stat(
            iconRes = R.drawable.thumb_up,
            count2 = item.likeCount,
            likeChecked = item.isLiked,
            onLikeClick = { onLikeClick(item) }
        )
        Stat(iconRes = R.drawable.comment, count2 = item.commentCount)
        Stat(iconRes = R.drawable.eye,     count2 = item.viewCount)
    }
}

그런 다음 PostResultPostDetailResult가 이 인터페이스를 구현하도록 수정하면 됩니다.


71-107: Stat 함수의 여러 개선 사항을 검토하세요.

이 함수에는 몇 가지 개선 가능한 부분이 있습니다:

  1. Line 79: 로컬 cap 함수가 DataFormatter.cap과 중복됩니다. 타입만 Long으로 다릅니다.
  2. Line 74: count1 파라미터가 현재 사용처에서 항상 기본값 0으로 남아있어 실질적으로 사용되지 않습니다.
  3. Line 80: count1.toInt() != 0 체크가 항상 false이므로 불필요한 분기입니다.
  4. Line 97: Icon 크기가 14.dp로 하드코딩되어 있는 반면, 다른 요소들은 screenWidthDp를 사용합니다.
💡 개선 제안
 @Composable
 private fun Stat(
     @DrawableRes iconRes: Int,
-    count1: Long = 0,
     count2: Long,
     likeChecked: Boolean = false,
     onLikeClick: () -> Unit = {}
 ) {
-    fun cap(n: Long) = if (n >= 1000) "999+" else n.toString()
-    val display = if (count1.toInt() != 0) "${cap(count1)}/${cap(count2)}" else cap(count2)
+    fun cap(n: Long) = if (n >= 1000) "999+" else n.toString()
+    val display = cap(count2)
 
     val interaction = remember { MutableInteractionSource() }
 
     Row(
         modifier = Modifier
             .width(screenWidthDp(55.dp))
             .height(screenHeightDp(17.dp))
             .padding(horizontal = screenWidthDp(3.dp), vertical = screenWidthDp(1.dp)),
         verticalAlignment = Alignment.CenterVertically,
         horizontalArrangement = Arrangement.spacedBy(screenWidthDp(4.dp))
     ) {
         Icon(
             painter = painterResource(iconRes),
             contentDescription = null,
             tint = if (likeChecked) SpotTheme.colors.B500 else Color.Unspecified,
             modifier = Modifier
-                .size(14.dp)
+                .size(screenWidthDp(14.dp))
                 .clickable(
                     indication = null,
                     onClick = onLikeClick,
                     interactionSource = interaction
                 )
         )
 
         Text(text = display, style = SpotTheme.typography.small_400, color = SpotTheme.colors.B500)
     }
 }

참고: 향후 count1이 필요한 경우(예: "현재/전체" 표시), 다시 추가할 수 있습니다.

feature/board/src/main/java/com/umcspot/spot/feature/board/post/content/PostViewmodel.kt (3)

6-8: 사용되지 않는 import 정리 필요

BoardListViewModel.BoardUiState, ImageRef, PostType, asSharedFlow가 import 되었으나 파일 내에서 사용되지 않습니다.

🔎 Proposed fix
-import com.umcspot.spot.feature.board.boardList.BoardListViewModel.BoardUiState
-import com.umcspot.spot.model.ImageRef
-import com.umcspot.spot.model.PostType
 import com.umcspot.spot.post.model.postDetail.PostDetailResult
 import com.umcspot.spot.post.model.postDetail.SendComment
 import com.umcspot.spot.post.repository.PostRepository
 import com.umcspot.spot.ui.state.UiState
 import dagger.hilt.android.lifecycle.HiltViewModel
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow

Also applies to: 16-16


86-97: update 블록 내 side effect 분리 권장

Line 94의 _likeSelected.value = likedupdate 람다 내부에서 발생하는 side effect입니다. update 람다는 순수 함수로 유지하고, side effect는 외부에서 처리하는 것이 좋습니다.

🔎 Proposed fix
 private fun applyLocalLike(liked: Boolean, delta: Long) {
+    _likeSelected.value = liked
     _uiState.update { state ->
         val success = state.data as? UiState.Success ?: return@update state
         val cur = success.data
         val updated = cur.copy(
             isLiked = liked,
             likeCount = (cur.likeCount + delta).coerceAtLeast(0L)
         )
-        _likeSelected.value = liked
         state.copy(data = UiState.Success(updated))
     }
 }

121-137: onSuccess 블록 및 중첩 Result 처리 개선

Lines 129-130의 빈 onSuccess 블록이 코드의 의도를 불명확하게 만듭니다. 또한 runCatchingResult 반환값의 이중 처리가 복잡합니다.

🔎 Proposed fix
 fun deletePost() {
     val current = (_uiState.value.data as? UiState.Success)?.data ?: return
     val postId = current.postId

     viewModelScope.launch {
-        runCatching {
-            postRepository.deletePost(postId = postId)
-        }.onSuccess { result ->
-            result.onSuccess {
-            }.onFailure { e ->
-                Log.e("PostViewModel", "deletePost failure", e)
-            }
-        }.onFailure { e ->
-            Log.e("PostViewModel", "deletePost error", e)
-        }
+        postRepository.deletePost(postId = postId)
+            .onFailure { e ->
+                Log.e("PostViewModel", "deletePost error", e)
+            }
     }
 }
feature/board/src/main/java/com/umcspot/spot/feature/board/post/content/PostContentScreen.kt (2)

55-55: 사용되지 않는 import 정리 필요

RejectDialog, B500, R500이 import 되었으나 파일 내에서 사용되지 않습니다.

🔎 Proposed fix
-import com.umcspot.spot.designsystem.component.modal.RejectDialog
 import com.umcspot.spot.designsystem.component.post.CommentUserInfo
 ...
-import com.umcspot.spot.designsystem.theme.B500
-import com.umcspot.spot.designsystem.theme.R500
 import com.umcspot.spot.designsystem.theme.SpotTheme

Also applies to: 60-61


143-152: 매직 넘버 delay(300) 설명 추가 권장

키보드 애니메이션을 기다리기 위한 delay로 보이지만, 의도를 명확히 하는 주석이 있으면 유지보수에 도움이 됩니다.

🔎 Proposed fix
 LaunchedEffect(isCommentFocused, post.comments.size) {
     if (isCommentFocused) {
         val lastIndex = 2 + post.comments.size - 1
         val target = lastIndex.coerceAtLeast(0)

+        // 키보드 애니메이션 완료 대기
         delay(300)

         listState.animateScrollToItem(target)
     }
 }
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between eb602e7 and 7b843c8.

📒 Files selected for processing (13)
  • core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/empty/EmptyAlert.kt
  • core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/DeleteModal.kt
  • core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/RejectModal.kt
  • core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/CommentUserInfoItem.kt
  • core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/CountsView.kt
  • core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/PostItem.kt
  • feature/board/src/main/java/com/umcspot/spot/feature/board/boardList/navigation/BoardListNavigation.kt
  • feature/board/src/main/java/com/umcspot/spot/feature/board/main/BoardViewmodel.kt
  • feature/board/src/main/java/com/umcspot/spot/feature/board/main/navigation/BoardNavigation.kt
  • feature/board/src/main/java/com/umcspot/spot/feature/board/post/content/PostContentScreen.kt
  • feature/board/src/main/java/com/umcspot/spot/feature/board/post/content/PostViewmodel.kt
  • feature/board/src/main/java/com/umcspot/spot/feature/board/post/posting/PostingViewModel.kt
  • feature/board/src/main/java/com/umcspot/spot/feature/board/post/posting/VerifyImageBrightness.kt
🚧 Files skipped from review as they are similar to previous changes (3)
  • core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/empty/EmptyAlert.kt
  • feature/board/src/main/java/com/umcspot/spot/feature/board/main/navigation/BoardNavigation.kt
  • feature/board/src/main/java/com/umcspot/spot/feature/board/boardList/navigation/BoardListNavigation.kt
🧰 Additional context used
🧬 Code graph analysis (6)
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/CommentUserInfoItem.kt (3)
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/ProfileImage.kt (1)
  • ProfileImage (20-58)
core/ui/src/main/java/com/umcspot/spot/ui/extension/DimensionExt.kt (1)
  • screenWidthDp (18-23)
core/designsystem/src/main/java/com/umcspot/spot/designsystem/theme/Theme.kt (1)
  • SpotTheme (54-79)
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/PostItem.kt (4)
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/TextButton.kt (1)
  • ClickSurface (203-244)
core/ui/src/main/java/com/umcspot/spot/ui/extension/DimensionExt.kt (2)
  • screenWidthDp (18-23)
  • screenHeightDp (11-16)
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/CountsView.kt (2)
  • CountView (30-48)
  • CountView (50-68)
core/designsystem/src/main/java/com/umcspot/spot/designsystem/theme/Theme.kt (1)
  • SpotTheme (54-79)
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/DeleteModal.kt (3)
core/ui/src/main/java/com/umcspot/spot/ui/extension/DimensionExt.kt (2)
  • screenWidthDp (18-23)
  • screenHeightDp (11-16)
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/TextButton.kt (1)
  • TextButton (247-299)
core/designsystem/src/main/java/com/umcspot/spot/designsystem/theme/Theme.kt (1)
  • SpotTheme (54-79)
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/RejectModal.kt (3)
core/ui/src/main/java/com/umcspot/spot/ui/extension/DimensionExt.kt (2)
  • screenWidthDp (18-23)
  • screenHeightDp (11-16)
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/TextButton.kt (1)
  • TextButton (247-299)
core/designsystem/src/main/java/com/umcspot/spot/designsystem/theme/Theme.kt (1)
  • SpotTheme (54-79)
feature/board/src/main/java/com/umcspot/spot/feature/board/post/content/PostViewmodel.kt (2)
feature/board/src/main/java/com/umcspot/spot/feature/board/main/BoardViewmodel.kt (1)
  • load (35-61)
feature/board/src/main/java/com/umcspot/spot/feature/board/post/posting/PostingViewModel.kt (1)
  • load (53-68)
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/CountsView.kt (2)
core/ui/src/main/java/com/umcspot/spot/ui/extension/DimensionExt.kt (2)
  • screenWidthDp (18-23)
  • screenHeightDp (11-16)
core/model/src/main/java/com/umcspot/spot/model/DataFormatter.kt (1)
  • cap (26-26)
🔇 Additional comments (18)
feature/board/src/main/java/com/umcspot/spot/feature/board/post/posting/VerifyImageBrightness.kt (1)

56-68: 샘플링 최적화가 잘 되어 있습니다.

스텝 샘플링(step sampling)을 사용하여 픽셀 접근 횟수를 크게 줄인 점이 좋습니다. 약 30x30 그리드로 샘플링하여 성능과 정확도 간 균형을 잘 맞췄습니다.

feature/board/src/main/java/com/umcspot/spot/feature/board/main/BoardViewmodel.kt (1)

90-92: LGTM!

setPostInfo 함수는 간결하고 명확합니다.

core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/DeleteModal.kt (1)

136-149: LGTM!

Preview 구현이 적절합니다. SpotTheme으로 감싸고 한국어 샘플 텍스트를 사용하여 실제 사용 환경을 잘 반영합니다.

core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/RejectModal.kt (3)

101-123: LGTM!

두 버튼의 레이아웃과 상태(R500State, G500State)가 적절하게 구성되어 있습니다. 버튼 간 간격과 크기도 디자인 시스템과 일관성 있습니다.


128-151: LGTM!

RejectDialog가 모든 파라미터를 RejectModal에 올바르게 전달합니다. DeleteDialog와 구조적으로 일관성 있습니다.


153-168: LGTM!

Preview가 private으로 적절히 설정되어 있고, 실제 사용 시나리오를 반영하는 한국어 텍스트를 사용합니다.

feature/board/src/main/java/com/umcspot/spot/feature/board/post/posting/PostingViewModel.kt (2)

27-43: 상태 관리 패턴이 적절합니다.

StateFlow를 사용한 상태 관리가 Android MVVM 모범 사례를 잘 따르고 있습니다. private MutableStateFlow와 public StateFlow 노출 패턴이 올바릅니다.


77-116: ImageRef.None 처리 방식은 올바르게 구현되어 있습니다.

PostRepositoryImpl에서 (posting.imageFile as? ImageRef.LocalUri)?.let {...} 패턴을 통해 ImageRef.None을 자동으로 null로 변환합니다. ImageRef.None이 선택되면 타입 캐스팅이 실패하여 imagePart가 null이 되고, 이를 서버로 전송하는 방식이 맞습니다. sealed interface를 사용한 이 구조는 UI 레이어에서 타입 안전성을 제공하면서 API 레이어에서는 올바르게 null 처리합니다.

core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/CommentUserInfoItem.kt (1)

45-54: LGTM!

프리뷰 함수가 올바르게 구현되어 있으며, 컴포넌트를 효과적으로 시각화합니다.

core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/CountsView.kt (1)

109-118: LGTM!

프리뷰가 적절하게 구성되어 있습니다.

core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/PostItem.kt (2)

89-108: LGTM!

프리뷰가 잘 구성되어 있으며 다양한 PostType과 데이터를 효과적으로 시각화합니다.


77-77: modifier 파라미터 오용을 수정하세요.

Line 77에서도 동일한 문제가 발생합니다.

🔎 제안하는 수정
-                Spacer(Modifier.weight(1f))
+                Spacer(Modifier.weight(1f))

참고: 이 라인은 이미 Modifier를 사용하고 있으므로 정상입니다. 다만 일관성을 위해 확인했습니다.

Likely an incorrect or invalid review comment.

feature/board/src/main/java/com/umcspot/spot/feature/board/post/content/PostViewmodel.kt (3)

40-56: LGTM!

다른 ViewModel들(BoardViewmodel, PostingViewModel)과 일관된 패턴으로 구현되어 있습니다. Loading, Success, Failure 상태를 적절히 처리합니다.


58-84: 낙관적 업데이트(Optimistic Update) 패턴 적용

좋아요 토글에 낙관적 업데이트와 실패 시 롤백 로직이 적절히 구현되어 있습니다. try-finallyinFlightLikes 정리도 잘 되어 있습니다.


99-119: LGTM!

댓글 전송 후 load(postId)를 호출하여 데이터를 갱신하는 패턴이 적절합니다. 입력 유효성 검사도 잘 되어 있습니다.

feature/board/src/main/java/com/umcspot/spot/feature/board/post/content/PostContentScreen.kt (3)

238-308: LGTM!

게시물 상세 화면 레이아웃이 잘 구성되어 있습니다. 메뉴 토글과 각 액션 콜백이 적절히 연결되어 있습니다.


310-400: LGTM!

ImageRef sealed class의 모든 케이스가 처리되어 있고, getIdentifier 호출이 remember로 캐싱되어 성능 최적화가 되어 있습니다.


428-504: LGTM!

소유자/비소유자에 따른 메뉴 분기가 명확하게 구현되어 있습니다. onDismiss()가 각 액션 전에 호출되어 메뉴가 올바르게 닫힙니다.

Comment on lines +29 to +30
private val _selected = MutableStateFlow<List<String>>(emptyList())
val selected = _selected.asStateFlow()
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for _selected usage in other files
rg -n '\b_selected\b' --type=kotlin feature/board/ | grep -v 'BoardViewmodel.kt'

Repository: SPOTeam/Android-V2

Length of output: 536


🏁 Script executed:

#!/bin/bash
# Also search for the public 'selected' property usage
rg -n '\bselected\b' --type=kotlin feature/board/ | grep -v 'BoardViewmodel.kt' | head -20

Repository: SPOTeam/Android-V2

Length of output: 2083


미사용 상태 변수 _selected 제거

이 ViewModel에서 선언되었으나 사용되지 않는 _selectedselected 프로퍼티를 제거하세요.

🤖 Prompt for AI Agents
In
feature/board/src/main/java/com/umcspot/spot/feature/board/main/BoardViewmodel.kt
around lines 29-30, the unused state properties `_selected` and `selected` are
declared but never referenced; remove both declarations from the ViewModel,
delete any now-unused imports related to MutableStateFlow/StateFlow if they
become unused, and run a quick project-wide search to ensure there are no
remaining references—if any call sites exist, refactor them to not rely on these
properties or replace with the appropriate alternative state before committing.

Comment on lines +418 to +420
Text(
text = comment.content.toString(),
style = SpotTheme.typography.medium_400,
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find CommentResult data class definition
rg -A 5 "data class CommentResult" --type kotlin

Repository: SPOTeam/Android-V2

Length of output: 739


comment.content가 nullable String이므로 안전한 처리 필요

CommentResult.contentString? 타입입니다. 현재 코드의 .toString()은 null일 때 "null" 문자열을 표시하게 됩니다. comment.content ?: "" 또는 comment.content.orEmpty()를 사용하여 명시적으로 null을 처리하세요.

🤖 Prompt for AI Agents
In
feature/board/src/main/java/com/umcspot/spot/feature/board/post/content/PostContentScreen.kt
around lines 418 to 420, comment.content is a nullable String and using
.toString() will render "null" when content is null; update the text value to
handle null safely by using either comment.content ?: "" or
comment.content.orEmpty() so an empty string is shown instead of "null", and
keep the existing style parameter unchanged.

Comment on lines +45 to +51
fun clear() {
_editingPostId.value = null
_title.value = ""
_body.value = ""
_postType.value = PostType.PASS_EXPERIENCE
_image.value = ImageRef.None
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

PostType 기본값이 일관되지 않습니다.

line 33에서는 PostType.FREE_TALK으로 초기화하지만, clear() 함수에서는 PostType.PASS_EXPERIENCE로 설정합니다. 이 불일치는 예상치 못한 동작을 유발할 수 있습니다.

🔎 일관성을 위한 수정 제안
 fun clear() {
     _editingPostId.value = null
     _title.value = ""
     _body.value = ""
-    _postType.value = PostType.PASS_EXPERIENCE
+    _postType.value = PostType.FREE_TALK
     _image.value = ImageRef.None
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fun clear() {
_editingPostId.value = null
_title.value = ""
_body.value = ""
_postType.value = PostType.PASS_EXPERIENCE
_image.value = ImageRef.None
}
fun clear() {
_editingPostId.value = null
_title.value = ""
_body.value = ""
_postType.value = PostType.FREE_TALK
_image.value = ImageRef.None
}
🤖 Prompt for AI Agents
In
feature/board/src/main/java/com/umcspot/spot/feature/board/post/posting/PostingViewModel.kt
around lines 45 to 51, the clear() function resets _postType to
PostType.PASS_EXPERIENCE which conflicts with the ViewModel's initial post type
(PostType.FREE_TALK at line ~33); update clear() to set _postType.value =
PostType.FREE_TALK so the default post type is consistent across initialization
and reset.

Comment on lines +53 to +68
fun load(postId: Long) {
_editingPostId.value = postId
viewModelScope.launch {
runCatching {
postRepository.getPostDetail(postId).getOrThrow()
}.onSuccess { res ->
Log.d("PostingViewModel", "load prevPosting: $res")
_title.value = res.title
_body.value = res.content
_postType.value = res.postType
_image.value = res.imageUrl
}.onFailure { e ->
Log.e("PostingViewModel", "load error", e)
}
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

로드 실패 시 editingPostId가 잘못된 상태로 남습니다.

line 54에서 데이터 로드 성공 여부와 관계없이 _editingPostId를 먼저 설정합니다. 로드가 실패하면 편집 모드로 표시되지만 실제 데이터는 로드되지 않아 상태 불일치가 발생합니다.

🔎 성공 시에만 editingPostId를 설정하도록 수정 제안
 fun load(postId: Long) {
-    _editingPostId.value = postId
     viewModelScope.launch {
         runCatching {
             postRepository.getPostDetail(postId).getOrThrow()
         }.onSuccess { res ->
             Log.d("PostingViewModel", "load prevPosting: $res")
+            _editingPostId.value = postId
             _title.value = res.title
             _body.value = res.content
             _postType.value = res.postType
             _image.value = res.imageUrl
         }.onFailure { e ->
             Log.e("PostingViewModel", "load error", e)
         }
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fun load(postId: Long) {
_editingPostId.value = postId
viewModelScope.launch {
runCatching {
postRepository.getPostDetail(postId).getOrThrow()
}.onSuccess { res ->
Log.d("PostingViewModel", "load prevPosting: $res")
_title.value = res.title
_body.value = res.content
_postType.value = res.postType
_image.value = res.imageUrl
}.onFailure { e ->
Log.e("PostingViewModel", "load error", e)
}
}
}
fun load(postId: Long) {
viewModelScope.launch {
runCatching {
postRepository.getPostDetail(postId).getOrThrow()
}.onSuccess { res ->
Log.d("PostingViewModel", "load prevPosting: $res")
_editingPostId.value = postId
_title.value = res.title
_body.value = res.content
_postType.value = res.postType
_image.value = res.imageUrl
}.onFailure { e ->
Log.e("PostingViewModel", "load error", e)
}
}
}
🤖 Prompt for AI Agents
In
feature/board/src/main/java/com/umcspot/spot/feature/board/post/posting/PostingViewModel.kt
around lines 53-68, _editingPostId is set before the network load which leaves
the ViewModel in "editing" mode on load failure; move the assignment so it only
happens when the load succeeds (set _editingPostId.value = postId inside the
onSuccess block) and ensure onFailure clears or resets _editingPostId (e.g., set
to null or -1) to avoid stale editing state.

Comment on lines +77 to +116
fun submit() {
val title = _title.value.trim()
val body = _body.value.trim()
val type = _postType.value
val image = _image.value
val targetId = _editingPostId.value

viewModelScope.launch {
runCatching {
if (targetId == null) {
// 새 글 생성
postRepository.postPost(
Posting(
title = title,
content = body,
postType = type,
imageFile = image
)
).getOrThrow()
} else {
// 수정
postRepository.editPost(
postId = targetId,
Posting(
title = title,
content = body,
postType = type,
imageFile = image
)
).getOrThrow()
}
}.onSuccess { res ->
Log.d("PostingViewModel", "finish create/edit Post: $res")
_submitState.value = UiState.Success(Unit)
}.onFailure { e ->
Log.e("PostingViewModel", "create/edit error", e)
_submitState.value = UiState.Failure(e.message ?: "error")
}
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

필수 입력값 검증이 누락되었습니다.

trim() 후 title과 body가 비어있는지 검증하지 않아 빈 게시글이 제출될 수 있습니다. 이는 잘못된 데이터가 서버로 전송되거나 예상치 못한 오류를 발생시킬 수 있습니다.

🔎 입력값 검증 추가 제안
 fun submit() {
     val title = _title.value.trim()
     val body = _body.value.trim()
     val type = _postType.value
     val image = _image.value
     val targetId = _editingPostId.value

+    // 입력값 검증
+    if (title.isEmpty() || body.isEmpty()) {
+        _submitState.value = UiState.Failure("제목과 내용을 입력해주세요")
+        return
+    }

     viewModelScope.launch {
         runCatching {
             if (targetId == null) {
                 // 새 글 생성
                 postRepository.postPost(
                     Posting(
                         title = title,
                         content = body,
                         postType = type,
                         imageFile = image
                     )
                 ).getOrThrow()
             } else {
                 // 수정
                 postRepository.editPost(
                     postId = targetId,
                     Posting(
                         title = title,
                         content = body,
                         postType = type,
                         imageFile = image
                     )
                 ).getOrThrow()
             }
         }.onSuccess { res ->
             Log.d("PostingViewModel", "finish create/edit Post: $res")
             _submitState.value = UiState.Success(Unit)
         }.onFailure { e ->
             Log.e("PostingViewModel", "create/edit error", e)
             _submitState.value = UiState.Failure(e.message ?: "error")
         }
     }
 }
🤖 Prompt for AI Agents
In
feature/board/src/main/java/com/umcspot/spot/feature/board/post/posting/PostingViewModel.kt
around lines 77 to 116, add input validation after trimming title and body: if
either title or body is empty, do not proceed with the network call, set
_submitState.value to UiState.Failure with a clear message (e.g., "Title cannot
be empty" or "Body cannot be empty") and return early; keep existing
coroutine/error handling for actual submission unchanged.


if (safeBitmap !== bitmap) safeBitmap.recycle()

return if (count == 0) 1f else sum / count
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

샘플링 실패 시 기본값을 재검토하세요.

count == 0일 때 1f(밝음)를 반환하면 Color.Black 틴트가 적용됩니다. 샘플링 영역이 비어있거나 이미지 크기가 0인 극단적인 경우, Color.White를 기본값으로 하는 것이 더 안전할 수 있습니다(가시성 측면에서).

🤖 Prompt for AI Agents
In
feature/board/src/main/java/com/umcspot/spot/feature/board/post/posting/VerifyImageBrightness.kt
around line 73, the function currently returns 1f when count == 0 which produces
a Color.Black tint; change the default to return 0f (Color.White) when sampling
fails or image area is empty, and optionally add a brief debug/warn log
indicating sampling produced zero samples to aid future debugging.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

♻️ Duplicate comments (3)
feature/board/src/main/java/com/umcspot/spot/feature/board/main/BoardViewmodel.kt (2)

29-30: 미사용 상태 변수 _selectedselected 제거 필요

이전 리뷰에서도 지적되었지만, _selectedselected 프로퍼티는 여전히 선언만 되고 코드 어디에서도 사용되지 않습니다. 불필요한 코드를 제거해주세요.


70-86: 낙관적 업데이트 실패 시 롤백 누락 (재지적)

이전 리뷰에서도 지적되었지만, Line 71에서 UI 상태를 낙관적으로 업데이트한 후 API 호출이 실패하면(Lines 84-86) 이전 상태로 롤백하지 않습니다. 이로 인해 UI가 불일치 상태(새로운 selected 값은 표시되지만 기존 bestBoards 데이터가 유지됨)에 놓일 수 있습니다.

🔎 롤백 로직 추가 제안
 viewModelScope.launch {
+    val previousState = cur
     _uiState.update { it.copy(user = UiState.Success(cur.copy(selected = type))) }

     runCatching { boardRepository.getBestBoard(type).getOrThrow() }
         .onSuccess { newTagBoards ->
             _uiState.update {
                 it.copy(user = UiState.Success(
                     cur.copy(
                         selected = type,
                         bestBoards = newTagBoards
                     )
                 ))
             }
         }
         .onFailure { e ->
             Log.e("BoardViewModel", "selectSort: $e")
+            // 실패 시 이전 상태로 롤백
+            _uiState.update { it.copy(user = UiState.Success(previousState)) }
         }
 }
data/board/src/main/java/com/umcspot/spot/board/repositoryimpl/BoardRepositoryImpl.kt (1)

18-49: 에러 복구 시 더미 데이터 반환은 실제 오류를 숨깁니다.

이전 리뷰에서 지적된 문제가 여전히 모든 조회 메서드에 존재합니다. recoverCatching으로 더미 데이터를 반환하면 호출자가 실제 네트워크 오류를 감지할 수 없어 사용자에게 적절한 에러 메시지를 표시할 수 없습니다.

프로덕션 환경에서는 실제 오류를 전파하고, 개발 환경에서만 더미 데이터를 반환하도록 변경하는 것을 권장합니다.

🔎 조건부 더미 데이터 반환 예시
 override suspend fun getRecentBoard(): Result<RecentPostResultList> =
     runCatching {
         boardService.getRecentBoard().result.toDomainList()
     }.onFailure { e ->
         Log.e("BoardRepository", "getRecentBoard failed", e)
-    }.recoverCatching {
+    }.recoverCatching { e ->
+        if (BuildConfig.DEBUG) {
             recentPostDummies(3)
+        } else {
+            throw e
+        }
     }

동일한 패턴을 getBestBoardgetFilteredPosts에도 적용하세요.

🧹 Nitpick comments (5)
data/board/src/main/java/com/umcspot/spot/board/repositoryimpl/BoardRepositoryImpl.kt (2)

24-24: 더미 데이터 개수가 메서드마다 다릅니다.

recentPostDummies(3)은 3개의 더미 항목을 반환하지만, bestPostDummies()는 기본값인 5개를 사용합니다. 의도적인 차이가 아니라면 일관성을 위해 동일한 개수를 사용하는 것을 권장합니다.

🔎 일관성 개선 제안
-            recentPostDummies(3)
+            recentPostDummies(5)  // 또는 모든 메서드에서 3 사용

51-60: 좋아요 메서드에 에러 로깅이 누락되었습니다.

postPostLikedeletePostLike 메서드는 다른 메서드들과 달리 실패 시 에러 로깅이 없습니다. 일관성과 디버깅을 위해 onFailure 블록을 추가하는 것을 권장합니다.

🔎 에러 로깅 추가 제안
 override suspend fun postPostLike(postId: Long): Result<Unit> =
     runCatching {
         boardService.postPostLike(postId)
+    }.onFailure { e ->
+        Log.e("BoardRepository", "postPostLike failed for postId=$postId", e)
     }

 override suspend fun deletePostLike(postId: Long): Result<Unit> =
     runCatching {
         boardService.deletePostLike(postId)
+    }.onFailure { e ->
+        Log.e("BoardRepository", "deletePostLike failed for postId=$postId", e)
     }
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/DeleteModal.kt (3)

36-41: 코드 스타일 일관성 개선 필요

파라미터 선언에서 콜론 주변 공백이 일관되지 않습니다. Kotlin 코딩 컨벤션에 따르면 콜론 앞에는 공백이 없고, 뒤에만 공백이 있어야 합니다.

🔎 제안된 수정
 @Composable
 fun DeleteModal(
-    modalTitle : String,
-    modalDes : String,
-    okButtonText : String,
+    modalTitle: String,
+    modalDes: String,
+    okButtonText: String,
     modifier: Modifier = Modifier,
     onClick: () -> Unit = {},
-    onDismiss:() -> Unit= {}
+    onDismiss: () -> Unit = {}
 ) {

99-108: 불필요한 후행 쉼표 제거

마지막 파라미터 뒤의 후행 쉼표는 제거하는 것이 좋습니다.

🔎 제안된 수정
 TextButton(
     modifier = Modifier
         .width(screenWidthDp(156.dp))
         .height(screenHeightDp(39.dp)),
     text = okButtonText,
     style = SpotTheme.typography.h5,
     onClick = onClick,
     shape = SpotShapes.Soft,
-    state = TextButtonState.G500State,
+    state = TextButtonState.G500State
 )

117-119: 코드 스타일 일관성 개선 필요

파라미터 선언에서 콜론 주변 공백이 일관되지 않습니다. DeleteModal과 동일한 스타일 문제입니다.

🔎 제안된 수정
 fun DeleteDialog(
     visible: Boolean,
-    modalTitle : String,
-    modalDes : String,
-    okButtonText : String,
+    modalTitle: String,
+    modalDes: String,
+    okButtonText: String,
     onClick: () -> Unit,
     onDismiss: () -> Unit,
 ) {
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7b843c8 and a72102d.

📒 Files selected for processing (7)
  • core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/DeleteModal.kt
  • core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/PostItem.kt
  • core/model/src/main/java/com/umcspot/spot/model/ImageRef.kt
  • data/board/src/main/java/com/umcspot/spot/board/repositoryimpl/BoardRepositoryImpl.kt
  • data/post/src/main/java/com/umcspot/spot/post/datasource/PostDataSource.kt
  • domain/post/src/main/java/com/umcspot/spot/post/model/posting/PostingResult.kt
  • feature/board/src/main/java/com/umcspot/spot/feature/board/main/BoardViewmodel.kt
🚧 Files skipped from review as they are similar to previous changes (3)
  • data/post/src/main/java/com/umcspot/spot/post/datasource/PostDataSource.kt
  • domain/post/src/main/java/com/umcspot/spot/post/model/posting/PostingResult.kt
  • core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/PostItem.kt
🧰 Additional context used
🧬 Code graph analysis (1)
feature/board/src/main/java/com/umcspot/spot/feature/board/main/BoardViewmodel.kt (3)
feature/board/src/main/java/com/umcspot/spot/feature/board/post/content/PostViewmodel.kt (1)
  • load (40-56)
feature/board/src/main/java/com/umcspot/spot/feature/board/post/posting/PostingViewModel.kt (1)
  • load (53-68)
feature/board/src/main/java/com/umcspot/spot/feature/board/boardList/BoardListViewmodel.kt (1)
  • load (50-70)
🔇 Additional comments (5)
feature/board/src/main/java/com/umcspot/spot/feature/board/main/BoardViewmodel.kt (3)

32-33: 잘 구현된 상태 관리

_selectedPostselectedPostsetPostInfo 함수에서 적절히 사용되고 있습니다.


35-61: 효율적인 데이터 로딩 구현

async/await를 사용한 병렬 데이터 페칭과 적절한 에러 핸들링이 잘 구현되어 있습니다. 파라미터 이름도 sortBy로 변경되어 가독성이 향상되었습니다.


90-92: 올바른 상태 업데이트 함수

setPostInfo 함수가 선택된 게시글 정보를 적절히 업데이트하고 있습니다.

core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/DeleteModal.kt (2)

124-132: 이전 리뷰 이슈가 해결되었습니다

이전 리뷰에서 지적된 Modifier.fillMaxWidth() 충돌 문제가 해결되었습니다. 현재 코드는 DeleteModal에 modifier를 전달하지 않아 DeleteModal 내부의 고정 너비(screenWidthDp(326.dp))가 올바르게 적용됩니다.


135-148: LGTM!

Preview 구현이 적절합니다. 명확한 예시를 제공하며 실제 사용 케이스를 잘 보여줍니다.

Comment on lines +65 to +69
Image(
painter = painterResource(R.drawable.dismiss),
contentDescription = null,
modifier = Modifier.clickable { onDismiss() }
)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

접근성 개선: contentDescription 추가 필요

닫기 버튼으로 사용되는 Image에 contentDescription이 null로 설정되어 있습니다. 스크린 리더 사용자를 위해 적절한 설명을 제공해야 합니다.

🔎 제안된 수정
 Image(
     painter = painterResource(R.drawable.dismiss),
-    contentDescription = null,
+    contentDescription = "닫기",
     modifier = Modifier.clickable { onDismiss() }
 )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Image(
painter = painterResource(R.drawable.dismiss),
contentDescription = null,
modifier = Modifier.clickable { onDismiss() }
)
Image(
painter = painterResource(R.drawable.dismiss),
contentDescription = "닫기",
modifier = Modifier.clickable { onDismiss() }
)
🤖 Prompt for AI Agents
In
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/DeleteModal.kt
around lines 65-69, the Image used as the close/dismiss button has
contentDescription = null; change this to provide an accessible, localized
description (e.g., contentDescription = stringResource(R.string.close) or pass a
contentDescription parameter into the composable) so screen readers announce its
purpose; update strings.xml with an appropriate key like "close" (localized) if
needed and use that stringResource here.

Comment on lines +72 to +77
Image(
painter = painterResource(R.drawable.delete),
contentDescription = null,
modifier = Modifier
.size(screenWidthDp(33.dp))
)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

접근성 개선: contentDescription 추가 필요

삭제 아이콘 Image에 contentDescription이 null로 설정되어 있습니다. 모달의 의미를 전달하는 중요한 시각적 요소이므로 적절한 설명을 제공해야 합니다.

🔎 제안된 수정
 Image(
     painter = painterResource(R.drawable.delete),
-    contentDescription = null,
+    contentDescription = "삭제 아이콘",
     modifier = Modifier
         .size(screenWidthDp(33.dp))
 )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Image(
painter = painterResource(R.drawable.delete),
contentDescription = null,
modifier = Modifier
.size(screenWidthDp(33.dp))
)
Image(
painter = painterResource(R.drawable.delete),
contentDescription = "삭제 아이콘",
modifier = Modifier
.size(screenWidthDp(33.dp))
)
🤖 Prompt for AI Agents
In
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/DeleteModal.kt
around lines 72 to 77, the Image for the delete icon sets contentDescription =
null which removes screen-reader context; replace the null with a meaningful,
localized description (e.g. contentDescription =
stringResource(R.string.delete_icon_description)), add the corresponding entry
in res/values/strings.xml (and translations as needed), and ensure the
description conveys the icon's purpose in the modal (e.g. "Delete" or "삭제") so
TalkBack users understand the action.

Comment on lines +11 to 19
fun String?.toImageRef(): ImageRef {
val s = this?.trim()
return when {
s.isNullOrEmpty() -> ImageRef.None
s.equals("null", ignoreCase = true) -> ImageRef.None
s.startsWith("http", ignoreCase = true) -> ImageRef.Url(s)
else -> ImageRef.Name(s) // 리소스 네임일 수도 있음
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

LocalUri 변환 로직이 누락되었습니다.

현재 toImageRef() 함수는 ImageRef.LocalUri 인스턴스를 생성하는 경로가 없습니다. Android 로컬 URI(content://, file://, android.resource:// 등)가 이 함수에 전달되면 ImageRef.Name으로 잘못 분류됩니다.

LocalUri 변형이 sealed interface에 존재하지만 이 변환 함수를 통해 도달할 수 없어, 이미지 처리 로직에서 오류를 유발할 수 있습니다.

🔎 로컬 URI 처리를 추가하는 제안
 fun String?.toImageRef(): ImageRef {
     val s = this?.trim()
     return when {
         s.isNullOrEmpty() -> ImageRef.None
         s.equals("null", ignoreCase = true) -> ImageRef.None
         s.startsWith("http", ignoreCase = true) -> ImageRef.Url(s)
+        s.startsWith("content://", ignoreCase = false) -> ImageRef.LocalUri(s)
+        s.startsWith("file://", ignoreCase = false) -> ImageRef.LocalUri(s)
+        s.startsWith("android.resource://", ignoreCase = false) -> ImageRef.LocalUri(s)
         else -> ImageRef.Name(s) // 리소스 네임일 수도 있음
     }
 }
🤖 Prompt for AI Agents
In core/model/src/main/java/com/umcspot/spot/model/ImageRef.kt around lines 11
to 19, the toImageRef() function never produces an ImageRef.LocalUri so Android
local URIs like content://, file:// or android.resource:// are misclassified as
Name; update the when checks to detect local URI prefixes (case-insensitive)
before falling back to Name and return ImageRef.LocalUri(s) for strings that
start with "content://", "file://", or "android.resource://"; keep existing
trimming/null/"null" checks and the http->Url branch intact.

runCatching {
val res = boardService.getLabeledBoardInfo()
res.result.toDomainList()
boardService.getFilteredPosts(cursor = cursor, postType = postType, size = size ).result.toDomainList()
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

불필요한 공백을 제거하세요.

size = size ) 부분에 닫는 괄호 앞에 불필요한 공백이 있습니다.

🔎 수정 제안
-            boardService.getFilteredPosts(cursor = cursor, postType = postType, size = size ).result.toDomainList()
+            boardService.getFilteredPosts(cursor = cursor, postType = postType, size = size).result.toDomainList()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
boardService.getFilteredPosts(cursor = cursor, postType = postType, size = size ).result.toDomainList()
boardService.getFilteredPosts(cursor = cursor, postType = postType, size = size).result.toDomainList()
🤖 Prompt for AI Agents
In
data/board/src/main/java/com/umcspot/spot/board/repositoryimpl/BoardRepositoryImpl.kt
around line 44, there is an extra space before the closing parenthesis in the
call `size = size )`; remove the stray space so the call reads `size = size)`
(or reformat the argument list) to eliminate the unnecessary whitespace.

@starshape7 starshape7 changed the title Feat/#11 built inboard 구현 Feat/#11 bulletinboard 구현 Dec 30, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT/#11] BulletinBoard 구현

2 participants