AppOrbit
← blog

Undo/Redo부터 자동 저장까지 — Flutter 에디터의 상태 관리 설계기 (3/5)

Flutter, Riverpod, StateNotifier, UndoRedo, 상태관리, NeonBoard

1. 들어가며: 에디터에는 "되돌리기"가 필요하다

NeonBoard 2.0의 에디터는 4개의 탭에 걸쳐 40개 이상의 설정을 조작할 수 있습니다. 폰트를 바꾸고, 색상을 고르고, 네온 글로우 강도를 올리고, 스크롤 방향을 전환하는 과정에서 사용자는 수십 번의 변경을 시도합니다.

이때 "아, 아까 그 폰트가 더 나았는데"라는 생각이 들면 어떻게 해야 할까요? 설정을 기억하고 있다가 수동으로 되돌려야 한다면, 그것만으로도 상당한 인지 부하가 됩니다. Photoshop이든 Figma든, 창작 도구에는 Undo/Redo가 기본 기능인 이유가 있습니다.

여기에 자동 저장도 필수적이었습니다. 사용자가 전광판을 열심히 꾸며놓고 앱을 실수로 종료하면, 그 작업이 날아가는 경험은 치명적입니다. 하지만 매 변경마다 즉시 저장하면 I/O 부담이 크고, Undo/Redo와의 상호작용도 복잡해집니다.

이번 글에서는 이 두 가지 기능을 Riverpod StateNotifier 위에서 어떻게 설계했는지, 그리고 실시간 프리뷰와 어떻게 연결했는지를 다루겠습니다.


2. CurrentBoardProvider: 에디터 상태의 중심

에디터에서 현재 편집 중인 Board 객체를 관리하는 것이 CurrentBoardNotifier입니다. 이 하나의 StateNotifier가 담당하는 역할은 세 가지입니다.

  1. 현재 상태 관리: Board의 모든 필드 변경을 반영
  2. Undo/Redo: 최대 20단계 히스토리 관리
  3. 자동 저장: 30초 간격으로 Hive에 영속화
// 의사코드: CurrentBoardNotifier 구조
class CurrentBoardNotifier extends StateNotifier<Board?> {
    undoStack: List<Board>       // 최대 20개
    redoStack: List<Board>       // undo 시 쌓임
    autoSaveTimer: Timer?        // 30초 주기
    hasUnsavedChanges: boolean   // 저장 필요 여부 플래그

    // Board의 각 필드를 변경하는 메서드들
    setMainText(text)
    setFontFamily(font)
    setTextColor(color)
    setScrollSpeed(speed)
    // ... 20개 이상의 setter
}

3. Undo/Redo: 무엇을 스택에 넣고, 무엇을 넣지 않을 것인가

Undo/Redo의 기본 원리는 단순합니다. 상태가 변경될 때마다 이전 상태를 스택에 push하고, Undo를 누르면 현재 상태를 Redo 스택에 넣은 뒤 Undo 스택에서 pop하면 됩니다.

// 의사코드: 기본 Undo/Redo 메커니즘
function pushUndo():
    if undoStack.length >= 20:
        undoStack.removeFirst()  // 가장 오래된 히스토리 버림
    undoStack.add(currentState)
    redoStack.clear()            // 새로운 변경이 생기면 redo 무효화

function undo():
    if undoStack.isEmpty: return
    redoStack.add(currentState)
    currentState = undoStack.removeLast()

function redo():
    if redoStack.isEmpty: return
    undoStack.add(currentState)
    currentState = redoStack.removeLast()

그런데 여기서 핵심적인 설계 결정이 필요했습니다. "모든 변경을 Undo 스택에 넣어야 하는가?"

이산적 변경 vs 연속적 변경

에디터의 변경 동작은 크게 두 종류로 나뉩니다.

이산적 변경(Discrete Changes): 폰트 선택, 색상 프리셋 탭, 토글 스위치 ON/OFF, 스크롤 방향 변경 등. 한 번의 탭으로 값이 확 바뀌는 동작입니다.

