본문 바로가기
About IT/기술 및 언어

커맨드패턴으로 만들어보는 undo/redo

by yjin_fe 2025. 8. 26.

들어가며.

figma나 canva 같은 화이트보드형 서비스에서 필수적인 undo, redo 기능이 있습니다. 언뜻 생각하면 간단할 것 같지만, 막상 구현하려고 하면 생각보다 복잡한 상황을 마주하게 됩니다. 저 역시 eapy:canvas에서 undo, redo를 구현하면서 복잡해지는 코드로 인해 골치가 아팠던 경험이 있습니다.

 

undo/redo 기능을 구현하는 방식은 요구사항과 제약 조건 등 프로젝트가 처한 상황에 따라 다를 것입니다. 안타깝게도 (대부분의 경우가 이렇겠지만), 저에겐 구현 시간이 많이 주어지지 않은 것이 큰 제약 조건이었습니다. 최초 mvp 배포가 얼마 남지 않은 상황에서, 핵심 기능 개발에 열중해야 했기에 처음에는 간단하게 일종의 메멘토 패턴과 유사한 상태 스냅샷 저장 방식으로 구현하였습니다.

 

하지만 mvp 배포 이후, 점차 많은 기능이 추가되면서 문제가 생기기 시작했습니다. 아이템의 순서 변경, 색상 변경, 이름 변경 등 아이템에 대한 사용자의 모든 액션을 undo/redo 스택에 담아야 했고, 각기 다른 종류의 액션을 처리하는 로직은 눈덩이처럼 불어났습니다. 결국 거대한 switch 문과 수많은 if 분기 등 코드의 복잡도가 상승하였고, 가독성이 좋지 않게 되었습니다. 보다 정제된 설계 없이 기능 구현만 하다가는 나중에 유지보수가 무척 어려워 질 것이라는 걸 직감하게 되었습니다. 어떤 식으로 코드를 리팩터링할 지 고민하던 차에, 디자인 패턴 중 하나인 커맨드 패턴으로 이를 처리해보자는 생각을 하게 되었습니다.

 

이 글에서는 간단한 투두 앱에서 undo/redo 기능을 구현해보며 초기에 디자인 패턴 없이 undo/redo를 구현했을 때 마주했던 문제점을 살펴보고, 커맨드 패턴을 어떻게 적용하였는지를 단계별로 공유해보고자 합니다.


커맨드 패턴이란.

본격적인 이야기를 하기 전에, 간단히 커맨드 패턴에 대해 소개하자면, 커맨드 패턴은 요청이나 작업을 객체로 캡슐화하는 디자인 패턴입니다. "무엇을 할지"를 객체로 만들어서 매개변수로 전달하거나 큐에 저장하거나 나중에 실행할 수 있습니다. 이렇게 하면 요청을 보내는 쪽과 처리하는 쪽을 분리할 수 있고, 동일한 인터페이스로 다양한 작업들을 처리할 수 있게 됩니다.

 

리모컨을 예시로 들어보겠습니다. 각 버튼에는 "TV 켜기", "볼륨 높이기" 등의 명령이 들어있고, 리모컨은 각 기기의 내부 구조나 동작 방식을 몰라도 됩니다. 단지 버튼을 누르면 해당 명령을 받은 객체가 알아서 작업을 수행하죠. 프로그램에서도 마찬가지로 다양한 작업들을 execute()라는 통일된 방식으로 처리할 수 있게 됩니다.


무식하지만 빨랐던 초기의 방식.

시간이 절대적으로 부족했던 MVP 개발 초기, 복잡한 설계 패턴을 고민할 시간에 핵심 기능 하나를 더 빠르게 완성하는 것이 중요했기 때문에 상태 전체를 저장하는 방식을 사용했습니다. (편의상 상태 스냅샷이라고 부르는) 이 방법의 가장 큰 장점은 '속도'와 '단순함'이었습니다.

 

상태 스냅샷 방식의 컨셉은 아주 단순합니다. 사용자가 캔버스에 유의미한 변경을 가할 때마다, 마치 카메라로 사진을 찍듯 그 순간의 캔버스 상태 전체를 배열에 저장하는 것입니다. '되돌리기(Undo)'는 앨범에서 이전 사진을 꺼내 보여주는 것과 같고, '다시 실행(Redo)'은 다음 사진으로 넘어가는 것과 같습니다.

 

