목록으로 돌아가기

Undo/Redo 기능 개선하기: 스택 기반에서 이벤트 소싱으로

2025-12-22 · 9 min read

최근에 캔버스 드로잉 애플리케이션에 사용할 Undo/Redo 기능을 개발한 경험에 대해 정리해보려고 합니다. 이전에 다른 프로젝트를 진행하면서 비슷한 요구사항을 다룬 적이 있었는데요, 이번에는 지난 번과는 조금 다르게 기존 방식의 문제점을 해결할 수 있는 방향으로 접근해 보았습니다.

기존 방식: 스택 기반 히스토리

이전 포스팅에서도 다룬 적이 있는 스택 기반 히스토리 관리 방식은 다음과 같습니다. 우선 사용자가 캔버스에 요소를 추가할 때마다 해당 요소를 히스토리에 순차적으로 저장합니다. 그 후 Undo 요청에는 마지막 요소를 제거하고 Redo 요청에는 제거된 요소를 다시 스택에 추가하는 방식입니다. 일반적으로 Redo 기능을 위해서는 추가적으로 하나의 스택이 더 필요합니다. 이를 위해 총 두 개의 스택을 관리하고 pastfuture로 정의했습니다.

1type SketchpadHistoryEntry = {
2 element: SketchpadElement;
3};
4
5type SketchpadHistory = {
6 past: SketchpadHistoryEntry[];
7 future: SketchpadHistoryEntry[];
8};
9
10const addHistory = (entry: SketchpadHistoryEntry) => {
11 setHistory({
12 past: [...history.past, entry],
13 future: [],
14 });
15};

기존 방식의 한계

스택 기반으로 구현했을 경우에는 구조가 매우 단순하고 손쉽게 구현할 수 있지만 몇 가지 한계가 있습니다. 요소가 하나씩 추가되는 상황에서는 문제가 없지만, 전체 내역을 지우는 동작이나 특정 요소를 선택적으로 제거했던 동작에 대해서는 구조적으로 되돌리기를 구현할 수 없다는 것입니다.

1// 전체 지우기한 동작을 되돌릴 수 없음
2const clear = () => {
3 setHistory({
4 past: [],
5 future: [],
6 });
7};

이 문제를 해결하기 위해 이벤트 소싱 패턴을 도입해 보았습니다.

이벤트 소싱이란?

이벤트 소싱(Event Sourcing)은 백엔드 진영에서 주로 사용되는 개념으로, 애플리케이션 상태의 모든 변경사항을 이벤트 시퀀스로 저장하는 패턴입니다. 모든 상태에 대한 변경은 연속적인 이벤트로 기록되며, 현재 상태는 이러한 이벤트들을 처음부터 재생하여 도출됩니다. 여기서 각 이벤트는 불변성을 가져야 하며, 발생한 행동이 무엇인지 나타낼 수 있어야 합니다.

1// 전통적인 방식: 상태를 직접 업데이트하기 때문에 최종 상태만 알 수 있음
2const ship = {
3 id: "ship-1",
4 location: "Hong Kong",
5};
1// 이벤트 소싱: 모든 변경을 이벤트로 저장
2const events = [
3 { type: "DEPARTED", location: "San Francisco", time: "2024-11-01" },
4 { type: "ARRIVED", location: "Los Angeles", time: "2024-11-02" },
5 { type: "DEPARTED", location: "Los Angeles", time: "2024-11-03" },
6 { type: "ARRIVED", location: "Hong Kong", time: "2024-11-05" },
7];
8
9// 이벤트를 재생하여 현재 상태를 구성
10const currentState = events.reduce(applyEvent, initialState);

이벤트 소싱 적용하기

이벤트 소싱 패턴을 적용하여 Undo/Redo 기능을 구현하기 위해서는 다음과 같은 구성 요소가 필요합니다.

1. 이벤트 타입

