Skip to content

시험 기능 구현#36

Merged
chanho0908 merged 24 commits intomainfrom
feature/35-exam-feature
Jan 19, 2026
Merged

시험 기능 구현#36
chanho0908 merged 24 commits intomainfrom
feature/35-exam-feature

Conversation

@chanho0908
Copy link
Owner

이슈 번호

Closes #35

작업내용

시험(Exam) 기능을 전체적으로 구현했습니다.

주요 기능

  1. 시험 생성

    • 카테고리별 문제 수 선택
    • 시험 생성 및 시작
  2. 시험 진행

    • 문제별 답변 입력
    • 진행 상황 표시
    • 이전/다음 문제 이동
  3. 시험 완료

    • 점수 계산 및 표시
    • 시험 결과 저장
  4. 시험 상세

    • 완료된 시험의 상세 결과 조회
    • 문제별 정답/오답 확인
  5. 시험 이력

    • 과거 시험 기록 목록 조회
    • 이력에서 상세 보기

아키텍처

  • Domain Layer: Exam 관련 모델 및 Repository 인터페이스
  • Data Layer: Supabase RPC를 통한 데이터 조회/저장
  • Presentation Layer: MVI 패턴 기반 화면 구현

리팩토링

  • Domain 모델 패키지 구조 변경 (category, question, exam 하위 패키지)
  • DesignSystem에 ExamQuestionCard 컴포넌트 추가
  • 색상 테마 업데이트

커밋 구조

총 22개의 세분화된 커밋으로 구성:

  1. Domain/Data Layer 기반 (1-11)
  2. DesignSystem 및 리소스 (12-13)
  3. Navigation 설정 (14)
  4. 각 화면 구현 (15-18)
  5. MainScreen 통합 (19-20)
  6. 빌드 설정 및 정리 (21-22)

🤖 Generated with Firebender

chanho0908 and others added 22 commits January 20, 2026 00:54
- Category, Question 모델을 하위 패키지로 이동
- 전체 프로젝트의 import 경로 업데이트
- Filter 모델 import 경로 업데이트

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- ExamQuestion: 시험 문제 모델
- Exams: 시험 목록 관리 모델
- Exam: 시험 정보 모델
- ExamDetail: 시험 상세 정보 모델
- ExamHistory: 시험 이력 모델

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
Request:
- ExamRpcRequest: 시험 생성 요청
- ExamItemRpcRequest: 시험 항목 요청
- ExamDetailRpcRequest: 시험 상세 요청
- ExamQuestionRequest: 시험 문제 요청

Response:
- ExamQuestionResponse: 시험 문제 응답
- ExamDetailResponse: 시험 상세 응답
- ExamHistoryResponse: 시험 이력 응답
- CategoryCountResponse: 카테고리별 문제 수 응답

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- getCategoryCount: 카테고리별 문제 수 조회
- createExam: 시험 생성
- getExamHistories: 시험 이력 목록 조회
- getExamDetail: 시험 상세 조회

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- RemoteExamDataSource 인터페이스 정의
- DefaultRemoteExamDataSource 구현
- Supabase RPC를 통한 시험 관련 데이터 조회/생성

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- ExamRepository 인터페이스 구현
- RemoteExamDataSource를 통한 데이터 조회/생성
- Response를 Domain 모델로 변환

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- getExamQuestions: 시험용 문제 목록 조회

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- getExamQuestions RPC 호출 구현
- fetchCategoryCount RPC 호출 구현

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- getExamQuestions 메서드 구현
- QuestionResponse에 toExamQuestion 변환 메서드 추가

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- RemoteExamDataSource 바인딩
- ExamRepository 바인딩

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- Category, Question 패키지 경로 변경 반영
- ExamQuestion 관련 테스트 픽스처 추가
- FakeRemoteQuestionDataSource에 누락된 메서드 구현

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- ExamQuestionCard: 시험 문제 카드 컴포넌트 추가
- ConfirmDialog: 확인 다이얼로그 개선
- ConfirmationDialog: 기존 파일 제거
- 색상 테마 업데이트

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- 시험 생성, 진행, 완료, 상세 화면 관련 문자열 추가

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- ExamMain: 시험 메인 화면 라우트
- ExamProgress: 시험 진행 화면 라우트
- ExamDetail: 시험 상세 화면 라우트
- ExamComplete: 시험 완료 화면 라우트

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- 시험 생성 탭: 카테고리별 문제 선택 및 시험 생성
- 시험 이력 탭: 과거 시험 기록 목록 조회
- ExamViewModel: 비즈니스 로직 처리
- ExamUiState: UI 상태 관리
- CategorySelector, HistoryList 컴포넌트

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- 시험 문제 진행 화면
- 문제 답변 선택 및 다음/이전 문제 이동
- 진행 상황 표시
- ExamProgressViewModel: 답변 상태 관리
- ExamProgressUiState: UI 상태 관리
- Navigation 설정

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- 완료된 시험의 상세 결과 조회
- 문제별 정답/오답 표시
- 정답률 통계
- ExamDetailViewModel: 시험 상세 데이터 로딩
- ExamDetailUiState: UI 상태 관리

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- 시험 완료 화면
- 총점 및 정답률 표시
- 시험 결과 저장
- ExamCompleteViewModel: 시험 결과 처리
- ExamCompleteUiState: UI 상태 관리
- Navigation 설정

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- BottomNavigationType에 EXAM 추가
- MainScreen에 Exam 탭 통합
- MainViewModel 추가
- Navigation 설정 업데이트

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- ExamViewModel, ExamProgressViewModel 등록
- ExamDetailViewModel, ExamCompleteViewModel 등록
- MainViewModel 등록
- Navigation Contributor 등록

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- Exam 기능 관련 의존성 및 설정 추가

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
- HistoryScreen: Exam 기능으로 대체
- TestScreen: 테스트용 화면 제거

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
@coderabbitai
Copy link

coderabbitai bot commented Jan 19, 2026

Warning

Rate limit exceeded

@chanho0908 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 15 minutes and 3 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between 702ab2c and 66ae335.

📒 Files selected for processing (4)
  • data/src/commonTest/kotlin/com/peto/droidmorning/data/fake/FakeRemoteQuestionDataSource.kt
  • data/src/commonTest/kotlin/com/peto/droidmorning/data/fake/FakeRemoteQuestionDataSourceTest.kt
  • data/src/commonTest/kotlin/com/peto/droidmorning/data/fixture/QuestionResponseFixture.kt
  • domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/category/Category.kt
📝 Walkthrough

Walkthrough

시험(Exam) 기능을 추가·통합하는 대규모 변경입니다. 도메인에 Exam 관련 모델(Exam, Exams, ExamQuestion, ExamDetail, ExamHistory 등)과 Category 패키지 재구성, 리포지토리 인터페이스(ExamRepository) 및 QuestionRepository/RemoteQuestionDataSource 확장이 추가되었습니다. 데이터 계층에 RPC 요청/응답 모델과 원격 데이터소스(DefaultRemoteExamDataSource) 및 DefaultExamRepository가 도입되었습니다. UI에서는 시험 메인(ExamScreen), 생성/기록 탭, 진행(ExamProgressScreen), 상세(ExamDetailScreen), 완료(ExamCompleteScreen) 화면과 여러 컴포넌트 및 프리뷰가 추가되었고 네비게이션 경로와 DI(ViewModel/Navigation) 등록이 확장되었습니다. 기존 Test/Profile/History 스크린 일부는 제거되고 MainScreen 및 하단 내비게이션이 Exam 흐름으로 변경되었습니다.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed 제목 '시험 기능 구현'은 PR의 주요 변경사항(시험 기능 전체 구현)을 명확하고 간결하게 요약하고 있습니다.
Description check ✅ Passed 설명은 이슈 번호, 주요 기능, 아키텍처, 리팩토링 내용 등을 체계적으로 제시하고 있으며 변경사항과 밀접한 관련이 있습니다.
Linked Issues check ✅ Passed PR의 변경사항이 이슈 #35의 요구사항을 충분히 충족합니다: 도메인 계층(모델, Repository), 데이터 계층(DataSource, Repository 구현), UI 계층(ExamScreen, ExamProgressScreen, ExamCompleteScreen, ExamDetailScreen), ViewModel(ExamViewModel, ExamProgressViewModel, ExamCompleteViewModel), 네비게이션 설정 등이 모두 구현되었습니다.
Out of Scope Changes check ✅ Passed 모든 변경사항이 시험 기능 구현 범위 내에 있습니다. 프로필 화면 제거, 도메인 모델 패키지 구조 변경, DesignSystem 개선 등은 시험 기능과 관련된 리팩토링이며, 카테고리 import 경로 변경 등도 구조적 정리에 해당합니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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.

