useRef + useTransition으로 따닥 이슈 막기

해진·2026년 1월 5일
post-thumbnail

커머스 기능 개발 중 발생한 중복 결제 요청(따닥) 이슈를 해결하기 위해 디바운싱, useState, useRef를 거쳐 useTransition을 활용한 동시성 제어 모델로 발전시킨 과정을 공유해요.

문제 상황: "주문이 두 번 들어갔어요"

현재 개발 중인 커머스 서비스는 결제 과정에서 ‘결제하기’ 버튼을 누르면 주문서(OrderSheet)를 만들어요. 이후 결제 진행 단계에 따라 주문서 상태가 바뀌어요.

그러나 QA 과정에서 치명적인 문제가 드러났어요. 네트워크가 불안정하거나 사용자가 버튼을 빠르게 여러 번 누르면, 누른 횟수만큼 주문서가 중복으로 생성됐어요. 이 구조는 데이터베이스에 필요 없는 데이터를 쌓을 뿐더러 의미 없는 네트워크 비용도 발생시켜요. 그렇기에 이 문제는 가장 먼저 해결해야 할 과제가 됐어요.

1차 접근: 디바운싱(Debouncing)과 스로틀링(Throttling)의 한계

가장 먼저 떠올린 해결책은 debouncethrottle을 사용하는 것이었어요.


const handleSubmit = debounce(async () => {
  await postOrder();
}, 300);

하지만 곧 이 방법의 한계가 드러났어요.

첫째, 네트워크 지연을 고려하지 않았어요.
디바운싱 시간을 300ms로 잡았지만, 네트워크 이슈에 따라 서버 응답이 300ms 이상 걸리는 상황도 생겨요. 그러나 사용자는 0.3초가 지나면 다시 버튼을 누를 수 있어요. 그 순간 또 다른 요청이 서버로 전달돼요.
즉 처음 보냈던 요청의 응답이 돌아오지 않은 상태인데 또 요청을 보낼 수 있는 상황이죠.

둘째, 목적이 어긋났어요.
디바운싱과 스로틀링은 이벤트 호출 횟수를 줄이려는 기법이에요. 비동기 작업이 끝날 때까지 요청을 완전히 막아 주지는 못해요.

그래서 다른 접근이 필요했어요.
이벤트가 처리 중인지 명확히 알아야하며 처리 중이라면, 추가 요청이 서버로 가지 않도록 아예 차단해야 했어요.

2차 접근: useState를 이용한 Locking

그렇다면 가장 직관적인 useState로 isPending 상태를 관리하면 어떨까요?

const [isPending, setIsPending] = useState(false);

const handleClick = async () => {
  if (isPending) return; // 락(Lock) 체크
  
  setIsPending(true); // 락 걸기
  await postOrder();
  setIsPending(false); // 락 풀기
};

이 코드는 겉으로 보면 논리적으로 완벽해 보여요. 하지만 React의 상태 업데이트 방식 때문에 실제 환경에서는 실패할 수 있어요.

문제의 원인은 React의 setState 동작 방식이에요. setState는 비동기로 처리되어 값은 바로 바뀌지 않고, 다음 렌더링 사이클에서야 반영돼요. 클릭 핸들러 안에서 읽는 isPending 값은 스냅샷 상태로 고정돼 있기에 첫 번째 클릭 이후 리렌더링이 끝나기 전에 두 번째 클릭이 들어오면, 여전히 false로 읽혀요.

결국 ‘따닥’을 막으려면 리렌더링을 기다리면 안 된다고 판단했어요. 클릭 순간에 즉시 값이 바뀌는 장치가 필요했어요.

3차 접근: useRef의 동기적 특성 활용

그래서 세 번째 접근으로 useRef를 선택했어요. useRef는 값이 바뀌어도 리렌더링을 일으키지 않지만, 값은 즉시 바뀌죠. 이 특성은 중복 클릭을 막는 데 적합했어요.


const isLock = useRef(false);

const handleClick = async () => {
  if (isLock.current) return; // 즉시 차단 가능
  isLock.current = true;
  
  await postOrder();
  isLock.current = false;
};

이 방식으로 중복 요청은 막았지만, 곧 다른 문제가 보였어요. 바로 ref는 리렌더링을 일으키지 않는다는 것이죠. 그래서 사용자에게 로딩 중이라는 신호를 화면으로 보여줄 수 없어요.

그래서 생각한 방식은 로직 제어는 useRef가 맡고, 화면 반응은 useState가 맡는 구조였어요.

isLock.current = true; // 논리적 차단
setIsLoading(true);    // UI 업데이트 (로딩 켜기)

try {
  await postOrder(); // 비동기 작업
} finally {
  isLock.current = false;
  setIsLoading(false); // UI 업데이트 (로딩 끄기)
}

