Skip to content

7주차 미션 안드로이드 2조 이준혁#24

Open
Sadturtleman wants to merge 5 commits into
Konkuk-KUIT:Sadturtlemanfrom
Sadturtleman:develop
Open

7주차 미션 안드로이드 2조 이준혁#24
Sadturtleman wants to merge 5 commits into
Konkuk-KUIT:Sadturtlemanfrom
Sadturtleman:develop

Conversation

@Sadturtleman

@Sadturtleman Sadturtleman commented Nov 13, 2025

Copy link
Copy Markdown

📝 미션
미션: PostViewModel 각 screen에 맞는 ViewModel로 분리

🙋 구현에 대한 설명
수동 주입(App Container) 구현
Repository 패턴 사용
ViewModel 분리
PostEditViewModel
PostDetailViewModel (삭제까지 같이 하시면 됩니다)
PostCreateViewModel
UiState 구현
PostViewModel 삭제
PostListViewModel의 refresh 함수를 메인화면에서 sideEffect로 항상 실행시키기

Summary by CodeRabbit

릴리즈 노트

  • 새로운 기능

    • 게시물 생성 시 이미지 업로드 기능 추가
    • 게시물 수정 시 이미지 업로드 기능 추가
  • 리팩토링

    • 의존성 주입 구조 도입
    • 화면별 독립적 상태 관리 구조로 개편
    • 저장소 계층 아키텍처 재구성

@coderabbitai

coderabbitai Bot commented Nov 13, 2025

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

개요

Android 애플리케이션에 의존성 주입(DI), 저장소 패턴, UI 상태 기반 아키텍처를 도입하면서 ViewModel을 리팩토링합니다. 제네릭 PostViewModel을 특화된 ViewModel들로 분리하고, 네트워크 계층에 이미지 업로드 기능을 추가하며, 콜백 기반 흐름을 상태 기반 렌더링으로 전환합니다.

변경 사항

응집도 / 파일(들) 변경 요약
의존성 주입 및 애플리케이션 초기화
.idea/codeStyles/Project.xml, .idea/codeStyles/codeStyleConfig.xml, app/src/main/AndroidManifest.xml, app/src/main/java/com/example/kuit6_android_api/App.kt, app/src/main/java/com/example/kuit6_android_api/data/di/AppContainer.kt
IDE 코드 스타일 구성을 추가하고, 애플리케이션 초기화 지점에서 AppContainer를 생성하여 PostRepository를 중앙 집중식으로 제공합니다.
저장소 계층
app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt, app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepositoryImpl.kt
PostRepository 인터페이스를 정의하고 PostRepositoryImpl에서 구현하여, API 호출을 캡슐화하고 에러 처리를 위임합니다.
네트워크 계층
app/src/main/java/com/example/kuit6_android_api/data/api/ApiService.kt, app/src/main/java/com/example/kuit6_android_api/data/api/RetrofitClient.kt
Multipart 이미지 업로드 엔드포인트를 추가하고 기본 URL을 하드코딩된 값으로 변경합니다.
UI 상태 클래스
app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostListUiState.kt, app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostDetailUiState.kt, app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostCreateUiState.kt, app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostEditUiState.kt, app/src/main/java/com/example/kuit6_android_api/ui/post/state/DeletePostUiState.kt, app/src/main/java/com/example/kuit6_android_api/ui/post/state/ImageUiState.kt, app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostUIState.kt
로딩, 성공, 에러 상태를 나타내는 Sealed 클래스들을 도입하여 상태 기반 UI 렌더링을 가능하게 합니다.
ViewModel 리팩토링
app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt, app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostDetailViewModel.kt, app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt, app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt, app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModelFactory.kt
제네릭 PostViewModel을 제거하고 각 화면별 특화된 ViewModel들을 도입하며, 팩토리를 통한 의존성 주입을 구현합니다.
ViewModel 제거
app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModel.kt
제네릭 PostViewModel을 완전히 삭제합니다.
화면 업데이트
app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt, app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt, app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostCreateScreen.kt, app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostEditScreen.kt
콜백 기반 상태 관리에서 StateFlow 기반 UI 상태 렌더링으로 전환하고, SnackbarHostState 의존성을 제거합니다.
네비게이션 및 메인
app/src/main/java/com/example/kuit6_android_api/ui/navigation/NavGraph.kt, app/src/main/java/com/example/kuit6_android_api/MainActivity.kt
NavGraph에서 SnackbarHostState 파라미터를 제거하고 화면별 ViewModel 팩토리 주입을 추가합니다.
유틸리티
app/src/main/java/com/example/kuit6_android_api/ui/post/util/UrlUtil.kt
URI를 File로 변환하고 파일명을 추출하는 유틸리티 함수를 제공합니다.

시퀀스 다이어그램

sequenceDiagram
    participant App as App (초기화)
    participant AC as AppContainer
    participant VM as ViewModel
    participant Repo as PostRepository
    participant API as ApiService
    participant UI as Screen (UI)

    App->>AC: AppContainer() 생성
    AC->>Repo: PostRepository 인스턴스 생성
    
    UI->>VM: viewModel(factory) 호출
    VM->>AC: container.postRepository 접근
    VM->>Repo: 저장소 메서드 호출
    Repo->>API: API 엔드포인트 호출
    API-->>Repo: 응답 반환
    Repo-->>VM: Result<T> 반환
    VM->>VM: uiState 업데이트 (Loading/Success/Error)
    VM-->>UI: StateFlow 구독 업데이트
    UI->>UI: 상태 기반 렌더링
Loading

예상 코드 리뷰 소요 시간

🎯 4 (복잡함) | ⏱️ ~60분

세부 주의 사항:

  • ViewModel 리팩토링: PostViewModel 제거 및 4개의 특화된 ViewModel 도입으로 인한 상태 관리 변경 검토 필요
  • 화면 컴포저블 수정: 4개의 화면 파일(PostListScreen, PostDetailScreen, PostCreateScreen, PostEditScreen)에서 콜백 기반에서 StateFlow 기반으로의 전환 로직 확인
  • 이미지 업로드 흐름: 새로운 MultipartBody.Part 처리, UriUtil 사용, 상태 관리(ImageUiState)의 일관성 검증
  • 의존성 주입: AppContainer 및 PostViewModelFactory의 의존성 초기화 순서와 App.kt의 onCreate 호출 시점 확인
  • 네트워크 계층: RetrofitClient의 하드코딩된 Base URL "http://3.34.136.227:8080" 사용 의도 및 영향 범위 확인

관련 PR

  • PR #17: 동일한 ApiService 이미지 업로드 및 UriUtil 유틸리티 변경으로 코드 레벨 관련성 있음
  • PR #16: ApiService 엔드포인트, RetrofitClient, NavGraph 및 화면/ViewModel 구성 변경을 공유하여 직접 관련
  • PR #19: 네트워크 계층(ApiService.uploadImage, RetrofitClient) 수정으로 관련성 있음

