React 19 환경에서 dnd-kit × React Query 드롭 jank 이슈 해결 (문제 해결)

Devinix·2026년 5월 15일

[문제 해결]

목록 보기
44/44

문제 상황

마이페이지 → 단골매장 관리에서 "순서 변경" 모드에 들어간 뒤, 첫 번째 카드를 세 번째 자리로 드래그해 놓으면 드롭 직후 첫 번째·두 번째 카드가 위로 한 번 튕겼다가 정상 위치로 내려오는 점프가 보였다. 카드는 결국 옳은 순서에 정착해서 기능 자체는 동작했지만, 한 프레임짜리 시각 jank가 사용자에게 그대로 드러났다.

이상한 점은 두 가지였다. 첫째, 로컬 개발 환경에서는 한 번도 재현되지 않고 배포된 production 빌드에서만 일관되게 일어났다. 둘째, API 연동 전 로컬 useState로만 순서를 관리하던 버전에서는 dev/production 모두 멀쩡했고, 동일 화면을 React Query 기반 optimistic update로 옮긴 뒤부터 발생했다.

원인

한 줄 요약

dnd-kit이 드래그 중 다른 카드에 입혀둔 transform을 드롭 시점에 동기적으로 클리어하는데, 짝을 이뤄야 할 React Query 캐시 변경 → 리렌더 → DOM 재정렬은 notifyManager.schedule()을 통해 마이크로태스크 큐로 밀려 한 tick 뒤에 일어난다. 그 갭 동안 stale한 transform과 옛 DOM 위치가 잠깐 노출되면서 카드가 튕긴 것처럼 보이는 race condition이다.

메커니즘

dnd-kit은 드래그 중 DOM 순서를 바꾸지 않는다. 비-드래그 카드에 transform: translateY(...)를 입혀 시각적으로만 자리를 비켜준다.

드래그 전:                 드래그 중 (Card1을 아래로):
[ Card1 ] (DOM y=0)        [ Card1 + transform: translateY(+2h) ]  ← 손가락 위치
[ Card2 ] (DOM y=h)        [ Card2 + transform: translateY(-h)  ]  ← 위로 비켜줌
[ Card3 ] (DOM y=2h)       [ Card3 + transform: translateY(-h)  ]  ← 위로 비켜줌

이 구조는 드롭 시점에 외부에서 배열을 새 순서로 재정렬해주면 transform이 자연스럽게 0이 되도록 설계돼 있다. 순서 재정렬과 transform 클리어가 같은 프레임에 일어나는 것이 전제다. 그 전제가 깨지면 다음 흐름이 만들어진다.

[T0] 드롭 (onDragEnd 진입)
    └─ queryClient.setQueryData(...) 호출
        ├─ 캐시는 즉시 새 순서로 업데이트 (동기)
        └─ subscriber 통지는 notifyManager.schedule()로 마이크로태스크 예약 (비동기)
    └─ dnd-kit 내부: transform = null 동기 적용
    └─ 이 시점 DOM: 아직 옛 순서

[T1] 마이크로태스크가 비기 전, 한 프레임 사이
    └─ DOM은 옛 순서, transform은 이미 null → 카드가 옛 위치로 잠깐 점프

[T2] React Query 통지 → 리렌더 → DOM 재정렬
    └─ useSortable이 새 순서 기준으로 transform 재계산
    └─ 일부 카드에 stale transform이 다시 잠깐 적용 → 추가 점프

[T3] transition으로 transform = 0 풀리며 정착 → "내려옴"

사용자가 본 "위로 튕긴 뒤 다시 내려옴"은 T1–T3의 합성이었다.

이 race가 dev에서는 안 보이고 production에서만 드러난 이유는 React 19의 concurrent rendering이 production 빌드에서 렌더링을 더 적극적으로 deferring하면서 마이크로태스크 갭을 더 벌렸기 때문이다. dev에서는 동기 경로가 우세해 갭이 사실상 한 프레임 안에 닫혔다. API 연동 전에는 useState만 썼기 때문에 같은 이벤트 핸들러 안에서 동기 배치되어 dnd-kit의 transform 클리어와 DOM 재정렬이 같은 tick에 들어왔고, SSOT를 React Query 캐시로 옮긴 순간 이 경로가 마이크로태스크를 타게 되면서 race가 비로소 드러났다.

해결 과정

시도 1·2 — 실패

먼저 onMutate에서 비동기로 하던 캐시 갱신을 onDragEnd에서 직접 동기로 호출해봤지만, 캐시 자체는 즉시 바뀌어도 subscriber 통지가 여전히 마이크로태스크 큐에 들어가 튕김이 그대로였다. 다음으로 react-domflushSync로 React 리렌더를 강제 flush해봤는데, React Query의 notifyManager는 React 스케줄러와 별개의 자체 큐를 쓰기 때문에 통지 단계의 비동기성을 잡지 못했다. 두 시도 모두 race를 좁히기만 했지 본질을 건드리지 못했다.

시도 3 — 발상 전환

