본문으로 건너뛰기
fe.run

useDeferredValue가 미루는 것

글 복사 완료!

검색창이 버벅일 때 debounce부터 떠올리지만, 막상 안 풀리는 이유가 있어요.

·15분·

검색창에 타이핑하면 아래 결과 목록이 매 글자마다 다시 그려져요. 입력이 한 박자씩 밀리길래 흔히 쓰는 debounce를 떠올렸는데, 저도 처음엔 대기 시간을 200ms로 할지 300ms로 할지 한참 고민했거든요. 근데 버벅임의 원인은 입력이 잦아서가 아니라 무거운 렌더가 입력 처리를 막고 있어서고, useDeferredValue는 고정 지연을 직접 고르지 않아도 React가 그 렌더를 알아서 뒤로 미뤄줘요.

검색창에 debounce를 걸었는데도 버벅인다

먼저 문제를 눈으로 봐야 해요. 아래 입력창에 빠르게 타이핑해보세요. 결과 목록이 일부러 무겁게 그려지도록 만들어놨어요. 글자를 칠 때마다 입력이 멈칫거리는 게 느껴질 거예요.

여기서 입력값에 debounce를 걸면 무거운 렌더를 덜 자주 일으킬 수는 있어요. 다만 한 번 렌더가 시작되면 그 작업이 끝날 때까지 입력은 여전히 막혀요. 타이핑이 멈춘 뒤 한 박자 늦게 목록이 따라오는 경험도 그대로고요. 그래서 "지연을 얼마로 잡느냐"를 고민하기 전에, 지금 막고 있는 게 누구인지부터 봐야 해요.

debounce와 throttle은 왜 한계가 있나

debounce와 throttle은 둘 다 함수가 너무 자주 불리는 걸 막는 기법이에요. debounce는 입력이 멈추고 일정 시간이 지나야 한 번 실행하고, throttle은 정해진 간격마다 한 번씩만 실행해요. 검색어가 바뀔 때마다 서버에 요청을 보내는 상황이라면 이 둘이 딱 맞아요. 요청 횟수 자체를 줄여주니까요.

const debounced = useMemo(
  () => debounce((value) => runSearch(value), 300),
  []
);

문제는 화면을 다시 그리는 비용까지 이 방식으로 줄이려 할 때예요. 첫째로 300이라는 숫자를 우리가 직접 골라야 해요. 빠른 기기에선 굳이 안 기다려도 되는데 300ms를 손해 보고, 느린 기기에선 300ms로도 부족해서 여전히 버벅여요. 둘째로 debounce된 콜백이 일으키는 렌더 자체는 끝까지 메인 스레드를 잡아요. 결국 입력을 막는 순간을 뒤로 미룰 뿐, 막는다는 사실은 변하지 않아요. React 공식 문서도 이 둘이 본질적으로 화면을 막는(blocking) 방식이라고 짚어요.

useDeferredValue는 이 중 화면을 그리는 비용 쪽을 다른 방식으로 풀어요. 다만 서버 요청을 줄이는 일까지 대신해주진 않아요. 그쪽은 여전히 debounce가 맡는 영역이라, 둘은 경쟁 관계가 아니라 같이 쓰는 짝에 가까워요.

useDeferredValue가 하는 일

useDeferredValue는 값 하나를 받아서, 그 값의 "한 박자 뒤처진 버전"을 돌려줘요. 값이 바뀌면 React는 일단 이전 값으로 화면을 빠르게 그리고, 그 직후 백그라운드에서 새 값으로 다시 그리기를 시도해요.

const deferredText = useDeferredValue(text);

핵심은 여기에 정해진 지연 시간이 없다는 거예요.

"There is no fixed delay caused by useDeferredValue itself." - react.dev useDeferredValue

debounce처럼 우리가 ms 단위를 고를 일이 없어요. 빠른 기기에선 뒤처진 렌더가 거의 즉시 따라잡아서 차이를 못 느끼고, 느린 기기에선 기기가 느린 만큼만 목록이 입력을 뒤따라와요. 지연이 기기 성능에 맞춰 알아서 조절되는 셈이에요.

