Skip to content

fix(timeletter): 타임레터 하위 화면 5종의 재진입 갱신 (#1921) - #1905

Open
koongmai wants to merge 8 commits into
developfrom
fix/703
Open

koongmai wants to merge 8 commits into
developfrom
fix/703

Conversation

@koongmai

@koongmai koongmai commented Sep 5, 2026 •

Copy link
Copy Markdown
Collaborator

📌𝘐𝘴𝘴𝘶𝘦𝘴

📎𝘞𝘰𝘳𝘬 𝘋𝘦𝘴𝘤𝘳𝘪𝘱𝘵𝘪𝘰𝘯

RecipientTimeLetterDetailScreen, RecipientTimeletterScreen, DraftLetterScreen, RecipientListScreen, TimeLetterDetailScreen과 해당 ViewModel 5개의 재진입 시 갱신을 연결합니다. LifecycleEventEffect(ON_RESUME)에서 ViewModel의 refreshOnReturn()을 호출하며 첫 resume를 건너뛰는 상태를 ViewModel 수명에 둡니다. DraftLetterViewModelTest와 RecipientListViewModelTest의 기존 테스트 변경을 포함합니다.

설정 담당 이관에 따라 기존 혼합 PR의 설정 파일 8개만 제거했습니다. 이 PR의 최종 변경 범위는 feature/timeletter/presentation 아래 12개 파일이며, 타임레터 구현과 원 작성자 커밋 이력은 유지합니다. 설정을 제외하는 분리 커밋은 1hyok이 작성했고 기존 타임레터 변경은 원본 e5a41720b083d40f71265091e684b3ad2c1f69cb와 동일합니다.

이번 분리로 기존 리뷰를 해결 처리하지 않습니다. 타임레터 리뷰 대응은 기존 작성자 koongmai의 이 PR에서 계속합니다.

💬 남은 리뷰

설정에 귀속되는 기존 리뷰 3개와 설정 테스트 항목은 #1930로 인수했으며, 이 PR의 타임레터 변경과 분리해서 후속 처리합니다.

🧪 검증

  • 설정 분리 이후 타임레터 파일 내용이 원본 HEAD와 동일함을 확인했습니다.
  • 설정·타임레터 분리 브랜치를 합친 트리가 원본 HEAD 트리와 완전히 일치합니다.
  • git diff --check 통과, 조회한 최신 develop과 merge-tree 충돌 없음.
  • 원본 HEAD CI의 lint·ktlint·unit test·screenshot 검증은 성공했습니다. 분리 HEAD는 push 이후 필수 CI로 재검증해야 합니다.

🧪 CI Test Plan

{
  "androidTest": {
    "mode": "none",
    "reason": "기존 PR에서 설정 파일만 분리하고 타임레터 화면·ViewModel·테스트 내용은 원본 HEAD 그대로 보존한다. 새 UI/네비게이션/렌더링 변경이 없으며 미해결 동작 및 테스트 리뷰는 후속 수정한다.",
    "tests": []
  }
}

koongmai and others added 3 commits August 30, 2026 21:06
LifecycleEventEffect(ON_RESUME) 은 화면에 처음 들어올 때도 한 번 불리는데,
그 시점엔 ViewModel init 이 이미 최초 로드를 마친 뒤라 9개 화면 모두
진입할 때마다 같은 데이터를 두 번씩 불러오고 있었다. develop에 나중에
추가된 TimeLetterLifecycleTest 의 정확한 호출 횟수 검증과 충돌하며 실제로
드러났다(재현: 특정 재진입 시나리오에서 호출 횟수가 기대보다 하나씩 밀림).

core:ui 에 ReloadOnReentryEffect 를 추가해 최초 ON_RESUME 은 건너뛰고
그 다음 재진입부터만 콜백을 부르도록 하고, 9개 화면의 중복된
LifecycleEventEffect 호출부를 여기로 교체한다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts:
#	feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/PushNotificationScreen.kt
#	feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/DraftLetterScreen.kt
@koongmai koongmai added the test-only-production-exempt 테스트 전용 프로덕션 선언 가드 우회 — 새 main 함수를 main 이 아직 안 불러도(후속 PR·다른 모듈이 곧 소비) 경고로 낮춰 통과한다 label Sep 5, 2026

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

「ON_RESUME 을 그대로 걸면 최초 진입에도 불려 init 로드와 중복된다」는 진단은 정확합니다. 다만 그걸 담은 자리가 저장소의 기존 판정과 어긋나고, 그 때문에 이 PR 이 겨냥한 시나리오에서 동작하지 않을 것으로 보입니다.

지적

  1. [blocking] remember 로는 「다른 화면 갔다가 돌아옴」에서 재조회가 일어나지 않는다 — ReloadOnReentryEffect.kt.

    var hasEnteredOnce by remember { mutableStateOf(false) }
    • Navigation Compose 는 앞으로 이동하면 이전 목적지의 컴포저블을 컴포지션에서 내립니다. SaveableStateHolder 가 되살리는 것은 rememberSaveable 이고 평범한 remember 는 사라집니다.
    • 그래서 돌아왔을 때 hasEnteredOnce 는 다시 false 로 시작하고, 그 직후의 ON_RESUME 이 「최초 진입」으로 판정돼 건너뜁니다. ViewModel 은 back stack entry 에 붙어 살아남으니 init 도 다시 안 돌고요 — 본문이 적은 「다른 화면 갔다가 돌아와도 처음 열 때 데이터가 그대로」가 그대로 남습니다.
    • 실제로 동작하는 경우는 컴포지션이 유지된 채 앱이 백그라운드 갔다 오는 것 하나입니다. ReloadOnReentryEffectTest 가 정확히 그 경우만 봅니다 — 같은 setContent 위에서 ON_PAUSE/ON_RESUME 만 돌리고 컴포저블을 내렸다 다시 올리지 않습니다. 두 경우를 가르는 테스트를 하나 추가하면 바로 드러납니다.
  2. [normal] 같은 문제를 푸는 정본이 이미 저장소에 있다 — refreshOnReturn

    • ReceiverHomeViewModel.kt:54-60 이 같은 「첫 resume 스킵」을 ViewModel 필드로 두고, 그 KDoc 이 왜 그 자리여야 하는지까지 적어 뒀습니다.

      VM 필드인 이유는 ReceivedAfternoteDetailViewModel 과 동일 — 프로세스 사망 후 복원에서도 init 로드와 수명이 일치한다.

      1번이 그 문장이 경계하던 바로 그 문제입니다. 플래그 수명이 init 로드 수명과 같아야 하는데 컴포지션 수명에 묶였습니다.

    • 정본에는 loadJob?.isActive 겹침 가드도 함께 있습니다(refreshOnReturn). 새 헬퍼에는 그 축이 없어, 최초 로드가 진행 중인 채 재진입하면 같은 조회가 겹칩니다.

    • 그리고 RefreshOnReturnWiringKonsistTest(#701) 밖으로 나갑니다. 그 가드는 refreshOnReturn 이라는 이름으로 「ViewModel 함수는 남고 화면 결선만 사라지는」 사고를 막습니다 — #1461 에서 실제로 일어났던 형태입니다. 새 이름을 쓰면 이번에 배선한 9개 화면이 그 보호 밖에 놓입니다.

    • 정본을 그대로 쓰면 1·2 가 함께 닫힙니다.

  3. [normal] test-only-production-exempt 오탐은 실재하지만 추적 번호가 없다

    • 확인했습니다 — validate-test-only-production-declarations.mjs:69 의 referencePattern 이 name[[:space:]]*\( 와 ::name 만 봅니다. ReloadOnReentryEffect { … } 처럼 괄호 없는 trailing lambda 로만 불리는 함수는 전부 「아무도 안 부른다」로 잡힙니다. 라벨을 붙이신 판단은 맞습니다.
    • 다만 이건 이 PR 한정이 아니라 Compose 의 effect·slot 함수 전반에 걸리는 구멍입니다. 본문의 「별도로 처리할 예정」에 이슈 번호를 붙여 주세요 — 번호 없이 두면 다음 사람이 같은 라벨을 근거 없이 붙이게 됩니다.
  4. [normal] core:ui 는 담당 모듈이 다르다 — core/ui/src/main/.../ReloadOnReentryEffect.kt 가 신규 프로덕션 파일입니다(지도상 담당 @1hyok). #1890 이 이 대조를 Repository Quality 게이트로 올리는 중입니다. 2번대로 정본을 쓰면 새 파일 자체가 없어져 같이 해소됩니다. #1485·#1578·#1705·#1706·#1710 에도 같은 사유를 남겼습니다.

@koongmai koongmai added the issue-assignee-exempt 담당자 대조 가드 우회 — 대표 Issue 담당자가 아니어도 이 PR 은 통과한다 label Sep 6, 2026
@koongmai

koongmai commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

[normal] core:ui 모듈 소유권 — 우선 라벨을 붙였습니다. 다만 blocking 지적(remember가 재진입 시나리오에서 실제로 동작 안 함)을 저장소 정본 패턴(refreshOnReturn)으로 바꾸면 이 신규 파일 자체가 없어져 이 지적도 함께 해소될 것 같습니다 — 그 방향으로 다시 작업하겠습니다.

ReloadOnReentryEffect 는 remember 로 첫 진입 여부를 기억했는데, Navigation
Compose 는 다른 화면으로 이동하면 이전 목적지의 컴포저블을 컴포지션에서
내린다 — remember 는 그 순간 사라진다. 그래서 "다른 화면 갔다가 돌아옴"에서는
방금 내려간 hasEnteredOnce 가 다시 false 로 시작해, 뒤이은 ON_RESUME 이 최초
진입으로 오판되어 재조회를 건너뛴다. 실제로 재조회가 일어나는 경우는 컴포지션이
유지된 채 앱이 백그라운드만 갔다 오는 경우 하나뿐이었다.

같은 문제(재진입 시 첫 resume 스킵 + 진행 중 로드와의 중복 방지)를 이미
ReceiverHomeViewModel 이 VM 필드(isFirstResume, loadJob)로 풀어 두었다 — VM
은 화면이 사라져도 back stack entry 에 붙어 살아남고, 그 정본을 그대로
적용했다.

- ReloadOnReentryEffect(core/ui, 담당 모듈이 다른 신규 프로덕션 파일이었다)를
  걷어내고, 영향받은 9개 화면(설정 4·타임레터 5)의 ViewModel 에 각각
  refreshOnReturn()을 추가했다. 화면 쪽은
  `LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refreshOnReturn() }`
  한 줄로 결선한다 — RefreshOnReturnWiringKonsistTest(#701)가 이 결선을 계속
  지킨다.
- RecipientListViewModel 은 flatMapLatest 가 겹치는 구독을 알아서 취소하므로
  loadJob 가드 없이 isFirstResume 만 적용했다.
- DraftLetterViewModelTest·RecipientListViewModelTest 에 refreshOnReturn 의
  "첫 진입 스킵, 재진입부터 갱신" 계약을 검증하는 테스트를 추가했다.

ReloadOnReentryEffect 가 없어지면서 트레일링 람다 호출 오탐으로 붙였던
test-only-production-exempt 라벨도 이 파일에 한해서는 더 이상 필요 없다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@koongmai koongmai removed issue-assignee-exempt 담당자 대조 가드 우회 — 대표 Issue 담당자가 아니어도 이 PR 은 통과한다 test-only-production-exempt 테스트 전용 프로덕션 선언 가드 우회 — 새 main 함수를 main 이 아직 안 불러도(후속 PR·다른 모듈이 곧 소비) 경고로 낮춰 통과한다 labels Sep 6, 2026
@koongmai

koongmai commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

지적하신 근본 원인 그대로 고쳤습니다 — remember 가 아니라 저장소 정본인 refreshOnReturn 패턴으로 다시 짰습니다.

1. remember 는 재진입에서 동작하지 않는다는 지적
ReloadOnReentryEffect(core/ui)를 완전히 걷어냈습니다. 대신 ReceiverHomeViewModel 이 이미 쓰고 있는 정본을 9개 화면(설정 4·타임레터 5) 모두에 그대로 적용했습니다:

  • 각 ViewModel 에 isFirstResume(첫 ON_RESUME 스킵) / loadJob(진행 중 로드와 겹치면 스킵) 필드를 추가하고 refreshOnReturn() 함수로 노출했습니다. VM 필드라 화면이 컴포지션에서 내려가도(다른 화면 이동) 살아남고, 프로세스 사망 복원에서도 init 로드와 수명이 일치합니다.
  • RecipientListViewModel 은 flatMapLatest 가 겹치는 구독을 알아서 취소해서 loadJob 가드 없이 isFirstResume 만 적용했습니다.
  • 화면 쪽은 전부 LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refreshOnReturn() } 한 줄로 결선했습니다.

2. 정본(refreshOnReturn)을 쓰라는 지적
그대로 적용했고, RefreshOnReturnWiringKonsistTest(#701) 로컬 실행 통과 확인했습니다 — 이제 9개 화면 모두 그 가드 보호 안에 있습니다.

3. test-only-production-exempt 오탐 추적 번호 부재 지적
ReloadOnReentryEffect 자체를 없앴더니 해당 오탐(트레일링 람다 호출을 못 잡는 문제)이 이 PR 에서는 더 이상 발생하지 않습니다 (validate-test-only-production-declarations.mjs --local 로컬 실행 확인). 라벨을 뗐습니다 — 이 오탐 자체는 Compose effect 함수 전반에 걸리는 별도 이슈라 이번 PR 범위 밖으로 남겨둡니다.

4. core:ui 모듈 소유권 지적
ReloadOnReentryEffect.kt 가 삭제되면서 이 PR 의 프로덕션 diff 는 이제 feature/setting/presentation · feature/timeletter/presentation 뿐입니다 — 담당 모듈 밖 파일이 없어져서 issue-assignee-exempt 라벨도 뗐습니다.

추가로 DraftLetterViewModelTest·RecipientListViewModelTest 에 refreshOnReturn() 의 "첫 진입 스킵, 재진입부터 갱신" 계약을 검증하는 테스트를 추가했습니다. compile / unit test / ktlint / konsist 모두 로컬에서 통과 확인했습니다. 재리뷰 요청드립니다.

@OnlyReadOneBook OnlyReadOneBook added the issue-assignee-exempt 담당자 대조 가드 우회 — 대표 Issue 담당자가 아니어도 이 PR 은 통과한다 label Sep 6, 2026

@OnlyReadOneBook OnlyReadOneBook 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.

원 지적 4건은 head e5a4172 코드에서 전부 닫힌 것을 확인했습니다. ReloadOnReentryEffect 는 파일과 테스트 모두 트리에서 사라졌고(head 트리 전수 조회에서 0건), 상태가 9개 ViewModel 의 isFirstResume 필드로 옮겨가 컴포지션이 내려가도 살아남습니다. 이름도 정본 refreshOnReturn 이고, RefreshOnReturnWiringKonsistTest.kt:36 의 규칙을 9개 화면에 손으로 대조해 전부 성립하는 것도 봤습니다. 변경 파일 20개가 전부 feature/setting 과 feature/timeletter 안이라 core:ui 프로덕션도 0줄입니다. test-only-production-exempt 라벨은 오탐 대상이 사라져 함께 떨어졌습니다.

그런데 정본에서 가져온 것이 이름과 두 가드까지입니다. 정본이 자동 갱신을 위해 따로 둔 로드 의미가 안 왔습니다. ReceiverHomeViewModel.kt:80 은 loadHome(showsLoading = false, keepsStateOnFailure = true), AfternoteDetailViewModel.kt:139 는 LoadTrigger.AutoRefresh, DiaryListViewModel.kt:72 도 같은 두 인자를 줍니다. RefreshOnReturnWiringKonsistTest.kt:32 의 KDoc 은 계약을 로딩 미방출, 실패 시 화면 유지, 첫 resume 스킵 셋으로 적고 앞의 둘은 각 ViewModel 단위 테스트가 지킨다고 못박습니다. 이 PR 은 셋째만 구현했습니다.

그래서 재진입해도 옛 값이 남던 문제가, 재진입하면 보던 것이 날아가는 문제로 바뀌었습니다.

지적

  1. [blocking] 자동 갱신이 읽던 화면 위에 로딩을 덮고, 실패하면 에러 화면으로 바꾼다. TimeLetterDetail, RecipientTimeletter, RecipientTimeLetterDetail, DraftLetter 네 곳입니다. ConnectedAccounts 와 PushNotification 은 화면이 isLoading 을 읽지 않아 지금은 무해하고 RecipientList 는 로딩·에러 상태 자체가 없습니다.
  2. [blocking] 전달 조건에서 저장하지 않은 선택이 서버 값으로 되돌아간다.
  3. [normal] 프로필 편집은 실패하면 입력 중이던 폼이 사라지고, 성공해도 화면에 닿지 않는다.
  4. [normal] 클래스 밖 호출처가 없는데 public 이 된 함수 넷.
  5. [normal] RecipientList 는 5초 넘게 자리를 비웠다 돌아오면 같은 조회가 두 번 나간다.
  6. [normal] 9개 중 7개에 계약을 잠그는 단위 테스트가 없다. 이 PR 이 더한 것은 DraftLetterViewModelTest 와 신규 RecipientListViewModelTest 둘뿐이고, RecipientTimeletter·RecipientTimeLetterDetail·TimeLetterDetail·ConnectedAccounts·DeliveryCondition·ProfileEdit·PushNotification 일곱은 비어 있습니다. 1~3 을 고쳐도 그 일곱은 다음 사람이 load() 를 손대면 조용히 되돌아갑니다.

1~5 는 인라인에 달았습니다. CI 는 전부 초록인데, 이 결함들은 CI 가 볼 수 있는 종류가 아닙니다.

고칠 방향은 konsist 가드의 KDoc 이 이미 적어 두었습니다. 각 load 에 showsLoading·keepsStateOnFailure 를 두거나 AfternoteDetailViewModel 처럼 LoadTrigger 로 계기를 나누고, 9개 각각에 그 계약을 잠그는 단위 테스트를 붙이면 됩니다.

return
}
if (loadJob?.isActive == true) return
load()

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] refreshOnReturn 이 진입·재시도용 load() 를 그대로 부릅니다. load() 는 :72 에서 Loading 을 방출하고 :85 에서 실패를 Error 로 덮으니, 자동 갱신이 읽던 편지를 스피너로 가리고 통신이 끊기면 통째로 지웁니다.

재현: 편지를 읽는 중 앱을 전환했다 돌아옵니다. ON_RESUME 이 load() 를 부르고 화면이 스피너가 되며 스크롤이 맨 위로 갑니다. 지하철처럼 끊기는 곳이면 그 요청이 실패해 읽던 편지가 사라지고, 뒤로 나갔다 다시 들어오기 전에는 복구되지 않습니다.

정본은 그걸 막으려고 인자를 둡니다. ReceiverHomeViewModel.kt:80 이 loadHome(showsLoading = false, keepsStateOnFailure = true), AfternoteDetailViewModel.kt:139 가 LoadTrigger.AutoRefresh 입니다.

같은 모양이 RecipientTimeletterViewModel:55(→:61, :66), RecipientTimeLetterDetailViewModel:62(→:68, :73), DraftLetterViewModel:49(→:55, :70) 에도 있습니다. 임시보관함은 이 PR 이전에도 DRAFT_CHANGED_RESULT 로 복귀 시 loadDrafts() 를 불렀으니 새로 생긴 문제는 아니고, 이제 경로가 둘이라 같은 조회가 두 번 나가고 두 응답이 경합하는 쪽이 문제입니다.

return
}
if (loadJob?.isActive == true) return
loadDeliveryConditions()

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] 저장하지 않은 선택이 조용히 되돌아갑니다. onConditionTypeSelected(:90)는 결과를 uiState.conditionType 에만 담고 onSave(:96)가 그 값을 읽는데, 여기서 부르는 loadDeliveryConditions 가 :79 에서 그 칸을 서버 값으로 통째 교체합니다.