즉 이 방식의 경우, 사용자가 어떤 액션을 했는지는 중요하지 않습니다. add든 delete든 update든, 그저 변경된 최종 결과물인 items 배열 전체를 히스토리 스택에 밀어 넣기만 하면 되었습니다. 다음은 이 아이디어를 코드로 구현한 핵심 로직입니다.

//❗️모든 상태의 '스냅샷'을 저장하는 배열
const [history, setHistory] = useState<{
  snapshots: Todo[][],
  currentIndex: number
}>({
  snapshots: [[]],
  currentIndex: 0
});

const handleAddItem = (text: string) => {
  const newItem: Todo = { /* ... */ };
  const newTodos = [...todos, newItem];
  
  // ❗️액션이 발생할 때마다 전체 todos 배열을 복사해서 저장
  setHistory(prev => {
    const newSnapshots = prev.snapshots.slice(0, prev.currentIndex + 1);
    newSnapshots.push([...newTodos]);
    
    return {
      snapshots: newSnapshots,
      currentIndex: newSnapshots.length - 1
    };
  });
  setTodos(newTodos);
};

const handleUndo = () => {
  setHistory(prev => {
    if (prev.currentIndex <= 0) return prev;
    
    const newIndex = prev.currentIndex - 1;
    // ❗️과거의 특정 상태(스냅샷)로 통째로 덮어쓴다.
    setTodos([...prev.snapshots[newIndex]]);
    
    return { ...prev, currentIndex: newIndex };
  });
};

위와 같은 방식 덕분에 MVP 일정에 맞춰 요구되었던 모든 기능을 구현하고 무사히 배포할 수 있었습니다. 그러나 사실 이 단순하고 빠르게 작성했던 코드는 얼마 지나지 않아 변경이 필요해졌습니다.

 

액션이 발생할 때마다 전체 todos 배열을 복사해서 저장하며, undo 시에는 과거의 특정 상태로 통째로 덮어쓰게 됩니다. 아이템이 10개 이하일 때는 모르겠지만, figma와 같은 실제 사용 예시를 생각하면 아이템이 50개 100개는 우습게 늘어나겠죠. 사용하는 기능 또한 단순히 추가, 제거가 아니라, 아이템의 색상, 폰트, 순서 변경 등 다양한 기능이 들어갈 것입니다. 즉, 초기에 구현한 상태 스냅샷 방식은 메모리 사용량 증가와 성능 저하로 이어질 수 있는 구조적 한계가 있었습니다.


리팩터링 1단계: '무엇'을 했는지를 기록하도록.

'상태 스냅샷' 방식의 가장 큰 문제는 '무엇을' 했는지는 상관없이, 그저 '최종 결과물'을 통째로 저장한다는 것이었습니다. 메모리 낭비는 물론, 나중에 어떤 행동을 되돌리는 것인지 파악하기도 어려웠습니다. 결국, 조금 더 작은 단위의 데이터를 저장하는 것이 필요했기에, 최종 결과물(State)이 아닌, 변경을 일으키는 행위(Action)를 저장하는 방식으로 변경해보기로 하였습니다.

 

사용자가 아이템을 추가하면, "ID가 123인 아이템을 추가했다"라는 정보만 undoStack에 저장하는 것입니다. 전체 데이터를 복사하는 것보다 훨씬 가볍고 명확한 방법입니다. 이렇게 하면 undoStack에는 { type: 'ADD', payload: { id: 123 } } 와 같은 액션 객체들이 순서대로 쌓이게 됩니다.

// useTodoHistory 훅의 일부
const useTodoHistory = ({ onStateChange }) => {
  const undoStack = useRef<Action[]>([]);
  const redoStack = useRef<Action[]>([]);

  const recordAction = (action: Action) => {
    // '행위'를 undoStack에 기록
    undoStack.current.push(action);
    redoStack.current = [];
  };

  const undo = (currentTodos) => {
    const lastAction = undoStack.current.pop();
    if (!lastAction) return;
    redoStack.current.push(lastAction);

    let newTodos;
    switch (lastAction.type) {
      case 'ADD':
        // '추가'를 되돌리는 것은 '삭제'
        newTodos = currentTodos.filter(t => t.id !== lastAction.payload.id);
        break;
        
      // other cases...
    }
    onStateChange(newTodos);
  };

  return { recordAction, undo };
};