🐰 저장소 패턴으로 떡 주고,
상태로 UI 구조 잡고,
팩토리로 의존성 심고,
ViewModel 들판 펼쳐 심으니,
아키텍처 정원 만발이네! 🌸

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Title check ⚠️ Warning PR 제목이 실제 변경 사항과 맞지 않습니다. 제목은 개인 이름(이준혁)만 포함하고 있으며, 실제 미션 내용인 PostViewModel 분리, 의존성 주입 구현, Repository 패턴 적용 등을 전혀 설명하지 않고 있습니다. PR 제목을 "PostViewModel 분리 및 의존성 주입 구현 (PostListViewModel, PostDetailViewModel, PostCreateViewModel, PostEditViewModel)" 또는 유사한 형태로 변경하여 주요 변경 사항을 명확히 하시기 바랍니다.
Docstring Coverage ⚠️ Warning Docstring coverage is 2.94% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

Tip

📝 Customizable high-level summaries are now available in beta!

You can now customize how CodeRabbit generates the high-level summary in your pull requests — including its content, structure, tone, and formatting.

  • Provide your own instructions using the high_level_summary_instructions setting.
  • Format the summary however you like (bullet lists, tables, multi-section layouts, contributor stats, etc.).
  • Use high_level_summary_in_walkthrough to move the summary from the description to the walkthrough section.

Example instruction:

"Divide the high-level summary into five sections:

  1. 📝 Description — Summarize the main change in 50–60 words, explaining what was done.
  2. 📓 References — List relevant issues, discussions, documentation, or related PRs.
  3. 📦 Dependencies & Requirements — Mention any new/updated dependencies, environment variable changes, or configuration updates.
  4. 📊 Contributor Summary — Include a Markdown table showing contributions:
    | Contributor | Lines Added | Lines Removed | Files Changed |
  5. ✔️ Additional Notes — Add any extra reviewer context.
    Keep each section concise (under 200 words) and use bullet or numbered lists for clarity."

Note: This feature is currently in beta for Pro-tier users, and pricing will be announced later.


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.

@alswlekk

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

✨ Finishing touches
🧪 Generate unit tests (beta)

  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Nov 14, 2025

Copy link
Copy Markdown

@alswlekk: I'll review the changes in this pull request right away.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🧹 Nitpick comments (21)
app/src/main/java/com/example/kuit6_android_api/data/api/RetrofitClient.kt (1)

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

BuildConfig를 더 이상 사용하지 않으므로 import를 제거해주세요.

-import com.example.kuit6_android_api.BuildConfig
app/src/main/java/com/example/kuit6_android_api/data/di/AppContainer.kt (1)

8-16: 수동 DI Container 구현이 잘 되었습니다

Lazy 초기화를 사용한 간단하고 효과적인 DI 구조입니다. apiService를 private으로 캡슐화하고 postRepository만 public으로 노출한 것이 좋습니다.

참고: 테스트 용이성을 높이려면 AppContainer를 인터페이스로 만들 수 있습니다:

interface AppContainer {
    val postRepository: PostRepository
}

class AppContainerImpl : AppContainer {
    private val apiService: ApiService by lazy {
        RetrofitClient.apiService
    }

    override val postRepository: PostRepository by lazy {
        PostRepositoryImpl(apiService)
    }
}

이렇게 하면 테스트에서 mock container를 주입하기 쉬워집니다. 하지만 현재 구조도 충분히 동작합니다.

app/src/main/java/com/example/kuit6_android_api/ui/post/state/ImageUiState.kt (1)

7-9: 이미지 업로드 응답 타입 개선 권장

검증 결과 API가 의도적으로 Map<String, String>을 반환하고 있습니다. UI 레이어에서는 imgUrl["imageUrl"]로 접근하고 있으며, 현재 동작은 정상입니다.

다만 다음과 같은 이유로 타입 안전성과 명확성 개선을 권장합니다:

  • Map 사용으로 의도된 키 구조가 명확하지 않음
  • 타입 안전성 부족으로 런타임 오류 가능성
  • 다른 응답 DTO들(PostResponse, AuthorResponse)은 data class 사용

다음과 같이 리팩토링하면 코드 품질 향상:

data class ImageUploadResponse(
    @SerialName("imageUrl") val imageUrl: String
)

data class Success(
    val imageUploadResponse: ImageUploadResponse
) : ImageUiState()

변경 필요 위치:

  • PostRepository.kt - 반환 타입을 Result<ImageUploadResponse>로 변경
  • PostRepositoryImpl.kt - uploadImage 함수 구현 수정
  • PostCreateScreen.kt (라인 99), PostEditScreen.kt (라인 103) - imgUrl 접근 로직 수정
app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt (1)

30-34: 에러 메시지를 개선하세요.

"error" 라는 fallback 메시지는 사용자에게 유용한 정보를 제공하지 못합니다. 좀 더 구체적인 에러 메시지나 문자열 리소스를 사용하는 것이 좋습니다.

다음과 같이 개선할 수 있습니다:

                 .onFailure { error ->
                     _uiState.value = PostListUiState.Error(
-                        message = error.message ?: "error"
+                        message = error.message ?: "게시글을 불러오는 중 오류가 발생했습니다"
                     )
                 }

또는 string resource를 사용하는 방법도 고려해보세요.

app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt (3)

51-54: 로딩 인디케이터를 중앙에 배치하세요.

CircularProgressIndicator가 화면 중앙에 배치되지 않아 UX가 좋지 않습니다.