재현 경로가 앱 전환보다 훨씬 흔합니다. DeliveryConditionScreen.kt:150 의 마지막 인사 편집으로 나갔다 돌아오면 그 자체로 ON_RESUME 이 발화합니다. 조건을 바꾼 뒤 마지막 인사를 손보고 오면 선택이 저장돼 있던 값으로 돌아가고, 사용자는 바꾼 줄 알고 나갑니다.

덧붙여 :85 가 세운 error = LOAD_FAILED 를 성공한 재조회(:75~:83)가 걷지 않습니다. 자동 갱신이 한 번 실패하면 빨간 안내가 ViewModel 수명 내내 남습니다.

loadProfile()
}

fun loadProfile() {

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] 이 화면은 재진입 갱신으로 얻는 것이 없고 잃는 것만 있습니다.

실패하면 :64 가 ProfileEditUiState.Error 로 덮고, ProfileEditScreen 의 when 은 Success 일 때만 폼을 그리므로 그 분기가 내려가면서 rememberTextFieldState 가 함께 폐기됩니다. 이름·전화번호를 고치던 중 앱을 전환했다 돌아오는데 그 순간 통신이 끊기면 입력하던 값이 같이 사라집니다.

성공해도 화면에는 안 닿습니다. rememberTextFieldState 는 새 initialText 를 반영하지 않으니 새로 받은 값이 폼에 들어가지 않습니다. 정본의 keepsStateOnFailure 가 막으려던 경우가 정확히 이겁니다.