// useHistoryStack을 사용하는 컴포넌트
function App() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const { recordAction, undo } = useTodoHistory({
    onStateChange: setTodos,
  });

  // 액션을 실행하고, 히스토리 스택에 기록하는 함수
  const execute = (action, newTodos) => {
    recordAction(action);
    setTodos(newTodos);
  };

  const handleAddTodo = (text) => {
    const newTodo = { id: Date.now().toString(), text, completed: false };
    // ❗️"추가했다"는 행위(action)를 객체로 정의
    const action = { type: 'ADD', payload: { id: newTodo.id } };
    execute(action, [...todos, newTodo]);
  };

  const handleUndo = () => {
    undo(todos);
  };

  return (
    <>
      {/* ... UI components */}
      <button onClick={handleUndo}>Undo</button>
    </>
  );
}

이제 액션에 대한 내용만 저장하면 되므로 메모리가 불필요하게 커지는 문제가 해결된 것처럼 보입니다. 하지만 아직 모든 문제가 해결된 것은 아닙니다. useHistoryStackundo 함수 내부를 보면, switch-case로 이루어진 코드들이 있습니다. 만약 더 많은 기능이 추가된다고 생각하면, case가 점차 늘어날 것입니다. 확장성을 생각하니 갑자기 눈앞이 깜깜해집니다.

 

모든 종류의 액션을 되돌리는 로직이 이 switch문 안에 집중되다 보니, 새로운 기능이 추가될 때마다 case를 계속해서 추가해야만 합니다. 결국 이 함수는 점점 비대해지고 복잡해질 것이고, 나중에는 유지보수 난이도가 상당히 올라가게 되겠죠. 즉, 유지보수가 어렵고 확장에 닫힌 구조라는 문제는 해결되지 않았습니다.


리팩터링 2단계: 커맨드 패턴을 적용해보면.

앞선 방식의 문제는 Undo/Redo를 관리하는 책임과 각 액션을 실행하는 책임useTodoHistory 훅 내 undo라는 함수에 뒤섞여 있다는 것이었습니다. 점점 기능이 추가될 수록 코드 복잡도가 올라갈 것이 분명하였기에 무언가 조치가 필요했습니다.

 

여기서 바로 커맨드 패턴을 적용해보기로 합니다.

커맨드 패턴 적용의 핵심은 다음과 같습니다.

  1. 관리자 (Invoker): undoStackredoStack을 관리하는 책임을 맡습니다. 이 관리자는 액션 객체를 받아서 스택에 넣고, Undo/Redo 요청이 오면 스택에서 꺼내 그대로 전달만 해줍니다. 관리자는 이 액션이 '무엇'을 하는지 전혀 알지 못합니다.
  2. 실행자 (Receiver): 관리자로부터 전달받은 액션 객체를 해석해서, 실제로 상태를 변경하는 책임을 맡습니다.

따라서, 온전히 관리자의 역할만 하도록 useTodoHistory훅을 수정하였습니다.

// 관리자 (Invoker): 히스토리 관리만 책임지는 커스텀 훅
const useTodoHistory = () => {
  const undoStack = useRef<Action[]>([]);
  const redoStack = useRef<Action[]>([]);

  const execute = (action: Action) => {
    undoStack.current.push(action);
    redoStack.current = [];
  };

  const undo = (): Action | null => {
    const lastAction = undoStack.current.pop();
    if (!lastAction) return null;
    redoStack.current.push(lastAction);
    
    // ❗️실제 로직을 실행하지 않고, 되돌릴 액션 정보를 그대로 반환
    return lastAction; 
  };
  // ... (생략)
};

