검색창에 한 글자 칠 때마다 화면이 멈칫거리는 문제, 한 번쯤 디버깅해봤을 거예요. 흔히 떠올리는 해법이 디바운스(debounce)랑 React 18에서 추가된 useTransition인데, 둘이 비슷해 보여도 동작 원리가 완전히 달라요.
잘못 고르면 입력 지연은 해결되는데 다른 문제가 생기거든요. 이 글에서는 useTransition vs 디바운스 차이를 정리하고, 어떤 상황에 뭘 써야 하는지 실전 케이스로 풀어볼게요.
한 줄 요약
- 디바운스는 일을 미루는 방식이에요. 일정 시간 입력이 멈춰야만 실제 작업을 시작해요.
- useTransition은 일의 우선순위를 낮추는 방식이에요. 작업은 바로 시작하지만 사용자 입력이 들어오면 잠깐 멈췄다가 다시 이어가요.
쉽게 말하면 디바운스는 "안 할 수도 있음", useTransition은 "하긴 하는데 양보하면서 함"이에요.
디바운스가 하는 일
디바운스는 마지막 호출 이후 N ms가 지나야 실제 함수를 실행하는 패턴이에요. 입력 중간 단계는 다 버려요.
예를 들어 사용자가 "react"를 친다고 치면 5번의 keystroke마다 함수가 호출되는데, 디바운스를 300ms 걸어두면 마지막 't' 이후 300ms가 지나야 한 번만 호출되거든요.
import { useEffect, useState } from 'react'
function useDebounce<T>(value: T, delay: number) {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(id)
}, [value, delay])
return debounced
}
이걸 검색 API 호출에 끼우면 5번 칠 때마다 5번 호출되던 게 한 번으로 줄어요. 서버 부하랑 요금 측면에서 확실히 이득이에요.
다만 디바운스는 무조건 지연이 생겨요. 300ms로 설정하면 사용자가 입력을 멈춘 뒤 0.3초가 지나야 결과가 나오기 시작하거든요. 결과가 늦게 보이는 건 어쩔 수 없는 트레이드오프예요.
useTransition이 하는 일
useTransition은 React 18에 들어온 동시성(concurrent) API예요. 상태 업데이트를 "긴급하지 않음"으로 마킹해서, React가 더 급한 일이 생기면 그 작업을 중단할 수 있게 해줘요.
import { useState, useTransition } from 'react'
export function SearchList({ items }: { items: string[] }) {
const [query, setQuery] = useState('')
const [filtered, setFiltered] = useState(items)
const [isPending, startTransition] = useTransition()
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value)
startTransition(() => {
setFiltered(items.filter(i => i.includes(e.target.value)))
})
}
return (
<>
<input value={query} onChange={onChange} />
{isPending && <span>업데이트 중...</span>}
<ul>{filtered.map(i => <li key={i}>{i}</li>)}</ul>
</>
)
}
setQuery는 input이 즉시 반응해야 하니까 일반 업데이트로 두고, 비싼 필터링 작업만 startTransition으로 감싸요. 이러면 사용자가 빠르게 타이핑할 때 React가 중간 결과를 그리지 않고, 가장 최근 입력 기준으로만 화면을 갱신해줘요.
핵심은 작업 자체를 지연시키는 게 아니라는 점이에요. 필터링 함수는 매번 실행되긴 하는데, React가 알아서 중간 렌더링을 건너뛰는 거예요.
둘의 차이를 한눈에

