개발/트러블슈팅

[React] 사용자 경험을 극대화하는 '낙관적 업데이트(Optimistic Update)'와 주의할 점

sjindev 2026. 3. 6. 14:03

개발을 하다 보면 "서버 응답을 기다리는 짧은 시간조차 사용자에게는 지루함이 될 수 있다"는 고민을 하게 되었다. 특히 좋아요 버튼, 삭제, 수정처럼 사소하고 빈번하게 일어나는 작업에서 더더욱... 오늘은 진행 중인 프로젝트 '코르크차지(CorkCharge)'의 그룹 관리 기능을 구현하며 적용한 낙관적 업데이트와 그 과정에서 마주친 Race Condition(경쟁 상태) 문제 해결 과정을 적어보려고 한다.

1. 낙관적 업데이트(Optimistic Update)란?

낙관적 업데이트는 "서버로 보낸 요청이 성공할 것이라고 낙관적으로 가정"하고, 서버 응답이 오기 전에 UI를 먼저 업데이트하는 기법이다.

  • 일반적인 방식: 요청 → 대기(Loding) → 응답 완료 → UI 업데이트 (느린 네트워크에서 답답함 유발)
  • 낙관적 업데이트: 요청 → UI 즉시 업데이트 → 응답 완료 → (실패 시) 롤백

2. 문제의 코드: "삭제했는데 왜 다시 나타나지?"

코르크차지의 그룹 삭제 로직을 처음 구현했을 때, 코드레빗(CodeRabbit)으로부터 날카로운 지적을 받았다. 바로 낙관적 업데이트와 서버 동기화 사이의 타이밍 이슈였다.

 

수정 전 코드 (문제 발생 지점)

// handleEditGroup 중 일부
const handleDeleteGroup = async (id: number) => {
  // [1] API 요청 보냄 (비동기)
  deleteBookmarkGroup(id); 

  // [2] 낙관적 업데이트: UI에서 즉시 제거
  setMyGroups((prev) => prev.filter((g) => g.id !== id));

  // [3] 목록 새로고침 (문제의 구간!)
  refreshGroups(); 
};

 

왜 문제가 될까? (Race Condition 발생)

  1. 사용자가 삭제를 클릭하면 setMyGroups에 의해 화면에서 항목이 즉시 사라진다. (기분 좋은 사용자 경험)
  2. 동시에 deleteBookmarkGroup(id) API가 서버로 날아간다. 하지만 DB에서 실제로 삭제되기까지는 수 밀리초에서 수 초가 걸린다.
  3. 문제는 refreshGroups()이다. API가 완료되기도 전에 refreshGroups가 실행되어 서버에 "전체 목록 줘!"라고 요청을 보낸다.
  4. 서버는 아직 삭제 처리가 안 된(DB에 남아있는) 데이터를 반환한다.
  5. 결과: 삭제된 줄 알았던 그룹이 잠시 후 다시 목록에 나타났다가 사라지는 '깜빡임' 현상이 발생한다.

3. 해결책: 실행 순서의 제어 (Async/Await)

이 문제를 해결하려면 "UI는 즉시 바꾸되, 서버 데이터 재조회는 반드시 서버 작업이 끝난 후에 수행한다"는 순서를 강제해야 한다.

 

수정 후 코드 (해결 완료)

const handleDeleteGroup = async (id: number) => {
  const targetGroup = myGroups.find((g) => g.id === id);
  if (!targetGroup) return;

  // 1단계: UI 먼저 업데이트 (사용자는 즉각적인 피드백을 받음)
  setMyGroups((prev) => prev.filter((g) => g.id !== id));

  try {
    // 2단계: 서버 삭제 요청을 await으로 기다림
    // 서버 DB에서 삭제가 완전히 끝날 때까지 다음 줄로 넘어가지 않음
    await deleteBookmarkGroup(id);
    
    // 3단계: 삭제가 확실히 완료된 후, 최신 서버 데이터를 가져옴 (동기화)
    await refreshGroups(); 

  } catch (error) {
    console.error('삭제 실패:', error);
    // 4단계: 만약 서버 요청이 실패한다면? 롤백(Rollback) 수행
    // 사용자에게 에러를 알리고 UI를 이전 상태로 되돌림
    setMyGroups((prev) => [...prev, targetGroup]);
    alert('그룹 삭제에 실패했습니다.');
  }
};

 

4. 회고: 왜 이렇게 해결해야만 했는가?

1) 신뢰성 있는 데이터 관리

낙관적 업데이트는 UI를 속이는 기법이다. 하지만 결국 최종적인 데이터의 주인(Source of Truth)은 서버다. 서버의 상태가 바뀌기 전에 조회를 요청하는 것은 Dirty Read와 유사한 상황을 클라이언트에서 만드는 것과 같다. await을 통해 작업의 원자성(Atomicity)과 순서를 보장해야 한다.

2) 에러 핸들링과 롤백(Rollback)의 중요성

낙관적 업데이트의 필수 짝꿍은 롤백이다. 서버가 500 에러를 뱉었는데 UI만 삭제되어 있다면 사용자는 데이터가 유실되었다고 오해할 수 있다. catch 블록에서 이전 상태를 복구하는 로직은 시스템의 안정성을 높인다.

3) 생성(Create) 시의 예외 상황

수정이나 삭제는 기존 ID를 알고 있어 낙관적 업데이트가 쉽다. 하지만 '생성'은 서버에서 생성된 실제 ID를 받아와야 하므로, 가짜 ID(예: Date.now())를 임시로 부여했다가 서버 응답 후 실제 ID로 교체하는 등 좀 더 복잡한 로직이 필요할 수 있다. (이번 프로젝트에서는 생성 시엔 모달을 띄워 응답을 기다리는 방식으로 처리했다.)

 

요약 및 결론

  • 낙관적 업데이트는 응답 대기 시간을 체감 0으로 만들어 사용자 경험을 극적으로 개선한다.
  • 하지만 서버 작업 완료 전 재조회(Race Condition)를 하게 되면 데이터 불일치(깜빡임) 현상이 발생한다.
  • 해결책: UI는 즉시 갱신하되, await을 사용하여 서버 응답을 확인한 후 동기화(Refresh)를 진행하고, 실패 시 롤백 로직을 반드시 갖춰야 한다.

낙관적 업데이트에 대하여 처음 접해보았고, 단순한 기능 구현외에 이런 '타이밍 이슈'(race condition 처리)를 전공과목으로만 공부해봤는데, 실제 내가 진행하는 프로젝트의 내가 맡은 프론트엔드 파트에서 고민하는 과정을 겪으니 신기했고, 전공지식이 쓸모있구나 생각도 들어 뿌듯했다....!