연속적 변경(Continuous Changes): 텍스트 입력 (키보드로 한 글자씩), 슬라이더 드래그 (폰트 크기, 스크롤 속도, 글로우 강도 등). 짧은 시간 안에 수십 번의 미세한 변경이 연속으로 발생하는 동작입니다.

만약 연속적 변경도 매번 스택에 push한다면 어떻게 될까요? 사용자가 "HELLO WORLD"를 타이핑하면 10개의 히스토리가 쌓이고, 한 번 Undo를 누르면 "HELLO WORL"이 됩니다. 사용자가 기대하는 것은 타이핑 전 상태로 돌아가는 것이지, 한 글자씩 되돌아가는 것이 아닙니다. 슬라이더도 마찬가지입니다. 폰트 크기를 24에서 36으로 드래그하는 동안 수십 번의 중간 값이 발생하지만, Undo에서 기대하는 것은 "24로 돌아가기"입니다.

따라서 이산적 변경만 Undo 스택에 push하고, 연속적 변경은 push하지 않는다는 규칙을 세웠습니다.

// 의사코드: 변경 유형에 따른 Undo 분기

// 이산적 변경 → Undo 스택에 push
function setFontFamily(font):
    pushUndo()                    // 변경 전 상태 저장
    state = state.copyWith(fontFamily: font)
    markUnsaved()

function toggleNeonGlow():
    pushUndo()
    state = state.copyWith(isNeonGlow: !state.isNeonGlow)
    markUnsaved()

// 연속적 변경 → Undo 스택에 push 하지 않음
function setMainText(text):
    state = state.copyWith(mainText: text)  // push 없음
    markUnsaved()

function setFontSize(size):
    state = state.copyWith(fontSize: size)  // push 없음
    markUnsaved()

이 구분 덕분에 Undo 스택에는 "의미 있는" 변경만 쌓이게 됩니다. 사용자 입장에서 Undo 한 번이 "이전의 의미 있는 상태"로 돌아가는 직관적인 경험이 됩니다.

20단계 제한의 이유

Undo 스택을 무제한으로 두면 메모리 사용량이 무한정 늘어납니다. Board 객체 하나가 40개 이상의 필드를 가지고 있으므로, 스택이 깊어질수록 부담이 커집니다. 20단계면 대부분의 실제 사용 시나리오를 충분히 커버하면서도, 메모리 사용량은 합리적인 수준으로 유지할 수 있었습니다. 20개를 초과하면 가장 오래된 히스토리부터 버립니다.


4. 자동 저장: 30초 Timer + 변경 감지 플래그

Undo/Redo와 함께 에디터의 또 다른 안전망인 자동 저장입니다.

왜 매 변경마다 저장하지 않는가

가장 단순한 접근은 state가 바뀔 때마다 즉시 Hive에 저장하는 것입니다. 하지만 이 방식에는 두 가지 문제가 있었습니다.

첫째, I/O 빈도 문제입니다. 슬라이더를 드래그하면 초당 수십 번의 state 변경이 발생합니다. 매번 디스크에 쓰는 것은 불필요한 부담입니다.

둘째, Undo와의 충돌입니다. 매 변경을 즉시 저장하면, Undo로 되돌린 뒤 앱을 종료하고 다시 열었을 때 "저장된 상태"가 어떤 것인지 혼란스러워집니다.

디바운스 방식의 자동 저장

최종적으로 채택한 방식은 30초 Timer + 변경 감지 플래그 + 디바운스 리셋입니다.

// 의사코드: 자동 저장 메커니즘

hasUnsavedChanges = false
autoSaveTimer: Timer?

function startAutoSaveTimer():
    autoSaveTimer?.cancel()
    autoSaveTimer = Timer.periodic(30초):
        if hasUnsavedChanges:
            saveToHive(currentState)
            hasUnsavedChanges = false

function onDiscreteFieldChanged():
    // 이산적 변경(폰트 선택, 토글 등)이 발생하면
    hasUnsavedChanges = true
    startAutoSaveTimer()   // 타이머를 리셋하여 30초 디바운스

function onContinuousFieldChanged():
    // 연속적 변경(슬라이더, 텍스트 입력)은 타이머를 리셋하지 않음
    hasUnsavedChanges = true

function dispose():
    if hasUnsavedChanges:
        saveToHive(currentState)  // 마지막 기회: 에디터를 나갈 때
    autoSaveTimer?.cancel()

여기서 흥미로운 점은 이산적 변경과 연속적 변경의 저장 전략이 다르다는 것입니다. 폰트를 바꾸거나 토글을 누르는 이산적 변경은 타이머를 리셋합니다. 즉, 마지막 이산적 변경으로부터 30초 후에 저장이 이루어지는 디바운스 방식입니다. 반면 슬라이더 드래그 같은 연속적 변경은 hasUnsavedChanges 플래그만 설정하고 타이머를 건드리지 않습니다. 이미 돌고 있는 타이머의 다음 주기에 자연스럽게 저장됩니다.

이 방식의 장점은 명확합니다. 30초 동안 아무리 많은 변경이 발생해도 저장은 최대 한 번만 이루어집니다. hasUnsavedChanges 플래그가 false이면 Timer가 실행되어도 I/O가 발생하지 않습니다.

그리고 dispose에서의 최종 저장이 안전망 역할을 합니다. 사용자가 에디터를 떠날 때, 마지막 30초 이내의 변경 사항이 아직 저장되지 않았을 수 있습니다. dispose에서 한 번 더 체크함으로써 데이터 유실을 방지합니다.

에디터 AppBar의 저장 상태 인디케이터

자동 저장이 사용자에게 보이지 않으면, 사용자는 불안해합니다. "지금 저장된 건가?" 이 불안을 해소하기 위해 AppBar에 저장 상태를 시각적으로 표시했습니다.

// 의사코드: 저장 상태 인디케이터
AnimatedSwitcher(
    child: hasUnsavedChanges
        ? 파란 점 (미저장 상태)           // "변경 사항이 있어요"
        : 초록 체크마크 (저장 완료)       // "안전하게 저장됐어요"
)

AnimatedSwitcher를 사용해서 파란 점과 초록 체크마크 사이의 전환이 부드럽게 일어납니다. 이 작은 인디케이터 하나가 사용자에게 "데이터가 안전하다"는 신뢰감을 줍니다.


5. 실시간 프리뷰: Provider Watch의 위력

에디터의 상단 30%를 차지하는 프리뷰 영역은 currentBoardProviderwatch하고 있습니다. 이것은 곧 Board 객체의 어떤 필드가 바뀌든, 프리뷰가 즉시 업데이트된다는 뜻입니다.

// 의사코드: 에디터 레이아웃
Column(
    children: [
        // 상단 30%: 실시간 프리뷰
        Expanded(flex: 3,
            child: BoardPreview(
                board: ref.watch(currentBoardProvider)
            )
        ),
        // 하단 70%: 4탭 에디터
        Expanded(flex: 7,
            child: TabBarView(tabs: [텍스트, 디자인, 모션, 화면])
        ),
    ]
)

ref.watch를 사용하므로, 하단 탭에서 폰트를 바꾸면 프리뷰의 폰트가 즉시 바뀌고, 네온 글로우를 켜면 프리뷰에 즉시 글로우가 나타납니다. 별도의 "적용" 버튼이 필요 없습니다.

이 구조가 가능한 이유는 Riverpod의 Provider 시스템이 위젯과 상태 사이의 반응형 연결을 자동으로 관리해 주기 때문입니다. 에디터 탭이 ref.read로 상태를 변경하면, 프리뷰는 ref.watch를 통해 변경을 자동 감지하고 리빌드됩니다. 이 과정에서 개발자가 수동으로 "프리뷰를 업데이트하라"는 코드를 작성할 필요가 전혀 없습니다.


6. 새 보드 자동 삭제 로직

에디터에는 사소하지만 UX를 크게 개선하는 로직이 하나 있습니다. 새 보드를 만들고 아무것도 편집하지 않은 채 뒤로 나가면, 그 보드가 자동으로 삭제되는 것입니다.

// 의사코드: 새 보드 자동 삭제
isNewBoard = (route가 /editor/new 인지 판별)

function onBackPressed():
    if isNewBoard AND board가 기본값 그대로:
        boardListProvider.delete(board.id)  // 쓰레기 데이터 방지
    else:
        saveToHive(currentState)            // 변경 사항 저장
    navigator.pop()

이 로직이 없다면, 사용자가 새 보드를 만들어 보려다가 마음이 바뀌어 뒤로 가면, 홈 화면에 "HELLO"라는 기본 텍스트의 빈 보드가 쌓이게 됩니다. 사소한 부분이지만, 반복적으로 사용하다 보면 빈 보드가 쌓이는 것은 생각보다 불쾌한 경험입니다.


7. 개발 과정의 챌린지

Undo 스택과 자동 저장의 타이밍 충돌

초기 구현에서 발견된 문제가 있었습니다. 자동 저장 타이머가 실행되는 시점에 사용자가 Undo를 수행하면, 저장된 상태와 현재 표시되는 상태가 어긋나는 것이었습니다.

시나리오를 재현하면 이렇습니다.

  1. 사용자가 폰트를 A → B로 변경 (Undo 스택에 A push)
  2. 자동 저장 실행 → Hive에 "폰트 B" 저장
  3. 사용자가 Undo 실행 → 화면에는 "폰트 A" 표시
  4. 앱 강제 종료
  5. 앱 재실행 → Hive에서 "폰트 B" 로드 → 사용자 혼란

해결 방법은 간단했습니다. Undo/Redo를 수행할 때도 markUnsaved()를 호출하는 것입니다. 그러면 다음 자동 저장 주기에 Undo된 상태가 저장됩니다. 그리고 dispose에서의 최종 저장이 마지막 안전망이 됩니다.

Provider 간 순환 참조 방지

CurrentBoardNotifier가 저장을 위해 boardListProvider를 참조하고, boardListProvider가 새 보드 생성 시 appSettingsProvider를 참조하는 구조에서, 초기에는 참조 관계가 꼬이는 문제가 있었습니다.

핵심 원칙은 **"데이터의 흐름이 단방향이어야 한다"**는 것이었습니다. currentBoardProvider는 편집 중인 상태만 관리하고, Hive 영속화는 boardListProvider의 메서드를 ref.read로 호출하는 방식으로 단방향성을 유지했습니다. ref.watch로 양방향 의존을 만들지 않는 것이 핵심이었습니다.


8. 마치며

에디터의 상태 관리를 설계하면서 가장 크게 배운 점은, **"좋은 UX는 좋은 상태 관리 구조에서 나온다"**는 것입니다.

Undo/Redo가 직관적으로 동작하려면, "이산적 변경과 연속적 변경"을 구분하는 세심한 설계가 필요합니다. 자동 저장이 안정적으로 동작하려면, 저장 타이밍과 Undo의 상호작용을 면밀히 고려해야 합니다. 실시간 프리뷰가 자연스럽게 동작하려면, Provider 간 데이터 흐름이 단방향이어야 합니다.

이런 것들은 기능 명세서에는 "Undo/Redo 지원", "자동 저장" 한 줄로 끝나는 항목이지만, 실제 구현에서는 수많은 엣지 케이스와 트레이드오프가 숨어 있었습니다.

다음 글에서는 NeonBoard의 수익화 전략인 광고 기반 모네타이제이션 아키텍처를 다루겠습니다. 인앱 결제 없이 보상형 광고만으로 프리미엄 콘텐츠를 제공하는 구조가 궁금하시다면 기대해 주세요.