Skip to content

refactor(afternote): 에디터 폼 상태·갱신 통로 재설계 — EditorFormState 카테고리별 sealed 분리 + updateForm 개별 콜백화 #675

Closed

Description

@OnlyReadOneBook

📜 Overview (Required)

에디터 폼의 상태 구조와 갱신 통로를 함께 손댄다. 원래 #674 로 나뉘어 있던 갱신 통로 작업을 이 이슈로 흡수했다 — 두 작업이 AfternoteEditorState 의 같은 19개 updateForm { it.copy(...) } 블록을 각자 재작성하므로, 나눠 진행하면 뒤엣것이 앞엣것을 통째로 다시 쓴다.

1. 카테고리 전용 값이 전환 후에도 남는다

EditorFormState 가 5개 카테고리(SOCIAL·GALLERY·BUSINESS·MEMORIAL·ESTATE)의 필드를 하나의 flat data class 에 모아 두고, 어느 필드가 지금 카테고리에서 유효한지는 소비처가 알아서 가려 쓴다. 리셋은 명시한 두 필드에만 걸려 있다.

// AfternoteEditorState.selectCategory (state/AfternoteEditorState.kt:89-98)
updateForm {
    it.copy(
        selectedCategory = category,
        // 카테고리 전환 시 서비스명은 항상 미선택으로 리셋 — 임의 기본값 확정 방지 (이슈 #468).
        selectedService = null,
        processingMethods = emptyList(),
    )
}

MEMORIAL 전용인 pickedMemorialPhotoUri·memorialVideoUrl·memorialThumbnailUrl·memorialPhotoUrl·memorialPlaylistSongs 5개는 리셋 대상이 아니다(리셋 3필드와 교집합 0). SOCIAL → MEMORIAL → SOCIAL 로 오가면 추억 노트 값이 폼과 SavedState 스냅샷에 그대로 남는다.

이 잔류는 무해하지 않다. 저장 payload 조립은 카테고리로 갈라 memorial* 을 무시하지만, 미디어 해석은 그 분기보다 앞에서 카테고리와 무관하게 돈다.

  • AfternoteNavGraphEditor.kt:131-137 이 SaveAfternoteMemorialMedia(...) 를 카테고리 조건 없이 항상 채워 넘긴다.
  • AfternoteEditorViewModel.buildSaveCommand(361-377)가 resolveMemorialMediaForSave(...) 를 매퍼 분기 이전에 무조건 호출한다.
  • 잔류한 content:// 는 MediaInput.Local 로 판정돼 실제 업로드를 태우고, 실패하면 MemorialPhotoSaveException("영정 사진 업로드에 실패했습니다.")(ResolveMemorialMediaForSaveUseCase.kt:55-58)로 SOCIAL 등록 전체가 그 문구로 차단된다.

이 유형은 이미 한 번 터졌다 — #213(카테고리 전환 시 이전 카테고리 actions 잔류, 저장 시 합쳐서 전송). 당시 processingMethods 를 리셋 목록에 넣어 막았지만 나머지 필드는 같은 자리에 그대로 있다. 필드가 늘 때마다 "이건 리셋 목록에 넣어야 하나" 를 사람이 기억해야 하는 구조다.

작업: 공용 필드를 base 로 두고 카테고리 전용 필드를 sealed 하위 타입으로 분리한다. 카테고리 전환이 곧 하위 타입 교체가 되므로 잔류가 구조적으로 불가능해진다.

구분 필드 근거
base (전 카테고리) loadedItemId · afternoteEditReceivers · leaveMessageBlocks · leaveMessageBlocksRestoreGeneration 수신자는 전 카테고리 공통. 남기실 말씀은 현재 MEMORIAL 이 렌더하지 않지만 #678 이 MEMORIAL 에 추가하는 작업이고, 프리필 읽기 경로는 이미 카테고리 무관이다(AfternoteEditorFormMapper.kt:61) — 하위로 내리면 #678 에서 되돌려야 한다
계정·갤러리 공유 (SOCIAL·BUSINESS·GALLERY) selectedService · processingMethods MEMORIAL 분기(AfternoteEditorContent.kt:233-253)가 둘 다 넘기지 않는다. 서비스 카탈로그도 카테고리별로 다르다
MEMORIAL 전용 pickedPhotoUri · videoUrl · thumbnailUrl · photoUrl · playlistSongs
ESTATE 전용 없음 AfternoteEditorContent.kt:279 → UnimplementedCategoryContent()

selectedCategory 는 필드가 아니라 하위 타입에서 파생된다. 리셋을 구조로 강제하려면 하위 타입이 카테고리마다 달라야 한다 — SOCIAL ↔ BUSINESS ↔ GALLERY 전환에서도 서비스명·처리 방법은 현재 리셋되며, 세 카테고리가 같은 하위 타입을 공유하면 그 리셋이 다시 명시적 코드로 돌아간다.

