폼 위주의 Android 앱에서 Kotlin MVI vs MVVM: UI 상태
Kotlin MVI와 MVVM을 폼이 많은 Android 앱 관점에서 비교합니다. 유효성 모델링, 낙관적 UI, 오류 상태, 오프라인 드래프트를 실용적으로 다루는 방법을 설명합니다.

폼이 많은 Android 앱이 금세 엉켜 보이는 이유
폼이 많은 앱은 느리거나 약해 보이기 쉬운데, 그 이유는 사용자가 지속적으로 코드가 내려야 할 작은 결정을 기다리기 때문입니다: 이 필드가 유효한가, 저장이 됐나, 오류를 보여줘야 하나, 네트워크가 끊기면 어떻게 하지?
폼은 여러 종류의 상태를 한꺼번에 섞기 때문에 상태 버그가 먼저 드러납니다: UI 상태(무엇이 보이나), 입력 상태(사용자가 입력한 것), 서버 상태(무엇이 저장되었는가), 임시 상태(진행 중인 것). 이들이 어긋나면 버튼이 잘못 비활성화되거나 오래된 오류가 남아 있거나 화면이 회전 후 리셋되는 등 ‘랜덤’한 동작이 생깁니다.
대부분의 문제는 네 가지 영역에 모입니다: 유효성 검사(특히 필드 간 규칙), 낙관적 UI(비동기 작업 중에도 빠른 피드백), 오류 처리(명확하고 복구 가능한 실패), 오프라인 드래프트(미완성 작업을 잃지 않기).
좋은 폼 UX는 몇 가지 간단한 규칙을 따릅니다:
- 유효성 검사는 필드에 가까이 놓고 도움이 되게 하세요. 입력을 막지 마세요. 보통 엄격한 검사는 제출 시에 합니다.
- 낙관적 UI는 사용자의 동작을 즉시 반영하되, 서버가 거부할 경우 깔끔하게 롤백할 수 있어야 합니다.
- 오류는 구체적이고 실행 가능한 형태로 보여주며 사용자의 입력을 지우지 마세요.
- 드래프트는 재시작, 중단, 불안정한 연결을 견뎌야 합니다.
그래서 폼에 관한 아키텍처 논쟁이 뜨거워집니다. 어떤 패턴을 고르느냐가 압박 상황에서 그 상태들이 얼마나 예측 가능하게 느껴지는지를 결정합니다.
빠른 복습: 평이한 말로 보는 MVVM과 MVI
MVVM과 MVI의 실질적 차이는 화면에서 변화가 어떻게 흘러가는가입니다.
MVVM(Model View ViewModel)은 보통 이렇게 보입니다: ViewModel이 화면 데이터를 보유하고(종종 StateFlow나 LiveData로 노출), 저장, 검증, 로드 같은 메서드를 제공합니다. UI는 사용자의 상호작용이 있을 때 ViewModel 함수를 호출합니다.
MVI(Model View Intent)은 보통 이렇게 보입니다: UI가 이벤트(의도)를 보냅니다, 리듀서가 이를 처리하고 현재 UI가 필요로 하는 단일 상태 객체로부터 화면을 렌더링합니다. 부작용(네트워크, DB)은 제어된 방식으로 트리거되고 결과는 다시 이벤트로 보고됩니다.
마인드셋을 기억하는 간단한 방법:
- MVVM은 “ViewModel은 어떤 데이터를 노출해야 하고 어떤 메서드를 제공해야 하는가?”를 묻습니다.
- MVI는 “어떤 이벤트들이 발생할 수 있고 그것들이 어떻게 하나의 상태를 변형하는가?”를 묻습니다.
간단한 화면에는 어느 패턴이나 잘 작동합니다. 필드 간 유효성, 자동저장, 재시도, 오프라인 드래프트가 생기면 누가 언제 상태를 바꿀 수 있는지에 대해 더 엄격한 규칙이 필요합니다. MVI는 기본적으로 그런 규칙을 강제합니다. MVVM도 잘 작동할 수 있지만 규율이 필요합니다: 일관된 업데이트 경로와 일회성 UI 이벤트(토스트, 내비게이션) 처리에 주의해야 합니다.
놀라움 없이 폼 상태를 모델링하는 법
가장 빨리 통제를 잃는 방법은 폼 데이터를 너무 많은 곳에 흩어놓는 것입니다: 뷰 바인딩, 여러 플로우, 그리고 ‘한 가지 불린값만 더’ 같은 것들. 폼이 많은 화면은 하나의 진실 소스가 있을 때 예측 가능하게 유지됩니다.
실용적인 FormState 구조
원시 입력과 신뢰할 수 있는 파생 플래그 몇 개를 담는 단일 FormState를 목표로 하세요. 조금 커 보이더라도 단조롭고 완전하게 유지하세요.
data class FormState(
val fields: Fields,
val fieldErrors: Map\u003cFieldId, String\u003e = emptyMap(),
val formError: String? = null,
val isDirty: Boolean = false,
val isValid: Boolean = false,
val submitStatus: SubmitStatus = SubmitStatus.Idle,
val draftStatus: DraftStatus = DraftStatus.NotSaved
)
sealed class SubmitStatus { object Idle; object Saving; object Saved; data class Failed(val msg: String) }
sealed class DraftStatus { object NotSaved; object Saving; object Saved }
이 구조는 필드 수준 유효성(입력별)을 폼 수준 문제(예: “총합은 > 0이어야 함”)과 분리합니다. isDirty나 isValid 같은 파생 플래그는 UI에서 다시 구현하지 말고 한 곳에서 계산하세요.
정리하면: 필드(사용자가 입력한 것), 유효성(무엇이 잘못됐는가), 상태(앱이 무엇을 하고 있는가), 더티 여부(마지막 저장 이후 무엇이 바뀌었나), 드래프트(오프라인 복사 여부)를 구분해 두세요.
일회성 효과는 어디에 두나
폼은 또한 일회성 이벤트(스낵바, 내비게이션, “저장됨” 배너)를 발생시킵니다. 이런 것을 FormState 안에 넣으면 회전이나 UI 재구독 시 다시 발생합니다.
MVVM에서는 별도의 채널(예: SharedFlow)로 효과를 방출하세요. MVI에서는 UI가 한 번 소비하는 Effects(또는 Events)로 모델링하세요. 이런 분리는 ‘유령’ 오류나 성공 메시지의 중복을 막습니다.
MVVM vs MVI에서의 유효성 검사 흐름
유효성 검사야말로 폼 화면이 약해지기 시작하는 지점입니다. 핵심 선택은 규칙이 어디에 있고 결과가 UI로 어떻게 돌아가는가입니다.
간단한 동기 규칙(필수, 최소 길이, 숫자 범위)은 UI가 아니라 ViewModel이나 도메인 레이어에서 실행해야 합니다. 이렇게 하면 규칙을 테스트하기 쉽고 일관됩니다.
비동기 규칙(예: “이 이메일이 이미 있는가?”)은 까다롭습니다. 로딩, 오래된 결과, 사용자가 다시 입력한 경우를 처리해야 합니다.
MVVM에서는 유효성이 상태와 헬퍼 메서드의 혼합이 되는 경우가 많습니다: UI가 텍스트 변경, 포커스 손실, 제출 클릭을 ViewModel에 보내면 ViewModel이 StateFlow/LiveData를 업데이트하고 필드별 오류와 파생된 canSubmit을 노출합니다. 비동기 검사는 보통 잡을 시작해 완료 시 로딩 플래그와 오류를 업데이트합니다.
MVI에서는 유효성이 더 명시적인 경향이 있습니다. 실용적인 책임 분배 예:
- 리듀서가 동기 유효성 검사를 실행하고 필드 오류를 즉시 업데이트합니다.
- 효과(Effect)가 비동기 유효성 검사를 실행하고 결과 인텐트를 디스패치합니다.
- 리듀서는 그 결과가 최신 입력과 여전히 일치할 때만 적용합니다.
마지막 단계가 중요합니다. 사용자가 이메일을 입력하는 동안 ‘고유 이메일’ 검사가 실행 중이면, 오래된 결과가 현재 입력을 덮어쓰면 안 됩니다. MVI는 상태에 마지막으로 검사한 값을 저장해 오래된 응답을 무시하는 로직을 세우기 쉽게 해줍니다.
낙관적 UI와 비동기 저장
낙관적 UI는 네트워크 응답이 오기 전에 저장이 성공한 것처럼 화면이 행동하는 것을 말합니다. 폼에서는 보통 저장 버튼이 “저장 중…”으로 바뀌고, 완료되면 작은 “저장됨” 표시가 나오며, 입력은 계속 사용 가능하거나(혹은 의도적으로 잠금) 되어 있습니다.
MVVM에서는 보통 isSaving, lastSavedAt, saveError 같은 플래그를 토글해 구현합니다. 위험은 겹치는 저장으로 인해 플래그가 일관성을 잃는 것입니다. MVI에서는 리듀서가 하나의 상태 객체를 업데이트하므로 “저장 중”과 “비활성화”가 모순될 가능성이 줄어듭니다.
중복 제출과 경쟁 조건을 피하려면 각 저장을 식별된 이벤트로 처리하세요. 사용자가 저장을 두 번 누르거나 저장 중에 편집하면 어떤 응답이 이길지 규칙이 필요합니다. 양 패턴에서 통하는 몇 가지 안전장치: 저장 중 버튼 비활성화(또는 탭 디바운스), 각 저장에 requestId나 버전 붙이기 및 오래된 응답 무시, 사용자가 떠날 때 진행 중 작업 취소, 저장 중 편집은 큐에 넣을지 또는 다시 더티로 표시할지 정의하기.
부분적인 성공도 흔합니다: 서버가 일부 필드는 수락하고 일부는 거부할 수 있습니다. 이를 명시적으로 모델링하세요. 필드별 오류와(필요하면) 필드별 동기화 상태를 유지해 전체적으로는 “저장됨”을 표시하면서도 수정이 필요한 필드를 강조할 수 있게 하세요.
사용자가 복구할 수 있는 오류 상태
폼 화면의 실패는 단순한 “무언가 잘못됨”보다 다양합니다. 모든 실패를 일반적인 토스트로만 처리하면 사용자는 데이터를 다시 입력하고 신뢰를 잃습니다. 목표는 항상 동일합니다: 입력을 안전하게 지키고, 명확한 해결 방법을 보여주며, 재시도를 자연스럽게 만드세요.
오류를 위치별로 분리하면 도움이 됩니다. 형식이 잘못된 이메일은 서버 장애와 다릅니다.
필드 오류는 인라인으로 해당 입력에 묶어두고, 폼 수준 오류는 제출 근처에 두어 무엇이 제출을 막는지 설명하세요. 네트워크 오류는 재시도 옵션을 주고 폼을 편집 가능하게 유지하세요. 권한/인증 오류는 재인증으로 유도하되 드래프트는 보존하세요.
핵심 복구 규칙: 실패 시 절대 사용자 입력을 지우지 마세요. 저장이 실패하면 현재 값을 메모리와 디스크에 유지하세요. 재시도는 사용자가 편집하지 않았다면 동일한 페이로드를 다시 보내야 합니다.
패턴 차이는 서버 오류를 UI 상태로 매핑하는 방식에 있습니다. MVVM에서는 여러 플로우나 필드를 업데이트하다 우발적 불일치가 생기기 쉽습니다. MVI에서는 보통 서버 응답을 하나의 리듀서 단계에서 fieldErrors와 formError를 함께 업데이트해 이런 실수를 줄입니다.
또한 무엇이 상태이고 무엇이 일회성 효과인지 결정하세요. 인라인 오류와 “제출 실패”는 상태에 들어가야 합니다(회전 후에도 유지되어야 함). 스낵바, 진동, 내비게이션 같은 일회성 동작은 효과로 두세요.
오프라인 드래프트와 진행 중 폼 복원
폼이 많은 앱은 네트워크가 괜찮아도 ‘오프라인처럼’ 느껴질 수 있습니다. 사용자는 앱을 전환하고, OS가 프로세스를 종료하고, 신호를 잃습니다. 드래프트는 사용자가 다시 시작하지 않도록 도와줍니다.
먼저 드래프트가 무엇인지 정의하세요. ‘정리된’ 모델만 저장하면 충분하지 않을 때가 많습니다. 화면이 보였던 그대로(부분 입력 포함)를 복원하고 싶을 가능성이 큽니다.
대부분 저장할 것은 원시 사용자 입력(타이핑한 문자열, 선택한 ID, 첨부 URI)과 나중에 안전하게 병합하기 위한 최소 메타데이터입니다: 마지막으로 알던 서버 스냅샷과 버전 마커(예: updatedAt, ETag 또는 간단한 증가값). 복원 시 유효성은 다시 계산하세요.
저장소 선택은 민감도와 크기에 따라 달라집니다. 작은 드래프트는 preferences에 보관할 수 있지만, 다단계 폼과 첨부파일은 로컬 DB가 더 안전합니다. 개인 데이터가 포함되면 암호화된 저장을 사용하세요.
진실의 출처가 어디에 있는지가 큰 아키텍처 질문입니다. MVVM에서는 팀이 보통 필드가 바뀔 때마다 ViewModel에서 영구화합니다. MVI에서는 리듀서 업데이트 후에 하나의 일관된 상태(또는 파생된 Draft 객체)를 저장하는 것이 더 간단할 수 있습니다.
자동저장 타이밍도 중요합니다. 매 키 입력마다 저장하면 시끄럽습니다; 짧은 디바운스(예: 300~800ms)와 단계 변경 시 저장을 결합하면 좋습니다.
사용자가 다시 온라인이 되었을 때 병합 규칙이 필요합니다. 실용적인 접근은: 서버 버전이 변경되지 않았다면 드래프트를 적용하고 제출하세요. 서버가 변경되었다면 ‘내 드래프트 유지’ 또는 ‘서버 데이터 로드’ 중 명확한 선택을 보여주십시오.
단계별: 어느 패턴으로든 신뢰할 수 있는 폼 구현하기
신뢰할 수 있는 폼은 UI 코드가 아니라 명확한 규칙에서 시작합니다. 모든 사용자 액션은 예측 가능한 상태로 이어져야 하고 모든 비동기 결과는 들어갈 한 곳이 있어야 합니다.
화면이 반응해야 할 액션을 적어 보세요: 타이핑, 포커스 손실, 제출, 재시도, 단계 이동. MVVM에서는 이들이 ViewModel 메서드와 상태 업데이트가 되고, MVI에서는 명시적 인텐트가 됩니다.
그다음 작은 단계로 빌드하세요:
- 편집, 블러, 제출, 저장 성공/실패, 재시도, 드래프트 복원 같은 전체 라이프사이클 이벤트를 정의합니다.
- 하나의 상태 객체를 설계합니다: 필드 값, 필드별 오류, 전체 폼 상태, ‘저장되지 않은 변경 여부’.
- 유효성을 추가합니다: 편집 중에는 가벼운 검사, 제출 시에는 더 무거운 검사.
- 낙관적 저장 규칙을 추가합니다: 즉시 변경되는 것과 롤백을 트리거하는 것을 정의합니다.
- 드래프트를 추가합니다: 디바운스된 자동저장, 열기 시 복원, 그리고 사용자에게 신뢰를 주기 위한 작은 “드래프트 복원됨” 표시를 보여줍니다.
오류를 경험의 일부로 다루세요. 입력은 보존하고, 수정이 필요한 부분만 강조하며, 한 가지 명확한 다음 행동(편집, 재시도, 드래프트 유지)을 제시하세요.
복잡한 폼 상태를 Android UI로 작성하기 전에 코드 없이 워크플로를 프로토타입해보고 싶다면 AppMaster 같은 노코드 플랫폼이 워크플로를 먼저 검증하는 데 유용할 수 있습니다. 그런 다음 동일한 규칙을 MVVM이나 MVI로 구현하면 놀람이 줄어듭니다.
예시 시나리오: 다단계 비용 보고서 폼
4단계 비용 보고서가 있다고 상상해보세요: 상세(날짜, 카테고리, 금액), 영수증 업로드, 메모, 검토 및 제출. 제출 후에는 Draft, Submitted, Rejected, Approved 같은 승인 상태를 보여줍니다. 까다로운 부분은 유효성, 실패 가능한 저장, 오프라인일 때 드래프트 보존입니다.
MVVM에서는 보통 ViewModel에 FormUiState(대개 StateFlow)를 보관합니다. 각 필드 변경은 onAmountChanged()나 onReceiptSelected() 같은 ViewModel 함수를 호출합니다. 유효성은 변경 시, 단계 이동 시, 또는 제출 시 실행됩니다. 일반 구조는 원시 입력 + 필드 오류 + 파생 플래그로 Next/Submit 활성화를 제어하는 방식입니다.
MVI에서는 같은 흐름이 명시적으로 됩니다: UI는 AmountChanged, NextClicked, SubmitClicked, RetrySave 같은 인텐트를 보냅니다. 리듀서는 새로운 상태를 반환합니다. 부작용(영수증 업로드, API 호출, 스낵바 표시)은 리듀서 밖에서 실행되고 결과는 이벤트로 되돌아옵니다.
실무에서는 MVVM이 함수를 추가하고 플로우를 업데이트하기 쉬운 반면, MVI는 모든 변경이 리듀서를 통해 흘러가기 때문에 상태 전이를 실수로 건너뛰기 어렵게 만듭니다.
흔한 실수와 함정
대부분의 폼 버그는 누가 진실을 소유하는지, 언제 유효성을 실행하는지, 비동기 결과가 늦게 도착했을 때 무슨 일이 일어나는지에 대한 규칙이 불명확해서 발생합니다.
가장 흔한 실수는 진실을 여러 곳에서 섞어 쓰는 것입니다. 어떤 텍스트 필드는 때로는 위젯에서 읽고, 때로는 ViewModel 상태에서 읽고, 때로는 복원된 드래프트에서 읽으면 무작위 리셋과 ‘내 입력이 사라졌다’는 보고가 나옵니다. 화면에 변경 가능한 값은 하나의 정식 상태에 넣고 나머지는 거기서 파생하세요(도메인 모델, 캐시 행, API 페이로드).
또 다른 쉬운 함정은 상태와 이벤트를 혼동하는 것입니다. 토스트, 내비게이션, “저장됨!” 배너는 일회성 이벤트입니다. 사용자가 편집할 때까지 남아 있어야 하는 오류 메시지는 상태입니다. 이들을 섞으면 회전 시 효과가 중복되거나 피드백이 누락됩니다.
자주 보이는 두 가지 정확성 문제:
- 특히 비용이 큰 검사에 대해 매 키 입력마다 과도하게 검증하는 것. 디바운스하거나 블러 시 검증하거나 터치된 필드만 검증하세요.
- 순서가 어긋난 비동기 결과를 무시하는 것. 사용자가 두 번 저장하거나 저장 후 편집하면 오래된 응답이 최신 입력을 덮어쓰지 않도록 request ID(또는 ‘최신 것만’ 논리)를 사용하세요.
마지막으로, 드래프트는 단순히 “JSON 저장”이 아닙니다. 버전 관리를 하지 않으면 앱 업데이트로 복원이 깨질 수 있습니다. 간단한 스키마 버전과 마이그레이션 전략을 추가하세요(매우 오래된 드래프트는 포기하고 새로 시작하는 것도 한 방법입니다).
출시 전 빠른 체크리스트
MVVM 대 MVI를 논하기 전에 폼에 하나의 명확한 진실 소스가 있는지 확인하세요. 화면에서 값이 바뀔 수 있다면 뷰 위젯이나 숨겨진 플래그가 아니라 상태에 있어야 합니다.
실용적 사전 출시 점검:
- 상태에 입력, 필드 오류, 저장 상태(idle/saving/saved/failed), 드래프트/큐 상태를 포함해 UI가 추측하지 않게 하세요.
- 유효성 규칙은 UI 없이도 순수하게 테스트 가능하게 하세요.
- 낙관적 UI는 서버 거부 시 롤백 경로를 갖추세요.
- 오류는 절대 사용자 입력을 지우면 안 됩니다.
- 드래프트 복원은 예측 가능하게: 자동 복원 배너를 명확히 하거나 ‘드래프트 복원’ 명시적 액션을 제공하세요.
실제 버그를 잡는 테스트 하나: 저장 중 비행기 모드를 켜고 끄고 두 번 재시도하세요. 두 번째 재시도가 중복을 만들면 안 됩니다. 요청 ID, 멱등 키, 또는 로컬 ‘대기중인 저장’ 표시로 재시도를 안전하게 만드세요.
답이 모호하면 먼저 상태 모델을 조이고, 그 다음 규칙을 강제하기 쉬운 패턴을 선택하세요.
다음 단계: 경로 선택과 더 빠른 구축
하나의 질문으로 시작하세요: 폼이 반쯤 업데이트된 이상한 상태가 되었을 때 비용이 얼마나 큰가? 비용이 낮다면 단순하게 가세요.
화면이 직선적이고 상태가 주로 “필드 + 오류”라면 그리고 팀이 ViewModel + LiveData/StateFlow로 이미 자신 있게 배포한다면 MVVM이 잘 맞습니다.
자동저장, 재시도, 동기화 같은 비동기 이벤트가 많거나 버그 비용이 큰 경우(결제, 규정 준수, 중요한 워크플로)에는 MVI가 더 적합합니다.
어떤 길을 선택하든 폼에 대한 가장 높은 투자 수익 테스트는 보통 UI를 건드리지 않습니다: 유효성 엣지 케이스, 상태 전이(편집→제출→성공/실패→재시도), 낙관적 저장 롤백, 드래프트 복원 및 충돌 처리.
백엔드, 관리자 화면, API까지 모바일 앱과 함께 필요하다면 AppMaster (appmaster.io)는 하나의 모델에서 프로덕션 수준의 백엔드, 웹, 네이티브 모바일 앱을 생성해 검증과 규칙을 여러 표면에서 일관되게 유지하는 데 도움을 줍니다.
자주 묻는 질문
간단한 흐름이고 팀이 StateFlow/LiveData, 일회성 이벤트 처리, 취소 전략에 대해 이미 일관된 규약을 가지고 있다면 MVVM을 선택하세요. 반대로 자동저장, 재시도, 업로드처럼 겹치는 비동기 작업이 많고 상태 전이가 엄격해야 하며 상태 변경이 여러 곳에서 ‘몰래’ 일어나는 것을 막고 싶다면 MVI가 더 적합합니다.
하나의 화면 상태 객체(예: FormState)를 만들어 원시 필드 값, 필드별 오류, 폼 수준 오류, Saving/Failed 같은 명확한 상태를 포함하세요. isValid나 canSubmit 같은 파생 플래그는 한 곳에서 계산되게 해서 UI는 단지 렌더링만 하도록 합니다.
편집 중에는 가볍고 비용이 적은 검사(필수 입력, 범위, 기본 형식)를 수행하고, 제출 시에는 엄격한 검사를 하세요. 유효성 검사 로직은 UI에 두지 말고 테스트 가능하게 분리하고, 오류는 상태에 저장해 회전(rotation)이나 프로세스 종료 후 복원 시에도 유지되게 하세요.
비동기 검사에 대해서는 ‘최신 입력이 우선’이라는 원칙을 따르세요. 검증한 값이나 요청·버전 id를 상태에 보관하고, 결과가 도착했을 때 현재 상태와 맞지 않으면 무시합니다. 이렇게 하면 옛날 응답이 새 입력을 덮어쓰는 문제를 막을 수 있습니다.
사용자 액션을 즉시 반영하되(예: Saving… 표시, 입력은 유지) 서버가 거부하면 되돌릴 수 있어야 합니다. 요청마다 id/버전 붙이기, 저장 중에는 버튼 비활성화 또는 디바운스, 저장 중 편집의 의미(필드 잠금, 재저장 큐, 또는 다시 더티 표시)를 정의하세요.
실패 시 절대 사용자의 입력을 지우지 마세요. 필드별 문제는 해당 입력 옆에 인라인으로 표시하고, 폼 수준 차단은 제출 액션 근처에 두세요. 네트워크 실패는 재시도 가능하게 하되 같은 페이로드를 다시 보내도록 하고, 사용자가 편집하면 그에 맞춰 재전송 조건을 바꾸세요.
영구 상태에 일회성 효과를 섞지 마세요. MVVM에서는 별도의 스트림(예: SharedFlow)으로 전송하고, MVI에서는 UI가 한 번 소비하는 Effects로 모델링하세요. 이렇게 하면 회전이나 재구독 때문에 스낵바가 중복 표시되거나 내비게이션이 반복되는 일을 피할 수 있습니다.
대부분 원시 사용자 입력(사용자가 쓴 문자열, 선택된 ID, 첨부 URI)과 안전하게 병합하기 위한 최소 메타데이터(마지막으로 알던 서버 스냅샷, 버전 표시자)를 저장하세요. 복원 시 유효성은 다시 계산하는 편이 안전하며, 개인 데이터가 포함되면 암호화된 저장소를 사용하세요.
짧은 디바운스(예: 수백 밀리초)로 자동저장하고, 단계 전환이나 앱 백그라운드 시에도 저장하세요. 매 키 입력마다 저장하면 잡음이 많아지고 충돌이 생기기 쉽습니다. 너무 적게 저장하면 프로세스 종료 시 작업을 잃을 위험이 있습니다.
서버와 드래프트에 각각 버전 표식을 두세요(예: updatedAt, ETag, 로컬 증분). 서버 버전이 바뀌지 않았다면 드래프트를 적용하고 제출하세요. 바뀌었다면 사용자가 명확히 선택하도록 하세요: 내 드래프트 유지 vs 서버 데이터 로드 — 아무것도 조용히 덮어쓰지 마세요.


