rendering

최고의 검색결과 목록이란 무엇인가

디바운싱, 캐싱, useDeferredValue로 만드는 검색 UX

관련 자료

UX 목표

  1. 사용자의 타이핑은 검색폼에 빠르게 반영되어야 합니다.
  2. 검색 결과도 빠르게 나타나야 합니다.
    • 로딩 스피너는 노출하지 않습니다.
    • 새로운 결과를 불러오는 동안에는 로딩 대신 기존 데이터를 유지하여 화면 깜빡임을 일으키지 않습니다.

구체적인 동작 예시

  1. 사용자가 'a'를 입력하는 즉시 검색폼에 'a'가 표시되어야 합니다.
  2. 로딩 스피너 없이 'a형 독감', 'AI'와 같은 관련 키워드가 빠르게 리스트업되어야 합니다.
  3. 이후 'b'를 추가 입력하여 'ab'가 되는 경우:
    • 새로운 결과를 가져오는 동안 기존 'a'에 대한 검색 결과를 유지합니다.
    • 'ab'에 대한 결과가 준비되면 즉시 화면을 갱신합니다.
  4. 다시 'b'를 지워 'a'만 남은 경우:
    • 'a'의 검색 결과를 빠르게 다시 노출해야 합니다.

왜 로딩 스피너를 지양해야 하는가?

로딩 스피너나 스켈레톤 UI는 근본적으로 데이터 로딩이 느릴 때 사용자가 체감하는 대기 시간을 줄이기 위한 차선책입니다.

화면이 깜빡이는 듯한 부정적인 사용자 경험을 유발할 수 있습니다.

시스템 응답이 빠르다면, 기존 데이터를 유지하다가 새로운 데이터로 즉시 교체하는 방식이 더 자연스러운 경험을 제공합니다.

핵심 구현 기술

  1. API 호출 최적화: 입력 시마다 호출 vs 입력 완료 후 호출(Debouncing)
  2. 캐싱: 이전 검색 결과를 저장 후 Backspace 입력 시('abcd' → 'abc') 네트워크 요청 없이 즉시 데이터를 표시
  3. 렌더링 지연: 검색폼 반응이 제일 빠르도록 검색 결과 목록 렌더링을 미루기

API 호출 전략

사용자 입력('a' → 'b' → 'c') 시점에 따른 두 가지 전략이 있습니다.

  1. 입력 완료 후 호출: 'abc' 입력이 완료된 시점에 한 번만 API를 호출합니다. (Debouncing 적용)
  2. 입력 시마다 호출: 'a', 'ab', 'abc' 각 입력 단계마다 즉시 API를 호출합니다. (네이버 방식)

첫 번째 방식은 불필요한 API 호출을 줄여 서버 부하를 낮출 수 있으나, Debouncing 지연 시간만큼 화면 반응 속도가 느립니다.

두 번째 방식은 입력과 동시에 요청을 보내므로 화면 반응이 가장 빠르지만, 그만큼 서버 부하가 증가합니다.

결국 서버 부하사용자 경험(반응 속도) 사이의 균형을 고려하여 전략을 선택해야 합니다.

캐싱

Tanstack Query 등의 라이브러리를 활용하면, 캐싱 기능을 손쉽게 구현할 수 있습니다.

렌더링 지연

useDeferredValue를 사용하면 검색폼 렌더링을 우선 처리하고, 목록 렌더링은 뒤로 미룰 수 있습니다.

기술 원리 탐구

디바운싱은 내부적으로 어떻길래 API 호출 횟수를 지연시킬 수 있을까요?

// 핵심 원리: 마지막 호출 이후 일정 시간이 지나야 실행
function debounce(fn, delay) {
  let timeoutId;
  return (...args) => {
    clearTimeout(timeoutId);  // 이전 타이머 취소
    timeoutId = setTimeout(() => fn(...args), delay);  // 새 타이머 설정
  };
}

동작 흐름:

  1. 사용자가 'a' 입력 → 300ms 타이머 시작
  2. 100ms 후 'b' 입력 → 이전 타이머 취소, 새 300ms 타이머 시작
  3. 100ms 후 'c' 입력 → 이전 타이머 취소, 새 300ms 타이머 시작
  4. 300ms 경과 → 'abc'로 API 호출

결과: 타이핑이 끝난 후 300ms 뒤에 1번만 호출

useDeferredValue는 어떻게 동작하길래 렌더링을 지연시킬 수 있을까요?

const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
 
// query는 즉시 바뀜 → 검색폼에 바로 반영
<input value={query} onChange={e => setQuery(e.target.value)}/>
 
// deferredQuery는 긴급 렌더링 끝날 때까지 이전 값 유지 → memo된 컴포넌트는 리렌더링 스킵
<SearchResult query={deferredQuery}/>  // memo 필수

동작 흐름:

  1. setQuery('abc') → 렌더링 2번 스케줄링 (긴급 + 지연)
  2. 긴급: query = 'abc' 반영, deferredQuery는 아직 이전 값 → 검색폼만 업데이트
  3. 지연: deferredQuery = 'abc' 반영 → 리스트 업데이트 (타이핑 중이면 중단됨)

흔한 오해

useDeferredValue를 쓰면 API 호출 횟수가 줄어드나요?

아니요. 네트워크 요청은 그대로 발생합니다. 지연되는 건 렌더링뿐입니다.

useDeferredValue does not by itself prevent extra network requests.

useDeferredValue를 쓰면 렌더링이 빨라지나요?

아니요. 렌더링 속도 자체는 그대로입니다. 우선순위만 조절합니다.

This does not make re-rendering of the SlowList faster. However, it tells React that re-rendering the list can be deprioritized so that it doesn't block the keystrokes.

그럼 useDeferredValue를 왜 쓰나요?

검색폼과 검색결과 목록 중 검색폼 렌더링을 우선시하기 위해서입니다.

사용자 디바이스 성능이 좋지 않아 "키보드 입력 즉시 반영" vs "검색결과 목록 렌더링" 중 하나만 선택해야 하는 상황이라면, 키보드 입력이 바로 화면에 반영되는 것이 더 중요하다고 생각합니다.

검색결과 목록은 렌더링 비용이 크지 않은데, 굳이 써야 하나요?

사용자 디바이스가 좋다면 useDeferredValue도 지연 없이 동작하므로, 안 쓴 것과 동일합니다.

정리하면, 디바이스가 좋으면 영향 없고 나쁘면 개선해주므로, 안 써야 할 이유는 약간의 코드 복잡성 증가 외에는 없다고 생각합니다.

함께 챙겨야 하는 UX

검색어 하이라이트

사용자가 입력한 검색어가 결과 목록의 어느 부분과 일치하는지 시각적으로 강조해야 합니다. 일치하는 부분을 빠르게 인지할 수 있어 검색 효율이 높아집니다.

에러 처리

API 호출이 실패할 수 있습니다. 에러 발생 시 사용자에게 명확한 피드백과 재시도 옵션을 제공해야 합니다. React의 ErrorBoundary를 활용하면 선언적으로 에러 UI를 처리할 수 있습니다.

Race Condition 방지

네트워크는 요청 순서대로 응답이 오지 않을 수 있습니다. '가' 요청보다 '가나' 요청의 응답이 먼저 도착하면, 사용자는 '가나'를 입력했는데 '가'의 결과를 보게 됩니다.

React Query는 내부적으로 이를 처리하여 항상 최신 요청의 결과만 반영합니다.