드롭 시점에 동기화하려고 하니까 race가 생긴다. 드래그 도중에 이미 순서가 맞춰져 있으면, 드롭 시점엔 transform만 풀리면 되므로 어긋날 일이 없다. onDragOver(드래그한 카드가 다른 sortable 위로 진입할 때마다 호출)에서 로컬 useState로 순서를 즉시 갱신하면 dnd-kit이 보여주는 시각과 실제 DOM이 매 순간 일치하게 된다. React Query 캐시는 더 이상 시각의 출처가 아니라 서버·다른 화면 공유용 저장소로 역할이 분리된다.

useRegisteredStoreList.ts에 로컬 displayList state를 두고, 드래그 라이프사이클별 핸들러를 분리했다.

const responseData = favoriteStoreListResponse?.data;

const [displayList, setDisplayList] = useState<FavoriteStoreDetail[]>(() => responseData ?? []);
const isDraggingRef = useRef(false);
const previousDataRef = useRef<MyFavoriteStoreListResponse | undefined>(undefined);

// 드래그 외 경로(초기 로드 / refetch / 롤백)로 캐시가 바뀔 때만 displayList 동기화
useEffect(() => {
  if (isDraggingRef.current) return;
  setDisplayList(responseData ?? []);
}, [responseData]);

const handleDragStart = () => {
  isDraggingRef.current = true;
  previousDataRef.current = queryClient.getQueryData(storeQueryKeys.myFavoriteList());
};

const handleDragOver = ({ active, over }: DragOverEvent) => {
  if (!over || active.id === over.id) return;
  const oldIndex = displayList.findIndex((item) => item.storeId === active.id);
  const newIndex = displayList.findIndex((item) => item.storeId === over.id);
  if (oldIndex === -1 || newIndex === -1) return;
  setDisplayList((prev) => arrayMove(prev, oldIndex, newIndex)); // 같은 핸들러 내 동기 배치
};

const handleDragEnd = () => {
  isDraggingRef.current = false;
  const orderedFavoriteIds = displayList.map((item) => item.favoriteId);

  // 캐시는 displayList에 맞춰 commit (시각엔 이미 영향 없음)
  queryClient.setQueryData<MyFavoriteStoreListResponse>(
    storeQueryKeys.myFavoriteList(),
    (old) => (old?.success ? { ...old, data: displayList } : old),
  );

  handleUpdateOrder({ orderedFavoriteIds, previousData: previousDataRef.current });
};

작업 중 한 번 빠진 함정: ?? [] 폴백을 useEffect 의존성에 직접 넣으면 매 렌더마다 새 빈 배열 리터럴이 생성돼 무한 루프(Maximum update depth exceeded)에 빠진다. 폴백은 effect 내부에서, dep에는 안정 참조인 responseData만 둬야 한다.

// ❌ 매 렌더마다 새 [] 참조 → 무한 루프
const cacheList = responseData ?? [];
useEffect(() => { setDisplayList(cacheList); }, [cacheList]);

// ✅ 폴백은 effect 내부, dep에는 원본 참조만
useEffect(() => { setDisplayList(responseData ?? []); }, [responseData]);

낙관 업데이트 훅 쪽은 onMutate에서 캐시 조작을 모두 걷어내고, handleDragStart에서 잡은 스냅샷을 인자로 받아 per-call로 롤백하도록 단순화했다. 이렇게 바꾸자 드롭 시점에는 dnd-kit이 transform만 풀면 되고 DOM은 이미 새 순서에 있어 동기화할 게 없다 — race 자체가 사라진다.

결론

이번 버그의 본질은 dnd-kit의 동기 DOM 효과와 React Query의 비동기 subscriber 통지가 같은 프레임에 정렬되지 않은 것이었다. 캐시가 동기로 바뀌어도 통지 → 리렌더는 마이크로태스크에 예약되므로 "동기처럼 보이는 setQueryData"라는 직관에 속으면 안 된다. flushSync는 React 자체 업데이트는 잡아주지만 외부 store 라이브러리의 자체 스케줄러까지 통제하지는 못한다는 점도 같이 기억해둘 만하다.

해결의 본질은 시각의 출처와 영속화의 출처를 분리한 것이다. 드래그 중 시각은 로컬 useState가 책임지고(이벤트 핸들러와 동기 배치되어 라이브러리와 매 순간 일치), 서버·다른 화면 동기화는 드롭 시점에 React Query 캐시로 한 번만 commit하는 구조. 매 프레임의 시각이 라이브러리에 의존하는 인터랙션(드래그·리사이즈·스크롤 등)에는 이 분리가 대부분 정답이다.

마지막으로, dev에서 멀쩡하다고 production에서도 멀쩡하다는 보장은 없다. React 18+의 동시성 렌더링과 production 빌드의 추가 최적화는 마이크로태스크 갭을 잘 드러내는 환경이다. 라이브러리와 외부 상태가 얽힌 인터랙션 버그가 의심되면 production 빌드로 별도 검증하는 습관을 들이는 게 좋다.

profile
React, Next.Js, React-Native

0개의 댓글