2. 폼 갱신이 상태 변환 람다 하나를 통로로 쓴다 (구 #674)

UI 파사드가 (EditorFormState) -> EditorFormState 를 받아 ViewModel 이 소유한 폼 SSOT 를 임의로 변환한다.

// AfternoteEditorViewModel.kt:204
fun updateForm(block: (EditorFormState) -> EditorFormState) {
    internalState.update { prev -> prev.copy(form = block(prev.form)) }
    persistFormSnapshot(internalState.value.form)
}

파사드 AfternoteEditorState 가 이 통로를 19곳에서 쓴다 — syncMemorialPlaylistSongs(71) · resetMemorialPlaylistFormSnapshot(76) · selectCategory(90, private — onCategorySelected·selectCategoryByNavKey 가 호출) · onServiceSelected(104) · onMemorialPhotoSelected(109) · onFuneralVideoSelected(113) · onFuneralThumbnailDataUrlReady(117) · onAddCustomService(130) · onAddAfternoteEditorReceiver(140) · onAfternoteEditorReceiverDelete(155) · addReceiverFromSelection(168) · replaceReceiversIfEmpty(182) · addEditorMessage(190) · removeEditorMessage(200) · persistEditorMessagesFromTyping(233) · applyFormPrefill(248) · addProcessingMethod(268) · deleteProcessingMethod(279) · editProcessingMethod(288).

공식 가이드가 권장하는 형태는 개별 콜백이다. State holders 는 "If the shorter-lived state holder needs certain information from a higher-scoped state holder, pass only the information it needs as a parameter instead of passing the state holder instance." 라고 적고, 예제에서 private val doSomething: () -> Unit 처럼 필요한 동작만 개별로 받는 형태를 보여 준다. UI layer 의 "Only sources or owners of data should be responsible for updating the data they expose." 도 같은 방향이다.

⚠️ 다만 "상태 변환 람다" 를 금지하는 문장은 두 문서 어디에도 없다(원문 대조 완료). 현 구조는 ViewModel 인스턴스를 넘기지 않으므로 명시적 금지 대상은 아니고, "권장 예시에서 벗어나 있다" 가 정확한 표현이다. 이 선을 넘겨 근거로 삼지 말 것.

작업: 파사드 19개 지점이 각자 대응하는 ViewModel 콜백을 호출하도록 바꾸고, 실제 copy(...) 는 ViewModel 안에서 수행한다. 파사드의 공개 메서드 이름·시그니처는 유지되므로 컴포저블 호출부는 영향이 없다.

📌 Child Issue(Optional)

No response

📍 Note (Optional)

Activity

  1. OnlyReadOneBook commented on Aug 2, 2026

    @OnlyReadOneBook
    CollaboratorAuthor

    범위 추가 — 도메인 Detail 도 같은 구조라 함께 봐야 한다. 본문엔 EditorFormState·EditorFormPrefill 만 적었는데, 그 앞단이 이미 flat 이라 두 층만 sealed 로 갈라도 여기서 다시 합쳐진 값을 받는다.

    같은 문제가 세 층에 있다.

    층 타입 카테고리 전용 필드가 flat 으로 섞인 예
    도메인 Detail credentials(SOCIAL·BUSINESS) · memorial(MEMORIAL) — 갤러리 상세도 둘 다 null 로 보유
    프리필 EditorFormPrefill accountId·password(계정형) · funeralVideoUrl·funeralThumbnailUrl·memorialPhotoUrl·memorialPlaylistSongs(MEMORIAL)
    폼 EditorFormState 위와 동일 + 카테고리 전환 시 잔류(본문 참조)

    Detail 은 상세 화면·에디터 프리필이 함께 쓰는 도메인 모델이라 파급이 프리필·폼보다 넓다(상세 3화면 + 수신자 상세 매퍼). 세 층을 한 번에 갈 것인지, 폼→프리필→도메인 순으로 나눌 것인지는 착수 시점에 정하면 되지만, 도메인을 빼놓고 위 두 층만 하면 절반만 해소된다는 점은 기록해 둔다.

    참고: Detail 은 2026-08-02 feat/566 에서 이미 한 차례 정리됐다 — DetailProcessing 해체(→ processingMethods·leaveMessage flat), playlist → memorial 리네임, DetailReceiver.receiverId non-null 화, 죽은 필드(atmosphere·profilePhoto) 제거. 카테고리별 분리는 손대지 않았으므로 그 위에 얹으면 된다.

  2. changed the title [-]refactor(afternote): EditorFormState 를 카테고리별 sealed 로 분리 — 전환 시 이전 카테고리 값 잔류 차단[/-] [+]refactor(afternote): 에디터 폼 상태·갱신 통로 재설계 — EditorFormState 카테고리별 sealed 분리 + updateForm 개별 콜백화[/+] on Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

enhancementNew feature or request

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions