junsobi

Menu

Close

Zustand를 활용한 Canvas에서의 Undo/Redo 설계

Zustand 상태 관리에서 Undo/Redo 기능을 효율적으로 구현하는 방법과 이에 따른 장점 및 설계 의도를 설명합니다.

List

이미지

Zustand를 활용한 Undo/Redo 설계

React에서 캔버스에 그려진 도형을 되돌리거나 다시 실행하는 Undo/Redo 기능을 구현하는 것은 흔히 까다로운 작업 중 하나입니다. 상태가 단순한 값을 넘어서 이전 상태의 기록을 보관하고 복원하는 방식으로 다뤄야 하기 때문입니다.

이 글에서는 Zustand를 활용한 Undo/Redo 기능 설계이를 선택한 이유 및 장점을 소개합니다.


1️⃣ Undo/Redo를 위한 기본 설계

Undo/Redo 기능을 구현하는 방법에는 여러 가지가 있지만, 이번 프로젝트에서는 히스토리 배열(history)과 실행 취소 배열(redoStack) 을 이용하는 방식을 선택했습니다.

✅ 선택한 방식: historyredoStack 활용

const useDrawingStore = create<DrawingState>()(
  persist(
    (set) => ({
      shapes: [],
      history: [] as Shape[][],
      redoStack: [] as Shape[][],
 
      addShape: (shape) =>
        set((state) => {
          const newHistory = [...state.history, state.shapes];
 
          if (newHistory.length > MAX_HISTORY_SIZE) {
            newHistory.shift();
          }
 
          return {
            history: newHistory,
            shapes: [...state.shapes, shape],
            redoStack: [] // 새로운 도형이 추가되면 redoStack 초기화
          };
        }),
 
      undo: () =>
        set((state) => {
          if (state.history.length === 0) return state;
          const previous = state.history.pop();
          return {
            shapes: previous || [],
            history: [...state.history],
            redoStack: [...state.redoStack, state.shapes]
          };
        }),
 
      redo: () =>
        set((state) => {
          if (state.redoStack.length === 0) return state;
          const next = state.redoStack.pop();
          return {
            shapes: next || [],
            redoStack: [...state.redoStack],
            history: [...state.history, state.shapes]
          };
        })
    }),
    {
      name: 'drawing-storage',
      storage: safeLocalStorage
    }
  )
);

2️⃣ 왜 이 방식을 선택했을까?

Undo/Redo를 구현하는 대표적인 방법 중 하나는 인덱스를 활용하는 방식입니다. 하지만 인덱스 기반 접근 방식보다 history 배열을 활용하는 것이 이번 프로젝트에서는 더 적합하다고 판단했습니다.

🔍 인덱스 기반 vs. history/redoStack 기반 비교

방식장점단점
인덱스 기반 접근 (Index-based approach)메모리 사용량이 적음특정 시점에서 상태를 수정하면 이후 기록을 유지하기 어려움
히스토리/스택 활용 (History Stack Approach)중간 상태의 수정에도 안정적으로 Undo/Redo 가능메모리를 더 사용하지만 최신 기기에서는 무리가 없음

history/redoStack 방식이 유리한 이유

  • 캔버스의 상태를 보존할 수 있음 → 특정 시점의 shapes 배열을 기록해두면, 그 시점으로 되돌릴 수 있음
  • 사용자가 도형을 추가하면 redoStack을 초기화 → 새로운 도형이 추가되었을 때, 기존 redoStack을 유지하는 대신 초기화하여 직관적인 동작 보장
  • Zustand와 궁합이 좋음 → 불필요한 렌더링 없이 set()을 활용하여 효율적으로 상태 변경

3️⃣ 구현된 Undo/Redo 버튼과 사용법

이제 구현된 Undo/Redo 기능을 사용하기 위해 버튼 컴포넌트를 추가해보겠습니다.

import { Undo, Redo } from 'lucide-react';
import { useDrawingStore } from '@/store/drawing-store';
import { Button } from '../ui/button';
 
const CanvasController = () => {
  return (
    <div className="flex w-full items-center justify-end gap-2">
      <UndoButton />
      <RedoButton />
      <ClearShapes />
    </div>
  );
};
 
export default CanvasController;
 
const UndoButton = () => {
  const undo = useDrawingStore((state) => state.undo);
  const historyLength = useDrawingStore((state) => state.history.length);
 
  return (
    <Button variant="outline" onClick={undo} disabled={historyLength === 0}>
      <Undo className="mr-1" />
      실행 취소
    </Button>
  );
};
 
const RedoButton = () => {
  const redo = useDrawingStore((state) => state.redo);
  const redoStackLength = useDrawingStore((state) => state.redoStack.length);
 
  return (
    <Button variant="outline" onClick={redo} disabled={redoStackLength === 0}>
      <Redo className="mr-1" />
      다시 실행
    </Button>
  );
};
 
const ClearShapes = () => {
  const clearShapes = useDrawingStore((state) => state.clearShapes);
 
  return (
    <Button variant="outline" onClick={clearShapes}>
      캔버스 초기화
    </Button>
  );
};

✨ 마무리

이 방식으로 Undo/Redo를 구현하면 이전 상태를 안전하게 보관하면서, 유연하게 되돌리거나 다시 실행할 수 있습니다.

이 설계를 선택한 이유는:

  • history 배열을 활용하면 언제든 특정 시점으로 돌아갈 수 있음
  • 새로운 도형이 추가될 때 redoStack을 초기화하여 예상치 못한 동작을 방지
  • zustand를 활용하면 불필요한 렌더링 없이 상태를 효율적으로 관리

결과적으로 Zustand의 persist와 history 스택을 활용한 설계가 유지보수성과 확장성이 뛰어나므로, React 기반 캔버스 애플리케이션에서 적극 활용할 수 있습니다. 🚀


관련 프로젝트 저장소 : https://github.com/junsobi/DrawingTool-with-KonvaReact