[FrontEnd] 삭제 실행 취소 UX — setTimeout + Snackbar로 5초 유예 구현하기

TNFUDS·2026년 4월 24일

FinTrack 프로젝트

목록 보기
14/14
post-thumbnail

개요

지출 내역 페이지에는 삭제 버튼이 있다.
기존에는 confirm() 팝업으로 한 번 더 물어보는 방식이었다.

하지만 실제로 써보면 이런 문제가 있다.

  • 사용자가 무의식적으로 “확인”을 누름
  • UX가 끊김
  • 실수 복구가 어려움

그래서

“삭제됨 → 실행 취소 가능”

이 UX를 프론트엔드만으로 구현해봤다.


핵심 아이디어

삭제 버튼을 눌러도 API를 바로 호출하지 않는다.

대신 아래 흐름으로 동작한다:

  1. UI에서 즉시 삭제 (Optimistic Update)
  2. 5초 타이머 시작 (setTimeout)
  3. Snackbar로 “실행 취소” 제공
  4. 5초 안에 취소 → 상태 복원
  5. 5초 후 → 실제 API 삭제

상태 설계

type PendingDelete = {
  expense: Expense;
  snapshot: Expense[];
  totalSnapshot: number;
};

const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null);
const [undoOpen, setUndoOpen] = useState(false);
const deleteTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

왜 useRef인가?

타이머 ID는 렌더링과 무관한 값이다.
useState를 쓰면 불필요한 리렌더가 발생한다.


삭제 핸들러

const handleDelete = (id: number) => {
  const target = expenses.find((e) => e.id === id);
  if (!target) return;

  // 기존 대기 삭제 확정
  if (deleteTimerRef.current) {
    clearTimeout(deleteTimerRef.current);
    deleteTimerRef.current = null;
    if (pendingDelete) deleteExpense(pendingDelete.expense.id);
  }

  const snapshot = [...expenses];
  const totalSnapshot = totalElements;

  // Optimistic Update
  setExpenses((prev) => prev.filter((e) => e.id !== id));
  setTotalElements((prev) => prev - 1);

  setPendingDelete({ expense: target, snapshot, totalSnapshot });
  setUndoOpen(true);

  // 5초 후 실제 삭제
  deleteTimerRef.current = setTimeout(async () => {
    try {
      await deleteExpense(id);
      fetchExpenses();
      fetchMonthlyTotal();
    } catch {
      // 실패 시 롤백
      setExpenses(snapshot);
      setTotalElements(totalSnapshot);
    } finally {
      setPendingDelete(null);
      setUndoOpen(false);
      deleteTimerRef.current = null;
    }
  }, 5000);
};

실행 취소 핸들러

const handleUndo = () => {
  if (!pendingDelete || !deleteTimerRef.current) return;

  clearTimeout(deleteTimerRef.current);
  deleteTimerRef.current = null;

  setExpenses(pendingDelete.snapshot);
  setTotalElements(pendingDelete.totalSnapshot);

  setPendingDelete(null);
  setUndoOpen(false);
};

컴포넌트 언마운트 시 처리

useEffect(() => {
  return () => {
    if (deleteTimerRef.current) {
      clearTimeout(deleteTimerRef.current);
    }
  };
}, []);

페이지 이동 시 타이머 누수 방지

Snackbar UI

<Snackbar
  open={undoOpen}
  anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
  message={`"${pendingDelete?.expense.categoryName}" 지출이 삭제됩니다`}
  action={
    <Button onClick={handleUndo} color="warning" size="small">
      실행 취소
    </Button>
  }
/>

포인트

  • autoHideDuration 사용X
  • 타이머는 setTimeout 하나로 통제

구조 설계

이 로직은 여러 페이지에서 재사용 가능하다.

DashboardPage          ← 삭제 로직
  └─ RecentExpenseSection ← UI만 담당

커스텀 훅으로 분리 가능

function useUndoDelete(onConfirm: (id: number) => Promise<void>) {
  const [pendingDelete, setPendingDelete] = useState(null);
  const [undoOpen, setUndoOpen] = useState(false);
  const deleteTimerRef = useRef(null);

  // handleDelete, handleUndo 구현

  return { handleDelete, handleUndo, undoOpen, pendingDelete };
}

하지만 상태 구조가 복잡하면 굳이 분리 안 해도 괜찮다

주의할 점

1. 스냅샷 방식의 한계

  • 필터 변경 시 데이터 mismatch 가능
  • 해결: undo 후 fetch 재호출

2. 연속 삭제 처리

  • 이전 삭제는 즉시 확정 → UX 단순화

3. 타이머 관리

  • 반드시 useRef 사용

결과

  • confirm() 제거
  • UX 끊김 없음
  • 실수 복구 가능
  • 백엔드 수정 없음


예시로 등록한 지출 내역이다.


삭제 아이콘을 누르면 지출 내역에서 즉시 사라지고 메세지가 뜬다.


이 때 실행취소를 누르지 않으면 내역이 없어진 것이 확정되며 그래프도 이를 반영해 다시 계산된다.


정리

UX는 크게 개선되지만 구현 비용은 낮다

profile
내 세상을 넓혀가는 중

0개의 댓글