다음과 같이 개선하세요:

         when (uiState){
             is PostListUiState.Loading -> {
-                CircularProgressIndicator()
+                Box(
+                    modifier = Modifier
+                        .fillMaxSize()
+                        .padding(paddingValues),
+                    contentAlignment = Alignment.Center
+                ) {
+                    CircularProgressIndicator()
+                }
             }

55-70: 타입 캐스팅을 개선하세요.

Line 64의 (uiState as PostListUiState.Success)는 안전하지 않은 캐스팅입니다. smart cast를 활용하는 것이 더 안전합니다.

다음과 같이 개선하세요:

             is PostListUiState.Success -> {
+                val successState = uiState as PostListUiState.Success
                 LazyColumn(
                     modifier = Modifier
                         .fillMaxSize()
                         .padding(paddingValues)
                         .background(MaterialTheme.colorScheme.background),
                     contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
                     verticalArrangement = Arrangement.spacedBy(16.dp)
                 ) {
-                    items((uiState as PostListUiState.Success).posts) { post ->
+                    items(successState.posts) { post ->
                         PostItem(
                             post = post,
                             onClick = { onPostClick(post.id) }
                         )
                     }
                 }
             }

72-74: 문자열 리소스를 사용하세요.

하드코딩된 에러 메시지는 다국어 지원과 유지보수에 좋지 않습니다. 문자열 리소스를 사용하는 것이 좋습니다.

strings.xml에 문자열을 정의하고 stringResource()를 사용하세요:

Text(stringResource(R.string.loading_failed))
app/src/main/java/com/example/kuit6_android_api/ui/post/util/UrlUtil.kt (3)

10-26: 로깅과 스레드 안전성을 개선하세요.

두 가지 개선이 필요합니다:

  1. Line 23의 printStackTrace()는 프로덕션 코드에 적합하지 않습니다. 적절한 로깅 프레임워크(Timber, Log 등)를 사용하세요.
  2. 파일 I/O 작업이 메인 스레드에서 호출될 경우 ANR을 유발할 수 있습니다. 백그라운드 스레드에서 호출해야 함을 문서화하거나, 함수 내부에서 처리하는 것을 고려하세요.

로깅 개선:

         } catch (e: Exception) {
-            e.printStackTrace()
+            Log.e("UriUtil", "Failed to convert URI to file", e)
             null
         }

KDoc 추가:

/**
 * URI를 File로 변환합니다.
 * 
 * @param context Android Context
 * @param uri 변환할 URI
 * @return 변환된 File 또는 실패 시 null
 * @note 이 함수는 I/O 작업을 수행하므로 백그라운드 스레드에서 호출해야 합니다.
 */
fun uriToFile(context: Context, uri: Uri): File? {

28-40: 쿼리 효율성을 개선하세요.

Line 30에서 contentResolver.query()를 모든 파라미터를 null로 호출하면 모든 컬럼을 반환합니다. 필요한 컬럼만 지정하면 더 효율적입니다.

     fun getFileName(context: Context, uri: Uri): String? {
         var fileName: String? = null
-        val cursor = context.contentResolver.query(uri, null, null, null, null)
+        val cursor = context.contentResolver.query(
+            uri, 
+            arrayOf(OpenableColumns.DISPLAY_NAME), 
+            null, 
+            null, 
+            null
+        )
         cursor?.use {
             if (it.moveToFirst()) {
                 val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
                 if (nameIndex != -1) {
                     fileName = it.getString(nameIndex)
                 }
             }
         }
         return fileName
     }

10-26: 임시 파일 정리 전략을 고려하세요.

context.cacheDir에 생성된 임시 파일들이 누적될 수 있습니다. Android가 자동으로 캐시를 정리하긴 하지만, 명시적인 정리 로직을 추가하는 것이 좋습니다.

다음과 같은 방법을 고려해보세요:

  • 이미지 업로드 성공 후 임시 파일 삭제
  • 앱 시작 시 오래된 캐시 파일 정리
  • WorkManager를 사용한 주기적 캐시 정리

예시:

// 업로드 성공 후
tempFile.delete()

// 또는 앱 시작 시
fun cleanOldCacheFiles(context: Context) {
    context.cacheDir.listFiles()?.forEach { file ->
        if (file.name.startsWith("image_") && 
            System.currentTimeMillis() - file.lastModified() > 24 * 60 * 60 * 1000) {
            file.delete()
        }
    }
}
app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepositoryImpl.kt (2)

12-24: 에러 로그에 throwable 전체를 넘기는 것이 디버깅에 더 유리합니다.

현재 onFailure에서 error.message.toString()만 로그로 남겨 스택 트레이스를 잃습니다. Log.e("PostRepository", "...", error)처럼 throwable을 함께 넘기면 원인 추적이 쉬워집니다. 이 패턴은 다른 메서드의 onFailure에도 동일하게 적용하면 좋겠습니다.

-        }.onFailure { error->
-            Log.e("PostRepository",error.message.toString())
-        }
+        }.onFailure { throwable ->
+            Log.e("PostRepository", "getPosts 실패", throwable)
+        }

12-96: CRUD 메서드의 runCatching + 응답 검사 로직을 공통 헬퍼로 추출하면 중복을 줄일 수 있습니다.

모든 메서드가 동일한 패턴(호출 → success && data != null 검사 → 예외 throw → 로그)을 반복합니다. 제네릭 헬퍼 함수를 하나 두고 apiService 호출과 기본 에러 메시지만 넘기도록 정리하면, 이후 응답 스키마 변경 시 수정 범위를 줄일 수 있습니다.

app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt (1)

7-16: 인터페이스는 명확하지만 uploadImage 응답을 전용 모델로 두면 더 타입 안전합니다.

uploadImageResult<Map<String, String>>를 반환하면 키 이름 오타나 스키마 변경 시 문제가 런타임까지 미뤄질 수 있습니다. 서버 응답 스키마가 고정돼 있다면 ImageUploadResult 같은 데이터 클래스를 정의해 반환 타입을 구체화하면 가독성과 안전성이 좋아집니다.

app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostDetailViewModel.kt (1)

16-21: 삭제 관련 UI 상태의 초기값을 Loading 대신 “아직 작업 없음”을 나타내도록 바꾸는 것을 고려해 보세요.

_deleteUiState가 초기부터 DeletePostUiState.Loading으로 설정돼 있어, 화면 진입만 해도 삭제와 관련된 로딩 UI가 노출될 수 있습니다. 삭제 요청 전에는 “Idle/None” 성격의 상태를 두고, 실제 deletePost 호출 시에만 Loading으로 전환하면 UI 의미가 더 명확해집니다.

Also applies to: 38-52

app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt (1)

15-77: 편집/이미지 업로드 로직은 명확하지만 다른 ViewModel과의 중복을 줄일 수 있습니다.

uploadImage·clearUploadedImageUrl 구현이 PostCreateViewModel과 거의 동일해 보여, 향후에는 공통 헬퍼 함수나 베이스 ViewModel로 공유하면 중복 제거와 변경 용이성이 좋아질 것 같습니다. 현재 상태도 동작 상 문제는 없어 보입니다.

app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostCreateScreen.kt (1)

70-72: Post 생성 결과를 UiState에 맞춰 처리하고 onPostCreated 호출 위치를 재검토하는 것이 좋습니다

현재 uiStatecollectAsState()로 구독하지만(Line [70]) 실제로는 어디에서도 사용하지 않고, 버튼 클릭 시 viewModel.createPost(...) 직후 바로 onPostCreated()를 호출하고 있습니다(Line [288]~[295]). 이 구조에서는 네트워크 오류나 서버 에러가 발생해도 화면이 곧바로 닫혀 생성 실패를 사용자가 알아채기 어렵고, PostCreateUiState 설계 의도와도 잘 맞지 않을 수 있습니다.

가능하다면 PostCreateUiState에 성공/실패 상태를 두고, LaunchedEffect(uiState)에서 성공 상태일 때만 onPostCreated()를 호출하며, 에러 상태에서는 Toast 또는 에러 UI를 노출하는 쪽으로 옮기는 것을 권장합니다. 해당 리팩터링 전에 일시적으로라도 uiState 변수를 사용하지 않는다면 제거해서 불필요한 경고를 줄일 수 있습니다.