기능은 동작했어요. 하지만 마음에 걸리는 점이 두 가지 남았어요.

첫째, 코드가 지나치게 명령형이었어요.
개발자가 로딩의 시작과 끝을 직접 관리해야 했어요. 중간에 return이 들어가거나 예외 처리가 어긋나면 버튼이 영원히 로딩 상태로 남을 위험이 컸어요. 저는 이런 실수를 구조적으로 막고 싶었어요.

둘째, UX 문제였어요.
useState는 즉각적인 화면 업데이트를 요구해요. 따라서 버튼을 누르는 순간 리렌더링이 우선돼요. 만약 이 클릭이 무거운 페이지 이동이나 복잡한 연산으로 이어진다면, 화면이 순간적으로 멈춘 것처럼 느껴질 수 있어요.

최종: useRef + useTransition

그래서 React 18의 useTransition을 도입했어요. 이 훅은 상태 업데이트의 우선순위를 낮춰요. “급하지 않은 작업”으로 처리해 달라고 React에 요청하는 방식이에요.

엄밀히 말하면, 제가 작성한 코드에서 API 요청(onClick)은 즉시 서버로 전송돼요. 하지만 그 요청이 완료되어 화면을 바꿀 때, React는 이 작업을 '급하지 않은 작업'으로 분류하여 메인 스레드를 차단하지 않고 백그라운드에서 렌더링을 계산해요. 이것이 사용자 경험을 해치지 않는 비결이에요.

정리해보면 다음과 같은 장점이 있어요.

첫째, 로딩 상태를 직접 켜고 끌 필요가 없어요
startTransition으로 비동기 작업을 감싸기만 하면, React가 isPending을 알아서 관리해요.

둘째, 부드러운 UX
react의 동시성을 활용하여 우선순위를 낮추기에 동시에 무거운 작업이 이어져도 현재 화면의 클릭과 애니메이션은 부드럽게 유지돼요.

import { useRef, useTransition, MouseEvent } from 'react';

export const useAsyncClick = (onClick?: (e: any) => Promise<void> | void) => {
  const [isPending, startTransition] = useTransition();
  const isLock = useRef(false);

  const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
    if (!onClick) return;

    // 1. 이중 방어 (Ref: 즉시 차단 / Pending: React 상태 차단)
    if (isLock.current || isPending) return;

    // 2. 동시성 모드로 실행
    // (Next.js App Router나 React 19 환경에서는 async 작업을 지원하여 isPending이 유지됩니다)
    startTransition(async () => {
      // 동기적으로 Lock을 걸어 '따닥' 원천 봉쇄
      isLock.current = true;

      try {
        await onClick(e);
      } catch (err) {
        console.error('[Action Error]', err);
      } finally {
        // 작업 완료 후 Lock 해제 (상태 관리는 React가 알아서 함)
        isLock.current = false;
      }
    });
  };

  return { handleClick, isPending };
};

최종적으로 저는 이 로직을 커스텀 훅으로 분리했어요. Promise를 반환하는 경우에만 락을 걸도록 제한했어요. 동기 함수까지 막을 이유는 없었기 때문이에요.

🤔 "잠깐, Ref는 여전히 수동으로 끄고 켜는데요?"

코드를 보면 ref는 여전히 직접 제어해요. 이 점이 모순처럼 보일 수도 있어요. 하지만 저는 역할이 다르다고 봤어요.

useRef는 진입 자체를 막는 장치예요. 클릭 순간에 즉시 반응해야 하므로 개발자가 책임지는 게 맞다고 생각했어요. 반면 useTransition은 화면 상태를 다뤄요. 로딩 표시와 렌더링 흐름은 React 시스템에 맡기는 편이 안전하다고 생각했어요.

그 결과 저는 하나의 플래그에만 집중할 수 있었어요. 중복 진입을 막는 락 하나만 명확하게 관리하면 되죠. 나머지 UI 상태는 React가 처리하니까요!

마무리

이번 경험을 통해 저는 단순한 ‘중복 클릭 방지’를 넘어서 React가 어떻게 렌더링하고, 동시성을 어떻게 다루는지 차근차근 이해할 수 있었어요.

React 19에서는 useActionState 훅이 등장해 이런 폼 액션 처리를 더 간결하게 만들었어요. 하지만 왜 이 코드가 필요한지, 어떤 문제가 숨어 있는지를 직접 고민해야 하는 것은 매우 중요한 경험이라고 생각해요.

현재 조건에서 가장 합리적인 조합이 무엇인지 따져보고 최선을 고민한 이 경험은 앞으로 코드에서 문제를 발견하고, 더 나은 선택지를 찾아가는 데 중요한 자산이 될 거라고 생각해요.

profile
안녕하세요, Frontend 개발자 윤해진입니다.

0개의 댓글