본문 바로가기

React

React 앱에서 Undo/Redo 상태 히스토리 기능 구현하기

Undo/Redo는 에디터, 디자인 툴, 폼 편집 등에서 필수적인 UX 요소입니다. React에서는 불변 상태 패턴을 활용해 비교적 간단히 구현할 수 있습니다. 이 글에서는 실무에서 바로 쓸 수 있는 커스텀 훅과 키보드 단축키, 최적화 포인트까지 다룹니다.

1. 설계: past/present/future 스택

핵심 아이디어는 현재 상태(present)를 기준으로 과거(past)와 미래(future)를 스택으로 관리하는 것입니다.

- setState: 현재 상태를 과거 스택에 푸시하고 새로운 상태를 현재로 설정하며 미래 스택을 비웁니다.
- undo: 과거에서 하나를 꺼내 현재로 설정하고 기존 현재를 미래에 푸시합니다.
- redo: 미래에서 하나를 꺼내 현재로 설정하고 기존 현재를 과거에 푸시합니다.

2. 커스텀 훅: useUndo

아래 훅은 다음을 지원합니다.

- 동일 상태 스킵(compare)
- 히스토리 길이 제한(maxLength)
- 시간 기반 그룹핑(groupWithin): 연속 입력을 묶어서 하나의 스냅샷으로 처리합니다.
- replace 옵션: 과거 기록을 남기지 않고 현재만 교체합니다(서버 페치 등).

import { useCallback, useState } from 'react';

export function useUndo(initialPresent, options = {}) {
  const { maxLength = 100, compare = Object.is, groupWithin = 0 } = options;

  const [history, setHistory] = useState(() => ({
    past: [],               // { value, ts }[]
    present: initialPresent,
    future: [],             // { value, ts }[]
    lastTs: 0,
  }));

  const setState = useCallback((next, { replace = false } = {}) => {
    setHistory(curr => {
      const nextValue = typeof next === 'function' ? next(curr.present) : next;
      if (compare(curr.present, nextValue)) return curr; // 변화 없음

      if (replace) {
        return { ...curr, present: nextValue };
      }

      const now = Date.now();
      let past = curr.past.slice();

      if (groupWithin > 0 && now - curr.lastTs <= groupWithin && past.length > 0) {
        // 최근 스냅샷을 현재로 교체하여 그룹핑
        past[past.length - 1] = { value: curr.present, ts: now };
      } else {
        past.push({ value: curr.present, ts: now });
        if (maxLength > 0 && past.length > maxLength) {
          past = past.slice(past.length - maxLength);
        }
      }

      return {
        past,
        present: nextValue,
        future: [],
        lastTs: now,
      };
    });
  }, [compare, groupWithin, maxLength]);

  const undo = useCallback(() => {
    setHistory(curr => {
      if (curr.past.length === 0) return curr;
      const prev = curr.past[curr.past.length - 1].value;
      const newPast = curr.past.slice(0, -1);
      const newFuture = [{ value: curr.present, ts: Date.now() }, ...curr.future];
      return {
        past: newPast,
        present: prev,
        future: newFuture,
        lastTs: Date.now(),
      };
    });
  }, []);

  const redo = useCallback(() => {
    setHistory(curr => {
      if (curr.future.length === 0) return curr;
      const next = curr.future[0].value;
      const newFuture = curr.future.slice(1);
      const newPast = [...curr.past, { value: curr.present, ts: Date.now() }];
      return {
        past: newPast,
        present: next,
        future: newFuture,
        lastTs: Date.now(),
      };
    });
  }, []);

  const clear = useCallback(() => {
    setHistory(curr => ({ past: [], present: curr.present, future: [], lastTs: 0 }));
  }, []);

  const checkpoint = useCallback(() => {
    setHistory(curr => {
      const now = Date.now();
      const past = [...curr.past, { value: curr.present, ts: now }];
      return { ...curr, past, lastTs: now };
    });
  }, []);

  const canUndo = history.past.length > 0;
  const canRedo = history.future.length > 0;

  return {
    state: history.present,
    setState,
    undo,
    redo,
    canUndo,
    canRedo,
    clear,
    checkpoint,
    history, // 디버깅용
  };
}

3. 사용 예시: 카운터 + 텍스트 입력

연속 입력은 groupWithin 옵션으로 적절히 묶어 UX를 개선합니다.

import React, { useEffect } from 'react';
import { useUndo } from './useUndo';

