디자인 비교 도구를 만들다 보면, 레이어를 이리저리 옮기는 일이 많다. Figma 이미지 위에 스냅샷을 올려놓고 위치를 조정하는데, 손이 미끄러져서 엉뚱한 데 놓는 경우가 생각보다 잦다.
그럴 때마다 "아 Cmd+Z 눌러야지" 하고 습관적으로 단축키를 누르게 되는데, 당연히 안 된다. Undo 기능이 없으니까.
Undo/Redo가 필요한 이유
사실 처음엔 없어도 괜찮다고 생각했다. 레이어 위치 좀 틀리면 다시 드래그하면 되니까. 근데 실제로 써보니까 생각이 달라졌다.
되돌리기 기능은 "실수해도 괜찮다"는 안전망이다.
이게 없으면 모든 조작이 신중해져야 하고, 그만큼 피로도가 올라간다.
특히 레이어가 여러 개일 때, 하나를 옮기다가 실수로 다른 걸 건드리면 원래 위치가 어디였는지 기억도 안 난다. 결국 Undo/Redo는 선택이 아니라 필수였다.
왜 직접 구현했나?
Zustand에는 zundo라는 temporal 미들웨어가 있다. 설치하고 감싸기만 하면 Undo/Redo가 뚝딱 된다. 근데 이걸 안 쓰고 직접 만들었다.
이유는 간단하다. 드래그 중에는 히스토리를 쌓으면 안 됐다.
레이어를 드래그할 때 mousemove 이벤트가 초당 수십 번 발생한다. 그때마다 위치가 바뀌는데, 이걸 전부 히스토리에 넣으면? Undo 한 번에 1픽셀씩 뒤로 가는 참사가 벌어진다.
내가 원한 건 "드래그 시작 전 위치 → 드래그 끝난 후 위치"를 하나의 단위로 묶는 거였다. 이런 세밀한 제어는 미들웨어로 어려웠다.
히스토리 구조 설계
핵심은 past와 future 두 개의 배열이다.
interface HistoryState {
past: Record<string, Layer>[]; // Undo 스택
future: Record<string, Layer>[]; // Redo 스택
}
- past: Undo할 때 꺼내 쓸 이전 상태들
- future: Redo할 때 꺼내 쓸 되돌린 상태들
새로운 변경이 일어나면 현재 상태를 past에 넣고, future는 비운다. Undo하면 past에서 꺼내서 현재 상태로 만들고, 원래 있던 현재 상태는 future로 보낸다.
구현 코드
실제 Zustand 스토어 코드를 보자.
export const useLayerStore = create<LayerStoreInternal>()((set, get) => ({
items: {},
_history: { past: [], future: [] },
// 히스토리에 현재 상태 저장
_pushHistory: () => {
const { items, _history } = get();
set({
_history: {
past: [..._history.past.slice(-49), structuredClone(items)],
future: [], // 새 변경 시 future 초기화
},
});
},
// Undo
undo: () => {
const { items, _history } = get();
if (_history.past.length === 0) return;
const previous = _history.past[_history.past.length - 1];
set({
items: previous,
_history: {
past: _history.past.slice(0, -1),
future: [structuredClone(items), ..._history.future],
},
});
},
// Redo
redo: () => {
const { items, _history } = get();
if (_history.future.length === 0) return;
const next = _history.future[0];
set({
items: next,
_history: {
past: [..._history.past, structuredClone(items)],
future: _history.future.slice(1),
},
});
},
}));
몇 가지 포인트:
- structuredClone: 깊은 복사가 필수다. 그냥 참조를 넣으면 나중에 원본이 바뀔 때 히스토리도 같이 바뀐다.
- slice(-49): 히스토리를 최대 50개로 제한했다. 무한정 쌓으면 메모리 문제가 생길 수 있다.
- future 초기화: 새로운 변경이 생기면 Redo 스택을 비운다. 과거로 돌아갔다가 새로운 행동을 하면, 원래 있던 미래는 사라지는 게 맞다.
드래그 최적화: skipHistory 옵션
드래그 중 히스토리를 안 쌓는 건 skipHistory 옵션으로 해결했다.
setPosition: (id, position, options) => {
// skipHistory가 true면 히스토리 저장 안 함
if (!options?.skipHistory) {
get()._pushHistory();
}
set((state) => ({
items: {
...state.items,
[id]: { ...state.items[id], position },
},
}));
},
사용하는 쪽에서는 이렇게 쓴다.
// 드래그 시작 시 - 히스토리 저장
onDragStart: () => {
layerStore.pushHistory();
},
// 드래그 중 - 히스토리 안 쌓음
onDrag: (position) => {
layerStore.setPosition(id, position, { skipHistory: true });
},
// 드래그 끝 - 이미 시작할 때 저장했으니 추가 저장 불필요
onDragEnd: () => {
// nothing
},
이렇게 하면 드래그를 아무리 길게 해도 히스토리는 딱 하나만 쌓인다.
버튼 비활성화
Undo/Redo 버튼은 할 수 있을 때만 활성화되어야 한다. canUndo와 canRedo 함수를 만들었다.
canUndo: () => get()._history.past.length > 0,
canRedo: () => get()._history.future.length > 0,
UI에서는 이걸 구독해서 버튼을 비활성화한다.
const canUndo = useLayerStore((s) => s.canUndo());
const canRedo = useLayerStore((s) => s.canRedo());
// 단축키도 상태에 따라 활성화
useHotkeys('mod+z', () => {
if (canUndo) undo();
}, { enabled: canUndo });

마치며
직접 구현하면서 느낀 건, Undo/Redo가 생각보다 단순한 개념이라는 거다. 결국 "이전 상태를 어딘가에 저장해두고, 필요할 때 꺼내 쓴다"가 전부다.
다만 실제 서비스에 적용하려면 고려할 게 좀 있다. 어떤 액션을 히스토리에 넣을지, 드래그처럼 연속적인 동작은 어떻게 처리할지, 메모리 관리는 어떻게 할지.
지금은 레이어 위치/투명도 정도만 Undo 대상이지만, 나중에 레이어 삭제나 이름 변경까지 확장해볼 예정이다. 그때는 Command 패턴을 도입해서 각 액션을 객체로 관리하는 것도 고려해봐야겠다.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
'FrontEnd > React' 카테고리의 다른 글
| Next.js로만 백엔드, 프론트엔드 구축하기 (0) | 2026.01.24 |
|---|---|
| Next.js 14 완벽 번역 (1) | 2023.10.27 |
| React Todo에서 UX개선 (1) | 2023.04.28 |
| 리액트에서 ...state 와 prev => ...prev 의 차이 (0) | 2023.04.25 |
| 최초 로그인시 토큰이 null로 보내지는 버그 (0) | 2023.04.16 |