1. 지연 여부
- 디바운스: 명시적으로 지연 (예: 300ms 대기)
- useTransition: 지연 없음 (바로 시작)
2. 중간 작업 처리
- 디바운스: 중간 호출은 아예 실행되지 않음
- useTransition: 함수는 매번 실행되지만 렌더링이 스킵됨
3. 줄여주는 비용
- 디바운스: 함수 실행 비용 (API 호출, 무거운 계산)
- useTransition: 렌더링 비용 (가상 DOM, reconciliation)
4. 적합한 영역
- 디바운스: 네트워크 요청, 외부 시스템 호출
- useTransition: 클라이언트 사이드 무거운 렌더링
5. 사용자 체감
- 디바운스: "결과가 좀 늦게 나옴"
- useTransition: "결과는 빠르게 따라오는데 그동안 살짝 옛날 결과가 보임"
어떤 상황에 뭘 쓸까
클라이언트에서 1만 개 리스트 필터링이라면
API 없이 메모리에 있는 데이터를 필터링한다면 useTransition이 정답이에요. 디바운스를 걸 이유가 없어요. 어차피 네트워크 비용이 없으니까 미룰 필요가 없거든요.
useTransition을 쓰면 입력은 즉각 반응하고 필터 결과는 React가 우선순위를 조정해서 매끄럽게 갱신해줘요. 디바운스를 걸면 오히려 결과가 늦게 보이기만 해서 손해예요.
검색 API를 때린다면
API를 호출하는 상황은 디바운스가 맞아요. useTransition만 써도 API는 매번 호출되거든요. 서버 비용이랑 rate limit 측면에서 의미가 없어요.
250~400ms 정도로 디바운스를 걸고, 응답받은 결과를 화면에 뿌리는 게 깔끔해요.
자동완성처럼 둘 다 필요하다면 같이 쓰기
가장 흔한 실전 케이스예요. 입력은 매끄러워야 하는데 API도 때려야 하고 결과 렌더링도 무거운 그런 거요. 이때는 둘을 같이 써요.
- 디바운스로 API 호출 횟수를 줄이고
- 받은 응답을 화면에 그리는 부분만 useTransition으로 감싸요
const debouncedQuery = useDebounce(query, 300)
const [isPending, startTransition] = useTransition()
const [results, setResults] = useState<Item[]>([])
useEffect(() => {
if (!debouncedQuery) return
fetchSearch(debouncedQuery).then((data) => {
startTransition(() => {
setResults(data)
})
})
}, [debouncedQuery])
API 부하랑 렌더링 부하를 각각의 도구로 분담시키는 거예요. 한 쪽만 쓸 때보다 체감 성능이 확실히 좋아져요.
탭 전환 같은 큰 화면 변경
탭을 눌렀을 때 페이지 전체가 바뀌면서 한참 멈춰 보일 때가 있어요. 이건 디바운스로 해결이 안 돼요. 탭 클릭을 미룰 수는 없으니까요.
이런 케이스는 useTransition을 써서 탭 자체는 즉시 활성화 상태로 바꾸고, 무거운 컨텐츠 렌더링만 transition으로 처리해요. 사용자 입장에서는 클릭이 반응하지 않는 느낌이 사라져요.
useDeferredValue도 같이 알아두기
useTransition이랑 자주 헷갈리는 게 useDeferredValue거든요. 둘 다 동시성 API라 효과는 비슷한데 쓰는 상황이 달라요.
- useTransition: 상태 업데이트 자체를 내가 통제할 수 있을 때 (setState를 내가 호출할 때)
- useDeferredValue: 상태가 props로 내려오는데 그걸 그대로 무겁게 쓰는 컴포넌트에서, 늦게 반응해도 되는 사본을 만들고 싶을 때
function ResultList({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query)
return <HeavyList query={deferredQuery} />
}

부모 컴포넌트의 state setter에 직접 접근할 수 없을 때 유용해요. 라이브러리에서 받은 컴포넌트를 감쌀 때 자주 쓰게 되더라구요.
자주 빠지는 함정
1. startTransition에 비동기 함수를 그냥 넣는 실수
startTransition(async () => ...)처럼 쓰는 케이스를 가끔 보는데, React 18에선 의도대로 동작하지 않아요.
transition은 동기적으로 끝나는 상태 업데이트에 마킹해야 하거든요. 비동기 결과를 transition으로 처리하려면 .then 안에서 startTransition을 호출하는 식이 맞아요. React 19부터는 async transition이 정식 지원되긴 하는데, 동작이 좀 달라서 별도 가이드를 참고해야 해요.
2. 디바운스로 호출 줄였다고 만족하기
API 호출 수만 줄여놓고 결과 렌더링이 무거우면 여전히 입력이 끊겨 보여요. 받은 데이터를 1000개씩 그리는 경우, 디바운스 + useTransition 조합이 필요해요.
3. debounce delay를 너무 길게 잡기
보통 250~400ms가 적당해요. 500ms 넘어가면 사용자가 "끊긴 거 아닌가" 의심하기 시작하거든요. 반대로 100ms 미만이면 거의 효과가 없어요.
4. useTransition을 멀티스레딩처럼 오해하기
JS는 여전히 단일 스레드라, 한 번 시작된 동기 계산이 끝날 때까진 입력도 못 받아요. 다만 React가 작업을 작은 단위로 쪼개서 사이사이 입력을 처리할 수 있게 해주는 거예요. 진짜 무거운 단일 계산이 있다면 Web Worker로 넘기는 게 답이에요.
5. 계산 자체가 무거우면 useMemo도 같이 보기
useTransition은 렌더 우선순위만 조정하지 계산량을 줄여주지 않거든요. 같은 입력에 같은 결과가 나오는 계산이면 메모이제이션을 같이 써야 효과가 커져요.

정리하면
도구를 고를 때 기준이 둘로 갈려요. 미뤄도 되는 작업이냐(디바운스), 미루면 안 되지만 양보해도 되는 작업이냐(useTransition)예요.
대부분의 검색이나 필터링 UX는 사실 두 가지를 섞어서 써요. API 호출은 디바운스로 줄이고, 클라이언트 렌더링은 useTransition으로 부드럽게 만드는 식이요. 한 쪽만 쓰는 것보다 체감이 훨씬 좋아져요.
새 기능 만들 때 처음부터 두 가지를 같이 떠올리는 습관이 생기면, 사용자 입력 끊김 문제로 디버깅하는 시간이 확 줄어들거든요. 비동기 호출 vs 동기 렌더링이라는 두 축으로 나눠서 생각해보면 어디에 뭘 끼울지가 자연스럽게 보여요.
'개발 > Frontend' 카테고리의 다른 글
| shadcn/ui 커스텀 테마 만들기 CSS 변수 가이드 (0) | 2026.06.09 |
|---|---|
| pnpm vs yarn 차이와 선택 기준 (0) | 2026.06.06 |
| React Hook Form Zod 폼 검증 실전 패턴 (0) | 2026.06.05 |
| Next.js 다크 모드 next-themes 적용법 (1) | 2026.06.04 |