Skip to content

fix(mindrecord): 수신인 조회 실패를 «등록 없음» 과 구분해 알린다 (#1019) - #1152

Merged
1hyok merged 2 commits into
developfrom
fix/1019-receiver-load-failure
Aug 26, 2026
Merged

1hyok merged 2 commits into
developfrom
fix/1019-receiver-load-failure

Conversation

@Sadturtleman

Copy link
Copy Markdown
Collaborator

Closes #1019

무엇이 문제였나

// 종전
runCatching { userRepository.getReceivers() }
    .onSuccess { receivers -> _uiState.update { it.copy(receivers = receivers) } }
    // onFailure 가 없다 — 실패가 조용히 사라진다

화면은 «아직 등록 안 함» 과 «못 불러옴» 을 같은 빈 목록으로 그렸다. 수신자 최소 1명이 필수인 경로가 있어(애프터노트 475) 조회 실패가 곧 «등록 불가» 로 이어지는데, 그 사유가 어디에도 없었다.

무엇을 바꿨나

  • DiaryWriteUiState.receiverLoadError 를 두고, 시트가 문구를 갈라 보여 준다. 실패일 때만 「다시 시도」가 붙는다 — 미등록에 재시도를 주면 눌러도 아무 일이 없다.
  • loadReceivers 를 public 으로 열어 그 재시도가 같은 경로를 다시 탄다.
  • 실패 문구는 도메인 고정값이다.

실기동에서 잡은 것 — 예외 원문 노출

처음엔 UiText.DynamicOrResource(value = e.message, …) 로 짰는데, 기내모드로 태워 보니 이렇게 떴다.

Unable to resolve host "afternote.kro.kr": No address associated with hostname

#614 가 수신자 화면에서 막은 것과 같은 형태라 UiText.Resource 로 고정하고, 그 성질을 테스트로 박았다.

수정 후 같은 경로:

(기내모드)        수신인 목록을 불러오지 못했어요.  +  다시 시도
(네트워크 복구 후 재시도 탭)   등록된 수신자가 없습니다.

두 상태가 화면에서 실제로 갈린다.

곁다리 — 죽은 코드 제거

이슈가 함께 든 consumeImageUploadError() 는 정의만 있고 호출부 0건이었다. 업로드 실패 문구의 수명은 «다음 업로드 시작까지»(uploadImage 진입에서 imageUploadError = null)이고, 화면에 걷는 어피던스가 따로 없다. 걷는 함수만 남겨 두면 다음 사람이 «어디선가 부르겠지» 로 읽으므로 지우고 수명을 주석으로 남겼다. 일기·데일리질문 두 곳 모두.

회귀 가드

ReceiverLoadFailureTest 4건 — 실패가 상태에 남는지 · 문구가 예외 원문이 아닌지 · 성공 시 남지 않는지 · 재시도 성공 뒤 걷히는지.

검증

게이트 결과
전 모듈 testDebugUnitTest BUILD SUCCESSFUL
:app:assembleDebug · :app:compileDebugAndroidTestKotlin BUILD SUCCESSFUL
실기동 위 참조 (기내모드 → 복구 재시도)

loadReceivers 가 실패를 조용히 흡수해, 화면이 «아직 등록 안 함» 과 «못 불러옴» 을 같은
빈 목록으로 그렸다. 수신자 최소 1명이 필수인 경로가 있어 조회 실패가 곧 등록 불가로
이어지는데 그 사유가 화면 어디에도 없었다.

- 상태에 receiverLoadError 를 두고 시트가 문구를 갈라 보여 준다. 실패일 때만 «다시 시도».
- 문구는 도메인 고정값이다 — 예외 원문을 쓰면 오프라인에서 «Unable to resolve host …»
  가 그대로 노출된다 (에뮬레이터에서 실제로 확인하고 고쳤다, #614 와 같은 규칙).
- 호출부 0건이던 consumeImageUploadError 를 지웠다. 업로드 실패 문구의 수명은 «다음
  업로드 시작까지» 이고, 걷는 함수만 두면 죽은 코드가 된다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@1hyok 1hyok left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

변경 요청

문제 정의와 해법 방향은 옳다. 실패를 빈 목록으로 흡수하면 사용자가 «등록 안 함» 으로 읽는다는 것, 예외 원문을 화면 문구로 쓰면 안 된다는 것(#614), 미등록에는 재시도를 주면 안 된다는 것 — 셋 다 맞고 테스트로도 박혔다. 곁다리인 consumeImageUploadError() 삭제도 실측으로 확인했다(호출부 0건, 두 VM 모두). DailyQuestionWriteScreen 은 수신인 시트를 쓰지 않으므로 «한쪽만 고친 것» 도 아니다.

막는 것은 하나다 — 재시도를 누른 직후, 이 PR 이 없애려던 그 혼동이 그대로 돌아온다.

  1. [blocking] 재시도가 «못 불러옴» 을 «등록 안 함» 으로 되돌린다 → DiaryWriteViewModel.kt:204 인라인 코멘트.
  2. [normal] 같은 «다시 시도» 문자열이 이제 셋이다 → strings.xml:215 인라인 코멘트.
  3. [normal] 재시도 어피던스가 이 모듈의 기존 것과 갈린다 (터치 타깃 포함) → ReceiverSelectBottomSheet.kt:133 인라인 코멘트.
  4. [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) }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[normal] 이 모듈엔 이미 «문구 + 재시도» 를 그리는 자리가 있다 — component/MindRecordErrorBox.kt. 주간리포트·일기·데일리질문·임시저장 네 화면이 그것을 쓰고, 재시도는 TextButton 으로 그린다.

여기만 평문 Text + Modifier.clickable 이라 둘이 갈린다.

  1. 모양 — 같은 앱 안에서 재시도가 두 형태가 된다.
  2. 터치 타깃 — TextButton 은 최소 48dp 를 보장하지만, captionLargeR 텍스트에 붙인 clickable 은 글자 높이(≈18dp)가 전부다. 접근성 최소 타깃에 못 미치고, 실패 상황에서 «눌렀는데 안 눌린다» 가 겹치면 체감이 나쁘다. Role.Button 을 준 것은 좋다 — 크기만 남았다.

MindRecordErrorBox 를 그대로 부르기는 어렵다(fillMaxSize() 라 시트 안에서 화면을 다 먹는다). 그 modifier 를 파라미터로 빼거나, 여기서 TextButton 으로만 바꿔도 2번은 닫힌다.

@@ -193,11 +192,25 @@ class DiaryWriteViewModel
}

// 실패해도 수신자 행이 "수신자 설정하기" 로 남을 뿐 작성 자체는 가능 — 에러는 조용히 무시.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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>
@Sadturtleman

Copy link
Copy Markdown
Collaborator Author

네 지적 모두 반영했습니다. 특히 1번은 제 실측 조건(기내모드)이 창을 가린 것이 맞습니다 — DNS 에서 즉시 실패하니 깜빡임조차 안 보였습니다.

1. [blocking] 재시도 중 «미등록» 으로 되돌아감 — 로딩 상태 추가

DiaryWriteUiState.isReceiverLoading 을 두고 loadReceivers() 가 요청 전후로 세웁니다. 시트는 이제 조회 중 · 조회 실패 · 미등록 세 상태를 가릅니다.

에뮬레이터에 4초 지연을 주입해 그 창을 직접 잡았습니다 (adb emu network delay 4000). 기내모드로는 관측할 수 없는 구간이라 이렇게 늘렸습니다.

시점 종전 지금
오프라인 진입 실패 문구 + 다시 시도 같음
재시도 탭 직후 (~4초) 「등록된 수신자가 없습니다.」 진행 표시
응답 도착 미등록 문구 미등록 문구 (이 계정은 실제로 수신인 0명)

가드도 넣었습니다 — ReceiverLoadFailureTest.재시도 중에는 미등록 문구가 아니라 조회 중이다. fake 의 람다가 요청이 떠 있는 그 창에서 실행되므로, 거기서 uiState.value 를 읽으면 사용자가 그 순간 보는 화면이 그대로 잡힙니다. isReceiverLoading = true 세팅을 빼면 이 테스트가 실패하는 것을 확인했습니다.

2. [normal] «다시 시도» 셋 → 하나

이 PR 이 추가한 mindrecord_write_receiver_retry 를 지웠고, 남은 둘도 같은 리터럴이라 mindrecord_error_retry 하나로 합쳤습니다(ReceiverMindRecordScreen 한 줄). 지금은 이 모듈에 «다시 시도» 단독 키가 하나뿐입니다 — 「셋 중 어디를 고쳐야 하는지」가 남지 않게요.

3. [normal] 재시도 어피던스 — TextButton

말씀대로 TextButton 으로 바꿨습니다(MindRecordErrorBox 와 같은 bodySmallB). MindRecordErrorBox 자체의 fillMaxSize() 를 파라미터로 빼는 쪽은, 그러면 이미 modifier 를 넘기는 세 화면이 조용히 fillMaxSize 를 잃어 레이아웃이 바뀝니다 — 리뷰가 제시한 다른 갈래를 택했습니다.

터치 타깃 실측 — uiautomator dump 기준 [440,2023][640,2149], 높이 126px @420dpi = 48dp. 종전 captionLargeR + clickable 은 글자 높이가 전부였습니다.

4. [normal] 정반대 주석 삭제

「에러는 조용히 무시」 줄, 지웠습니다.


CI 통과, lintDebug clean 입니다.

@1hyok 1hyok left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

재리뷰 — 네 건 모두 닫혔습니다. 승인합니다

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 SUCCESSFUL
    

    develop 의 DiaryWriteViewModel 변경(#1008, onDateSelected 제거)과 이 PR 의 loadReceivers 변경이 같은 클래스의 다른 자리라 겹치지 않는 것도 확인했습니다.

  • 실기동 QA — 미수행. 1번은 위 역검증으로 코드 사실이 고정됐고, 나머지 셋은 런타임이 새 정보를 주지 않는 종류입니다(문자열 통합 · 컴포넌트 교체 · 주석 삭제).

  • 시안 대조 — 시트 상태는 시안에 없습니다. Figma 2671:17921 을 다시 열어 확인했습니다. 진입 화면(수신자 행 «박채연님에게 ›»)까지만 있고 시트의 조회 중·실패·미등록 어느 것도 없습니다. 미답변 디자인 질의는 FE 재량이라는 위임대로, 3번을 레포 안 선례(MindRecordErrorBox 의 TextButton)로 맞춘 처리가 이 자리의 정답입니다.

  • 교차 범위: ReceiverSelectBottomSheet 호출부는 DiaryWriteScreen 하나뿐이고, DailyQuestionWriteScreen 은 이 시트를 쓰지 않습니다(직전 리뷰 판정 유지, 병합본에서 재확인).

아래 nit 한 건은 승인을 막지 않습니다.

// 을 고른다 — 목록은 아직 비었고 오류만 지워진 창이다. 이 PR 이 없애려던 혼동이
// 사용자 손으로 되돌아오므로, 세 상태를 모두 가른다 (#1019 리뷰).
if (isLoading) {
CircularProgressIndicator(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[nit] 이 모듈의 로딩 표기는 지금까지 전부 shimmerLoadingPlaceholder(core:ui) 였습니다 — TodayQuestionCard:159 · RecordCategoryCard:145 · MemoryDetailOverlay:83. CircularProgressIndicator 는 이 시트가 처음입니다.

다만 여기서는 스피너가 더 맞다고 봅니다. shimmer 는 «들어올 콘텐츠의 자리를 미리 아는» 경우에 그 자리를 흐리게 그리는 장치인데, 이 시트는 목록이 몇 줄이 될지 모르는 빈 중앙이라 흉내 낼 골격이 없습니다.

기록 삼아 남깁니다 — 이 모듈에 «자리를 모르는 로딩» 이 하나 더 생기면 그때는 둘 중 하나를 공용으로 올리는 편이 낫겠습니다.

@1hyok
1hyok merged commit 1d67880 into develop Aug 26, 2026
14 checks passed
@1hyok
1hyok deleted the fix/1019-receiver-load-failure branch August 26, 2026 10:52
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.

fix(mindrecord): 수신인 조회 실패가 무음 흡수 — 작성 화면이 «수신인 없음» 과 구분하지 못함

2 participants