Undo/Redo 기능 구현에 대한 고민

강지수·2025년 5월 3일
0

최종 프로젝트 Uuno

목록 보기
4/6

Uuno 에디터 강지수

개요

디지털 명함 에디터를 개발하면서 가장 깊이 고민했던 기능 중 하나는 바로 Undo/Redo 기능이었다. 단순해 보이지만 실제로 구현하려면 복잡한 상태 관리, 요소 추적, ID 문제, 그리고 사용자 경험까지 고려해야 하는 만만치 않은 문제였다. 이 글에서는 Undo/Redo 기능을 개발하며 겪은 시행착오, 고민의 흔적들, 그리고 최종적으로 도달한 해결책을 공유하고자 한다.

1. "되돌리기" 기능의 중요성

에디터에서 사용자가 작업 중에 실수했을 때 "되돌리고 싶다"는 요구는 필수적이다. 이는 단순히 상태를 관리하는 것을 넘어서, 변경 전의 상태를 저장하고, 그 상태로 복원하는 메커니즘이 필요하다는 것을 의미한다.

2. 첫 번째 접근: 개별 요소 기반 저장 방식

처음에는 가능한 한 단순하게 접근하고 싶었기에, 개별 요소의 변경 사항만 기록하는 방식을 선택했다. 이러한 선택에는 다음과 같은 이유가 있었다:

  • 직관적인 구현: 요소 하나가 변경되면 그 요소만 기록하면 되므로 구조가 간단하게 느껴졌다.
  • 메모리 효율성: 전체 상태를 매번 복사하는 것보다 변경된 요소만 기록하는 것이 메모리 사용 측면에서 효율적이라고 판단했다.
  • 유연한 확장성: 텍스트 외에도 이미지, 도형 등 다양한 요소가 추가될 것을 고려했을 때, 개별 요소 추적 방식이 더 유연할 것으로 보였다.

이러한 판단으로 초기 구현은 다음과 같은 구조로 시작했다:

export interface EditorState {
  histories: TextElement[];  // 개별 요소들의 기록
  historyIdx: number;        // 현재 기록 위치
  showElements: TextElement[]; // 화면에 표시되는 요소들
  // ... 기타 상태 및 함수들
}
  • 요소 추가 시: histories 배열에 새 요소 추가
  • 요소 수정 시: updateText(id, updates)를 통해 ID에 맞는 요소를 찾아 업데이트
  • Undo 실행 시: historyIdx--로 이전 상태로 이동
  • Redo 실행 시: historyIdx++로 다음 상태로 이동

3. 발생한 문제점들

이 접근 방식은 초기에는 괜찮아 보였지만, 실제로 구현하고 테스트하면서 여러 문제점이 드러났다.

3.1. 첫 번째 요소만 기록되는 문제

const updatedElement = state.histories.map(el => el.id === id ? { ...el, ...updates } : el);
const updatedHistories = [...state.histories, updatedElement[0]];

이 코드에서는 map() 함수가 전체 배열을 반환하는데, 그 중 [0]번 요소만 히스토리에 추가했다. 이로 인해 수정한 요소가 아닌, 항상 배열의 첫 번째 요소만 기록되는 문제가 발생했다. 실제로 디버깅해보니 내가 수정하고 있는 요소의 ID는 048c...인데, 히스토리에 저장되는 것은 매번 0032...와 같은 고정된 ID였다.

3.2. showElements와 history 간의 불일치

개별 요소 기반 저장 방식에서는 화면에 표시되는 요소들(showElements)과 히스토리에 저장된 요소들(histories) 간에 동기화 문제가 발생했다. 예를 들어, 요소의 위치를 변경했을 때 showElements에는 반영되었지만, histories에는 제대로 기록되지 않는 등의 불일치가 생겼다.

3.3. ID 추적의 복잡성

여러 요소가 추가, 수정, 삭제될 때 각 요소의 ID를 정확히 추적하는 것이 생각보다 복잡했다. 특히 Undo/Redo 과정에서 ID를 기준으로 요소를 찾아 업데이트해야 하는데, 이 과정에서 예상치 못한 버그가 자주 발생했다.