Also applies to: 285-295

app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt (4)

65-67: LaunchedEffect 키를 postId로 두면 재사용 시 더 안전합니다

현재 상세 로딩은 LaunchedEffect(Unit) 안에서 viewModel.loadDetail(postId)를 한 번만 호출합니다(Line [65]~[67]). 만약 동일 Composable 인스턴스를 재사용하면서 postId만 바뀌는 네비게이션 패턴을 사용한다면, 새 ID로 이동해도 로딩이 다시 수행되지 않을 수 있습니다. 아래처럼 키를 postId로 두는 편이 안전합니다.

-    LaunchedEffect(Unit) {
-        viewModel.loadDetail(postId)
-    }
+    LaunchedEffect(postId) {
+        viewModel.loadDetail(postId)
+    }

103-107: 로딩 상태에서 아무 UI도 표시되지 않아 사용자 경험이 애매할 수 있습니다

when (uiState)PostDetailUiState.Loading 분기가 비어 있어(Line [103]~[107]), 데이터 로딩 중에는 화면이 그냥 비어 보일 가능성이 있습니다. 최소한 중앙에 CircularProgressIndicator나 "로딩 중..." 텍스트 정도를 표시해 주면 현재 상태를 사용자에게 명확히 전달할 수 있습니다.


108-203: Success 분기에서 uiState 캐스팅을 반복하기보다는 지역 변수로 한 번만 꺼내는 편이 좋습니다

PostDetailUiState.Success 분기 안에서 (uiState as PostDetailUiState.Success) 캐스팅을 여러 번 반복해서 사용하고 있습니다(Line [127], [129], [155], [162], [170], [185], [195] 등). 다음처럼 한 번만 꺼낸 뒤 post를 사용하는 쪽이 가독성과 유지보수 측면에서 더 좋습니다.