이벤트는 애플리케이션에서 발생할 수 있는 모든 상태 변경을 나타냅니다. Redux의 액션과 유사하게 생각할 수 있습니다. 아래에서는 간단하게 한개의 요소를 추가하는 이벤트와 전체 엘리먼트를 삭제하는 이벤트를 정의해 보겠습니다.

1type SketchpadEvent =
2 | {
3 type: "add";
4 payload: { element: SketchpadElement };
5 }
6 | {
7 type: "clear";
8 };

2. 이벤트 스토어

이벤트 스토어는 발생하 이벤트를 순차적으로 기록하는 저장소입니다. Redo 기능을 구현하기 위해 2개의 이벤트 스토어를 관리해야 합니다.

1type SketchpadEventStore = SketchpadEvent[];
2
3type SketchpadHistory = {
4 past: SketchpadEventStore;
5 future: SketchpadEventStore;
6};

3. 스냅샷

스냅샷은 특정 시점의 애플리케이션 상태를 나타냅니다. 이벤트 소싱에서는 스냅샷을 직접 저장하는 대신, 이벤트를 재생하여 스냅샷을 계산합니다.

1type SketchpadSnapshot = { elements: SketchpadElement[] };

4. 스냅샷 복원

기존 스냅샷과 발생한 이벤트의 조합을 통해 특정 시점의 스냅샷을 복원할 수 있습니다.

1const applyEvent = (
2 snapshot: SketchpadSnapshot,
3 event: SketchpadEvent,
4): SketchpadSnapshot => {
5 switch (event.type) {
6 case "add":
7 return {
8 elements: [...snapshot.elements, event.payload.element],
9 };
10 case "clear":
11 return {
12 elements: [],
13 };
14 default:
15 return event satisfies never;
16 }
17};
18
19const computeSnapshot = (
20 eventHistory: SketchpadEventHistory,
21): SketchpadSnapshot => {
22 return eventHistory.reduce(applyEvent, { elements: [] });
23};
24
25const Sketchpad = () => {
26 const history = useHistory();
27
28 const snapshot = computeSnapshot(history.past);
29
30 return (
31 <Stage>
32 <Layer>
33 {snapshot.elements.map((element) => (
34 <SketchpadElement key={element.id} element={element} />
35 ))}
36 </Layer>
37 </Stage>
38 );
39};

Undo/Redo 구현하기

이벤트 소싱 방식에서는 Undo/Redo 동작을 아래와 같이 구현할 수 있습니다. Undo 시에는 마지막 이벤트를 미래 목록으로 이동시키고, Redo 시에는 반대로 미래 목록의 이벤트를 과거 목록으로 이동시킵니다.

1const undo = () => {
2 const lastEvent = past[past.length - 1];
3
4 setHistory({
5 past: past.slice(0, -1),
6 future: [lastEvent, ...future],
7 });
8};
9
10const redo = () => {
11 const nextEvent = future[0];
12
13 setHistory({
14 past: [...past, nextEvent],
15 future: future.slice(1),
16 });
17};

이 구조에서는 전체 지우기 동작도 하나의 이벤트로 취급되기 때문에 되돌리기가 가능합니다. 뿐만 아니라 추후에 특정 요소를 삭제하는 동작, 요소의 속성을 변경하는 동작이 추가되는 경우에도 동일한 패턴으로 쉽게 확장할 수 있습니다.

마치며

스택 기반 히스토리 관리에서 이벤트 소싱 패턴으로 전환하면서 Undo/Redo 기능의 확장성과 유연성을 크게 개선할 수 있었습니다. 물론 모든 상황에서 이벤트 소싱이 정답은 아닙니다. 단순한 Undo/Redo만 필요하다면 스택 기반 방식도 충분히 효과적입니다. 하지만 요구사항이 많아지고 애플리케이션이 지원해야하는 동작이 많아질 때, 이벤트 소싱 패턴을 고려해보는 것이 좋은 선택이 될 수 있다고 생각합니다.

관련 링크

목록으로 돌아가기