Skip to content

refactor: 화면 상태 규약 통일 + TaskBox (Phase E1~E4) - #369

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

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

Conversation

@YuGeonHui

Copy link
Copy Markdown
Collaborator

ViewModel마다 제각각이던 상태 표현을 하나로 맞췄습니다. VM당 상태 채널이 정확히 1개가 됐습니다 (7 → 5).


E1 SettingsViewModel — 규약의 참조 구현

sections(렌더 결과)가 상태 자리에 있고, 실제 상태 셋이 private 필드로 흩어져 있었습니다.

// 전
private(set) var sections: [Section] = [] { didSet { ... } }
private var addressText = "불러오는 중…"
private var hasUpdate = false
private var isLoggingOut = false

// 후
struct State: Equatable { var addressText; var hasUpdate; var isLoggingOut }
var sections: [Section] { Self.sections(from: state, currentVersion: currentVersion) }

rebuildSections() 수동 호출 4곳이 사라졌습니다 — 상태가 바뀌면 렌더 결과가 따라오는 게 당연해졌기 때문입니다.

순수 함수가 되면서 ViewModel 인스턴스 없이 상태 조합만으로 렌더 결과를 검증합니다(신규 6개). 이전엔 UseCase 스텁을 조립하고 비동기 로딩을 기다려야 했습니다.

E2 WithdrawViewModel — 파생을 저장하지 않는다

canSubmit 저장 필드와 refreshCanSubmit() 호출 4곳 제거. 저장하면 selected/otherText/isSubmitting이 바뀔 때마다 갱신을 잊지 않아야 하고, 그 자체가 불일치의 원인입니다.

otherText도 State 안으로 넣었습니다 — 밖에 있으면 "선택은 기타인데 입력이 비어 있다" 같은 조합이 타입으로 표현되지 않습니다.

E3 HomeAddress·Search — 보조 콜백 제거

두 VM이 상태 채널을 둘씩 갖고 있었습니다(onSavingChange, onFieldsChange).

슬롯을 따로 흘리던 이유(매 키 입력마다 전체를 push하면 UITextField 커서가 튄다)는 정당하지만, diff는 계약이 아니라 VC의 일입니다. VC가 직전 상태를 들고 바뀐 것만 반영합니다:

private var rendered: SearchViewModel.State?

private func render(_ state: SearchViewModel.State) {
    defer { rendered = state }
    if rendered?.fields != state.fields { renderFields(state.fields) }
    renderContent(state.content)
}

비용은 VC당 if 한 줄, 이득은 규약 통일입니다.

E4 TaskBox — 재진입 규칙을 타입으로

ViewModel마다 Task 필드를 나열하고(홈 9개, 검색 5개) deinit에서 전부 취소하며, 재진입 방지는 guard xxxTask == nil 수기 가드로 처리했습니다. 필드를 하나 늘릴 때마다 deinit에 한 줄을 잊지 않아야 했고, 어떤 작업이 어떤 규칙을 따르는지는 주석으로만 남아 있었습니다.

Policy 용도
.replaceExisting 최신 입력만 의미 있는 조회 (위치 재조회, 키워드 검색)
.skipIfRunning 중복 발사가 해로운 동작 (당김 새로고침, 제출)
.independent 나중 것이 앞 것을 취소하면 안 되는 작업 (저장)

.skipIfRunning의 완료 정리를 테스트로 못박았습니다 — 이걸 잊는 것이 #366에서 고친 "당김 새로고침 영구 잠김" 의 원인이었습니다.

Mutex 기반인 이유: 소유자의 deinit에서 일괄 취소할 수 있어야 하는데 deinit은 nonisolated라 액터 격리된 저장 프로퍼티를 만질 수 없습니다.

CLAUDE.md의 "catch-all Shared/Common 금지, 목적별 모듈" 규약에 따라 CoreConcurrency로 신설했습니다.


Swift 6 격리 노트

모듈 기본 격리가 .mainActor라 파일 수준 타입까지 격리를 얻어 Sendable 제약을 만족하지 못하는 경우를 세 번 만났습니다. 전부 nonisolated 명시로 해결하고 이유를 주석에 남겼습니다 — 다음 사람이 같은 곳에서 막히지 않도록.