// 실행자 (Receiver): 실제 상태를 변경하는 컴포넌트
function App() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const { execute, undo } = useTodoHistory();

  const handleAddTodo = (text: string) => {
    const newTodo = { id: Date.now().toString(), text, completed: false };
    const action = { type: 'ADD', payload: { id: newTodo.id, todo: newTodo } };
    
    // 관리자에게 액션 기록을 맡김
    execute(action);
    setTodos(prev => [...prev, newTodo]);
  };
  
  const handleUndo = () => {
    const actionToUndo = undo();
    if (!actionToUndo) return;

    // 관리자에게 받은 액션을 해석해서 직접 실행
    switch (actionToUndo.type) {
      case 'ADD':
        setTodos(prev => prev.filter(t => t.id !== actionToUndo.payload.id));
        break;
      case 'DELETE':
        setTodos(prev => [...prev, actionToUndo.payload.todo]);
        break;
      case 'TOGGLE':
        setTodos(prev => prev.map(t =>
          t.id === actionToUndo.payload.id
            ? { ...t, completed: actionToUndo.payload.oldCompleted }
            : t
        ));
        break;
      // ...
    }
  };

 // ... (생략)
}

이 리팩터링을 통해 useTodoHistory의 구조가 훨씬 명확해졌습니다. useTodoHistory 훅은 이제 어떤 종류의 액션이든 상관없이 재사용 가능하며, 기능이 추가되더라도 내부 코드를 변경할 필요가 없어졌습니다! 이제 퇴근을 하도록 하죠

 

하지만 위 코드를 살펴본 여러분들은 모두 개운함은 커녕 찜찜함을 느끼셨을 겁니다.

왜냐하면 사실 이 방식은 useTodoHistoryundo함수 내부에 있던 거대한 switch문을 실제 훅이 사용되는 컴포넌트로 위치만 옮겼기 때문입니다. 확장성을 고려한다면 여전히 switch-case구조의 문제를 해결해야만 합니다. 어떻게 해결하면 좋을까요?


리팩터링 3단계: 진화한 커맨드 패턴.

앞서 커맨드 패턴을 호기롭게 적용했지만, 여전히 과제가 남아있었습니다. 한 곳에서 모든 종류의 액션을 처리하는 중앙집권적인 구조 자체를 변경하고 확장성을 높여야 했습니다.

 

이를 위해 각 '행위'를 execute(실행)와 undo(취소) 메서드를 가진 독립된 객체로 캡슐화하는 방식을 택했습니다. 이 객체는 자신이 무슨 일을 해야 하는지, 그리고 그 일을 어떻게 되돌려야 하는지를 알고 있습니다.

 

먼저, 모든 커맨드 객체가 따라야 할 설계도(interface)를 정의합니다.

// 커맨드 객체의 인터페이스
interface Command {
  execute(): void;
  undo(): void;
}

 

만약 ‘할 일 추가’라는 기능을 구현한다고 하면 다음과 같이 커맨드를 만들 수 있습니다.

// '할 일 추가 커맨드'
class AddTodoCommand implements Command {
  private newTodo: Todo;
  private setTodos: React.Dispatch<React.SetStateAction<Todo[]>>;
  
  constructor(newTodo: Todo, setTodos: Function) {
    this.newTodo = newTodo;
    this.setTodos = setTodos;
  }

  execute(): void {
    this.setTodos(prevTodos => [...prevTodos, this.newTodo]);
  }
  undo(): void {
    this.setTodos(prevTodos => prevTodos.filter(todo => todo.id !== this.newTodo.id));
  }
}

 

이제 관리자(Invoker)의 역할을 하는 useTodoHistory는 다음과 같이 변경이 됩니다.

function useTodoHistory() {
  const undoStack = useRef<Command[]>([]);
  const redoStack = useRef<Command[]>([]);

  const execute = (command: Command) => {
    command.execute();
    undoStack.current.push(command);
    redoStack.current = [];
  };

  const undo = () => {
    const command = undoStack.current.pop();
    if (command) {
      command.undo();
      redoStack.current.push(command);
    }
  };

  const redo = () => {
    const command = redoStack.current.pop();
    if (command) {
      command.execute();
      undoStack.current.push(command);
    }
  };
  
  // ... (중략)
}

 