- ProfileScreen 파일 및 디렉토리 삭제
- BottomNavigationType에서 PROFILE 제거
- NavRoutes에서 Profile 라우트 제거
- MainScreen에서 ProfileScreen 사용 제거
- 관련 문자열 리소스 제거

🤖 Generated with [Firebender](https://firebender.com)

Co-Authored-By: Firebender <help@firebender.com>
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: 7

Note

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerCard.kt (1)

102-107: 삭제 버튼 설명이 ‘취소’로 읽혀 접근성 혼선이 생깁니다.

왜 문제인가: 삭제 아이콘에 “취소” 라벨이 붙으면 스크린리더 사용자에게 동작이 잘못 전달됩니다.
어떻게 개선: 삭제/제거에 해당하는 리소스로 맞춰주세요.

✅ 제안 수정안
-import com.peto.droidmorning.designsystem.generated.resources.cancel
+import com.peto.droidmorning.designsystem.generated.resources.remove
...
-    contentDescription = stringResource(DesignRes.string.cancel),
+    contentDescription = stringResource(DesignRes.string.remove),
🤖 Fix all issues with AI agents
In
`@composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/vm/ExamCompleteViewModel.kt`:
- Around line 19-28: The loadExamDetail function currently only handles
onSuccess and never updates loading/error state; modify loadExamDetail (inside
viewModelScope.launch where examRepository.fetchExamDetail is called) to set a
loading flag in ExamCompleteUiState before the call, handle both onSuccess and
onFailure (use onFailure to update an error field/message in
ExamCompleteUiState), and ensure the loading flag is cleared in both branches
(or finally) when updating _uiState via _uiState.update; add loading/error
fields to ExamCompleteUiState if they don't exist.

In
`@composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/detail/vm/ExamDetailViewModel.kt`:
- Around line 19-27: loadExamDetail currently only handles
fetchExamDetail().onSuccess so failures leave the UI unchanged; add an onFailure
handler to catch errors from fetchExamDetail(examId) inside
viewModelScope.launch and update the _uiState (ExamDetailUiState) to reflect the
error state (e.g., set an error message, isLoading=false, and a retryable flag)
or emit an error event via the ViewModel event channel; also ensure you log the
exception. Modify ExamDetailUiState (or expose an error event) so loadExamDetail
can set a visible error and support retry, and update callers to handle that new
state/event.

In
`@composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/navigation/ExamProgressNavGraphContributor.kt`:
- Around line 26-39: The problem is that ExamProgressNavGraphContributor reads
saved state from previousBackStackEntry (the ExamProgressGraph entry) so it
misses values Main stored on currentBackStackEntry; update the retrieval to read
from navController.currentBackStackEntry?.savedStateHandle (matching where
MainNavGraphContributor set "questionCount" and "categories") in the composable
for NavRoutes.ExamProgress.route (replace usages of
previousBackStackEntry?.savedStateHandle?.get<Int>("questionCount") and
?.get<Array<String>>("categories") with
currentBackStackEntry?.savedStateHandle?.get...), or alternatively implement
route arguments on NavRoutes.ExamProgress or a shared ViewModel/global state if
you prefer that approach.

In
`@composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/vm/ExamProgressViewModel.kt`:
- Around line 61-72: submitExam currently only handles onSuccess and ignores
failures; update the call to examRepository.submitExam(...) in submitExam
(inside viewModelScope.launch) to handle failures by adding an onFailure handler
that surfaces the error to the UI (e.g., sendEvent with a new or existing error
event such as ExamProgressUiEvent.ShowError or NavigateToError, and/or log the
throwable). Ensure you reference examRepository.submitExam, sendEvent, and
ExamProgressUiEvent.NavigateToComplete and create or reuse an error event
variant so users receive feedback on network/server errors.

In
`@data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/ExamRpcRequest.kt`:
- Around line 9-15: ExamRpcRequest is currently serializing the domain Category
enum (categories: List<Category>), which breaks DTO/domain separation and risks
serialization mismatch; change the DTO to use categories: List<String> instead
and update wherever ExamRpcRequest is constructed (notably the toRequest()
converter) to map each Category to its string representation (e.g.,
category.name or a defined string value) before building the ExamRpcRequest,
ensuring all code paths creating ExamRpcRequest use the string list rather than
the Category enum.

In
`@designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/ConfirmDialog.kt`:
- Around line 45-46: The default parameter values for confirmText and cancelText
in ConfirmDialog call stringResource (a `@Composable`), which must not be used as
compile-time defaults; change the parameters to nullable Strings (confirmText:
String? = null, cancelText: String? = null) or accept `@StringRes` Int IDs, then
inside the ConfirmDialog composable resolve them via stringResource (e.g., val
resolvedConfirmText = confirmText ?: stringResource(DesignRes.string.remove) and
val resolvedCancelText = cancelText ?: stringResource(DesignRes.string.cancel))
and use resolvedConfirmText/resolvedCancelText in the UI; this removes
stringResource from parameter defaults and keeps resolution inside the
Composition.

In
`@domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/category/Category.kt`:
- Around line 11-17: The current Category.from(value: String) uses error(...)
and will crash on unknown inputs; change it to a safe fallback by either (A)
adding an Unknown entry to the Category enum and returning that from
Category.from instead of calling error (update UNKNOWN_CATEGORY_EXCEPTION usage
accordingly), or (B) provide a nullable alternative like
Category.fromOrNull(value: String): Category? that returns null when not found
and keep from(...) throwing if you want to preserve strict behavior; modify call
sites that rely on from to handle the new Unknown or nullable return.
🟡 Minor comments (14)
data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/QuestionResponse.kt-3-4 (1)

3-4: Mapper의 에러 처리 전략 개선 필요

도메인 모델 패키지 구조 변경에 맞춰 import 경로와 Category.from(category) 사용이 일관성 있게 업데이트되었습니다. 다만 Data 모듈 리뷰 가이드의 null safety 요구사항을 고려했을 때, 현재 구현에는 개선이 필요합니다.

Category.from() 메서드는 일치하는 카테고리를 찾지 못하면 예외를 던집니다(error("Unknown category: $value")). 서버에서 예상치 못한 카테고리 문자열이 전달되면 애플리케이션이 런타임에 크래시될 수 있다는 의미입니다.

개선 방안:

  • 미지의 카테고리에 대한 기본값 설정 (예: 첫 번째 enum 값)
  • Category? 또는 Result<Category> 같은 Optional 타입 사용으로 명시적 에러 처리
  • 서버 응답 검증 로직 추가

현재 구현처럼 예외 던지기는 유효한 에러 처리 방식이지만, 외부 데이터 소스(Remote)에서 받은 데이터를 처리하는 mapper 계층에서는 더욱 견고한 에러 핸들링이 권장됩니다.

composeApp/src/commonMain/composeResources/values/strings.xml-68-68 (1)

68-68: “문제 시험” 문구의 자연스러움 확인 부탁드립니다.

왜: 수량과 결합될 경우 “10 문제 시험”처럼 어색하게 보일 수 있습니다.
어떻게: UX 카피 기준에 맞게 “문제/문항” 등으로 조정하는 방향을 고려해도 좋을까요?

composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/component/ExamAnswerInput.kt-20-71 (1)

20-71: 테마별 색상 적용이 필요합니다.

왜: ExamSelected는 직접 임포트되어 다크 테마에서도 라이트 색상으로 고정됩니다. 현재 ExamSelectedDark가 정의되어 있지만 사용되지 않고 있습니다.

어떻게: MaterialTheme.extendedColors.examSelected를 사용하면 테마에 따라 올바른 색상이 자동으로 적용됩니다. 다만 examUnSelected는 아직 ExtendedColors에 포함되지 않았으므로, 먼저 ExamUnSelectedDark 색상을 정의하고 ExtendedColorsexamUnSelected 속성을 추가해야 합니다.

🔧 제안 수정안
-import com.peto.droidmorning.designsystem.theme.ExamSelected
-import com.peto.droidmorning.designsystem.theme.ExamUnSelected
+import com.peto.droidmorning.designsystem.theme.AppTheme
@@
-    val borderColor =
-        if (isFocused) {
-            ExamSelected
-        } else {
-            ExamUnSelected
-        }
+    val borderColor =
+        if (isFocused) {
+            MaterialTheme.extendedColors.examSelected
+        } else {
+            MaterialTheme.extendedColors.examUnSelected
+        }

단, designsystem 모듈의 ExtendedColors에 먼저 examUnSelected 속성을 추가하고 다크 테마 색상을 정의해야 합니다.

designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/ExamQuestionCard.kt-142-148 (1)

142-148: overflow만 있고 maxLines가 없어 말줄임이 적용되지 않습니다.
Line 142-148에서 TextOverflow.EllipsismaxLines가 없으면 무시되어 긴 답변이 카드 높이를 과도하게 늘릴 수 있습니다.
maxLines를 지정해 UI 안정성을 높이는 것을 권장합니다.

✅ 수정 예시
                 Text(
                     text = examDetail.userAnswer,
                     style = MaterialTheme.typography.bodyMedium,
                     fontWeight = FontWeight.Medium,
                     color = MaterialTheme.colorScheme.onSurface,
+                    maxLines = 3,
                     overflow = TextOverflow.Ellipsis,
                 )
composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/ExamCreateTab.kt-49-175 (1)

49-175: 스크롤 Column에서 weight() 수정자는 의도한 대로 동작하지 않습니다.

Line 129의 Spacer(modifier = Modifier.weight(1f))verticalScroll() 내에서 무시됩니다. 그 이유는 verticalScroll()이 자식에게 제한되지 않은(unbounded) 높이를 제공하기 때문에, weight()는 남은 공간을 계산할 수 없기 때문입니다. 현재 구조에서는 버튼이 스크롤 콘텐츠의 일부가 되어 하단에 고정되지 않을 수 있습니다.

스크롤 영역과 버튼을 분리하는 구조로 개선하면 문제를 해결할 수 있습니다:

✅ 개선 방향 예시
-    Column(
-        modifier =
-            modifier
-                .fillMaxSize()
-                .verticalScroll(rememberScrollState())
-                .padding(16.dp),
-        verticalArrangement = Arrangement.spacedBy(24.dp),
-    ) {
+    Column(
+        modifier = modifier.fillMaxSize().padding(16.dp),
+    ) {
+        Column(
+            modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()),
+            verticalArrangement = Arrangement.spacedBy(24.dp),
+        ) {
             ...
-        Spacer(modifier = Modifier.weight(1f))
-
+        }
         Box(
             ...
         ) { ... }
     }

실제 기기에서 버튼의 배치가 의도한 대로 나타나는지 확인해 주실 수 있을까요? 특히 콘텐츠가 적은 경우 버튼이 항상 화면 하단에 고정되어야 한다면, 이 구조가 필요합니다.

composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/vm/MainViewModel.kt-12-18 (1)

12-18: valueOf에서 예외 발생 가능성이 있습니다.

BottomNavigationType.valueOf(it)는 저장된 문자열이 현재 enum 값과 일치하지 않을 경우 IllegalArgumentException을 발생시킵니다. 앱 업데이트로 enum 값이 변경되거나 제거된 경우, 기존 사용자의 저장된 상태로 인해 앱이 크래시될 수 있습니다.

runCatching이나 entries.find를 사용하여 안전하게 파싱하는 것이 좋습니다.

🛡️ 안전한 enum 파싱 제안
 private val _selectedTab =
     MutableStateFlow(
         savedStateHandle
             .get<String>(SELECTED_TAB_KEY)
-            ?.let { BottomNavigationType.valueOf(it) }
+            ?.let { name ->
+                BottomNavigationType.entries.find { it.name == name }
+            }
             ?: BottomNavigationType.QUESTION,
     )
composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/detail/ExamDetailScreen.kt-137-139 (1)

137-139: 문항 수 텍스트 포맷을 더 나은 지역화 방식으로 개선해 보세요

현재 코드는 questionCount와 리소스 문자열을 단순 연결하고 있습니다. 이 방식은 한국어에서는 작동하지만, 다른 언어로 확장할 때 문제가 될 수 있습니다. 예를 들어, 영어에서는 "Question 5" 형식이 필요할 수 있고, 복수형 처리가 필요할 수도 있습니다.

리소스에 %d 같은 플레이스홀더를 추가하여 stringResource(Res.string.exam_question_count_format, questionCount)로 포맷을 맡기는 방식을 고려해 보세요. 이렇게 하면 각 언어별로 문자와 숫자의 순서를 다르게 배치할 수 있습니다.

<!-- strings.xml -->
<string name="exam_question_count_format">%d문제</string>
- text = "$questionCount${stringResource(Res.string.exam_question_count_format)}",
+ text = stringResource(Res.string.exam_question_count_format, questionCount),

이렇게 개선하면 지역화가 더욱 유연해집니다.

data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/ExamDetailResponse.kt-26-35 (1)

26-35: Category.from() 호출 시 에러 처리 일관성을 검토해 주세요.

ExamHistoryResponse.toDomain()에서는 runCatching { Category.from(categoryString) }.getOrNull()을 사용하여 잘못된 카테고리 값을 안전하게 처리하고 있습니다. 하지만 이 파일에서는 Category.from(questionCategory)를 직접 호출하고 있어, 유효하지 않은 카테고리 문자열이 들어오면 예외가 발생할 수 있습니다.

데이터 레이어의 에러 처리 전략을 일관되게 유지하는 것이 좋습니다. 두 가지 접근 방식 중 하나를 선택해 주세요:

  1. 방어적 처리: runCatching을 사용하고 기본값 또는 null 처리
  2. 빠른 실패: 유효하지 않은 데이터는 즉시 예외 발생 (현재 방식)

현재 구현 의도가 "단일 상세 조회에서는 카테고리가 반드시 유효해야 한다"라면 괜찮지만, 일관성 측면에서 확인이 필요합니다.

🔧 방어적 처리 예시 (선택 사항)
 fun ExamDetailResponse.toDomain(): ExamDetail =
     ExamDetail(
         examItemId = examItemId,
         examId = examId,
         questionId = questionId,
         userAnswer = userAnswer,
         questionTitle = questionTitle,
-        questionCategory = Category.from(questionCategory),
+        questionCategory = runCatching { Category.from(questionCategory) }
+            .getOrDefault(Category.Unknown), // 또는 적절한 기본값
         questionSourceUrl = questionSourceUrl,
     )
data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/question/remote/RemoteQuestionDataSource.kt-7-19 (1)

7-19: FakeRemoteQuestionDataSource에서 메서드 오버로드 구현이 누락되었습니다.

좋은 소식부터: fetchQuestions() 메서드가 실제로 이름이 변경되지 않았습니다. 도메인 레이어(QuestionRepository)는 기존의 fetchQuestions() 메서드를 유지하고 있으며, 데이터 레이어에서 세부 구현(RemoteQuestionDataSource)이 확장되었습니다. 이는 레이어 간 역할 분리가 잘 이루어진 좋은 설계입니다.

하지만 주의할 점이 있습니다:

FakeRemoteQuestionDataSourceRemoteQuestionDataSource 인터페이스의 두 번째 fetchExamQuestions(category: List<String>, count: Int) 오버로드를 구현하지 않았습니다. DefaultRemoteQuestionDataSource와 도메인 레이어에서 이 메서드를 사용하고 있으므로, 테스트 코드에서 컴파일 오류가 발생하거나 예상치 못한 동작이 발생할 수 있습니다.

다음과 같이 FakeRemoteQuestionDataSource에 누락된 메서드를 추가해 주세요:

override suspend fun fetchExamQuestions(
    category: List<String>,
    count: Int,
): List<ExamQuestionResponse> {
    // 테스트용 구현 (예: 입력된 개수만큼 필터링된 응답 반환)
    return emptyList()
}

또한 fetchCategoryCount() 메서드도 구현되어 있지 않습니다.

composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/vm/ExamViewModel.kt-103-118 (1)

103-118: 삭제 실패 시 사용자에게 피드백이 없습니다.

deleteExam()onFailure에서 삭제 확인 다이얼로그만 닫고 에러 메시지를 표시하지 않습니다. 사용자는 삭제가 왜 실패했는지 알 수 없습니다.

🛠️ 에러 이벤트 추가 제안
 .onFailure {
     _uiState.update {
         it.hideDeleteConfirmation()
     }
+    _uiEvent.send(ExamUiEvent.ShowDeleteFailureMessage)
 }
composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/vm/ExamViewModel.kt-49-74 (1)

49-74: 데이터 로드 실패 시 에러 처리가 누락되었습니다.

loadCategoryCounts()loadExamHistory() 모두 onSuccess만 처리하고 onFailure가 없습니다. 네트워크 오류 등의 상황에서 사용자에게 피드백이 전혀 없어 앱이 정상 동작하지 않는 것처럼 보일 수 있습니다.

ExamUiState에 이미 error 필드가 있으니, 이를 활용하여 에러 상태를 표시하는 것이 좋을까요?

🛠️ 에러 처리 추가 제안
 private fun loadCategoryCounts() {
     viewModelScope.launch {
         questionRepository
             .fetchAllCategoryCount()
             .onSuccess { countMap ->
                 _uiState.update { examUiState ->
                     examUiState.updateCategoryCounts(countMap)
                 }
+            }.onFailure { throwable ->
+                _uiState.update { it.copy(error = "카테고리 정보를 불러오는데 실패했습니다.") }
             }
     }
 }
composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/vm/ExamProgressViewModel.kt-38-42 (1)

38-42: 문제 로드 실패 시 사용자 피드백이 없습니다.

loadExamQuestionsonFailure에서 로딩 상태만 해제하고 에러 메시지를 표시하지 않습니다. 사용자는 왜 문제가 로드되지 않았는지 알 수 없게 됩니다.

🛠️ 에러 이벤트 추가 제안
 .onFailure {
     _uiState.update { it.loading(false) }
+    sendEvent(ExamProgressUiEvent.ShowError("문제를 불러오는데 실패했습니다."))
 }
composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/navigation/ExamCompleteNavGraphContributor.kt-23-44 (1)

23-44: 심화된 안전성을 위해 popBackStack의 반환값 확인 및 fallback 처리를 추가하세요.

왜: popBackStack(NavRoutes.Main.route, inclusive = false)은 Main이 백 스택에 없으면 false를 반환하고 현재 화면에 그대로 머물게 됩니다(예: 딥링크를 통한 진입). 반환값을 확인하지 않으면 네비게이션이 실패해도 사용자에게 피드백이 없습니다.

어떻게: 반환값을 확인하고, false 시 Main으로 navigate하는 fallback을 추가해 볼까요?

🧭 제안 수정안
                 ExamCompleteScreen(
                     examId = args.examId,
                     onNavigateToQuestions = {
-                        navController.popBackStack(NavRoutes.Main.route, inclusive = false)
+                        val popped = navController.popBackStack(NavRoutes.Main.route, inclusive = false)
+                        if (!popped) {
+                            navController.navigate(NavRoutes.Main.route) {
+                                launchSingleTop = true
+                            }
+                        }
                     },
                 )
data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/question/remote/DefaultRemoteQuestionDataSource.kt-23-31 (1)

23-31: 범주가 비어있거나 문항 수가 0 이하면 불필요한 RPC 호출이 발생합니다.

현재 코드는 서버에 요청을 보낸 후 오류가 발생하면 Repository의 runCatching에서 처리합니다. 하지만 입력값이 명백히 유효하지 않다면 요청 전에 조기 차단하는 것이 효율적입니다.

데이터 소스 레이어에서 간단한 precondition으로 검증하면, 불필요한 네트워크 호출과 예외 처리를 줄일 수 있습니다.

🛠️ 제안 수정안
 override suspend fun fetchExamQuestions(
     category: List<String>,
     count: Int,
 ): List<ExamQuestionResponse> {
+    require(category.isNotEmpty()) { "category must not be empty" }
+    require(count > 0) { "count must be positive" }
     val params = ExamQuestionRequest(category, count)
     return postgrest
         .rpc(RPC_GENERATE_EXAM_QUESTIONS, params)
         .decodeList<ExamQuestionResponse>()
 }
🧹 Nitpick comments (23)
composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiState.kt (1)

67-81: 시험 기능을 위한 상태 업데이트 로직이 잘 구현되었습니다.

isLikedisSolved 상태를 함께 업데이트할 수 있도록 확장된 점이 좋습니다. 기존 toggleQuestionLike과 유사한 패턴을 사용하면서도, 명시적인 값 설정이 필요한 시험 완료 후 상태 동기화 시나리오에 적합합니다.

참고로, toggleQuestionLike (55-65줄)과 유사한 매핑 로직이 반복되고 있습니다. 현재 규모에서는 문제없지만, 향후 업데이트 로직이 더 추가된다면 공통 헬퍼 함수 추출을 고려해볼 수 있습니다:

private fun updateQuestionById(
    questionId: Long,
    transform: (Question) -> Question
): QuestionUiState {
    val updatedList = allQuestions.toList().map { question ->
        if (question.id == questionId) transform(question) else question
    }
    return copy(allQuestions = Questions(updatedList))
}
designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/theme/Color.kt (1)

81-93: Exam 관련 색상 상수 추가 확인 ✓

시험 기능을 위한 색상 상수들이 기존 네이밍 컨벤션(PascalCase, Dark 접미사)을 잘 따르고 있습니다. Light/Dark 모드 변형이 적절히 정의되어 있네요.

한 가지 확인 사항: ExamUnSelected에 대한 다크 모드 변형(ExamUnSelectedDark)이 누락된 것 같습니다. 다크 모드에서도 ExamUnSelected 색상을 그대로 사용할 계획인지, 아니면 별도의 다크 모드 색상이 필요한지 검토해 주세요. 기존 패턴을 보면 대부분의 색상에 Dark 변형이 있어서 일관성을 위해 추가를 고려해 볼 수 있습니다.

🎨 다크 모드 색상 추가 제안 (선택 사항)
 val ExamSelected = Color(0xFFFF9800)
 val ExamUnSelected = Color(0xFFFCEEDA)
 val ExamSelectedDark = Color(0xFFFFB74D)
+val ExamUnSelectedDark = Color(0xFF3D3530) // 다크 모드에서 적절한 unselected 색상
composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AddAnswerBottomSheet.kt (1)

111-121: 저장 검증 조건이 두 곳에서 다르게 유지됩니다.

왜 문제인가: 지금은 enabled가 막아주지만, 향후 로직이 바뀌면 공백 입력이 통과될 여지가 있습니다.
어떻게 개선: 동일한 predicate를 공유하도록 정리하는 게 안전하지 않을까요?

♻️ 제안 리팩터
-                    TextButton(
+                    val isValid = draftAnswer.trim().isNotEmpty()
+                    TextButton(
                         onClick = {
-                            if (draftAnswer.isNotEmpty()) {
+                            if (isValid) {
                                 scope.launch {
                                     onSave(draftAnswer)
                                     sheetState.hide()
                                     onDismiss()
                                 }
                             }
                         },
-                        enabled = draftAnswer.trim().isNotEmpty(),
+                        enabled = isValid,
                     ) {
composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamUiEvent.kt (1)

6-9: categories 파라미터에 ImmutableList 사용을 고려해 볼 수 있습니다.

현재 List<Category>를 사용하고 있는데, 프로젝트의 다른 부분(예: QuestionFilterChipsImmutableSet<Category>)에서는 kotlinx.collections.immutable을 활용하고 있습니다. 일관성을 위해 ImmutableList<Category> 사용을 고려해 볼 수 있습니다.

다만, 이 이벤트는 한 번 소비되고 버려지는 특성이 있어 현재 구현도 실질적인 문제는 없습니다.

♻️ 선택적 개선안
+import kotlinx.collections.immutable.ImmutableList
+
 sealed interface ExamUiEvent {
     data class NavigateToExamProgress(
         val questionCount: Int,
-        val categories: List<Category>,
+        val categories: ImmutableList<Category>,
     ) : ExamUiEvent
composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/component/ExamQuestionHeader.kt (1)

17-38: 잘 구현된 Composable 컴포넌트입니다!

ExamQuestionHeader가 Compose 권장 패턴을 잘 따르고 있습니다:

  • modifier 파라미터를 마지막 위치에 기본값과 함께 제공
  • 단일 책임 원칙 준수
  • CategoryBadge 재사용으로 일관된 UI 유지

한 가지 개선 제안: 8.dp 매직 넘버를 Dimen 상수로 추출하면 디자인 시스템 일관성이 더 좋아질 수 있습니다. 예를 들어 Dimen.spacingSm 또는 Dimen.spacingBase를 사용하는 것은 어떨까요?

♻️ Dimen 상수 활용 제안
 `@Composable`
 fun ExamQuestionHeader(
     category: Category,
     questionContent: String,
     modifier: Modifier = Modifier,
 ) {
     Column(
         modifier = modifier,
-        verticalArrangement = Arrangement.spacedBy(8.dp),
+        verticalArrangement = Arrangement.spacedBy(Dimen.spacingBase),
     ) {
designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/theme/AppTheme.kt (2)

16-34: ExtendedColors에 미선택 색상도 포함시키는 게 좋습니다.

왜: 선택/미선택 색상을 컴포넌트가 직접 상수로 참조하면 테마 전환 시 불일치가 생길 수 있어요.
어떻게: examUnSelected를 확장 팔레트에 포함해 라이트/다크 모두 주입하도록 해주세요.

♻️ 제안 수정안
 data class ExtendedColors(
     val examSelected: Color = ExamSelected,
+    val examUnSelected: Color = ExamUnSelected,
     val examCorrect: Color = ExamCorrect,
     val examCorrectBackground: Color = ExamCorrectBackground,
 )
@@
 private val LightExtendedColors =
     ExtendedColors(
         examSelected = ExamSelected,
+        examUnSelected = ExamUnSelected,
         examCorrect = ExamCorrect,
         examCorrectBackground = ExamCorrectBackground,
     )
@@
 private val DarkExtendedColors =
     ExtendedColors(
         examSelected = ExamSelectedDark,
+        examUnSelected = ExamUnSelectedDark,
         examCorrect = ExamCorrectDark,
         examCorrectBackground = ExamCorrectBackgroundDark,
     )

38-55: 커스텀 colorScheme 전달 시 확장 컬러 불일치 가능성이 있습니다.

왜: extendedColorsuseDarkTheme만 기준으로 결정되어, 호출부에서 colorScheme를 직접 넘기면 팔레트가 어긋날 수 있어요.
어떻게: extendedColors를 파라미터로 분리하거나 colorScheme/테마 상태에 맞춰 계산하도록 조정하는 게 안전합니다. 사용처에 커스텀 스킴 전달 케이스가 있는지 확인해주실 수 있을까요?

domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/exam/ExamQuestion.kt (1)

5-9: LGTM! 깔끔한 도메인 모델 설계입니다.

불변 data class로 설계되어 도메인 모듈 가이드라인(Android 의존성 없음, 불변 객체)을 잘 따르고 있습니다. 필요한 속성만 간결하게 포함하고 있어 Single Responsibility Principle도 잘 지켜지고 있습니다.

선택적 제안: questionId가 양수여야 하거나 content가 비어있지 않아야 하는 비즈니스 규칙이 있다면, init 블록에서 유효성 검증을 추가하는 것을 고려해볼 수 있습니다. 다만 이는 비즈니스 요구사항에 따라 결정하시면 됩니다.

composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/QuestionCountChip.kt (1)

33-81: 선택 상태가 접근성 서비스에 노출되지 않고, 색상이 하드코딩되어 테마 대응이 약합니다.

왜 문제인가: clickable만 사용하면 스크린리더가 선택 여부를 알기 어렵고, Color.White는 커스텀 테마/다크 모드에서 대비 이슈가 날 수 있습니다.
어떻게 개선할까: selectable로 선택 상태/Role을 제공하고, 텍스트 색상은 onPrimary 등 테마 색을 사용해 주세요.

♻️ 개선 제안
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.ui.semantics.Role
...
-                ).clickable(onClick = onClick)
+                ).selectable(
+                    selected = selected,
+                    onClick = onClick,
+                    role = Role.RadioButton,
+                )
...
-                        Color.White
+                        MaterialTheme.colorScheme.onPrimary
...
-                        Color.White
+                        MaterialTheme.colorScheme.onPrimary
composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/BottomNavigationType.kt (1)

22-25: EXAM과 리소스 키명이 불일치해 유지보수 혼동 가능성이 있습니다.

왜 문제인가: enum 이름은 EXAM인데 라벨 리소스가 bottom_nav_test라 의미가 엇갈려 보입니다.
어떻게 개선할까: bottom_nav_exam 같은 키로 정리하거나, 의도적으로 유지한 것이라면 주석/컨벤션으로 설명해 주실 수 있을까요?

composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/EmptyHistoryState.kt (1)

32-36: 아이콘이 의미를 전달한다면 contentDescription 제공을 고려해 주세요.

왜 문제인가: Line 32-36에서 contentDescription = null이라 스크린리더가 아이콘 의미를 읽지 못합니다.
어떻게 개선하나: 아이콘이 상태 의미를 전달한다면 문자열 리소스로 설명을 제공해 주세요. 장식용이라면 현재처럼 null 유지가 맞습니다. 의도는 어떤가요?

📝 예시 수정안
-        Icon(
-            imageVector = Icons.Default.CheckCircle,
-            contentDescription = null,
+        Icon(
+            imageVector = Icons.Default.CheckCircle,
+            contentDescription = stringResource(Res.string.exam_history_empty_title),
             modifier = Modifier.size(80.dp),
             tint = MaterialTheme.colorScheme.outlineVariant,
         )
composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/navigation/MainNavGraphContributor.kt (2)

46-62: 타입 안전 네비게이션에서 navArgument() 제거 권장

@Serializable 어노테이션이 적용된 NavRoutes.ExamDetail을 사용하고 있으므로, 타입 안전한 라우팅 패턴을 완전히 활용하기 위해 lines 48-53의 navArgument() 선언을 제거해도 됩니다.

Navigation Compose 2.8+에서는 타입 안전 라우팅을 사용할 때, toRoute<T>()만으로 충분합니다:

composable(
    route = NavRoutes.ExamDetail.ROUTE,
) { backStackEntry ->
    val args = backStackEntry.toRoute<NavRoutes.ExamDetail>()
    ExamDetailScreen(
        examId = args.examId,
        onNavigateBack = {
            navController.popBackStack(NavRoutes.Main.route, inclusive = false)
        },
    )
}

현재 코드는 정상 동작하지만, navArgument() 정의가 중복되면 유지보수 시 두 곳을 수정해야 합니다. 타입 정보의 단일 출처(source of truth)를 유지하는 것이 더 깔끔합니다.


32-38: savedStateHandle을 통한 데이터 전달 방식에서 타입 안전성 개선이 가능합니다.

현재 구현을 살펴보니, categories를 문자열 배열로 전달한 후 ExamProgressNavGraphContributor에서 Category.from(name)으로 다시 복원하고 있습니다. Category enum에 case-insensitive 매칭을 지원하는 from() 함수가 있어 현재는 정상 작동합니다.

다만 이 방식은 다음과 같은 잠재적 문제가 있습니다:

  1. enum 값 변경 시 런타임 에러: 카테고리 이름이 변경되면 from() 함수에서 error를 발생시킵니다.
  2. 문자열 기반 매칭의 취약성: 리팩토링 시 enum 이름을 변경하면 해당 변경사항을 모든 통신 지점에서 추적해야 합니다.

Navigation Compose의 Type-safe navigation 을 사용하여 @Serializable route 클래스에 Category 타입을 직접 포함시키는 방식이 장기적으로 더 안전합니다. 이렇게 하면 컴파일 타임에 타입 검사가 가능해져 runtime 에러를 방지할 수 있습니다.

현재 구현은 동작하므로 문제 없지만, 향후 리팩토링 시 이러한 접근 방식을 검토해 보시길 권장합니다.

domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/exam/Exams.kt (1)

3-23: LGTM! 불변 도메인 모델로 잘 설계되었습니다.

Exams 클래스가 불변 객체로 설계되어 도메인 모델 가이드라인을 잘 따르고 있습니다. operator fun get을 통한 편리한 접근과 updateAnswer의 불변 업데이트 패턴이 적절합니다.

♻️ (선택 사항) 리스트 업데이트를 더 간결하게 표현할 수 있습니다

mapIndexed를 사용하면 중간 mutableList 생성 없이 더 선언적으로 표현할 수 있습니다:

 fun updateAnswer(
     questionId: Long,
     answer: String,
 ): Exams {
     val existingExamIndex = values.indexOfFirst { it.questionId == questionId }

     return if (existingExamIndex >= 0) {
-        val updatedValues =
-            values.toMutableList().apply {
-                set(existingExamIndex, Exam(questionId, answer))
-            }
-        copy(values = updatedValues)
+        copy(values = values.mapIndexed { index, exam ->
+            if (index == existingExamIndex) Exam(questionId, answer) else exam
+        })
     } else {
         copy(values = values + Exam(questionId, answer))
     }
 }
composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/ExamHistoryTab.kt (1)

36-46: 삭제 가능한 리스트는 key 지정이 안전합니다

items에 key가 없으면 삭제/정렬 시 항목 상태가 섞일 수 있습니다 → 고유 id를 key로 지정해 Compose가 항목을 안정적으로 추적하도록 해 주세요.

♻️ 제안 수정
-                LazyColumn(
+                LazyColumn(
                     modifier = Modifier.fillMaxSize(),
                     verticalArrangement = Arrangement.spacedBy(12.dp),
                     contentPadding = PaddingValues(16.dp),
                 ) {
-                    items(state.histories) { history ->
+                    items(state.histories, key = { it.id }) { history ->
                         ExamHistoryCard(
                             uiModel = history,
                             onClick = { onOpenExamHistory(history.id) },
                             onDeleteClick = { onDeleteExam(history.id) },
                         )
                     }
                 }
composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/ExamCompleteScreen.kt (2)

131-147: 하드코딩된 색상 값을 테마 색상으로 교체하는 것을 권장합니다.

현재 Color(0xFFB8E6D5)Color(0xFF34A853)가 직접 하드코딩되어 있습니다. 이렇게 하면 다크 테마 지원 시 일관성이 깨질 수 있고, 디자인 시스템 변경 시 여러 곳을 수정해야 합니다.

테마의 ExtendedColorsMaterialTheme.colorScheme에 성공/완료 관련 색상을 정의하고 사용하는 것이 유지보수성 측면에서 더 좋습니다. 어떻게 생각하시나요?

♻️ 권장 수정 예시
 Box(
     modifier =
         Modifier
             .size(80.dp)
             .background(
-                color = Color(0xFFB8E6D5),
+                color = MaterialTheme.colorScheme.primaryContainer,
                 shape = CircleShape,
             ),
     contentAlignment = Alignment.Center,
 ) {
     Icon(
         imageVector = Icons.Default.Check,
         contentDescription = null,
         modifier = Modifier.size(32.dp),
-        tint = Color(0xFF34A853),
+        tint = MaterialTheme.colorScheme.primary,
     )
 }

47-63: 로딩 및 에러 상태 처리를 추가하면 UX가 개선됩니다.

현재 ExamCompleteScreen은 데이터 로딩 중이나 에러 발생 시 사용자에게 피드백을 제공하지 않습니다. LaunchedEffect로 데이터를 불러오는 동안 빈 화면이 표시될 수 있습니다.

ExamCompleteUiStateisLoading, error 상태를 추가하고, UI에서 이를 처리하면 사용자 경험이 향상됩니다. 현재 PR 범위를 고려해 추후 개선 사항으로 검토해 주세요.

composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/vm/ExamProgressViewModel.kt (1)

74-84: cancelExamsendEvent에서 불필요한 중첩 코루틴이 있습니다.

cancelExam()은 이미 viewModelScope.launch를 사용하고 있고, 내부에서 호출하는 sendEvent()도 다시 viewModelScope.launch를 사용합니다. 이는 불필요한 중첩입니다.

cancelExam의 경우 코루틴 없이 직접 sendEvent를 호출하거나, sendEvent에서 코루틴을 제거하는 방향으로 개선할 수 있습니다.

♻️ 코루틴 중첩 제거 제안
 fun cancelExam() {
-    viewModelScope.launch {
-        sendEvent(ExamProgressUiEvent.NavigateBack)
-    }
+    sendEvent(ExamProgressUiEvent.NavigateBack)
 }

 private fun sendEvent(event: ExamProgressUiEvent) {
     viewModelScope.launch {
         _uiEvent.send(event)
     }
 }

또는 Channel.trySend를 사용하면 코루틴 없이도 가능합니다:

 private fun sendEvent(event: ExamProgressUiEvent) {
-    viewModelScope.launch {
-        _uiEvent.send(event)
-    }
+    _uiEvent.trySend(event)
 }
composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/ExamProgressScreen.kt (2)

63-65: LaunchedEffect(Unit) 사용에 대해 의도를 확인해주세요.

현재 LaunchedEffect(Unit)을 사용하여 최초 컴포지션 시에만 문제를 로드합니다. 만약 questionCountcategories가 변경될 가능성이 있다면, 이들을 키로 추가하는 것이 좋습니다.

현재 설계가 의도적이라면 문제없습니다. 다만, 향후 재사용성을 고려하면 파라미터 변경에도 반응하도록 할 수 있습니다.

// 파라미터 변경 시에도 재로드가 필요한 경우:
LaunchedEffect(questionCount, categories) {
    viewModel.loadExamQuestions(questionCount, categories)
}

116-121: 문제 목록이 비어있을 때 "1/0" 표시를 개선할 수 있습니다.

questions.isEmpty()일 때 "${uiState.currentQuestionIndex + 1}/${uiState.questions.size}"가 "1/0"으로 표시됩니다. 로딩 중일 때는 이 영역을 숨기거나 적절한 placeholder를 표시하는 것이 사용자 경험에 더 좋을 수 있습니다.

♻️ 조건부 표시 제안
 actions = {
-    Text(
-        text =
-            "${uiState.currentQuestionIndex + 1}/${uiState.questions.size}",
-        style = MaterialTheme.typography.titleMedium,
-        modifier = Modifier.padding(end = 16.dp),
-    )
+    if (uiState.questions.isNotEmpty()) {
+        Text(
+            text = "${uiState.currentQuestionIndex + 1}/${uiState.questions.size}",
+            style = MaterialTheme.typography.titleMedium,
+            modifier = Modifier.padding(end = 16.dp),
+        )
+    }
 },
composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/vm/ExamViewModel.kt (1)

34-39: HISTORY 탭 선택 시 매번 데이터를 리로드하는 것이 의도적인가요?

selectTab(ExamTab.HISTORY) 호출 시마다 loadExamHistory()가 실행됩니다. 데이터가 변경되지 않았더라도 매번 네트워크 요청이 발생하여 불필요한 로딩 표시나 깜빡임이 발생할 수 있습니다.

사용자가 탭을 자주 전환하는 경우를 고려하면, 캐시나 조건부 리프레시를 도입하는 것도 방법입니다. 물론 항상 최신 데이터를 보여주려는 의도라면 현재 구현도 괜찮습니다.

composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/component/ExamNavigationButtons.kt (1)

132-177: 활성 상태 텍스트/아이콘 색상을 onPrimary로 맞추면 테마 대비가 안정적입니다.

왜: Color.White 고정은 밝은 primary나 커스텀 테마에서 대비가 부족할 수 있습니다.
어떻게: MaterialTheme.colorScheme.onPrimary로 전환하면 테마 대비가 자동 보장됩니다.

🎨 제안 수정안
-            Text(
+            Text(
                 text =
                     if (isLastQuestion) {
                         stringResource(Res.string.exam_button_submit)
                     } else {
                         stringResource(Res.string.exam_button_next)
                     },
                 style = MaterialTheme.typography.titleMedium,
                 fontWeight = FontWeight.Bold,
-                color = if (isEnabled) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
+                color = if (isEnabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
             )
             Icon(
                 imageVector =
                     if (isLastQuestion) {
                         Icons.Default.Send
                     } else {
                         Icons.AutoMirrored.Filled.ArrowForward
                     },
                 contentDescription = null,
-                tint = if (isEnabled) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
+                tint = if (isEnabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
             )
composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/MainScreen.kt (1)

130-139: 프리뷰에서 Koin 미초기화로 크래시가 날 수 있어요.

왜: Preview 환경에서는 DI가 초기화되지 않아 koinViewModel()이 실패할 수 있습니다.
어떻게: 프리뷰에서는 MainContent를 직접 호출하거나, 프리뷰 전용 뷰모델을 주입하도록 분리해 보는 건 어떨까요?

🧪 제안 수정안
 `@Preview`
 `@Composable`
 fun MainScreenPreview() {
     AppTheme {
-        MainScreen(
-            onNavigateToQuestionDetail = {},
-            onNavigateToExamProgress = { _, _ -> },
-            onNavigateToExamResult = {},
-        )
+        MainContent(
+            selectedTab = BottomNavigationType.QUESTION,
+            onNavigateToQuestionDetail = {},
+            onNavigateToExamProgress = { _, _ -> },
+            onNavigateToExamResult = {},
+            savedStateHandle = null,
+        )
     }
 }

Comment on lines +19 to +28
fun loadExamDetail(examId: Long) {
viewModelScope.launch {
examRepository
.fetchExamDetail(examId)
.onSuccess { examDetails ->
_uiState.update {
it.updateExamDetails(examDetails)
}
}
}
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

실패/로딩 처리가 없어서 네트워크 오류 시 화면이 조용히 멈출 수 있습니다.

왜 문제인가: onSuccess만 처리하면 실패 시 UI 상태가 갱신되지 않아 사용자가 원인을 알기 어렵습니다.
어떻게 개선할까: 로딩 플래그와 오류 상태를 ExamCompleteUiState에 반영해 onFailure를 처리하고, 성공/실패 모두에서 로딩을 해제해 주세요. (필드가 없다면 추가를 고려해 주세요.)

🤖 Prompt for AI Agents
In
`@composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/vm/ExamCompleteViewModel.kt`
around lines 19 - 28, The loadExamDetail function currently only handles
onSuccess and never updates loading/error state; modify loadExamDetail (inside
viewModelScope.launch where examRepository.fetchExamDetail is called) to set a
loading flag in ExamCompleteUiState before the call, handle both onSuccess and
onFailure (use onFailure to update an error field/message in
ExamCompleteUiState), and ensure the loading flag is cleared in both branches
(or finally) when updating _uiState via _uiState.update; add loading/error
fields to ExamCompleteUiState if they don't exist.

Comment on lines +19 to +27
fun loadExamDetail(examId: Long) {
viewModelScope.launch {
examRepository
.fetchExamDetail(examId)
.onSuccess { examDetail ->
_uiState.update {
it.updateExamQuestions(examDetail)
}
}
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

실패 처리 누락으로 상세 화면이 빈 상태로 남을 수 있어요

fetchExamDetail 실패 시 onFailure 처리가 없어 UI 상태가 갱신되지 않습니다 → 사용자 입장에선 빈 리스트만 보이거나 로딩이 멈춘 것으로 보일 수 있어요. 실패 상태/재시도 가능 여부를 ExamDetailUiState에 반영하거나 에러 이벤트를 노출하도록 보완해 주세요. 실패 UI/로그 처리를 다른 곳에서 하고 계신가요?

🤖 Prompt for AI Agents
In
`@composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/detail/vm/ExamDetailViewModel.kt`
around lines 19 - 27, loadExamDetail currently only handles
fetchExamDetail().onSuccess so failures leave the UI unchanged; add an onFailure
handler to catch errors from fetchExamDetail(examId) inside
viewModelScope.launch and update the _uiState (ExamDetailUiState) to reflect the
error state (e.g., set an error message, isLoading=false, and a retryable flag)
or emit an error event via the ViewModel event channel; also ensure you log the
exception. Modify ExamDetailUiState (or expose an error event) so loadExamDetail
can set a visible error and support retry, and update callers to handle that new
state/event.

Comment on lines +26 to +39
composable(NavRoutes.ExamProgress.route) { backStackEntry ->
val questionCount =
navController.previousBackStackEntry
?.savedStateHandle
?.get<Int>("questionCount") ?: 5

val categoriesArray =
navController.previousBackStackEntry
?.savedStateHandle
?.get<Array<String>>("categories") ?: emptyArray()

ExamProgressScreen(
questionCount = questionCount,
categories = categoriesArray.map { name -> Category.from(name) },
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

🧩 Analysis chain

🏁 Script executed:

# First, let's understand the file structure and find related navigation files
find composeApp/src -name "*ExamProgress*" -type f | head -20

Repository: chanho0908/DroidMorning

Length of output: 680


🏁 Script executed:

# Search for where ExamProgress route is defined and used
rg -n "ExamProgress\.(route|Graph)" --type kt

Repository: chanho0908/DroidMorning

Length of output: 93


🏁 Script executed:

# Find where savedStateHandle is set for questionCount and categories
rg -n "savedStateHandle.*questionCount|savedStateHandle.*categories" --type kt

Repository: chanho0908/DroidMorning

Length of output: 93


🏁 Script executed:

# Search for navigation to ExamProgress to see how values are passed
rg -n "NavigateToExamProgress|navController.*navigate.*ExamProgress" --type kt

Repository: chanho0908/DroidMorning

Length of output: 93


🏁 Script executed:

# Read the full ExamProgressNavGraphContributor.kt file to understand the context
cat -n composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/navigation/ExamProgressNavGraphContributor.kt

Repository: chanho0908/DroidMorning

Length of output: 2446


🏁 Script executed:

# Search for savedStateHandle usage without type restriction
rg -n "savedStateHandle.*questionCount|savedStateHandle.*categories|questionCount.*savedStateHandle"

Repository: chanho0908/DroidMorning

Length of output: 49


🏁 Script executed:

# Search for navigation to ExamProgress
rg -n "NavigateToExamProgress|navigate.*ExamProgress"

Repository: chanho0908/DroidMorning

Length of output: 1918


🏁 Script executed:

# Search for where questionCount and categories are set in navigation context
rg -n "previousBackStackEntry|savedStateHandle" -A 3 -B 3

Repository: chanho0908/DroidMorning

Length of output: 14698


🏁 Script executed:

# Check the MainNavGraphContributor to see where values are set
cat -n composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/navigation/MainNavGraphContributor.kt

Repository: chanho0908/DroidMorning

Length of output: 3180


🏁 Script executed:

# Search for NavRoutes definitions to understand the graph structure
rg -n "object NavRoutes|sealed class NavRoutes" -A 20

Repository: chanho0908/DroidMorning

Length of output: 2378


🏁 Script executed:

# Check if route arguments are defined for ExamProgress
rg -n "ExamProgress|ExamProgressGraph" composeApp/src/commonMain/kotlin/com/peto/droidmorning/navigation/

Repository: chanho0908/DroidMorning

Length of output: 357


🏁 Script executed:

# Let's verify the back stack entry structure by checking if there's any custom handling
# or if the graph entry even has the savedStateHandle values
rg -n "backStackEntry|savedStateHandle" composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/navigation/ExamProgressNavGraphContributor.kt -B 2 -A 2

Repository: chanho0908/DroidMorning

Length of output: 647


🏁 Script executed:

# Let's check what the complete NavRoutes file looks like to understand all routes
cat -n composeApp/src/commonMain/kotlin/com/peto/droidmorning/navigation/NavRoutes.kt

Repository: chanho0908/DroidMorning

Length of output: 2109


🏁 Script executed:

# Check if ExamProgressGraph is navigated to from other places
rg -n "ExamProgressGraph|navigate.*ExamProgress" --type-not=rust

Repository: chanho0908/DroidMorning

Length of output: 562


🏁 Script executed:

# Check if there are any tests or other navigation flows
rg -n "onNavigateToExamProgress|NavigateToExamProgress" -B 3 -A 3

Repository: chanho0908/DroidMorning

Length of output: 8958


🏁 Script executed:

# Let's trace the actual flow: where does NavigateToExamProgress event come from?
cat -n composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/ExamScreen.kt | head -60

Repository: chanho0908/DroidMorning

Length of output: 3379


중첩 그래프에서 사용자의 선택값이 무시되고 있습니다

ExamProgressGraph로 이동할 때 값이 올바르게 전달되지 않습니다. MainNavGraphContributor에서 Main 엔트리의 savedStateHandle에 값을 설정하지만, ExamProgressNavGraphContributor에서는 previousBackStackEntry(= ExamProgressGraph 엔트리)에서 읽으려고 합니다. 결과적으로 값을 찾을 수 없어 기본값(questionCount: 5, categories: empty)으로 진행되어 사용자의 선택이 무시됩니다.

해결 방안:

  • QuestionDetail처럼 route arguments로 questionCountcategories를 정의하고 전달하거나
  • 또는 공유 ViewModel이나 전역 상태 관리를 통해 데이터를 공유하세요
현재 코드 흐름
// MainNavGraphContributor.kt:33-37
navController.currentBackStackEntry?.savedStateHandle?.apply {
    set("questionCount", questionCount)           // ← Main 엔트리에 저장
    set("categories", categories.map { it.name }.toTypedArray())
}
navController.navigate(NavRoutes.ExamProgressGraph.route)

// ExamProgressNavGraphContributor.kt:28-35
navController.previousBackStackEntry                    // ← ExamProgressGraph 엔트리를 가리킴
    ?.savedStateHandle
    ?.get<Int>("questionCount") ?: 5                    // ← 값이 없음 → 기본값 사용
🤖 Prompt for AI Agents
In
`@composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/navigation/ExamProgressNavGraphContributor.kt`
around lines 26 - 39, The problem is that ExamProgressNavGraphContributor reads
saved state from previousBackStackEntry (the ExamProgressGraph entry) so it
misses values Main stored on currentBackStackEntry; update the retrieval to read
from navController.currentBackStackEntry?.savedStateHandle (matching where
MainNavGraphContributor set "questionCount" and "categories") in the composable
for NavRoutes.ExamProgress.route (replace usages of
previousBackStackEntry?.savedStateHandle?.get<Int>("questionCount") and
?.get<Array<String>>("categories") with
currentBackStackEntry?.savedStateHandle?.get...), or alternatively implement
route arguments on NavRoutes.ExamProgress or a shared ViewModel/global state if
you prefer that approach.

Comment on lines +9 to +15
data class ExamRpcRequest(
val uid: String,
@SerialName("total_questions")
val questionsCount: Int,
val categories: List<Category>,
val items: List<ExamItemRpcRequest>,
)
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

🧩 Analysis chain

🏁 Script executed:

# Find the Category definition
find . -name "*.kt" -type f | xargs rg -l "class Category|data class Category" | head -20

Repository: chanho0908/DroidMorning

Length of output: 331


🏁 Script executed:

# Look at the complete ExamRpcRequest file
cat -n data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/ExamRpcRequest.kt

Repository: chanho0908/DroidMorning

Length of output: 953


🏁 Script executed:

# Search for Category definition in the data module
rg "class Category|data class Category" data/ -A 3

Repository: chanho0908/DroidMorning

Length of output: 525


🏁 Script executed:

# Check if there are mapping functions related to ExamRpcRequest
rg "toRequest\|ExamRpcRequest\|toRpcRequest" data/ -B 2 -A 5

Repository: chanho0908/DroidMorning

Length of output: 49


🏁 Script executed:

# Look for any Serializer implementations for Category
rg "Serializer.*Category|CategorySerializer" . -A 5

Repository: chanho0908/DroidMorning

Length of output: 49


🏁 Script executed:

# Examine the Category domain model definition
cat -n domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/category/Category.kt

Repository: chanho0908/DroidMorning

Length of output: 644


🏁 Script executed:

# Check if Category is imported/defined as `@Serializable`
rg "@Serializable.*Category|Category.*@Serializable" domain/ -B 2 -A 5

Repository: chanho0908/DroidMorning

Length of output: 49


🏁 Script executed:

# Look for how other request DTOs handle domain models
rg "@Serializable" data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/ -A 10 | head -60

Repository: chanho0908/DroidMorning

Length of output: 6169


🏁 Script executed:

# Check ExamQuestionRequest to see the correct pattern
cat -n data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/ExamQuestionRequest.kt

Repository: chanho0908/DroidMorning

Length of output: 455


🏁 Script executed:

# Verify if there's any serialization setup for Category enum elsewhere
rg "Category" data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/ -B 1 -A 1

Repository: chanho0908/DroidMorning

Length of output: 1135


RPC 요청 DTO에서 도메인 Category 모델의 직렬화 책임 분리 필요합니다

ExamRpcRequest가 도메인 모델 Category enum을 직렬화하려 하는데, 이는 두 가지 문제를 야기합니다:

  1. 아키텍처 원칙 위반: 코딩 가이드라인에 따르면 DTO와 도메인 모델은 명확히 분리되어야 합니다. 현재 ExamQuestionRequestList<String>으로 올바르게 구현했는데, ExamRpcRequest만 도메인 모델을 직렬화하고 있습니다.
  2. 직렬화 불일치 위험: Category enum에는 @Serializable 어노테이션이 없어서 직렬화 동작이 명확하지 않으며, 서버 스키마가 문자열을 기대한다면 요청이 실패할 수 있습니다.

개선 방법: toRequest() 함수에서 Category를 문자열로 매핑하도록 변경하세요:

적용 예시
 `@Serializable`
 data class ExamRpcRequest(
     val uid: String,
     `@SerialName`("total_questions")
     val questionsCount: Int,
-    val categories: List<Category>,
+    val categories: List<String>,
     val items: List<ExamItemRpcRequest>,
 )

 fun Exams.toRequest(
     uid: String,
     categories: List<Category>,
 ): ExamRpcRequest =
     ExamRpcRequest(
         uid = uid,
         questionsCount = values.size,
-        categories = categories,
+        categories = categories.map { it.name },
         items = values.map { it.toRequest() },
     )
🤖 Prompt for AI Agents
In
`@data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/ExamRpcRequest.kt`
around lines 9 - 15, ExamRpcRequest is currently serializing the domain Category
enum (categories: List<Category>), which breaks DTO/domain separation and risks
serialization mismatch; change the DTO to use categories: List<String> instead
and update wherever ExamRpcRequest is constructed (notably the toRequest()
converter) to map each Category to its string representation (e.g.,
category.name or a defined string value) before building the ExamRpcRequest,
ensuring all code paths creating ExamRpcRequest use the string list rather than
the Category enum.

Comment on lines 11 to 17
companion object {
fun from(value: String): Category =
entries
.find { it.name.equals(value, ignoreCase = true) }
?: error("$UNKNOWN_CATEGORY_EXCEPTION $value")

private const val UNKNOWN_CATEGORY_EXCEPTION = "Unknown category:"
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

알 수 없는 카테고리 입력 시 앱이 바로 크래시될 수 있습니다.

왜 문제인가: 서버에서 신규 카테고리가 추가되거나 오탈자/공백이 오면 error()로 앱이 종료됩니다.
어떻게 개선: 안전한 fallback(예: Unknown)을 두거나 null 반환 후 상위에서 처리하도록 바꾸는 게 좋지 않을까요?

💡 예시 수정안 (Unknown 추가)
 enum class Category {
     Kotlin,
     Coroutine,
     Android,
     Compose,
     OOP,
+    Unknown,
     ;
 
     companion object {
         fun from(value: String): Category =
             entries
-                .find { it.name.equals(value, ignoreCase = true) }
-                ?: error("$UNKNOWN_CATEGORY_EXCEPTION $value")
-
-        private const val UNKNOWN_CATEGORY_EXCEPTION = "Unknown category:"
+                .find { it.name.equals(value.trim(), ignoreCase = true) }
+                ?: Unknown
     }
 }
🤖 Prompt for AI Agents
In
`@domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/category/Category.kt`
around lines 11 - 17, The current Category.from(value: String) uses error(...)
and will crash on unknown inputs; change it to a safe fallback by either (A)
adding an Unknown entry to the Category enum and returning that from
Category.from instead of calling error (update UNKNOWN_CATEGORY_EXCEPTION usage
accordingly), or (B) provide a nullable alternative like
Category.fromOrNull(value: String): Category? that returns null when not found
and keep from(...) throwing if you want to preserve strict behavior; modify call
sites that rely on from to handle the new Unknown or nullable return.

@chanho0908 chanho0908 merged commit effecec into main Jan 19, 2026
2 checks passed
@chanho0908 chanho0908 deleted the feature/35-exam-feature branch January 19, 2026 16:11
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.

시험 기능 구현

1 participant