검증

모듈 테스트
SettingsFeature 18 (신규 6)
SearchFeature 26
CoreConcurrency 7 (신규)

남은 것

  • E5 — Home State 4축 재설계 (불법 조합 1,035 → 0), TaskBox 적용
  • F — UseCase 21개 → 11개

🤖 Generated with Claude Code

ViewModel마다 제각각이던 상태 표현을 하나로 맞춘다. **VM당 상태 채널이 정확히
1개**가 됐다(7 → 5).

## E1 SettingsViewModel — 규약의 참조 구현

`sections`(렌더 결과)가 상태 자리에 있고 실제 상태 셋(`addressText`·`hasUpdate`·
`isLoggingOut`)이 private 필드로 흩어져 있었다. State를 신설하고 `sections`를
순수 함수로 만들었다.

`rebuildSections()` 수동 호출 4곳이 사라졌다 — 상태가 바뀌면 렌더 결과가 따라오는
것이 당연해졌기 때문이다.

순수 함수가 되면서 **ViewModel 인스턴스 없이** 상태 조합만으로 렌더 결과를
검증할 수 있다(신규 테스트 6개). 이전에는 UseCase 스텁을 조립하고 비동기 로딩이
끝나기를 기다려야 했다.

## E2 WithdrawViewModel — 파생을 저장하지 않는다

`canSubmit` 저장 필드와 `refreshCanSubmit()` 호출 4곳을 제거했다. 저장하면
`selected`/`otherText`/`isSubmitting`이 바뀔 때마다 갱신을 잊지 않아야 하고, 그
자체가 불일치의 원인이 된다.

`otherText`도 State 안으로 넣었다 — 밖에 있으면 "선택은 기타인데 입력이 비어
있다" 같은 조합이 타입으로 표현되지 않는다.

## E3 HomeAddress·Search — 보조 콜백 제거

두 VM이 상태 채널을 둘씩 갖고 있었다(`onSavingChange`, `onFieldsChange`).

슬롯을 따로 흘리던 이유(매 키 입력마다 전체를 push하면 `UITextField` 커서가
튄다)는 정당하지만, **diff는 계약이 아니라 VC의 일이다.** VC가 직전 상태를 들고
바뀐 것만 반영한다(`rendered` 필드, `if` 한 줄).

## E4 TaskBox — 재진입 규칙을 타입으로

ViewModel마다 `Task` 필드를 나열하고(홈 9개, 검색 5개) `deinit`에서 전부
취소하며, 재진입 방지는 `guard xxxTask == nil` 수기 가드로 처리했다. 필드를 하나
늘릴 때마다 `deinit`에 한 줄을 잊지 않아야 했고, 어떤 작업이 어떤 규칙을 따르는지는
주석으로만 남아 있었다.

    .replaceExisting  최신 입력만 의미 있는 조회
    .skipIfRunning    중복 발사가 해로운 동작
    .independent      나중 것이 앞 것을 취소하면 안 되는 작업(저장)

`.skipIfRunning`의 완료 정리를 테스트로 못박았다 — **이걸 잊는 것이 #366에서
고친 "당김 새로고침 영구 잠김"의 원인**이었다.

`Mutex` 기반인 이유: 소유자의 `deinit`에서 일괄 취소할 수 있어야 하는데 `deinit`은
nonisolated라 액터 격리된 저장 프로퍼티를 만질 수 없다.

CLAUDE.md의 "catch-all Shared/Common 금지, 목적별 모듈" 규약에 따라
`CoreConcurrency`로 신설했다.

## Swift 6 격리

모듈 기본 격리가 `.mainActor`라 파일 수준 타입까지 격리를 얻어, `Sendable` 제약을
만족하지 못하는 경우를 세 번 만났다. 전부 `nonisolated` 명시로 해결하고 이유를
주석에 남겼다.

검증: SettingsFeature 18 / SearchFeature 26 / CoreConcurrency 7

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@YuGeonHui
YuGeonHui merged commit 9307041 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