
React에서 캔버스에 그려진 도형을 되돌리거나 다시 실행하는 Undo/Redo 기능을 구현하는 것은 흔히 까다로운 작업 중 하나입니다. 상태가 단순한 값을 넘어서 이전 상태의 기록을 보관하고 복원하는 방식으로 다뤄야 하기 때문입니다.
이 글에서는 Zustand를 활용한 Undo/Redo 기능 설계와 이를 선택한 이유 및 장점을 소개합니다.
Undo/Redo 기능을 구현하는 방법에는 여러 가지가 있지만, 이번 프로젝트에서는 히스토리 배열(history)과 실행 취소 배열(redoStack) 을 이용하는 방식을 선택했습니다.
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
}
)
);
Undo/Redo를 구현하는 대표적인 방법 중 하나는 인덱스를 활용하는 방식입니다. 하지만 인덱스 기반 접근 방식보다 history
배열을 활용하는 것이 이번 프로젝트에서는 더 적합하다고 판단했습니다.
방식 | 장점 | 단점 |
---|
인덱스 기반 접근 (Index-based approach) | 메모리 사용량이 적음 | 특정 시점에서 상태를 수정하면 이후 기록을 유지하기 어려움 |
히스토리/스택 활용 (History Stack Approach) | 중간 상태의 수정에도 안정적으로 Undo/Redo 가능 | 메모리를 더 사용하지만 최신 기기에서는 무리가 없음 |
- 캔버스의 상태를 보존할 수 있음 → 특정 시점의
shapes
배열을 기록해두면, 그 시점으로 되돌릴 수 있음
- 사용자가 도형을 추가하면 redoStack을 초기화 → 새로운 도형이 추가되었을 때, 기존 redoStack을 유지하는 대신 초기화하여 직관적인 동작 보장
- Zustand와 궁합이 좋음 → 불필요한 렌더링 없이
set()
을 활용하여 효율적으로 상태 변경
이제 구현된 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