// 각 요소별로 ID를 추적하는 로직이 필요
undo: () => {
  const state = get();
  if (state.historyIdx > 0) {
    const prevIdx = state.historyIdx - 1;
    const prevState = state.histories[prevIdx];
    const updatedShowElements = state.showElements.map((el) =>
      el.id === prevState.id ? { ...el, ...prevState } : el
    );

    set({
      historyIdx: prevIdx,
      showElements: updatedShowElements,
    });
  }
}

이 코드에서는 히스토리에서 이전 상태를 가져와 현재 화면의 요소들과 ID로 매칭하여 업데이트한다. 하지만 요소가 삭제되거나 새로 추가된 경우에는 이 로직이 제대로 작동하지 않았다.

4. 디버깅과 문제 해결 시도

이러한 문제들을 해결하기 위해 다양한 시도를 했다:

4.1. 콘솔 로그를 통한 상태 추적

updateText: (id, updates) => {
  console.log('[updateText] id:', id);
  console.log('[updateText] selectedElementId:', selectedElementId);
  // ... 
}

디버깅을 통해 실제 선택된 요소와 업데이트되는 요소 간의 불일치를 확인했다.

4.2. find() 함수 사용으로 개선

map()[0] 대신 find()를 사용하여 정확한 요소를 찾도록 수정했다.

const updatedElement = state.histories.find(el => el.id === id);
if (updatedElement) {
  const updatedHistories = [
    ...state.histories.slice(0, state.historyIdx + 1),
    { ...updatedElement, ...updates }
  ];
  // ... 코드 계속
}

이렇게 수정하니 적어도 올바른 요소가 히스토리에 기록되기 시작했다.

4.3. 위치 정보 정확성 확보

Konva 캔버스에서의 요소 위치 정보를 정확히 가져오기 위해 getAbsolutePosition()을 활용했다.

onDragEnd={(e) => {
  const node = shapeRefs.current[el.id];
  const absPos = node
    ? node.getAbsolutePosition()
    : { x: el.x, y: el.y };
  updateText(el.id, {
    x: absPos.x,
    y: absPos.y,
  });
}}

5. 깨달음: 스냅샷 방식의 필요성

여러 문제를 해결하려 노력했지만, 근본적인 해결책은 접근 방식 자체를 바꾸는 것이었다. 개별 요소 기반에서 전체 상태의 스냅샷 방식으로 변경하는 것이었다.

5.1. 스냅샷 구조란?

스냅샷 방식은 각 상태 변경 시점에 전체 요소 목록의 복사본을 저장하는 방식이다

interface HistoryEntry {
  elements: TextElement[]; // 이 시점의 전체 요소 목록
  action: 'add' | 'update' | 'remove'; // 작업 종류 (선택적)
  elementId?: string; // 작업 대상 ID (선택적)
}

export interface EditorState {
  histories: HistoryEntry[];
  historyIdx: number;
  showElements: TextElement[];
  // ... 기타 상태 및 함수들
}

5.2. 스냅샷 방식의 장점

  • 상태 일관성 보장: 전체 상태를 한 번에 저장하므로 상태 간 일관성이 유지된다.
  • ID 추적 불필요: 개별 요소의 ID를 추적할 필요가 없어 로직이 단순해진다.
  • 확장성: 새로운 요소 타입이 추가되어도 히스토리 구조를 변경할 필요가 없다.
  • 구현 간결성: Undo/Redo 구현이 매우 간결해진다.
undo: () => {
  const state = get();
  if (state.historyIdx > 0) {
    const prevIdx = state.historyIdx - 1;
    const prevHistoryEntry = state.histories[prevIdx];

    set({
      historyIdx: prevIdx,
      showElements: prevHistoryEntry.elements
    });
  }
},