아까 그 무거운 목록에 deferredText를 먹여볼게요. 입력값 text는 곧바로 입력창에 반영하고, 무거운 목록에는 뒤처진 값을 넘기는 거예요. 똑같이 빠르게 타이핑해보세요. 입력창은 매끄럽게 따라오고, 목록만 살짝 흐려졌다가 따라잡아요.

textdeferredText가 다른 동안에는 목록이 옛 데이터를 보여준다는 뜻이라, 그 사이에 살짝 흐리게 처리하면 "지금 갱신 중"이라는 신호를 줄 수 있어요. 입력이 막히지 않는 이유는 다음 절에 있어요.

왜 자동 최적화인가, 버려지는 렌더

useDeferredValue가 미루는 백그라운드 렌더는 그냥 늦게 시작하는 게 아니라, 도중에 멈출 수 있어요(interruptible). 무거운 목록을 그리는 중에 사용자가 키를 또 누르면, React는 그리던 작업을 버리고 입력부터 처리한 다음 다시 백그라운드 렌더를 시작해요.

"React will abandon that re-render, handle the keystroke, and then start rendering in the background again." - react.dev useDeferredValue

그리던 렌더를 미련 없이 버리고, 키 입력을 먼저 처리하고, 그다음 다시 백그라운드에서 그리기 시작한다는 뜻이에요. 입력이 목록 렌더보다 빠르면 목록은 타이핑이 멈춘 뒤에야 최종 모습으로 그려져요.

이게 바로 "자동 최적화"의 정체예요. 우리가 우선순위 숫자를 매기거나 타이머를 맞추는 게 아니라, React의 동시성(concurrent) 스케줄러가 급한 일(입력)과 안 급한 일(무거운 목록)을 알아서 양보시켜요. 어떤 값을 뒤로 미룰지만 지정하면, 언제 얼마나 미룰지는 스케줄러가 알아서 정해요. 그리던 렌더를 버려도 괜찮은 이유는, 정작 급한 입력창 반영은 이미 끝나 있어서예요. 남은 건 뒷전으로 미뤄둔 숙제 같은 목록 렌더라, 버리고 새로 시작해도 사용자가 잃는 게 없죠.

1

이전 값으로 먼저 그림

text가 바뀌면 일단 deferredText의 이전 값으로 화면을 빠르게 그려요. 입력은 즉시 반영되고요.

2

백그라운드에서 새 값 시도

그 직후 새 deferredText로 무거운 목록을 백그라운드에서 다시 그리기 시작해요.

3

키 입력이 끼어들면 버림

목록을 그리는 도중 또 키를 누르면, 그리던 걸 버리고 키 입력부터 처리해요.

4

타이핑이 멈추면 그제야 완성

입력이 목록 렌더보다 빠르면 목록은 타이핑이 멈춘 뒤에 최종 모습으로 그려져요.

debounce가 "일단 기다렸다가 한 번에 막고 그린다"라면, 이쪽은 "먼저 그려보다가 급한 일이 오면 양보한다"예요. 어느 게 무엇을 다시 그리게 만드는지는 리렌더가 일어나는 조건을 같이 보면 더 또렷해져요.

이게 실제로 동작하려면, memo 함정

여기엔 빠뜨리기 쉬운 전제가 하나 있어요. 무거운 목록 컴포넌트가 memo로 감싸져 있어야 한다는 거예요. text가 바뀌면 부모는 곧바로 다시 그려지는데, 그 시점에 deferredText는 아직 이전 값이라 자식에 넘기는 props가 그대로예요. 이때 자식이 memo로 감싸져 있으면 props가 같으니 리렌더를 건너뛰어요. 미뤄진 효과가 바로 여기서 나와요.

근데 memo가 없으면 어떻게 될까요. 부모가 다시 그려질 때 자식도 무조건 따라 그려져요. deferredText가 옛 값이든 새 값이든 상관없이요. 아래는 위와 똑같은 코드에서 memo만 벗긴 거예요. 다시 버벅이는 게 느껴질 거예요.

목록 컴포넌트가 아니라 그 안의 계산이 무거운 경우라면, 그 계산을 useMemo로 감싸 deferredText에 의존하게 만들어도 같은 원리로 동작해요. 어느 쪽이든 "값이 그대로일 때 일을 건너뛸 수 있는 상태"를 만들어두는 게 조건이에요. 언제 memouseMemo가 실제로 의미 있는지는 useMemo가 필요한 순간에서 더 자세히 다뤘어요.

