0%

Frontend

Redux-Saga 환경에서 낙관적 업데이트로 UX 심폐소생하기

신*진··수정됨 2026.07.22

Redux-Saga 환경에서 낙관적 업데이트로 UX 심폐소생하기

안녕하세요! 오늘은 최근 사내 관리자 페이지의 기능을 추가하면서 구현했던 낙관적 업데이트(Optimistic Update) 경험을 공유해보려고 합니다.

혹시 관리자 페이지에서 토글 스위치를 눌렀는데, 한참 뒤에야 "똑딱"하고 넘어가는 경험 해보신 적 있나요? 사실 저도 처음엔 "서버가 응답을 줘야 UI를 바꾸는 게 안전하지!"라고 생각했었는데, 실제 사용자(운영팀 분들)의 피드백을 들어보니 그 1~2초의 지연이 업무 몰입도를 엄청나게 방해하더라고요.

그래서 이번에 Redux-Saga 환경에서 어떻게 하면 데이터는 안전하게 지키면서 UI는 빛보다 빠르게 바꿀 수 있을지 고민한 흔적들을 정리해봤습니다.


1. 아니, 토글이 왜 이렇게 답답해?

기존의 제 코드는 아주 전형적인 방식이었어요.

  1. 사용자가 Switch 클릭

  2. API 요청 (Saga 호출)

  3. 서버 응답 대기 (1~2초 소요)

  4. 성공하면 리스트 전체 다시 조회(Re-fetch)

  5. 그제서야 리스트가 갱신되며 Switch가 바뀜

개발자 입장에서는 "데이터 정합성" 측면에서 가장 깔끔한 방법이죠. 하지만 사용자 입장에서는 "내가 누른 게 맞나?" 싶어서 한 번 더 누르게 되고, 결국 의도치 않은 중복 요청으로 이어지기도 했습니다. 무엇보다 UX가 너무 '절거덕'거렸죠.


2. 해결책: 일단 믿고 바꿔보자 (낙관적 업데이트)

낙관적 업데이트(Optimistic Update) 는 이름 그대로 "이 요청은 무조건 성공할 거야!"라고 낙관적으로 가정하고, 서버 응답이 오기도 전에 UI부터 바꿔버리는 패턴입니다.

성공하면 그대로 두고, 만에 하나 실패하면 그때 가서 "아차차, 미안!" 하고 원래대로 되돌리는(Rollback) 방식이죠.


3. 설계 고민: Redux vs 로컬 State

구현에 앞서 가장 고민했던 지점은 "낙관적인 상태값을 어디에 저장할 것인가" 였습니다.

처음에는 Redux store를 바로 수정하려고 했어요. 그런데 Redux-Saga의 특성상 API 호출 중에 다른 액션이 들어오거나, 리스트 재조회 액션과 타이밍이 꼬이면 데이터 충돌(Race Condition) 이 발생할 확률이 높더라고요.

예를 들어, 제가 스위치를 켰는데(True), 그 찰나에 리스트가 재조회되면서 서버의 예전 데이터(False)가 들어와버리면 스위치가 켜졌다가 다시 꺼지는 '깜빡임' 현상이 생깁니다.

그래서 저는 "서버 데이터(Redux)""화면용 데이터(Local State)" 를 분리하기로 했습니다.

  • Redux (evalsList): 서버에서 내려준 '진짜' 원본 데이터.

  • Local State (optimisticEvalsList): 사용자에게 즉각 보여줄 '낙관적' 데이터.


4. 실제 구현 코드 (핵심 로직)

핵심은 컴포넌트 내부에서 이 두 상태를 어떻게 핸들링하느냐에 있습니다.

(1) 상태 선언과 동기화

Redux 원본 데이터가 바뀌면(서버 통신 완료 등), 로컬 상태도 같이 업데이트해줍니다.

// src/components/template/EvalsTemplate/EvalsListTemplate.js