redo: () => {
  const state = get();
  if (state.historyIdx < state.histories.length - 1) {
    const nextIdx = state.historyIdx + 1;
    const nextHistoryEntry = state.histories[nextIdx];

    set({
      historyIdx: nextIdx,
      showElements: nextHistoryEntry.elements
    });
  }
}

5.3. 스냅샷 방식의 단점

  • 메모리 사용량 증가: 매번 전체 상태를 복사하므로 메모리 사용량이 증가한다.
  • 중복 저장: 같은 요소가 반복해서 저장될 수 있었다.

하지만 현대 웹 환경에서 이 정도의 메모리 사용은 크게 문제가 되지 않으며, 코드의 안정성과 명확성을 위해 기꺼이 감수할 수 있는 수준이다.

6. 최종 구현: 스냅샷 기반 Undo/Redo

최종적으로는 각 변경 사항마다 전체 상태의 스냅샷을 저장하는 방식으로 구현했다

addText: (element) => {
  const state = get();
  const newElements = [...state.showElements, element];

  // 현재 historyIdx 이후의 기록을 버리고 새 히스토리 항목 추가
  const updatedHistories = [
    ...state.histories.slice(0, state.historyIdx + 1),
    {
      elements: newElements,
      action: 'add',
      elementId: element.id
    }
  ];

  // 상태 업데이트
  set({
    histories: updatedHistories,
    showElements: newElements,
    historyIdx: updatedHistories.length - 1,
  });
},

updateText: (id, updates) => {
  const state = get();
  // 요소 업데이트
  const updatedElements = state.showElements.map((el) =>
    el.id === id ? { ...el, ...updates } : el
  );

  // 히스토리 업데이트
  const updatedHistories = [
    ...state.histories.slice(0, state.historyIdx + 1),
    {
      elements: updatedElements,
      action: 'update',
      elementId: id
    }
  ];

  set({
    histories: updatedHistories,
    showElements: updatedElements,
    historyIdx: updatedHistories.length - 1,
  });
}

이 방식은 코드가 간결해지고, 버그 발생 가능성이 크게 줄어들었다. 전체 상태를 스냅샷으로 저장하므로 ID 추적이나 개별 요소 매핑에 대한 걱정 없이 Undo/Redo가 정확하게 작동한다.

7. 배운 점과 앞으로의 계획

7.1. 배운 점

  1. 단순함의 가치: 때로는 조금 더 메모리를 사용하더라도 코드가 단순하고 직관적인 것이 좋다.
  2. 전체 상태 관리의 중요성: 부분적인 상태 관리는 예상치 못한 문제를 일으킬 수 있다.
  3. 디버깅의 중요성: 콘솔 로그 등을 통한 철저한 디버깅이 문제 해결의 열쇠였다.

7.2. 앞으로의 계획

  1. 성능 최적화: 요소가 많아졌을 때 성능 이슈가 없는지 모니터링하고 필요시 최적화하기(방법은 고민 해봐야 함.)
  2. UI 피드백 개선: Undo/Redo 시 사용자에게 어떤 변경이 이루어졌는지 시각적 피드백 제공하기
  3. 기능 확장: 다양한 요소 타입(이미지, 도형 등)에 대한 지원 추가하기

8. 마무리

Undo/Redo 기능을 구현하면서 가장 큰 깨달음은 단순한 생각이 라는 것이었다. 처음에는 메모리 효율성을 위해 복잡한 개별 요소 추적 방식을 선택했지만, 그로 인한 버그와 혼란은 그 이점을 크게 상쇄했다. 처음부터 효율성을 갖추고 개발하면 좋겠지만 그럴 수 없는 경우도 있다는 것을 깨달았다.

결국, 전체 상태를 스냅샷으로 저장하는 단순한 접근 방식이 코드의 가독성, 유지보수성, 그리고 안정성을 모두 개선해주었다. 이는 "간결함이 복잡함보다 낫다"는 소프트웨어 개발의 기본 원칙을 다시 한번 확인시켜 준 경험이었다.

profile
프론트엔드 잘하고 싶다

0개의 댓글