Repository navigation
fix(mindrecord): 수신인 조회 실패를 «등록 없음» 과 구분해 알린다 (#1019) - #1152
Conversation
loadReceivers 가 실패를 조용히 흡수해, 화면이 «아직 등록 안 함» 과 «못 불러옴» 을 같은 빈 목록으로 그렸다. 수신자 최소 1명이 필수인 경로가 있어 조회 실패가 곧 등록 불가로 이어지는데 그 사유가 화면 어디에도 없었다. - 상태에 receiverLoadError 를 두고 시트가 문구를 갈라 보여 준다. 실패일 때만 «다시 시도». - 문구는 도메인 고정값이다 — 예외 원문을 쓰면 오프라인에서 «Unable to resolve host …» 가 그대로 노출된다 (에뮬레이터에서 실제로 확인하고 고쳤다, #614 와 같은 규칙). - 호출부 0건이던 consumeImageUploadError 를 지웠다. 업로드 실패 문구의 수명은 «다음 업로드 시작까지» 이고, 걷는 함수만 두면 죽은 코드가 된다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1hyok
left a comment
There was a problem hiding this comment.
변경 요청
문제 정의와 해법 방향은 옳다. 실패를 빈 목록으로 흡수하면 사용자가 «등록 안 함» 으로 읽는다는 것, 예외 원문을 화면 문구로 쓰면 안 된다는 것(#614), 미등록에는 재시도를 주면 안 된다는 것 — 셋 다 맞고 테스트로도 박혔다. 곁다리인 consumeImageUploadError() 삭제도 실측으로 확인했다(호출부 0건, 두 VM 모두). DailyQuestionWriteScreen 은 수신인 시트를 쓰지 않으므로 «한쪽만 고친 것» 도 아니다.
막는 것은 하나다 — 재시도를 누른 직후, 이 PR 이 없애려던 그 혼동이 그대로 돌아온다.
- [blocking] 재시도가 «못 불러옴» 을 «등록 안 함» 으로 되돌린다 →
DiaryWriteViewModel.kt:204인라인 코멘트. - [normal] 같은 «다시 시도» 문자열이 이제 셋이다 →
strings.xml:215인라인 코멘트. - [normal] 재시도 어피던스가 이 모듈의 기존 것과 갈린다 (터치 타깃 포함) →
ReceiverSelectBottomSheet.kt:133인라인 코멘트. - [normal] 이 PR 의 논리와 정반대인 주석이 남았다 →
DiaryWriteViewModel.kt:194인라인 코멘트.
검증
| 항목 | 결과 |
|---|---|
CI (head 903ada4f) |
전 항목 pass |
git merge-tree origin/develop |
clean |
| 교차 확인 — #1121 과 상호 병합 | clean (두 PR 이 DiaryWriteScreen·DiaryWriteViewModel·DiaryWriteUiState 를 함께 건드린다) |
| 이슈 범위 | #1019 의 두 갈래(수신인 실패 구분 · consumeImageUploadError) 모두 처리됨 |
실기동 QA — 미수행. 위 1번은 상태 머신에 «불러오는 중» 이 아예 없다는 코드 사실이라 실행이 새 정보를 주지 않는다. 나머지 셋도 런타임 재현이 필요한 종류가 아니다. 저자의 기내모드 실측은 본문에 있는 그대로 받아들였다 — 다만 그 조건이 1번을 가린다(인라인 참조).
시안 대조 — 진입 화면만 존재. Figma 2671:17921 을 열어 확인했다. 수신자 행(«박채연님에게 ›»)까지는 시안에 있으나 시트의 조회 실패 상태는 시안에 없다. 오류 상태 미제공은 이 파일에서 일반적이고, 미답변 디자인 질의는 FE 재량이라는 위임이 있으므로 블로커로 잡지 않는다 — 다만 3번(어피던스 통일)은 그 재량을 레포 안의 기존 선례 로 맞추자는 이야기다.
| fun loadReceivers() { | ||
| viewModelScope.launch { | ||
| runCatching { userRepository.getReceivers() } | ||
| _uiState.update { it.copy(receiverLoadError = null) } |
There was a problem hiding this comment.
[blocking] loadReceivers() 는 진입에서 receiverLoadError 를 지우는데, «불러오는 중» 을 표현하는 상태가 없다. 그래서 재시도를 누른 순간부터 응답이 올 때까지 시트가 이렇게 흐른다.
실패 문구 + 다시 시도 →(탭)→ «등록된 수신자가 없습니다» →(응답)→ 실패 문구 + 다시 시도
가운데 창에서는 receivers 가 여전히 빈 목록이고 receiverLoadError 만 null 이라, 시트의
text = loadError ?: stringResource(R.string.mindrecord_write_receiver_empty),가 미등록 문구 를 고른다. 이 PR 이 없애려던 바로 그 혼동이고, 이번엔 사용자가 «다시 시도» 를 눌러서 자기 손으로 불러온 화면이라 더 그럴듯하게 읽힌다.
창의 길이는 서버가 정한다. 기내모드는 DNS 에서 즉시 실패하므로 깜빡임에 그치지만, 서버가 응답하지 않는 경우엔 core/network/.../NetworkModule.kt:28-35 설정상 연결 10초(CONNECT_TIMEOUT_SECONDS) · 호출 전체 30초(CALL_TIMEOUT_SECONDS)까지 간다. PR 본문의 실측이 기내모드였던 것이 이 창을 짧게 만들어 가린 것으로 보인다.
로딩 상태 하나면 닫힌다.
// DiaryWriteUiState
val isReceiverLoading: Boolean = false,fun loadReceivers() {
viewModelScope.launch {
_uiState.update { it.copy(isReceiverLoading = true, receiverLoadError = null) }
runCatchingCancellable { userRepository.getReceivers() }
.onSuccess { … it.copy(isReceiverLoading = false, …) }
.onFailure { … it.copy(isReceiverLoading = false, receiverLoadError = …) }
}
}시트는 isReceiverLoading 이면 두 문구 대신 진행 표시를 그리면 된다. 덤으로 첫 진입에서 조회가 끝나기 전에 시트를 연 경우도 같은 자리에서 덮인다 — 그쪽은 이 PR 이전부터 있던 창이다.
| <string name="mindrecord_home_category_count_unavailable">–</string> | ||
| <!-- 수신인 조회 실패 — «등록된 수신인 없음» 과 구분해야 한다 (#1019). --> | ||
| <string name="mindrecord_error_receiver_load_failed">수신인 목록을 불러오지 못했어요.</string> | ||
| <string name="mindrecord_write_receiver_retry">다시 시도</string> |
There was a problem hiding this comment.
[normal] 같은 «다시 시도» 가 이 모듈 문자열에 이제 셋이다. 같은 파일 실측:
:199 mindrecord_error_retry «다시 시도»
:209 mindrecord_receiver_retry «다시 시도»
:215 mindrecord_write_receiver_retry «다시 시도» ← 이 PR
문구를 손볼 일이 생기면 셋 중 어디를 고쳐야 하는지 아무도 확신하지 못하고, 하나만 고치면 화면마다 다른 말이 뜬다. 기존 키를 쓰면 줄이 하나도 늘지 않는다.
| if (loadError != null && onRetry != null) { | ||
| Spacer(modifier = Modifier.height(12.dp)) | ||
| Text( | ||
| text = stringResource(R.string.mindrecord_write_receiver_retry), |
There was a problem hiding this comment.
[normal] 이 모듈엔 이미 «문구 + 재시도» 를 그리는 자리가 있다 — component/MindRecordErrorBox.kt. 주간리포트·일기·데일리질문·임시저장 네 화면이 그것을 쓰고, 재시도는 TextButton 으로 그린다.
여기만 평문 Text + Modifier.clickable 이라 둘이 갈린다.
- 모양 — 같은 앱 안에서 재시도가 두 형태가 된다.
- 터치 타깃 —
TextButton은 최소 48dp 를 보장하지만,captionLargeR텍스트에 붙인clickable은 글자 높이(≈18dp)가 전부다. 접근성 최소 타깃에 못 미치고, 실패 상황에서 «눌렀는데 안 눌린다» 가 겹치면 체감이 나쁘다.Role.Button을 준 것은 좋다 — 크기만 남았다.
MindRecordErrorBox 를 그대로 부르기는 어렵다(fillMaxSize() 라 시트 안에서 화면을 다 먹는다). 그 modifier 를 파라미터로 빼거나, 여기서 TextButton 으로만 바꿔도 2번은 닫힌다.
| @@ -193,11 +192,25 @@ class DiaryWriteViewModel | |||
| } | |||
|
|
|||
| // 실패해도 수신자 행이 "수신자 설정하기" 로 남을 뿐 작성 자체는 가능 — 에러는 조용히 무시. | |||
There was a problem hiding this comment.
[normal] 이 주석이 그대로 남았다. 바로 아래 새 KDoc 은 «실패를 빈 목록으로 흡수하지 않는다» 고 말하는데, 이 줄은 «에러는 조용히 무시» 라고 말한다 — 정반대다.
게다가 빈 줄로 떨어져 나와 이제 어느 함수에도 붙어 있지 않다. 지우면 된다.
시트는 «오류 문구 ?: 미등록 문구» 로 고른다. 재시도를 누르면 오류만 지워지고 목록은 아직 비어 있어, 응답이 올 때까지 «등록된 수신자가 없습니다» 가 떴다 — 이 PR 이 없애려던 혼동이 사용자가 직접 누른 화면으로 되돌아온다. 기내모드는 DNS 에서 즉시 실패해 이 창을 가렸고, 서버 무응답이면 연결 10초·호출 30초까지 간다. - DiaryWriteUiState 에 isReceiverLoading 을 두고, loadReceivers() 가 요청 전후로 세운다 - 시트가 조회 중 · 조회 실패 · 미등록 세 상태를 가른다 - 재시도를 TextButton 으로 바꿔 MindRecordErrorBox 와 모양을 맞추고 터치 타깃을 48dp 로 올린다 (종전 평문 Text + clickable 은 글자 높이 ≈18dp 가 전부였다) - «다시 시도» 중복 문자열 셋을 mindrecord_error_retry 하나로 합친다 - 이 PR 의 논리와 정반대인 «에러는 조용히 무시» 주석을 지운다 가드: ReceiverLoadFailureTest 가 요청이 떠 있는 창의 상태를 fake 안에서 관찰한다. isReceiverLoading 세팅을 빼면 실패하는 것을 확인했다. 실기동: 오프라인 진입 → 실패 문구 + 다시 시도, 재시도 탭 → 진행 표시(지연 4초 주입), 응답 → 미등록 문구. 재시도 버튼 실측 높이 126px @420dpi = 48dp. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
네 지적 모두 반영했습니다. 특히 1번은 제 실측 조건(기내모드)이 창을 가린 것이 맞습니다 — DNS 에서 즉시 실패하니 깜빡임조차 안 보였습니다. 1. [blocking] 재시도 중 «미등록» 으로 되돌아감 — 로딩 상태 추가
에뮬레이터에 4초 지연을 주입해 그 창을 직접 잡았습니다 (
가드도 넣었습니다 — 2. [normal] «다시 시도» 셋 → 하나이 PR 이 추가한 3. [normal] 재시도 어피던스 — TextButton말씀대로 터치 타깃 실측 — 4. [normal] 정반대 주석 삭제「에러는 조용히 무시」 줄, 지웠습니다. CI 통과, |
1hyok
left a comment
There was a problem hiding this comment.
재리뷰 — 네 건 모두 닫혔습니다. 승인합니다
blocking 1번을 «상태 하나 추가» 로 끝내지 않고, 시트가 고르는 세 갈래를 전부 가른 것이 맞는 처리입니다. 남은 셋도 제안한 방향 그대로입니다.
| # | 지적 | 처리 |
|---|---|---|
| 1 | [blocking] 재시도가 «못 불러옴» 을 «등록 안 함» 으로 되돌린다 | 닫힘 — 아래 |
| 2 | [normal] 같은 «다시 시도» 문자열이 셋 | 닫힘 — 둘을 지우고 mindrecord_error_retry 하나로 |
| 3 | [normal] 재시도 어피던스·터치 타깃 | 닫힘 — TextButton 으로 통일 |
| 4 | [normal] 이 PR 과 정반대인 주석 잔존 | 닫힘 — 삭제 |
1번 — 가드가 실제로 서는지 되돌려 확인했습니다
isReceiverLoading 을 넣고 시트를 조회 중 → 실패 → 미등록 세 갈래로 가른 것, 그리고 첫 진입도 같은 자리에서 덮인 것(loadReceivers() 가 init(:60)에서 불리므로, 조회가 끝나기 전에 시트를 연 창 — 이 PR 이전부터 있던 쪽 — 도 함께 닫혔습니다)까지 확인했습니다.
새 테스트가 «통과만 하는 테스트» 가 아닌지는 옛 동작을 되살려 놓고 봤습니다.
// DiaryWriteViewModel.loadReceivers() — 로딩 표기만 되돌린다
-_uiState.update { it.copy(isReceiverLoading = true, receiverLoadError = null) }
+_uiState.update { it.copy(receiverLoadError = null) }$ ./gradlew :feature:mindrecord:presentation:testDebugUnitTest --tests '*ReceiverLoadFailureTest*'
ReceiverLoadFailureTest > 재시도 중에는 미등록 문구가 아니라 조회 중이다 FAILED
조회 중 상태가 없어 시트가 «미등록» 을 고른다
5 tests completed, 1 failed
되돌리면 정확히 그 한 건만 빨개집니다. fake 의 람다가 «요청이 떠 있는 창» 에서 실행된다는 점을 이용해 그 순간의 상태를 관찰한 구성이라, 창의 길이(연결 10초·호출 30초)에 기대지 않고 결정적으로 고정됩니다.
2번 — 지운 키가 어디에도 남지 않았습니다
mindrecord_receiver_retry 는 develop 쪽 ReceiverMindRecordScreen.kt:264 가 아직 참조하고 있던 키입니다. 이 PR 이 그 참조를 같은 커밋에서 mindrecord_error_retry 로 옮겨 놓아, 병합 후에도 끊어진 참조가 없습니다 (develop 은 그 파일을 건드리지 않아 PR 쪽 변경이 그대로 남습니다).
$ grep -rn "mindrecord_receiver_retry\|mindrecord_write_receiver_retry" . # develop 병합본
(0건)
검증
-
CI: head
4683f1722전 체크 green (Unit Test · Android Lint · Ktlint · Screenshot · CodeQL 포함),mergeable=MERGEABLE. -
develop 접점 — 실제로 병합해 빌드했습니다. develop(
71679cd23)은 이 PR 과 같은 파일 넷(DiaryWriteScreen·DiaryWriteUiState·DiaryWriteViewModel·strings.xml)을 건드렸습니다.merge-tree충돌 0건에 더해, 실제 병합본(09bc90213)에서:./gradlew :feature:mindrecord:presentation:ktlintCheck \ :feature:mindrecord:presentation:compileDebugKotlin \ :feature:mindrecord:presentation:compileDebugUnitTestKotlin \ :app:compileDebugAndroidTestKotlin → BUILD SUCCESSFULdevelop 의
DiaryWriteViewModel변경(#1008,onDateSelected제거)과 이 PR 의loadReceivers변경이 같은 클래스의 다른 자리라 겹치지 않는 것도 확인했습니다. -
실기동 QA — 미수행. 1번은 위 역검증으로 코드 사실이 고정됐고, 나머지 셋은 런타임이 새 정보를 주지 않는 종류입니다(문자열 통합 · 컴포넌트 교체 · 주석 삭제).
-
시안 대조 — 시트 상태는 시안에 없습니다. Figma 2671:17921 을 다시 열어 확인했습니다. 진입 화면(수신자 행 «박채연님에게 ›»)까지만 있고 시트의 조회 중·실패·미등록 어느 것도 없습니다. 미답변 디자인 질의는 FE 재량이라는 위임대로, 3번을 레포 안 선례(
MindRecordErrorBox의TextButton)로 맞춘 처리가 이 자리의 정답입니다. -
교차 범위:
ReceiverSelectBottomSheet호출부는DiaryWriteScreen하나뿐이고,DailyQuestionWriteScreen은 이 시트를 쓰지 않습니다(직전 리뷰 판정 유지, 병합본에서 재확인).
아래 nit 한 건은 승인을 막지 않습니다.
| // 을 고른다 — 목록은 아직 비었고 오류만 지워진 창이다. 이 PR 이 없애려던 혼동이 | ||
| // 사용자 손으로 되돌아오므로, 세 상태를 모두 가른다 (#1019 리뷰). | ||
| if (isLoading) { | ||
| CircularProgressIndicator( |
There was a problem hiding this comment.
[nit] 이 모듈의 로딩 표기는 지금까지 전부 shimmerLoadingPlaceholder(core:ui) 였습니다 — TodayQuestionCard:159 · RecordCategoryCard:145 · MemoryDetailOverlay:83. CircularProgressIndicator 는 이 시트가 처음입니다.
다만 여기서는 스피너가 더 맞다고 봅니다. shimmer 는 «들어올 콘텐츠의 자리를 미리 아는» 경우에 그 자리를 흐리게 그리는 장치인데, 이 시트는 목록이 몇 줄이 될지 모르는 빈 중앙이라 흉내 낼 골격이 없습니다.
기록 삼아 남깁니다 — 이 모듈에 «자리를 모르는 로딩» 이 하나 더 생기면 그때는 둘 중 하나를 공용으로 올리는 편이 낫겠습니다.
Closes #1019
무엇이 문제였나
화면은 «아직 등록 안 함» 과 «못 불러옴» 을 같은 빈 목록으로 그렸다. 수신자 최소 1명이 필수인 경로가 있어(애프터노트 475) 조회 실패가 곧 «등록 불가» 로 이어지는데, 그 사유가 어디에도 없었다.
무엇을 바꿨나
DiaryWriteUiState.receiverLoadError를 두고, 시트가 문구를 갈라 보여 준다. 실패일 때만 「다시 시도」가 붙는다 — 미등록에 재시도를 주면 눌러도 아무 일이 없다.loadReceivers를public으로 열어 그 재시도가 같은 경로를 다시 탄다.실기동에서 잡은 것 — 예외 원문 노출
처음엔
UiText.DynamicOrResource(value = e.message, …)로 짰는데, 기내모드로 태워 보니 이렇게 떴다.#614 가 수신자 화면에서 막은 것과 같은 형태라
UiText.Resource로 고정하고, 그 성질을 테스트로 박았다.수정 후 같은 경로:
두 상태가 화면에서 실제로 갈린다.
곁다리 — 죽은 코드 제거
이슈가 함께 든
consumeImageUploadError()는 정의만 있고 호출부 0건이었다. 업로드 실패 문구의 수명은 «다음 업로드 시작까지»(uploadImage진입에서imageUploadError = null)이고, 화면에 걷는 어피던스가 따로 없다. 걷는 함수만 남겨 두면 다음 사람이 «어디선가 부르겠지» 로 읽으므로 지우고 수명을 주석으로 남겼다. 일기·데일리질문 두 곳 모두.회귀 가드
ReceiverLoadFailureTest4건 — 실패가 상태에 남는지 · 문구가 예외 원문이 아닌지 · 성공 시 남지 않는지 · 재시도 성공 뒤 걷히는지.검증
testDebugUnitTest:app:assembleDebug·:app:compileDebugAndroidTestKotlin