수행자(Receiver)에 위와 같은 코드를 적용하면 아래와 같이 간단하게 코드가 변하게 됩니다.

function App() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const { execute, undo } = useTodoHistory();

  const handleAddTodo = (text: string) => {
    const newTodo = { id: Date.now().toString(), text, completed: false };
    
    const command = new AddTodoCommand(newTodo, setTodos);
    execute(command);
  };
  
  // 필요없어진 switch-case구문
  return (
    <div>
      <button onClick={() => handleAddTodo("새 할일")}>Add</button>
      <button onClick={undo}>Undo</button>
      {/* ... */}
    </div>
  );
}

 

드디어 switch-case문이 코드에서 완전히 사라졌습니다. 이제 '할 일 상태 변경'이나 '순서 변경' 같은 새로운 기능이 필요하면, 그저 새로운 Command 클래스를 하나 더 만들기만 하면 됩니다. 기존의 useTodoHistory훅은 수정할 필요가 없고, 새로운 기능에 대한 handler 내부에서 새로운 command만 만들어서 실행하면 되는 것이죠. 이로써 각 기능은 서로에게 아무런 영향을 주지 않는 독립적인 액션 객체가 되었고, 어떤 기능이 추가되어도 유지보수가 쉬운 확장 가능한 구조가 완성되었습니다.


그렇다면 커맨드 패턴만이 정답일까?

긴 리팩터링 여정 끝에 우리는 커맨드 패턴이라는 강력하고 확장성 높은 해답을 찾았습니다.

 

물론 '행위(Verb)'에 집중한 커맨드 패턴 대신, '상태(Noun)'에 집중하는 메멘토 패턴으로 발전시키는 길도 존재합니다. 우리가 처음 시도했던 '상태 스냅샷'에서 출발해, 캡슐화를 통해 데이터의 무결성을 보장하는 방식이죠. 하지만 여기서 한 걸음 더 나아가, Figma 같은 협업 디자인 툴을 생각해 봅시다. 여러 사용자가 동시에 같은 대상을 수정하려 할 때, 이 패턴들만으로 충분할까요?

 

이 때에는 커맨드 패턴의 철학이 확장된 OT(Operational Transformation)나 CRDT 같은 개념이 필요해집니다. 여기서 다루기에는 다소 큰 주제이니 간단히 OT의 방식을 살펴보면, 각 사용자의 '행위(Command)'에 순서를 부여하고 조율하여 모든 클라이언트가 충돌 없이 같은 결과를 보도록 만들어주는 방식이라 할 수 있습니다.

 

우리는 다양한 방식으로 undo/redo 기능을 구현할 수 있습니다. 단지, 구체적인 요구사항과 팀이 처한 상황에 따른 최선의 선택이 필요한 것이죠. 실제로 저 역시 빠르게 mvp 개발을 해야 했던 경우엔 메멘토 패턴과 비슷한 상태 스냅샷 방식이 최선이었습니다. 이후 프로덕트의 개발 상황과 가용 리소스 및 프로덕트 전략에 따라 이번에 소개한 리팩터링 3단계 중 2단계까지 적용할 수 있었습니다. switch 문을 완전히 제거하는 마지막 단계는 언젠가 해결해야 할 기술 부채로 남겨두게 되었습니다. 만약 해당 프로덕트에서 동시 편집과 협업 기능이 추가되어야 한다면, 좀 더 정제된 커맨드 패턴을 기반으로 확장시켰을 것입니다.

 

결국 가장 중요한 것은 어떤 것이 절대적인 정답이라기 보다, 주어진 현실 상황 안에서 가장 적절한 코드를 작성하기 위해 끊임없이 고민하는 과정 그 자체이지 않을까 하는 생각이 듭니다.

 

긴 글 읽어주셔서 감사합니다.

p.s. 전체 예시 코드가 보고 싶으시다면 아래 github 저장소를 참고해주세요.

https://github.com/Cyjin-jani/undo-redo-pattern

본문 및 github의 코드 예시는 이해를 돕기 위한 간단한 예시일 뿐입니다.

실제 프로덕션용 코드로 사용하기에는 수정이 필요한 부분이 많다는 점 참고바랍니다.