애드블럭 종료 후 사이트를 이용해 주세요.

ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Zustand stale 플래그와 변경 직전 pre-fetch로 VERSION_CONFLICT 숨긴 작업기
    React 2026. 7. 13. 19:23
    728x90
    반응형

    프로젝트 소스 패널에서 폴더를 삭제했을 뿐인데 "잠시 후 다시 시도해 주세요"라는 빨간 토스트가 떴다. 코드를 따라가 보니 서버 응답은 400 VERSION_CONFLICT였다. 우리 서버는 폴더 변경에 낙관적 동시성 제어를 쓴다. 폴더마다 version: number를 들고 있고, 삭제·이름 변경·이동 요청에 내가 알고 있는 expectedVersion을 실어 보내면 서버가 현재 버전과 비교한다. 어긋나면 거부한다. 문제는 사용자 모르게 그 버전이 올라가는 경로가 있었다는 것이다. 외부 소스를 폴더로 적재하는 백그라운드 "불러오기" 작업이 끝나면 서버 폴더 버전이 오른다. 그런데 브라우저 캐시는 옛 버전 그대로다.

    재현 경로는 이랬다. 사용자가 불러오기를 시작하고, 모달을 닫거나 새로고침하고, 백그라운드 작업이 폴더를 새로 만들며 기존 폴더 버전을 올리고, 그 사실을 모르는 사용자가 폴더를 삭제한다. 보낸 expectedVersion은 옛 v3, 서버는 v4. 충돌. 사용자 입장에선 "그냥 삭제를 눌렀을 뿐"이다. React Query·Zustand로 서버 상태를 다루다 보면 버전이나 ETag를 요구하는 API에서 똑같은 일을 겪는다. 이 글은 그 충돌을 사용자 눈에 안 보이게 흡수한 과정을 정리한 작업기다. 코드는 2026년 중반, React Query v5·Zustand v5 기준이다.

    처음엔 완료되면 그냥 다시 불러왔다

    첫 시도는 단순했다. 백그라운드 작업이 끝난 걸 폴링으로 감지하면 그 자리에서 폴더 목록을 invalidate해 새로 받았다. 버그는 사라졌다. 대신 다른 게 깨졌다. 사용자가 폴더를 고르거나 이름을 바꾸던 도중에 목록이 발밑에서 재정렬됐다. 새 폴더가 불쑥 끼어들고, 문서 수가 바뀌고, 스크롤이 튀었다. 충돌 에러를 없애려다 더 거슬리는 경험을 만든 셈이다.

    그래서 방향을 틀었다. 완료 순간에 보여주는 건 "새로고침하시겠어요?" 배너까지만. 실제 목록 갱신은 사용자가 변화를 기대하는 순간으로 미뤘다. 직접 새로고침을 누르거나, 어차피 무언가를 바꾸려고 변경 요청을 보내는 순간이다. 질문을 바꾼 게 핵심이었다. "언제 새로고침하느냐"가 아니라 "사용자가 모르게 무엇만 동기화하면 충분하냐". 답은 화면 전체가 아니라 그 폴더의 버전 하나였다.

    낡은 건 캐시지, 화면이 아니다

    여기서 한 번 의심하고 갈 게 있었다. "캐시에 폴더 목록이 이미 있으니 거기서 버전을 읽으면 되잖아?" 안 된다. 캐시(['folder', projectId])가 들고 있는 값이 바로 옛 버전 v3이다. 그게 stale의 정의다. queryClient.getQueryData로 읽어도 v3이 나온다. v4는 서버에만 있다. 그러니 버전을 맞추려면 네트워크로 한 번은 물어봐야 한다. 다만 그 결과로 화면을 다시 그릴 필요는 없다. 버전 숫자만 꺼내 쓰고 나머지는 버린다. 이 구분이 뒤에 나올 "조용한 pre-fetch"의 전제가 된다.

    감지하는 곳과 바꾸는 곳을 3줄짜리 스토어로 잇다

    진짜 걸림돌은 감지와 소비의 위치가 다르다는 거였다. "캐시가 낡았다"를 아는 쪽은 백그라운드 작업 상태를 폴링 하는 소스 패널이고, 그걸 써야 하는 쪽은 한참 아래에 있는 폴더 이름 변경·삭제 핸들러다. 둘은 컴포넌트 트리상 멀다. prop으로 내려보내자니 거쳐야 할 컴포넌트가 너무 많았다.

    그래서 신호선 하나를 전역에 뒀다. Zustand 스토어인데 상태는 불리언 하나뿐이다.

    // store/folderStaleStore.ts
    import { create } from 'zustand';
    
    interface FolderStaleState {
      isFolderListStale: boolean;
      markFolderListStale: () => void;
      clearFolderListStale: () => void;
    }
    
    export const useFolderStaleStore = create<FolderStaleState>((set) => ({
      isFolderListStale: false,
      markFolderListStale: () => set({ isFolderListStale: true }),
      clearFolderListStale: () => set({ isFolderListStale: false }),
    }));

    감지부는 작업이 "진행 중 → 완료"로 넘어간 순간 플래그를 켠다. 실패는 서버 버전을 올리지 않으니 켜지 않았다. 이 작은 구분이 뒤에서 불필요한 네트워크를 줄여준다.

    // 소스 패널: processing → terminal 전환을 감지한 순간
    if (current === 'COMPLETED' || current === 'PARTIALLY_COMPLETED') {
      setBannerType(current === 'COMPLETED' ? 'success' : 'partial');
      useFolderStaleStore.getState().markFolderListStale(); // "캐시가 낡았다" 신호
    }
    // FAILED는 버전을 올리지 않으므로 stale로 표시하지 않는다

    여기서 의도한 선택이 하나 있다. 소비할 때 이 값을 훅(useFolderStaleStore(s => s.isFolderListStale))으로 구독하지 않고 getState()로 읽었다. 클릭 핸들러 안에서 필요한 건 "그 순간의 최신값"이지, 이 값이 바뀔 때마다 컴포넌트를 다시 그릴 이유는 없어서다. React 트리 밖에서 명령형으로 읽는, 의도된 비구독이다.

    바꾸기 직전에 딱 한 번 당겨온다

    소비부는 mutate를 부르기 직전에 플래그를 본다. 켜져 있을 때만 최신 폴더를 한 번 당겨와 그 버전으로 교체한다. 삭제 핸들러가 이렇다. 이름 변경도 구조가 같다.

    const handleDelete = async () => {
      let expectedVersion = folder.version;
      const { isFolderListStale, clearFolderListStale } = useFolderStaleStore.getState();
    
      // 낡았을 때만 최신 폴더를 당겨온다 — 정상 흐름엔 추가 호출이 없다
      if (isFolderListStale) {
        try {
          const freshList = await getFolderList({ projectId });
          const freshFolder = freshList.folders.find((f) => f.id === folder.id);
          if (freshFolder) expectedVersion = freshFolder.version; // 최신 버전으로 교체
          clearFolderListStale();
        } catch {
          // pre-fetch가 실패해도 옛 버전으로 진행한다 — 어차피 서버가 최종으로 막는다
        }
      }
    
      deleteFolderMutation.mutate(
        { projectId, deleteFolders: [{ folderId: folder.id, expectedVersion }] },
        { onError: handleError, onSuccess: () => { /* 선택 정리 + invalidate */ } },
      );
    };

    처음엔 stale 여부를 안 보고 매 변경마다 pre-fetch 했다. 충돌은 안 났지만, 충돌이 날 일 없는 평범한 삭제까지 매번 한 박자 느려졌다. 그래서 게이트를 끼웠다. 덕분에 평상시 클릭 경로엔 추가 네트워크가 한 건도 없다. pre-fetch 실패를 throw하지 않은 것도 같은 결의 판단이다. 원래 충돌이 안 났을 수도 있는데, 회복 시도가 실패했다고 사용자 작업까지 막을 이유는 없다.

    pre-fetch는 창을 좁힐 뿐, 닫지 못한다

    정직하게 말하면 이 패턴은 충돌을 0으로 만들지 못한다. pre-fetch로 v4를 받은 뒤 mutate가 나가기 전, 그 짧은 사이에 또 다른 작업이 버전을 v5로 올릴 수 있다. 창이 좁아졌을 뿐 닫힌 게 아니다. 그래서 서버 에러 핸들러를 지우지 않고 최종 안전망으로 남겼다.

    // 잔여 충돌을 위한 최종 안전망 (useApiError)
    [FolderErrorCode.VERSION_CONFLICT]: () => {
      toast.error('일시적인 오류입니다', { description: '잠시 후 다시 시도해 주세요' });
      queryClient.invalidateQueries({ queryKey: folderQueryKeys.folderList(projectId) });
    },

    선제적 pre-fetch는 흔한 다수를 조용히 통과시키고, fallback은 드문 잔여를 받는다. 둘은 경쟁이 아니라 층이다. TanStack Query를 쓴다면 이 pre-fetch를 onMutate로 끌어올려 버전 기반 mutation 전반의 가드로 일반화할 수도 있다. 다만 우리 코드는 아직 핸들러 인라인이다. 호출처가 둘 뿐이라 추상화 비용이 이득보다 컸다고 판단했다.

    남은 빚: 전역 boolean이라 프로젝트 경계를 모른다

    이 스토어의 약점도 적어둔다. isFolderListStaleprojectId로 나뉘지 않은 전역 불리언이다. 한 프로젝트에서 플래그가 켜진 채 다른 프로젝트로 이동하면, 그쪽 첫 변경에서 불필요한 pre-fetch가 한 번 돈다. 받아오는 건 그 프로젝트의 올바른 목록이라 결과가 틀리진 않지만, 신호의 의미는 샌 셈이다. 호출처가 더 늘면 Record<projectId, boolean>이나 projectId 인자로 좁혀야 한다. 지금은 비용이 "불필요한 GET 1회"라, 빚으로 적어두고 넘어갔다. 측정해 보고 거슬리면 갚을 자리다.

    정리하며

    서버가 버전 기반 동시성 제어를 쓰고 사용자 모르게 그 버전을 올리는 경로가 있으면, 캐시는 낡고 변경은 충돌한다. 전역 stale 플래그로 "낡음"을 감지부에서 소비부로 흘려보내고, 바꾸기 직전 최신 버전만 pre-fetch 해 실어 보내면 충돌은 사용자 눈에 닿지 않는다. 단, pre-fetch는 창을 좁힐 뿐이라 서버 에러 핸들러는 반드시 남긴다.

    지금 코드베이스에서 expectedVersion(또는 version·etag)을 받는 mutation을 한 번 grep 해 보라. 그 화면에 백그라운드로 데이터가 바뀌는 경로가 하나라도 있으면, 3줄짜리 stale 스토어부터 만들어 변경 핸들러에 게이트를 끼워라.

    돌아보면 내가 짠 건 충돌 처리 코드가 아니라 "사용자가 믿을 수 있는 상태"라는 인터페이스였다. 서버의 동시성 모델이라는 내부 사정을 사용자 눈에 안 보이게 흡수하는 일. 화면을 사람에게 맞추든 요청을 서버에 맞추든, 인터페이스를 다듬는 craft는 결국 하나다. 버전 충돌을 토스트로 떠넘기는 대신 조용히 흡수하는 순간, 그건 에러 핸들링이 아니라 UX 설계가 된다.

    반응형

    댓글

Designed by Tistory.