useDeferredValue를 넣었는데도 안 빨라진다면, 십중팔구 무거운 자식이 memo로 감싸져 있지 않아서예요. 두 훅은 짝으로 움직여요.

미루는 건 state가 아니라 값이에요

useDeferredValue에 넘기는 게 꼭 useState로 만든 state일 필요는 없어요. 훅 이름이 useDeferredState가 아니라 useDeferredValue인 게 힌트예요. 렌더 도중에 계산한 파생값을 그대로 넘겨도 되거든요. Josh Comeau는 이 점을 콕 짚어요.

"The hook is called useDeferredValue, not useDeferredState. There's no rule that says the value has to be a state variable." - Josh Comeau

미루는 대상이 state 변수라는 규칙은 없어요. 렌더 중에 만든 값이면 뭐든 넘길 수 있다는 뜻이에요.

이게 실전에서 자주 막히는 지점이에요. 슬라이더 몇 개로 그림자 CSS를 만들어 코드 블록으로 보여준다고 해볼게요. 무거운 건 색상이나 흐림 값 자체가 아니라, 그걸로 만든 CSS 문자열을 문법 강조해서 그리는 부분이에요. 이때 입력 슬라이더를 하나하나 미루는 게 아니라, 그것들로 만든 결과 문자열 하나만 미루면 돼요.

// state가 아니라, state로 만든 결과를 미뤄요.
const cssCode = generateShadowCss(color, blur, spread);
const deferredCssCode = useDeferredValue(cssCode);
 
// 무거운 문법 강조는 memo로 감싸고 deferred 값만 받아요.
<HighlightedCode code={deferredCssCode} />

입력이 몇 개든 미루는 건 그 결과 하나면 충분해요. 미룰 대상을 "state 개수"가 아니라 "무거운 결과 하나"로 보면 코드가 훨씬 단순해져요.

정리하면

검색창이 버벅이는 진짜 원인은 입력이 잦아서가 아니라 무거운 렌더가 입력 처리를 막아서예요. debounce와 throttle은 함수 호출 횟수를 줄여서 서버 요청 같은 곳엔 잘 맞지만, 렌더가 메인 스레드를 잡는 문제는 그대로 남겨요. useDeferredValue는 고정 지연을 고르는 대신 어떤 값을 미룰지만 지정하고, 미루는 타이밍과 중단은 React 스케줄러에 맡겨요. 단, 무거운 자식을 memo로 감싸야 그 효과가 살아난다는 전제를 꼭 챙기세요.

개발자가 손으로 거는 최적화에서 React가 알아서 처리하는 최적화로 무게중심이 옮겨가는 흐름은 리액트 최적화가 달라진 진짜 이유에서도 같은 결로 이어져요.

자주 묻는 질문

답을 펼치기 전에 스스로 답해보세요

useDeferredValue는 값을 감싸요. 주로 부모에서 내려온 prop 값을 한 박자 뒤처지게 만들 때 써요. useTransition은 상태를 바꾸는 함수, 즉 setState 호출을 급하지 않은 작업으로 표시할 때 써요. 내가 직접 상태를 업데이트하는 자리면 useTransition, 남이 준 값을 받아서 미루고 싶은 자리면 useDeferredValue가 자연스러워요.
아니요. useDeferredValue는 화면을 다시 그리는 비용을 미루는 도구지, 요청 횟수를 줄이는 도구가 아니에요. 검색어가 바뀔 때마다 서버를 부른다면 그쪽은 여전히 debounce나 throttle로 막아야 하고, 둘을 함께 쓰면 돼요.
React 19에서 추가된 두 번째 인자예요. 첫 렌더에는 뒤처질 이전 값이 없어서 기본적으로 미루지 않는데, initialValue를 주면 첫 렌더에 그 값을 쓰고 곧바로 실제 값으로 다시 그리도록 예약해요. 초기 화면부터 미루는 동작이 필요할 때만 챙기면 돼요.

참고 자료

관련 글