Repository navigation
refactor(afternote): 에디터 폼 상태·갱신 통로 재설계 — EditorFormState 카테고리별 sealed 분리 + updateForm 개별 콜백화 #675
Description
📜 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." 도 같은 방향이다.
작업: 파사드 19개 지점이 각자 대응하는 ViewModel 콜백을 호출하도록 바꾸고, 실제 copy(...) 는 ViewModel 안에서 수행한다. 파사드의 공개 메서드 이름·시그니처는 유지되므로 컴포저블 호출부는 영향이 없다.
📌 Child Issue(Optional)
No response
📍 Note (Optional)
- KDoc 두 곳을 정면으로 갈아엎어야 한다.
EditorFormStateKDoc 1923행이 UI layer 의 "Use a single UI state object to handle states that are related to each other" 를 인용해 단일 객체를 정당화하고, 4345행은processingMethods가 "카테고리 전환 시 리셋되므로 카테고리별로 나눌 이유가 없다"고 못박는다. 전자는 같은 문단이 예외로 든 "Unrelated data types" 가 이 경우라는 점에서 적용 범위가 다르고, 후자는 리셋되지 않는 5필드가 실재하므로 전제가 틀렸다. - 선행 결정 — 이탈 가드 지문 계약.
AfternoteEditorScreen.kt:266의editorContentSignature는 폼을 통째toString()하고selectedCategory만 SOCIAL 로 중립화해 "카테고리 구경은 변경 아님" 을 만든다. sealed 로 쪼개면 서브타입이 달라져 지문이 반드시 달라지므로, 이 계약과 그것을 고정한EditorContentSignatureCompletenessTest가 함께 깨진다. 대체 설계가 구현보다 앞서야 한다. - 비용 정정 — polymorphic 직렬화는 필요 없다.
EditorFormState자체에@Serializable이 없고, SavedState 스냅샷은 별도 DTOEditorFormSnapshot(AfternoteEditorViewModel.kt:77-137)이며categoryName: String판별자(79행)를 이미 갖고 있다. flat DTO ↔ sealed 도메인 매핑만when으로 바꾸면 되고 스냅샷 키(editor_form_snapshot_v1, 43행)도 유지된다. - updateForm 전환은 2026-05-02 "reducer 유지" 결정을 뒤집은 것이다. 당시 근거로 기록돼 있던 사용자 발언 인용이 실제 발언이 아니었고, 공식 문서를 대조한 뒤 전환하기로 확정했다.
AfternoteEditorUiHolder는 갱신 통로 전환 대상이 아니다 — 탭·다이얼로그·드롭다운·TextFieldState만 들고 ViewModel 참조가 없어 State holders 문서의 UI logic state holder 정의에 정확히 부합한다.EditorFormPrefill(model/EditorFormPrefill.kt) 도 flat 형태(갤러리 상세에도accountId가""로 채워짐:AfternoteEditorFormMapper.kt:59)라 함께 본다.- 곁다리로
Funeral*심볼 잔재를 함께 정리한다.FuneralVideoUpload.kt·FuneralVideoUploadScreenshotTest.kt+ 호출부로 8곳/3파일이며, refactor(afternote): MemorialGuideline 명칭 잔재 정리 — Memorial 로 심볼 통일 #499(MemorialGuideline*→Memorial*)가 남긴 폐기 명칭이다. 별도 이슈로 쪼개기엔 합본 임계(~50파일) 훨씬 아래이고 이번 작업이 이미 에디터 memorial 영역을 건드린다. refactor(afternote): MemorialGuideline 명칭 잔재 정리 — Memorial 로 심볼 통일 #499 와 같은 기준으로 내부 심볼만 바꾸고 표시 문구·strings 키(afternote_editor_funeral_video_*, "장례식에 남길 영상")는 시안 용어라 유지한다. - base 브랜치는
develop이 아니라feat/566(fix(afternote): 수정 진입 시 기존 수신자 prefill 및 분위기 기능 제거 (closes 566) #679)이다. fix(afternote): 수정 진입 시 기존 수신자 prefill 및 분위기 기능 제거 (closes 566) #679 가EditorFormState.kt·AfternoteEditorState.kt·AfternoteEditorViewModel.kt를 이미 재작성했고(selectedLastWish·playlistSongCount·playlistAlbumCovers제거,funeral*→memorial*,messageBlocks→leaveMessageBlocks), develop 은 feat/566 의 순수 조상이다(rev-list --left-right --count origin/develop...origin/feat/566=0 7). 본문의 필드명·지점 수는 fix(afternote): 수정 진입 시 기존 수신자 prefill 및 분위기 기능 제거 (closes 566) #679 기준이다(develop 기준으로는 20곳 —selectedLastWish갱신 1곳이 fix(afternote): 수정 진입 시 기존 수신자 prefill 및 분위기 기능 제거 (closes 566) #679 에서 기능째 제거됐다). - feat(core): non-fatal 오류 계측 확대 — 홈·애프터노트·수신자 흐름 handled 실패 기록 (closes 544) #605·fix(afternote): 등록 실패 시 서버 원문 대신 일반 문구 폴백 — 5xx 내부 SQL 노출 차단 (closes 511) #621·fix(afternote): 수신자 열람 인증 스낵바 서버 문구 표시를 안내된 4xx 거절로 제한 (closes 651) #655 와의 겹침은
AfternoteEditorViewModel.kt의 import·catch 지점뿐이다 — 세 PR 모두EditorFormState.kt·AfternoteEditorState.kt를 포함하지 않는다. 착수 차단 요인이 아니다.
Activity
OnlyReadOneBook commentedon Aug 2, 2026
범위 추가 — 도메인
Detail도 같은 구조라 함께 봐야 한다. 본문엔EditorFormState·EditorFormPrefill만 적었는데, 그 앞단이 이미 flat 이라 두 층만 sealed 로 갈라도 여기서 다시 합쳐진 값을 받는다.같은 문제가 세 층에 있다.
Detailcredentials(SOCIAL·BUSINESS) ·memorial(MEMORIAL) — 갤러리 상세도 둘 다null로 보유EditorFormPrefillaccountId·password(계정형) ·funeralVideoUrl·funeralThumbnailUrl·memorialPhotoUrl·memorialPlaylistSongs(MEMORIAL)EditorFormStateDetail은 상세 화면·에디터 프리필이 함께 쓰는 도메인 모델이라 파급이 프리필·폼보다 넓다(상세 3화면 + 수신자 상세 매퍼). 세 층을 한 번에 갈 것인지, 폼→프리필→도메인 순으로 나눌 것인지는 착수 시점에 정하면 되지만, 도메인을 빼놓고 위 두 층만 하면 절반만 해소된다는 점은 기록해 둔다.참고:
Detail은 2026-08-02feat/566에서 이미 한 차례 정리됐다 —DetailProcessing해체(→processingMethods·leaveMessageflat),playlist→memorial리네임,DetailReceiver.receiverIdnon-null 화, 죽은 필드(atmosphere·profilePhoto) 제거. 카테고리별 분리는 손대지 않았으므로 그 위에 얹으면 된다.[-]refactor(afternote): EditorFormState 를 카테고리별 sealed 로 분리 — 전환 시 이전 카테고리 값 잔류 차단[/-][+]refactor(afternote): 에디터 폼 상태·갱신 통로 재설계 — EditorFormState 카테고리별 sealed 분리 + updateForm 개별 콜백화[/+]