loadConnectedAccounts()
}

fun loadConnectedAccounts() {

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] 클래스 밖에서 부르는 곳이 없는데 public 이 됐습니다. 여기와 DeliveryConditionViewModel:65, ProfileEditViewModel:52 셋은 private fun 에서 넓어졌고, PushNotificationViewModel:53 의 refresh 는 develop 에서 init 안에 있던 본문을 떼어 새로 만든 public 함수입니다. 넷 다 호출처는 같은 파일의 init 와 refreshOnReturn 뿐입니다.

validate-test-only-production-declarations.mjs 가 같은 파일 안 호출을 main 참조로 세기 때문에(ownFileReferences) Repository Quality 는 초록입니다. 가드가 안 잡는 것이지 필요해서 열린 표면은 아닙니다.

그리고 이 화면은 결선 자체를 다시 볼 만합니다. 소셜 로그인에서 돌아오는 그 순간이 ON_RESUME 인데 loadJob 가드는 link() 가 띄운 코루틴을 보지 못합니다. 방금 연동한 계정이 예전 값으로 되돌아갈 수 있습니다.

*/
private var isFirstResume = true

fun refresh() {

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] flatMapLatest 가 겹침을 취소한다는 설명은 코드에서 참입니다. 다만 취소되는 것은 겹친 구독이고 서버 호출 횟수는 다릅니다.