const [optimisticEvalsList, setOptimisticEvalsList] = useState([]);
const [lastToggleAction, setLastToggleAction] = useState(null); // 롤백용 정보 저장
const [loadingStates, setLoadingStates] = useState({}); // 행별 로딩 상태

// Redux 원본이 갱신되면 로컬 상태에 복사하여 동기화
useEffect(() => {
  if (evalsList) {
    setOptimisticEvalsList([...evalsList]);
  }
}, [evalsList]);

(2) 토글 핸들러: 3단계 프로세스

Switch를 누르는 순간 실행되는 로직입니다.

const handleToggle = (id, field, currentValue) => {
  const newValue = !currentValue;

  // 1. UI 즉시 반영 (낙관적 업데이트)
  setOptimisticEvalsList(prev =>
    prev.map(item => item.id === id ? { ...item, [field]: newValue } : item)
  );

  // 2. 실패를 대비해 현재 상태 기록 (롤백용)
  setLastToggleAction({ id, field, originalValue: currentValue });

  // 3. 서버 요청 (Saga 실행)
  dispatch(toggleAction({ id, [field]: newValue }));
};

(3) 에러 발생 시 롤백 (Safety Net)

Saga가 API 요청을 끝내고 loadingfalse가 되었을 때, 만약 error가 있다면 원래대로 되돌립니다.

useEffect(() => {
  if (lastToggleAction && !loading) {
    if (error) {
      // 실패 시: 기록해둔 원본값으로 롤백
      const { id, field, originalValue } = lastToggleAction;
      setOptimisticEvalsList(prev =>
        prev.map(item => item.id === id ? { ...item, [field]: originalValue } : item)
      );
      message.error("변경에 실패했습니다. 다시 시도해주세요.");
    }
    setLastToggleAction(null); // 액션 기록 초기화
  }
}, [loading, error, lastToggleAction]);

5. 디테일 한 스푼: 행별 로딩 상태

낙관적 업데이트를 했다고 해서 로딩 표시를 완전히 없애면 안 됩니다. 사용자가 여러 개를 연속으로 누를 때 "이게 지금 가고 있는 건가?" 하는 확신은 줘야 하거든요.

저는 loadingStates라는 객체를 만들어 해당 행의 Switch만 스피너가 돌게 처리했습니다.

// state 예시: { "published_42": true }
<Switch
  loading={!!loadingStates[`published_${record.id}`]}
  checked={record.published}
  onChange={() => handleToggle(record.id, 'published', record.published)}
/>

6. 삽질 노트: 이벤트 버블링 (AntD Table)

이건 구현하다가 발견한 꿀팁인데, Ant Design Table의 onRow 기능을 쓰고 있다면 Switch를 클릭했을 때 상세 페이지로 이동해버리는 대참사가 발생할 수 있습니다.

저는 onCell 설정과 stopEvent 유틸 함수를 만들어 3단계로 이벤트 전파를 막았습니다. (셀 클릭, Div 클릭, Switch 클릭 모두 차단!)

const stopEvent = e => {
  if (!e) return;
  e.stopPropagation();
};

7. 무엇이 달라졌나?

비교 항목 기존 방식 낙관적 업데이트 적용 후 반응 속도 1.5초 ~ 2초 (서버 의존) 즉시 (0.1초 미만) 사용자 경험 답답함, 중복 클릭 유발 쾌적함, 조작의 확신 네트워크 매번 Re-fetch (비용 높음) 성공 시 Re-fetch 생략 가능

마치며

세 가지만 기억하세요!

  1. 먼저 바꾼다 — 서버 응답 기다리지 말고, 클릭 즉시 로컬 State를 바꿔요.

  2. 원본을 저장해둔다 — 실패했을 때 돌아갈 값을 미리 기록해둬야 해요.

  3. 실패하면 되돌린다 — error 상태를 감지해서 조용히 롤백해요.

오늘의 결론 "먼저 바꾸고, 실패하면 되돌린다."

읽기 도구

8분 읽기

이 글이 도움이 되었나요?

다음으로 읽기