-            is PostDetailUiState.Success -> {
-                Column(
+            is PostDetailUiState.Success -> {
+                val post = (uiState as PostDetailUiState.Success).post
+
+                Column(
@@
-                            if ((uiState as PostDetailUiState.Success).post.author.profileImageUrl != null) {
+                            if (post.author.profileImageUrl != null) {
@@
-                                    model = (uiState as PostDetailUiState.Success).post.author.profileImageUrl,
+                                    model = post.author.profileImageUrl,
@@
-                                    text = (uiState as PostDetailUiState.Success).post.author.username,
+                                    text = post.author.username,
@@
-                                    text = formatDateTime((uiState as PostDetailUiState.Success).post.createdAt),
+                                    text = formatDateTime(post.createdAt),
@@
-                    (uiState as PostDetailUiState.Success).post.imageUrl?.let { imageUrl ->
+                    post.imageUrl?.let { imageUrl ->
@@
-                            text = (uiState as PostDetailUiState.Success).post.title,
+                            text = post.title,
@@
-                            text = (uiState as PostDetailUiState.Success).post.content,
+                            text = post.content,

이렇게 정리하면 캐스팅 중복이 사라지고, 후속 필드 추가/변경 시에도 수정 범위가 줄어듭니다.


69-80: 삭제 실패 시 사용자에게도 피드백을 주는 것이 좋습니다

삭제 결과를 처리하는 LaunchedEffect(deleteUiState)에서 DeletePostUiState.Error인 경우에는 다이얼로그를 닫고 Log.e만 호출합니다(Line [75]~[78]). 이 경우 사용자 입장에서는 삭제 시도를 했지만 아무 변화도 없는 것처럼 보일 수 있어 혼란스럽습니다.

삭제 실패 시에는 Toast나 Snackbar 등으로 "삭제에 실패했습니다" 같은 메시지를 보여주거나, 최소한 다이얼로그를 유지한 채 에러 메시지를 표시하는 UX를 고려해 보시면 좋겠습니다.

Also applies to: 214-231

app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostEditScreen.kt (1)

70-72: 수정 완료 네비게이션을 edit 결과 UiState와 연동하는 것을 고려해 보세요

uiStateimgUiStatecollectAsState()로 구독하고 있지만(Line [70][72]), 수정 버튼 클릭 시에는 viewModel.editPost(postId, request) 호출 직후 곧바로 onPostUpdated()를 실행합니다(Line [245][253]). 이 경우 실제 수정 API가 실패해도 곧바로 뒤로 이동하거나 리스트를 갱신하는 등 "성공한 것처럼" 보이는 UX가 될 수 있습니다.

PostEditUiState에 성공/에러 상태가 정의되어 있다면, 버튼에서는 editPost만 호출하고, 별도의 LaunchedEffect(uiState)에서 성공 상태일 때만 onPostUpdated()를 실행하며 에러 상태에는 Toast/에러 문구를 표시하는 구조로 옮기는 것을 추천합니다. 이렇게 하면 네트워크/서버 오류에 대한 사용자 피드백과 화면 전환 타이밍을 더 명확하게 제어할 수 있습니다.

Also applies to: 242-253

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 731bf94 and b214183.

📒 Files selected for processing (29)
  • .idea/codeStyles/Project.xml (1 hunks)
  • .idea/codeStyles/codeStyleConfig.xml (1 hunks)
  • app/src/main/AndroidManifest.xml (1 hunks)
  • app/src/main/java/com/example/kuit6_android_api/App.kt (1 hunks)
  • app/src/main/java/com/example/kuit6_android_api/MainActivity.kt (0 hunks)
  • app/src/main/java/com/example/kuit6_android_api/data/api/ApiService.kt (2 hunks)
  • app/src/main/java/com/example/kuit6_android_api/data/api/RetrofitClient.kt (1 hunks)
  • app/src/main/java/com/example/kuit6_android_api/data/di/AppContainer.kt (1 hunks)
  • app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt (1 hunks)
  • app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepositoryImpl.kt (1 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/navigation/NavGraph.kt (6 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostCreateScreen.kt (6 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt (4 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostEditScreen.kt (3 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt (3 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/state/DeletePostUiState.kt (1 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/state/ImageUiState.kt (1 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostCreateUiState.kt (1 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostDetailUiState.kt (1 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostEditUiState.kt (1 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostListUiState.kt (1 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostUIState.kt (1 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/util/UrlUtil.kt (1 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt (1 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostDetailViewModel.kt (1 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt (1 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt (1 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModel.kt (0 hunks)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModelFactory.kt (1 hunks)
💤 Files with no reviewable changes (2)
  • app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModel.kt
  • app/src/main/java/com/example/kuit6_android_api/MainActivity.kt
🔇 Additional comments (12)
.idea/codeStyles/codeStyleConfig.xml (1)

1-5: IDE 설정 파일은 일반적으로 VCS에서 제외됩니다.

.idea 디렉토리의 파일들은 로컬 IDE 구성이므로 많은 Android 프로젝트에서 .gitignore에 추가됩니다. 이 파일들이 리포지토리에 커밋되어야 하는지 프로젝트 정책을 확인해주세요. 팀 전체가 동일한 코드 스타일을 유지해야 한다면, 이 설정들을 공유하는 것이 도움이 될 수 있습니다.

.idea/codeStyles/Project.xml (1)

1-157: 코드 스타일 구성이 Android 모범 사례를 따릅니다.

IDE 코드 스타일 설정이 잘 구성되어 있습니다:

  • Java import 순서(라인 6-34): 표준 Android 규칙에 따라 패키지별로 정리됨
  • Kotlin: KOTLIN_OFFICIAL 기본값 사용 (표준)
  • XML 정렬(라인 41-153): android 네임스페이스 속성 우선 순위 지정, Android 레이아웃 파일의 모범 사례 준수

이 파일들이 팀 전체의 일관된 코드 스타일을 강제하려는 의도라면 리포지토리에 포함하는 것이 적절할 수 있습니다. 다만 .idea 디렉토리 포함 정책을 확인해주세요.

app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostEditUiState.kt (1)

1-15: UI State 구조 잘 구현되었습니다

표준적인 sealed class 패턴으로 Loading, Success, Error 상태를 명확하게 표현하고 있습니다.

참고: 여러 UI State 클래스들(PostEditUiState, PostCreateUiState, DeletePostUiState)이 유사한 구조를 가지고 있는데, 필요시 제네릭 UiState를 고려할 수 있습니다. 하지만 현재처럼 각 화면별로 명시적인 타입을 사용하는 것도 타입 안정성과 가독성 측면에서 좋은 접근입니다.

app/src/main/java/com/example/kuit6_android_api/data/api/ApiService.kt (1)

43-47: 이미지 업로드 엔드포인트 구현 확인

Multipart 파일 업로드가 올바르게 구성되었습니다. 백엔드 API 명세를 확인하여 다음 사항이 일치하는지 검증해주세요:

  • 엔드포인트 경로: /api/images/upload
  • 파라미터 이름: file (현재 @part에 명시적 이름이 없어 변수명 "file"을 사용)
  • 응답 형식: Map<String, String> (어떤 키들이 포함되는지 문서화 권장)

필요시 @part 어노테이션에 명시적 이름을 지정할 수 있습니다:

@Part("file") file: MultipartBody.Part
app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostCreateUiState.kt (1)

1-16: LGTM!

게시글 생성을 위한 UI State가 명확하게 정의되었습니다. Loading, Success, Error 상태를 적절히 표현하고 있습니다.

app/src/main/AndroidManifest.xml (1)

17-17: Application 클래스 등록 검증 완료

App 클래스가 올바르게 구현되어 있으며, AppContaineronCreate()에서 정상적으로 초기화하고 있습니다. AndroidManifest.xml의 android:name=".App" 등록은 적절합니다.

app/src/main/java/com/example/kuit6_android_api/ui/post/state/DeletePostUiState.kt (1)

6-16: 원본 리뷰 의견이 API 계약과 맞지 않습니다

코드 검증 결과, deletePost API는 실제로 BaseResponse<PostResponse>를 반환하도록 정의되어 있습니다:

  • ApiService.kt: suspend fun deletePost(@Path id: Long): BaseResponse<PostResponse>
  • PostRepository.kt: suspend fun deletePost(postId: Long): Result<PostResponse>
  • PostRepositoryImpl.kt: 동일하게 Result<PostResponse> 반환

따라서 DeletePostUiStateSuccess 상태에서 PostResponse를 저장하는 것은 실제 API 계약과 완벽하게 일치하는 올바른 구현입니다. 변경이 필요 없습니다.

Likely an incorrect or invalid review comment.

app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostDetailUiState.kt (1)

5-15: 잘 구현되었습니다!

UI 상태를 나타내는 sealed class가 올바르게 구현되었으며, 프로젝트의 다른 UI 상태 클래스들과 일관된 패턴을 따르고 있습니다.

app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostListUiState.kt (1)

5-15: 잘 구현되었습니다!

PostList UI 상태가 올바르게 정의되었으며, 프로젝트의 UI 상태 패턴과 일관성을 유지하고 있습니다.

app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt (1)

18-20: 초기화 로직이 올바르게 구현되었습니다.

init 블록에서 loadPosts()를 호출하여 ViewModel 생성 시 데이터를 자동으로 로드하는 패턴이 적절합니다.

app/src/main/java/com/example/kuit6_android_api/ui/navigation/NavGraph.kt (1)

36-36: ViewModel 팩토리 패턴이 올바르게 적용되었습니다.

각 화면별로 적절한 ViewModel을 팩토리를 통해 주입하고 있으며, 일관된 패턴을 유지하고 있습니다.

Also applies to: 51-55, 70-74, 92-96

app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt (1)

15-65: PostCreateViewModel의 상태 관리와 책임 분리가 명확합니다.

생성 요청과 이미지 업로드를 각각 별도의 StateFlow로 분리하고, ResultonSuccess/onFailure로 UI 상태를 갱신하는 패턴이 깔끔합니다. 현재 구현으로도 읽기 쉽고 유지보수하기 좋아 보입니다.

Comment on lines +6 to +12
class App: Application() {
lateinit var container: AppContainer

override fun onCreate() {
super.onCreate()
container = AppContainer()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

container 프로퍼티의 가시성과 불변성을 개선하세요.

container가 public lateinit var로 선언되어 외부에서 변경 가능합니다. 이는 캡슐화를 깨뜨리고 의도하지 않은 수정을 허용할 수 있습니다.

다음 diff를 적용하여 private으로 변경하거나 읽기 전용으로 노출하세요:

 class App: Application() {
-    lateinit var container: AppContainer
+    private lateinit var container: AppContainer
 
     override fun onCreate() {
         super.onCreate()
         container = AppContainer()
     }
 }

또는 읽기 전용 접근이 필요한 경우:

 class App: Application() {
-    lateinit var container: AppContainer
+    private lateinit var _container: AppContainer
+    val container: AppContainer
+        get() = _container
 
     override fun onCreate() {
         super.onCreate()
-        container = AppContainer()
+        _container = AppContainer()
     }
 }
📝 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
class App: Application() {
lateinit var container: AppContainer
override fun onCreate() {
super.onCreate()
container = AppContainer()
}
class App: Application() {
private lateinit var container: AppContainer
override fun onCreate() {
super.onCreate()
container = AppContainer()
}
}
🤖 Prompt for AI Agents
In app/src/main/java/com/example/kuit6_android_api/App.kt around lines 6 to 12,
the container property is declared as a public lateinit var which allows
external mutation; change it to a private mutable backing property and expose a
public read-only accessor (or make it a private lateinit var and add a public
val getter) so callers can only read the container but not replace it;
alternatively initialize it with a private val via lazy in onCreate and expose a
public val for safe, immutable access.


private val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.baseUrl("http://3.34.136.227:8080")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Base URL을 하드코딩하지 말고 BuildConfig 사용 권장

Base URL을 하드코딩하면 다음과 같은 문제가 발생합니다:

  • 개발/스테이징/프로덕션 환경 전환이 어려움
  • HTTP 사용으로 인한 보안 취약점 (HTTPS 사용 권장)
  • 빌드 설정의 유연성 상실

BuildConfig.BASE_URL로 되돌리고, build.gradle에서 환경별로 다른 URL을 설정하는 것이 좋습니다.

-        .baseUrl("http://3.34.136.227:8080")
+        .baseUrl(BuildConfig.BASE_URL)

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In app/src/main/java/com/example/kuit6_android_api/data/api/RetrofitClient.kt
around line 23, the baseUrl is hardcoded to "http://3.34.136.227:8080"; change
it to use BuildConfig.BASE_URL instead and ensure the URL in BuildConfig is
HTTPS, then add per-build-type/baseFlavor BASE_URL entries in module-level
build.gradle (or build.gradle.kts) so dev/staging/prod can supply different
endpoints; update Retrofit setup to reference BuildConfig.BASE_URL and remove
the hardcoded literal.

Comment on lines +26 to +38
override suspend fun getPostDetail(postId: Long): Result<PostResponse> {
return runCatching {
val response = apiService.getPostsDetail(postId)

if (response.success && response.data != null){
response.data
} else{
throw Exception(response.message ?: "삭제 실패")
}
}.onFailure { error ->
Log.e("PostRepository", error.message.toString())
}
}

Copy link
Copy Markdown

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

에러 메시지가 실제 수행하는 작업과 일치하지 않습니다.

getPostDetail에서는 상세 조회 실패 시 "삭제 실패"를, updatePost·deletePost에서는 모두 "생성 실패"를 사용하고 있어 유지보수 시 혼동을 줄 수 있습니다. 각 메서드의 역할에 맞게 메시지를 구분해 주는 편이 좋겠습니다.

-            } else{
-                throw Exception(response.message ?: "삭제 실패")
+            } else{
+                throw Exception(response.message ?: "게시글 불러오기 실패")
             }
...
-            }else{
-                throw Exception(response.message ?: "생성 실패")
+            }else{
+                throw Exception(response.message ?: "수정 실패")
             }
...
-            }else{
-                throw Exception(response.message ?: "생성 실패")
+            }else{
+                throw Exception(response.message ?: "삭제 실패")
             }

Also applies to: 56-83


🏁 Script executed:

#!/bin/bash
# Find and examine PostRepositoryImpl.kt file
find . -name "PostRepositoryImpl.kt" -type f 2>/dev/null | head -5

Length of output: 87


🏁 Script executed:

#!/bin/bash
# Display the PostRepositoryImpl.kt file content to verify error messages
find . -name "PostRepositoryImpl.kt" -type f -exec cat {} \; 2>/dev/null

Length of output: 3259


에러 메시지를 각 메서드의 실제 작업과 일치시켜 주세요.

getPostDetail, updatePost, deletePost 메서드의 에러 메시지가 실제 수행하는 작업과 맞지 않습니다.

     override suspend fun getPostDetail(postId: Long): Result<PostResponse> {
         return runCatching {
             val response = apiService.getPostsDetail(postId)

             if (response.success && response.data != null){
                 response.data
             } else{
-                throw Exception(response.message ?: "삭제 실패")
+                throw Exception(response.message ?: "게시글 불러오기 실패")
             }
         }.onFailure { error ->
             Log.e("PostRepository", error.message.toString())
         }
     }

     override suspend fun updatePost(
         postId: Long,
         request: PostCreateRequest
     ): Result<PostResponse> {
         return runCatching {
             val response = apiService.updatePost(postId, request)
             if (response.success && response.data != null){
                 response.data
             }else{
-                throw Exception(response.message ?: "생성 실패")
+                throw Exception(response.message ?: "수정 실패")
             }
         }.onFailure { error ->
             Log.e("PostRepository", error.message.toString())
         }
     }

     override suspend fun deletePost(postId: Long): Result<PostResponse> {
         return runCatching {
             val response = apiService.deletePost(postId)
             if (response.success && response.data != null){
                 response.data
             }else{
-                throw Exception(response.message ?: "생성 실패")
+                throw Exception(response.message ?: "삭제 실패")
             }
         }.onFailure { error ->
             Log.e("PostRepository", error.message.toString())
         }
     }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepositoryImpl.kt
around lines 26 to 38, the thrown/logged error messages are generic or
mismatched for the operation: update the exception message thrown and the Log.e
message in getPostDetail to reflect fetching post details (e.g., "게시물 조회 실패" or
"getPostDetail failed: ...") and similarly audit and correct the messages in
updatePost and deletePost to describe their actual actions (e.g., "게시물 수정
실패"/"updatePost failed: ..." and "게시물 삭제 실패"/"deletePost failed: ..."); ensure
you include response.message if available and pass the error details into the
logger.

Comment on lines +65 to +67
navController.previousBackStackEntry
?.savedStateHandle
?.set("refreshNeeded", true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

refreshNeeded 플래그 소비 로직을 확인하세요.

savedStateHandlerefreshNeeded 플래그를 설정하고 있지만, 제공된 PostListScreen.kt 코드에서 이 플래그를 관찰하고 새로고침을 트리거하는 로직이 보이지 않습니다. PR 설명에서는 "refresh function is executed from the main screen as a side effect"라고 명시되어 있습니다.

다음 스크립트를 실행하여 refreshNeeded 플래그 소비 로직을 확인하세요:

Also applies to: 87-89


🏁 Script executed:

#!/bin/bash
# PostListScreen에서 savedStateHandle 사용 여부 확인
rg -n -A 10 -B 3 'savedStateHandle|refreshNeeded' --type=kt

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Search for saveStateHandle and refreshNeeded in Kotlin files using glob pattern
rg -n -A 5 -B 2 'refreshNeeded|savedStateHandle.*get' -g '*.kt'

Length of output: 1986


🏁 Script executed:

#!/bin/bash
# Also search for PostListScreen file to understand its structure
fd -e kt -type f -name '*PostListScreen*' | head -20

Length of output: 170


🏁 Script executed:

#!/bin/bash
# Search for LaunchedEffect in PostListScreen files
fd -e kt -type f -name '*PostListScreen*' -exec cat {} \;

Length of output: 170


🏁 Script executed:

#!/bin/bash
# Find PostListScreen file with corrected fd syntax
fd -e kt -name '*PostListScreen*'

Length of output: 230


🏁 Script executed:

#!/bin/bash
# Search more broadly for savedStateHandle reads and refreshNeeded references
rg -n 'savedStateHandle.get|\.get\("refreshNeeded"' -g '*.kt'

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for refresh function definitions and calls
rg -n -B 2 -A 5 'fun.*refresh|LaunchedEffect' -g '*.kt' | head -100

Length of output: 7556


🏁 Script executed:

#!/bin/bash
# Search for all occurrences of refreshNeeded (any read/write operation)
rg -n 'refreshNeeded' -g '*.kt'

Length of output: 259


🏁 Script executed:

#!/bin/bash
# Find all screen files in the post package
find . -name '*Screen.kt' -path '*/ui/post/screen/*' 2>/dev/null

Length of output: 335


🏁 Script executed:

#!/bin/bash
# Search for screen composable functions in post package
rg -n '@Composable' -A 2 -g '*.kt' app/src/main/java/com/example/kuit6_android_api/ui/post/screen/ | head -50

Length of output: 1261


🏁 Script executed:

#!/bin/bash
# Check if PostListScreen exists anywhere
rg -n 'fun PostListScreen|@Composable.*PostList' -g '*.kt'

Length of output: 103


🏁 Script executed:

#!/bin/bash
# Search for where refresh is called as a side effect (LaunchedEffect calling refresh)
rg -n -B 3 -A 3 'viewModel.refresh|\.refresh\(\)' -g '*.kt'

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Get the full content of PostListScreen.kt to verify consumption logic
cat ./app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt

Length of output: 2856


savedStateHandle에서 refreshNeeded 플래그를 읽고 새로고침을 트리거하는 로직이 완전히 누락되었습니다.

PostListScreen.kt:

  • LaunchedEffect가 없고 savedStateHandle을 읽지 않습니다.
  • viewModel.refresh() 호출이 없습니다.

PostListViewModel.kt:

  • refresh() 함수가 존재하지만 코드베이스 어디에서도 호출되지 않습니다.

NavGraph.kt (67, 89줄):

  • refreshNeeded 플래그를 설정하기만 하고 읽는 곳이 없습니다.

PostListScreen에서 다음을 구현해야 합니다:

  • LaunchedEffectsavedStateHandle.getStateFlow("refreshNeeded", false) 관찰
  • 플래그가 true가 되면 viewModel.refresh() 호출

또한: 87-89줄에도 동일하게 적용됩니다.

🤖 Prompt for AI Agents
In app/src/main/java/com/example/kuit6_android_api/ui/navigation/NavGraph.kt
around lines 65-67 and 87-89, the NavGraph currently sets savedStateHandle
"refreshNeeded" but nothing reads it; update PostListScreen to observe that flag
and call the existing PostListViewModel.refresh(): inside PostListScreen add a
LaunchedEffect that collects savedStateHandle.getStateFlow("refreshNeeded",
false) (or use get<Boolean>) and when it becomes true call viewModel.refresh(),
then reset the flag to false via savedStateHandle.set("refreshNeeded", false)
(or setValue) to avoid repeated triggers; ensure this logic is implemented for
both navigation entry points referenced in NavGraph.

Comment on lines +78 to +94
var uploadedImageUrl by remember { mutableStateOf<String?>(null) }

val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
selectedImageUri = uri
uri?.let {
selectedImageUri = it

val file = UriUtil.uriToFile(context, it)
if (file != null) {
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
// file을 인자로해서 viewModel의 uploadImage 호출
viewModel.uploadImage(body)
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

이미지 제거/업로드 실패 후에도 이전 imageUrl 이 요청에 포함될 수 있습니다

uploadedImageUrl는 업로드 성공 시에만 갱신되고, 업로드 실패(ImageUiState.Error, Line [101][109])나 X 버튼으로 이미지를 제거할 때(Line [263][277])는 초기화되지 않습니다. 특히 한 번 업로드에 성공한 뒤 새 이미지를 선택했다가 업로드가 실패하면, 프리뷰는 새 selectedImageUri를 보여주지만(Line [248][261]) PostCreateRequest에는 여전히 이전 uploadedImageUrl(Line [288][292])이 포함될 수 있어 실제로 저장되는 이미지가 사용자 기대와 다르게 됩니다.

새 이미지를 선택하거나 업로드가 실패/취소되는 경우에는 항상 uploadedImageUrlnull로 초기화해서 로컬 프리뷰 상태와 서버로 전송되는 값이 일치하도록 맞춰 주는 편이 안전합니다.

@@
-    var uploadedImageUrl by remember { mutableStateOf<String?>(null) }
+    var uploadedImageUrl by remember { mutableStateOf<String?>(null) }
@@
-    ) { uri: Uri? ->
-        uri?.let {
-            selectedImageUri = it
-
-            val file = UriUtil.uriToFile(context, it)
+    ) { uri: Uri? ->
+        uri?.let {
+            selectedImageUri = it
+            // 새 이미지를 선택하면 이전 업로드 URL을 초기화합니다.
+            uploadedImageUrl = null
+
+            val file = UriUtil.uriToFile(context, it)
@@
-    LaunchedEffect(imgUiState) {
-        when (imgUiState) {
+    LaunchedEffect(imgUiState) {
+        when (imgUiState) {
             is ImageUiState.Success -> {
                 uploadedImageUrl = (imgUiState as ImageUiState.Success).imgUrl["imageUrl"]
             }
             is ImageUiState.Error -> {
-                Toast
+                // 업로드 실패 시에도 이전 URL이 남지 않도록 초기화합니다.
+                uploadedImageUrl = null
+                Toast
                     .makeText(
                         context,
                         (imgUiState as ImageUiState.Error).message,
                         Toast.LENGTH_SHORT
                     )
                     .show()
@@
-                            IconButton(
-                                onClick = {
-                                    selectedImageUri = null
-                                    viewModel.clearUploadedImageUrl()
-                                },
+                            IconButton(
+                                onClick = {
+                                    selectedImageUri = null
+                                    // 사용자가 이미지를 제거하면 로컬/뷰모델 상태를 모두 비웁니다.
+                                    uploadedImageUrl = null
+                                    viewModel.clearUploadedImageUrl()
+                                },

이렇게 하면 업로드 성공, 실패, 이미지 제거 상황 모두에서 프리뷰와 서버 요청 간의 상태가 일관되게 유지됩니다.

Also applies to: 96-112, 248-279, 285-295

Comment on lines +77 to 94
var selectedImageUri by remember { mutableStateOf<Uri?>(null)}
var uploadedImageUrl by remember { mutableStateOf<String?>(null) }
var isLoaded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()

val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
selectedImageUri = uri
uri?.let {
selectedImageUri = it
val file = UriUtil.uriToFile(context, it)
if (file != null) {
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)

viewModel.uploadImage(body)
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

이미지 변경 후 업로드 실패 시 이전 imageUrl 이 계속 전송될 수 있습니다

PostEditScreen에서도 uploadedImageUrl는 업로드 성공 시에만 갱신되고(Line [100][104]), 새 이미지를 선택할 때나 업로드 실패 시에는 초기화되지 않습니다(Line [84][93], [105][111]). 이미 한 번 업로드에 성공해 uploadedImageUrl가 설정된 상태에서 새 이미지를 선택했다가 업로드가 실패하면, 프리뷰는 새 selectedImageUri를 보여주지만(Line [194][207]) 실제 수정 요청에서는 여전히 이전 uploadedImageUrl가 사용되어(Line [245]~[251]) 저장 결과가 사용자 기대와 달라질 수 있습니다.

새 이미지를 선택하거나 업로드가 실패하는 경우에는 항상 uploadedImageUrlnull로 초기화해 주는 것이 안전합니다.

@@
-    ) { uri: Uri? ->
-        uri?.let {
-            selectedImageUri = it
-            val file = UriUtil.uriToFile(context, it)
+    ) { uri: Uri? ->
+        uri?.let {
+            selectedImageUri = it
+            // 새 이미지를 선택하면 이전 업로드 URL을 초기화합니다.
+            uploadedImageUrl = null
+
+            val file = UriUtil.uriToFile(context, it)
@@
-    LaunchedEffect(imgUiState) {
-        when (imgUiState) {
+    LaunchedEffect(imgUiState) {
+        when (imgUiState) {
             is ImageUiState.Success -> {
                 uploadedImageUrl = (imgUiState as ImageUiState.Success).imgUrl["imageUrl"]
             }
             is ImageUiState.Error -> {
-                Toast.makeText(
+                // 업로드 실패 시에도 이전 URL이 남지 않도록 초기화합니다.
+                uploadedImageUrl = null
+                Toast.makeText(
                     context,
                     (imgUiState as ImageUiState.Error).message,
                     Toast.LENGTH_SHORT
                 ).show()

이렇게 하면 "이미지 A로 저장 후 → 이미지 B 선택 → 업로드 실패 → 수정하기" 시나리오에서도 더 이상 이미지 A URL이 다시 전송되지 않습니다.

Also applies to: 100-113, 194-228, 242-252

🤖 Prompt for AI Agents
In
app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostEditScreen.kt
around lines 77-94 (and related 100-113, 194-228, 242-252): currently
uploadedImageUrl is only set on successful upload, so when the user selects a
new image (or the upload fails) the old uploadedImageUrl may remain and be sent
with the save request; fix by resetting uploadedImageUrl = null whenever a new
image is selected (inside the imagePickerLauncher callback before starting
upload) and also ensure the viewModel upload failure path clears
uploadedImageUrl (or expose an upload state callback to set it null on error) so
the preview can show selectedImageUri while the persisted image URL is cleared
and the save request will not send the stale URL.

Comment on lines +5 to +15
sealed class PostUIState {
data object Loading: PostUIState()

data class Success(
val post: PostResponse
): PostUIState()

data class Error(
val message: String
): PostUIState()
} No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

네이밍 일관성 문제를 수정해주세요.

이 클래스는 PostUIState로 명명되었지만, PR 요약에 언급된 다른 UI 상태 클래스들(PostListUiState, PostDetailUiState, PostCreateUiState 등)은 UiState (소문자 'i')를 사용합니다. 코드베이스 전체에서 일관된 네이밍 컨벤션을 유지하는 것이 중요합니다.

다음 diff를 적용하여 네이밍을 일관되게 수정하세요:

-sealed class PostUIState {
-    data object Loading: PostUIState()
+sealed class PostUiState {
+    data object Loading: PostUiState()
 
     data class Success(
         val post: PostResponse
-    ): PostUIState()
+    ): PostUiState()
 
     data class Error(
         val message: String
-    ): PostUIState()
+    ): PostUiState()
 }

또한, 이 PostUIState 클래스가 PostDetailUiState와 중복되거나 겹치는 용도로 사용되지 않는지 확인이 필요합니다. 다음 스크립트를 실행하여 두 상태 클래스의 사용처를 확인해주세요:


🏁 Script executed:

#!/bin/bash
# Description: PostUIState와 PostDetailUiState의 사용처를 확인하여 중복 여부를 검증합니다.

# PostUIState 사용처 검색
echo "=== PostUIState 사용처 ==="
rg -n "PostUIState" --type kotlin -C 3

echo -e "\n=== PostDetailUiState 사용처 ==="
rg -n "PostDetailUiState" --type kotlin -C 3

Length of output: 16197


PostUIState는 사용되지 않는 중복 코드입니다. 삭제하거나 용도를 명확히 해주세요.

검색 결과에 따르면 PostUIState는 자신의 파일에서만 정의되고 코드베이스 전체에서 사용되지 않습니다. 반면 PostDetailUiStatePostDetailViewModelPostDetailScreen에서 활발하게 사용되고 있으며, 두 클래스의 구조가 정확히 동일합니다(Loading, Success, Error).

선택지:

  1. Dead code 제거: 불필요한 중복 코드이므로 PostUIState.kt 파일 자체를 삭제하세요.
  2. 명확한 용도 구분: 만약 PostUIState가 다른 목적(예: 게시글 목록 화면)으로 사용될 예정이라면, 그 용도를 명시하고 관련 ViewModel/Screen에서 사용되도록 수정하세요.

만약 파일을 유지한다면, 최소한 다음 네이밍 일관성 수정은 필수입니다:

  • PostUIStatePostUiState (다른 UI 상태 클래스들과 일관성 유지)
🤖 Prompt for AI Agents
In app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostUIState.kt
around lines 5-15, PostUIState is duplicate and unused; either delete the file
to remove dead code, or if it’s intended for a different screen (e.g., post
list) explicitly wire it into the corresponding ViewModel/Screen and update its
purpose in comments; if you keep it, rename to PostUiState to match naming
conventions, update any references, and remove unused imports so the class is
actually used and consistent with PostDetailUiState.

Comment on lines +14 to +18
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
as App

val postRepository = application.container.postRepository
create(postRepository)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Application 타입 캐스팅에 안전성 검사를 추가하세요.

as App 형변환이 실패하면 ClassCastException이 발생합니다. Application이 App 타입이 아닌 경우를 대비한 안전장치가 필요합니다.

다음 diff를 적용하여 안전한 캐스팅을 사용하세요:

     initializer {
         val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
-                as App
+                as? App
+                ?: throw IllegalStateException("Application must be an instance of App")
 
         val postRepository = application.container.postRepository
         create(postRepository)
     }
📝 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
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
as App
val postRepository = application.container.postRepository
create(postRepository)
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
as? App
?: throw IllegalStateException("Application must be an instance of App")
val postRepository = application.container.postRepository
create(postRepository)
🤖 Prompt for AI Agents
In
app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModelFactory.kt
around lines 14 to 18, the code performs an unsafe cast "as App" on the
Application which can throw ClassCastException; replace it with a safe cast (as?
App) and handle the null result (e.g., throw an IllegalStateException or return
a sensible fallback) before accessing application.container.postRepository so
the factory fails with a clear message instead of a ClassCastException.

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.

2 participants