recipients 는 stateIn(WhileSubscribed(5_000)) 이라 화면을 떠나 5초가 지나면 상류가 끊깁니다. 돌아오면 ON_START 의 재구독으로 조회가 한 번 나가고, 곧바로 ON_RESUME 의 refreshOnReturn 이 refreshRequests 를 올려 그 요청을 취소하고 두 번째 조회를 겁니다. receiverListFlow 는 콜드 플로우이고 UserRepositoryImplTest.kt:149 가 새 구독마다 서버를 다시 친다고 잠가 두었습니다.

새 테스트는 backgroundScope 로 계속 수집해서 이 구간을 재현하지 않습니다. 그리고 refresh() 를 클래스 밖에서 부르는 곳이 그 테스트뿐이라, 프로덕션 시그니처를 테스트가 정한 모양이 됩니다. refreshOnReturn 이 refreshRequests.value++ 를 직접 하면 공개할 이유가 없습니다.

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.

최신 HEAD f0cfec520에서도 남아 있습니다. WhileSubscribed(5_000) 구독이 중단된 뒤 복귀하면 STARTED에서 cold receiverListFlow가 다시 수집되어 getReceivers()를 호출하고, 뒤따르는 ON_RESUME에서 refreshRequests가 증가해 같은 flow를 다시 구독합니다. 이미 시작된 첫 HTTP 요청은 flatMapLatest 취소만으로 전송되지 않았다고 보장할 수 없습니다. 구독 재개와 복귀 갱신을 하나의 로드로 합치고, 수집 중단 5초 경과 → 재구독 → refreshOnReturn() 순서에서 요청이 한 번인지 테스트해 주세요. 현재 추가 테스트는 계속 수집 중인 상태만 다룹니다. 또한 refresh()는 프로덕션에서 같은 파일 내부에서만 호출되므로 private으로 좁히고 refreshOnReturn() 계약을 통해 테스트해 주세요.