export default function Editor() {
  const {
    state,
    setState,
    undo,
    redo,
    canUndo,
    canRedo,
  } = useUndo({ count: 0, text: '' }, { maxLength: 50, groupWithin: 300 });

  // 키보드 단축키 (Cmd/Ctrl+Z, Shift+Cmd/Ctrl+Z, Cmd/Ctrl+Y)
  useEffect(() => {
    const onKeyDown = (e) => {
      const isEditable = (
        e.target &&
        (
          e.target.isContentEditable ||
          ['INPUT', 'TEXTAREA'].includes(e.target.tagName)
        )
      );
      // 입력 필드에 포커스가 있을 때는 브라우저 기본 Undo에 맡깁니다.
      if (isEditable) return;

      const mod = e.metaKey || e.ctrlKey;
      if (mod && e.key.toLowerCase() === 'z') {
        e.preventDefault();
        if (e.shiftKey) redo(); else undo();
      } else if (mod && e.key.toLowerCase() === 'y') {
        e.preventDefault();
        redo();
      }
    };
    window.addEventListener('keydown', onKeyDown);
    return () => window.removeEventListener('keydown', onKeyDown);
  }, [undo, redo]);

  return (
    <div style={{ display: 'grid', gap: 12 }}>
      <div>
        <button onClick={() => setState(s => ({ ...s, count: s.count + 1 }))}>+
        </button>
        <button onClick={() => setState(s => ({ ...s, count: s.count - 1 }))}>-</button>
      </div>

      <input
        value={state.text}
        onChange={(e) => {
          const v = e.target.value;
          // 입력은 연속 편집을 그룹핑하여 히스토리 폭주를 막습니다.
          setState(s => ({ ...s, text: v }));
        }}
        placeholder="텍스트 입력"
      />

      <div>
        <button onClick={undo} disabled={!canUndo}>Undo</button>
        <button onClick={redo} disabled={!canRedo}>Redo</button>
      </div>

      <pre>{JSON.stringify(state, null, 2)}</pre>
    </div>
  );
}

4. 최적화 포인트

- 히스토리 길이 제한: maxLength로 메모리 사용을 제어합니다.
- 동일 상태 스킵: compare로 얕은 비교(Object.is) 또는 커스텀 비교를 적용합니다.
- 시간 그룹핑: groupWithin(ms)로 타이핑 같은 다수 업데이트를 스냅샷 하나로 묶습니다.
- replace 사용: 서버 응답으로 상태를 덮어쓸 때 히스토리를 남기지 않도록 replace 옵션을 사용합니다.
- 불변 업데이트: 과거 스냅샷이 참조로 공유되므로 현재 상태를 절대 직접 변이하지 않습니다.

5. Redux/Zustand와의 통합

전역 상태에서도 동일한 패턴을 적용할 수 있습니다. Redux에서는 고차 리듀서로 감싸는 방식이 일반적입니다.

// 간단한 undoable 리듀서 래퍼
export const undoable = (reducer, { limit = 100 } = {}) => {
  const initialPresent = reducer(undefined, { type: '@@INIT' });
  const initial = { past: [], present: initialPresent, future: [] };

  return (state = initial, action) => {
    switch (action.type) {
      case 'UNDO': {
        if (state.past.length === 0) return state;
        const prev = state.past[state.past.length - 1];
        return {
          past: state.past.slice(0, -1),
          present: prev,
          future: [state.present, ...state.future],
        };
      }
      case 'REDO': {
        if (state.future.length === 0) return state;
        const next = state.future[0];
        return {
          past: [...state.past, state.present].slice(-limit),
          present: next,
          future: state.future.slice(1),
        };
      }
      default: {
        const newPresent = reducer(state.present, action);
        if (Object.is(newPresent, state.present)) return state;
        return {
          past: [...state.past, state.present].slice(-limit),
          present: newPresent,
          future: [],
        };
      }
    }
  };
};

Zustand는 미들웨어로 스냅샷을 관리하거나, 특정 슬라이스에만 useUndo를 적용해 UI 레벨에서 관리하는 방식이 실용적입니다.

6. 비동기/서버 데이터와 Undo

- 낙관적 업데이트: 요청 전 setState로 즉시 반영하고 실패 시 undo 또는 오류 처리합니다.
- 체크포인트: 큰 작업 전 checkpoint로 명확한 경계를 만들어 사용자가 예측 가능한 undo를 할 수 있도록 합니다.
- 페치 결과 대체: 서버 응답으로 상태를 교체할 때 replace: true로 히스토리를 오염시키지 않습니다.

7. 키보드 및 접근성

- Cmd/Ctrl+Z, Shift+Cmd/Ctrl+Z, Cmd/Ctrl+Y를 지원하되, 입력 필드 포커스 시에는 기본 브라우저 Undo와 충돌하지 않도록 처리합니다.
- 버튼에 aria-label을 부여해 스크린리더 사용성을 높입니다.

8. 테스트 체크리스트

- 동일 상태 입력 시 히스토리 증가 여부 확인(compare).
- 연속 입력 그룹핑 동작(groupWithin).
- Undo/Redo 경계 조건: 빈 past/future에서 호출 시 안전성.
- 히스토리 길이 제한 동작(maxLength).
- 비동기 실패 시 되돌리기 시나리오.

정리하면, past/present/future 스택과 불변 상태 원칙만 지키면 React에서 Undo/Redo는 깔끔하게 구현할 수 있습니다. 위 훅을 프로젝트에 적용하고 도메인에 맞게 옵션을 튜닝하면, 안정적이고 예측 가능한 편집 경험을 제공할 수 있습니다.