Skip to content

refactor: Home State를 4축으로 재설계 — 불법 조합을 표현 불가능하게 (Phase E5) - #374

Merged
YuGeonHui merged 1 commit into
env/devfrom
refactor/v2-phase-e5-home-state
Sep 24, 2026
Merged

YuGeonHui merged 1 commit into
env/devfrom
refactor/v2-phase-e5-home-state

Conversation

@YuGeonHui

Copy link
Copy Markdown
Collaborator

무엇이 문제였나

HomeViewModel.State의 저장 필드가 10개라 표현 가능 조합이 1,536개였고, 그중 상당수는 화면으로 말이 되지 않았다. 아래가 전부 타입상 표현 가능했다.

불법 조합 화면에서 보이는 모습
등록 없는데 banner 있음 알람이 없는데 카운트다운이 뜬다
카드 없는데 selectedRouteId 있음 무슨 경로인지 모르는 "해제" 버튼
등록 없는데 syncedAt 있음 "HH:mm 확인 기준"이 홀로 떠 있다
아무것도 없는데 isAlarmBusy 버튼이 영영 비활성
숨겨진 칩이 isChipBusy —

그래서 cancelAlarmTapped가 다섯 필드를 한 번에 고쳐야 했다 — 하나씩 고치면 didSet이 중간 상태를 VC로 내보내기 때문이다.

이 방식은 실제로 버그를 만들었다. selectedRoute = nil의 didSet이 뒤이은 state = newState에 덮여, 지난 막차 카드에 등록 버튼이 남았다(누르면 tooLate로 실패). 고칠 때 "지역 사본에도 함께 반영하라"는 주석을 다는 것 말고는 방법이 없었다.

어떻게 바꿨나

저장 필드를 4축으로 묶는다: departure / arrivalText / chip / session.

enum HomeSessionState {
    case empty
    case card(Card, busy: Bool)                       // 등록 전 카드
    case past(Card)                                   // 지난 막차 — 등록 버튼 없음
    case armed(Registration, card: Card?, busy: Bool) // 카드는 복원 중 nil 가능
}
  • 배너·확인 시각은 Registration 안에 산다 → 등록 없이 존재할 수 없다
  • 경로 id는 Card가 들고 있다 → 카드와 갈라질 수 없다 (위 버그의 구조적 원인 제거)
  • busy는 카드나 등록이 있는 case 안에만 있다 → 빈 상태의 영구 비활성이 불가능
  • HomeChipState → 텍스트 없이 busy일 수 없다

전이는 이제 case 하나를 고르는 일이다: selecting / arming / disarmed / withBanner / settingBusy.

블라스트 반경을 한 파일로 막았다

읽기 이름은 계산 프로퍼티로 그대로 유지했다 — routeCard·banner·alarmButton·isAlarmBusy·recentRouteChipText·selectedRouteId·registeredRouteId·syncedAt·freshnessText.

저장 구조를 고치는 일이 HomeViewController(444줄)와 테스트 81개 참조의 전면 개명으로 번지지 않게 하려는 의도적 선택이다. 덕분에 기존 테스트 66개가 한 줄도 바뀌지 않고 통과한다 — 리팩토링이 동작을 바꾸지 않았다는 가장 강한 증거다.

신설 테스트

표 테스트 14개, 스텁 0개 · ViewModel 0개. "배너는 등록 없이 존재할 수 없다", "아무것도 없는데 진행 중일 수 없다", "등록된 채 다른 경로를 고르면 버튼은 등록이다" 같은 성질을 직접 고정한다.

검증

항목 결과
AtchaV2 Debug / Stage / Release 3구성 모두 통과
HomeFeature 80 통과 (기존 66 무수정 + 신규 14)
Domain · CoreStorage · AtchaData · AtchaV2 112 · 44 · 64 · 39
DesignSystem · SearchFeature · SettingsFeature 73 · 26 · 18

이것으로 리팩토링 계획의 Phase A~F가 전부 완료된다.

🤖 Generated with Claude Code

HomeViewModel.State의 저장 필드가 10개라 표현 가능 조합이 1,536개였고,
그중 상당수가 화면으로 말이 되지 않는 상태였다. 아래가 전부 타입상
표현 가능했다:

- 등록이 없는데 배너가 있다
- 카드가 없는데 selectedRouteId가 있다 (버튼이 "해제"로 남는다)
- 등록이 없는데 syncedAt이 있다 ("HH:mm 확인 기준"이 떠 있다)
- 아무것도 없는데 isAlarmBusy가 true다 (버튼이 영영 비활성)
- 숨겨진 칩이 진행 중이다

그래서 cancelAlarmTapped가 다섯 필드를 한 번에 고쳐야 했다 — 하나씩
고치면 didSet이 중간 상태를 VC로 내보내기 때문이다. 실제로 이 방식이
버그를 만든 적도 있다(selectedRoute = nil의 didSet이 뒤이은
state = newState에 덮여, 지난 막차에 등록 버튼이 남았다).

저장 필드를 4축으로 묶는다: departure / arrivalText / chip / session.
- HomeChipState — 텍스트 없이 busy일 수 없다
- HomeSessionState — empty / card / past / armed 4케이스.
  배너·확인 시각은 Registration 안에 살아서 등록 없이 존재할 수 없고,
  경로 id는 Card가 들고 있어 카드와 갈라질 수 없다.

전이는 이제 case 하나를 고르는 일이다(selecting / arming / disarmed /
withBanner / settingBusy).

**읽기 이름은 계산 프로퍼티로 그대로 유지했다** — routeCard·banner·
alarmButton·isAlarmBusy·recentRouteChipText·freshnessText. 저장 구조를
고치는 일이 VC(444줄)·테스트 81개 참조의 전면 개명으로 번지지 않게 하려는
의도적 선택이고, 덕분에 기존 테스트 66개가 **한 줄도 바뀌지 않고** 통과한다.

표 테스트 14개 신설(스텁 0개, ViewModel 0개) — "배너는 등록 없이 존재할
수 없다", "아무것도 없는데 진행 중일 수 없다" 같은 성질을 고정한다.

부수: 쓰이지 않게 된 alarmButtonMode static 제거(로직은
HomeSessionState.alarmButton으로), 앞 Phase에서 남은 고아 주석 1줄 제거.

검증: 3구성 빌드 + Domain 112 · CoreStorage 44 · AtchaData 64 · AtchaV2 39
· DesignSystem 73 · HomeFeature 80(+14) · SearchFeature 26 · SettingsFeature 18.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@YuGeonHui
YuGeonHui merged commit aa025f3 into env/dev Sep 24, 2026
1 check passed
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