@github-actions

github-actions Bot commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

열린 PR 중 이 PR 과 같은 파일을 서로 다르게 고친 것이 있다. 어느 쪽이 먼저 머지되든 나중 쪽은 base 를 병합해야 통과한다 (#1750).

  • fix(timeletter): 서버 실패 상태와 재시도 처리 (#714) #1365 — feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/RecipientListScreen.kt, feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientListViewModel.kt, feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientListViewModelTest.kt

머지 자체는 GitHub 이 막는다 — 한쪽이 들어가면 다른 쪽은 CONFLICTING 이 되어 merge queue 에 못 들어간다.
이 라벨은 그 전에 순서를 정하라는 신호다. 먼저 갈 쪽을 정하고, 나중 쪽은 그 머지 뒤에 base 를 병합한다.
상대 PR 에도 같은 안내가 달린다.

이 코멘트는 리컨사일러가 매 실행 현재 상태로 고쳐 쓴다. 해소되면 sibling-conflict 라벨은 자동으로 떨어진다.

기존 PR #1905의 설정 화면 4개와 ViewModel 4개를 PR merge base 상태로 되돌려 별도 설정 PR에서 인수한다. 타임레터 화면 5개, ViewModel 5개, 테스트 2개의 기존 구현과 작성자 이력은 그대로 유지한다.

Source: #1905
Source-Head: e5a4172
@OnlyReadOneBook OnlyReadOneBook changed the title fix(setting,timeletter): 상세·하위 화면 재진입 갱신 (#703) fix(timeletter): 타임레터 하위 화면 5종의 재진입 갱신 (#1921) Sep 6, 2026
@OnlyReadOneBook OnlyReadOneBook removed the issue-assignee-exempt 담당자 대조 가드 우회 — 대표 Issue 담당자가 아니어도 이 PR 은 통과한다 label Sep 6, 2026
@github-actions github-actions Bot removed the awaiting-author 변경요청 뒤 작성자 조치 없음 — 공은 작성자에게 있다 label Sep 6, 2026

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

지난 리뷰의 지적 3건(컴포지션 수명 플래그, refreshOnReturn 정본·konsist 보호, core:ui 신규 파일)은 fix(setting,timeletter): 상세·하위 화면 재진입 갱신을 컴포지션이 아닌 ViewModel 수명에 묶는다 로 해소된 것을 확인했습니다. 남은 건 하나입니다.

  • 자동 갱신이 읽던 화면을 덮어씁니다. DraftLetterViewModel.loadDrafts(), RecipientTimeletterViewModel.load(), RecipientTimeLetterDetailViewModel.load(), TimeLetterDetailViewModel.load() 모두 시작하자마자 _uiState.value = ...Loading 을 세우고, 실패하면 ...Error 로 갈아탑니다. refreshOnReturn() 이 이 함수를 그대로 부르므로 하위 화면에서 돌아올 때마다 목록·상세가 스피너로 사라지고, 백그라운드 갱신이 한 번 실패하면 잘 보이던 화면이 통째로 오류 화면이 됩니다.
  • 같은 문제를 설정 쪽 자매 PR #1930 이 keepsStateOnFailure 로 풀었습니다 — 자동 갱신 경로에서는 Loading 을 세우지 않고, 실패해도 이미 표시된 값이 있으면 그대로 둡니다(ProfileEditViewModel·ConnectedAccountsViewModel·PushNotificationViewModel). 같은 방식으로 맞춰 주시고, 「자동 갱신 실패가 표시 중인 성공 상태를 덮지 않는다」는 단위 테스트를 VM 4개에 하나씩 붙여 주세요.

DraftLetterViewModel.loadDrafts(), RecipientTimeletterViewModel.load(),
RecipientTimeLetterDetailViewModel.load(), TimeLetterDetailViewModel.load()
가 refreshOnReturn() 을 통해 불릴 때도 무조건 Loading 을 방출하고 실패 시
Error 로 갈아탔다. 하위 화면에서 돌아올 때마다 스피너가 뜨고, 백그라운드
자동 갱신이 한 번 실패하면 잘 보이던 화면이 통째로 에러 화면이 됐다.

자매 PR #1930의 keepsStateOnFailure 패턴과 동일하게 각 load 함수에
showsLoading·keepsStateOnFailure 파라미터를 추가해, refreshOnReturn() 은
로딩을 방출하지 않고 실패해도 기존 Success 상태를 유지하도록 했다. 최초
진입·명시적 재시도는 기존과 동일하게 Loading→Error 경로를 그대로 쓴다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@OnlyReadOneBook OnlyReadOneBook 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.

최신 HEAD를 재리뷰했습니다. 네 상세/목록 VM의 자동 갱신 실패 시 성공 상태 유지와 관련 테스트는 반영되었습니다. 다만 수신인 목록의 재구독과 ON_RESUME 갱신이 겹치는 문제는 남아 있어 변경 요청을 유지합니다. 기존 인라인 스레드에 현재 코드 기준 재현 조건과 수정 요청을 덧붙였습니다.

@github-actions
github-actions Bot dismissed OnlyReadOneBook’s stale review September 14, 2026 02:55

@Sadturtleman의 더 늦은 승인(review #5193521641)을 최종 판정으로 사용합니다. 저장소 정책은 PR 전체에서 가장 최근의 APPROVED/CHANGES_REQUESTED 판정을 우선합니다.

koongmai and others added 2 commits October 26, 2026 12:38
develop 병합 후 Receiver 에서 authCode 가, TimeLetter 에서 deliveredAt 이 빠져
RecipientListViewModelTest·TimeLetterDetailViewModelTest 가 컴파일되지 않았다.
없어진 인자만 걷고 검증 내용은 그대로 둔다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sibling-conflict 열린 형제 PR 과 같은 파일에서 충돌 — 나중에 머지되는 쪽이 base 를 병합해야 한다. 해소되면 자동으로 떨어진다

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(timeletter): 타임레터 하위 화면 5종의 재진입 갱신 (#703 분리)

3 participants