Live Search - 실시간 검색

중급 (Intermediate)예상 1.5시간

동작하는 전체 코드: examples/search/ — make test가 함께 돌리고, 릴리스 게이트(CI)가 태그마다 다시 돌리는 예제다.

이 튜토리얼에서는 실시간 검색 기능을 만들며 디바운스와 키보드 내비게이션을 학습합니다.

학습 목표#

  • .debounce 이벤트 수정자
  • focus_on() 포커스 관리
  • push_js(JS()) 클라이언트 명령
  • 키보드 내비게이션
  • 로딩 상태 표시

완성 미리보기#

검색어 입력 시 실시간으로 결과가 표시됩니다:

  • 입력 디바운스 (300ms)
  • 화살표 키로 결과 탐색
  • Enter로 선택
  • Escape로 닫기

1. 모델 정의#

search/models.py:

from django.db import models


class Book(models.Model):
    """검색 대상 책"""
    title = models.CharField(max_length=200)
    author = models.CharField(max_length=100)
    description = models.TextField(blank=True)
    category = models.CharField(max_length=50, blank=True)
    published_year = models.PositiveSmallIntegerField(null=True)

2. 컴포넌트 정의#

search/live.py:

from django.db.models import Q

from wireview import Component, JS

from .models import Book


class XLiveSearch(Component):
    """실시간 검색 컴포넌트"""

    class Meta:
        template_name = "search/live_search.html"

    query: str = ""
    results: list[Book] = []
    selected_index: int = -1  # 현재 선택된 결과
    is_open: bool = False     # 드롭다운 표시 여부
    selected_book: Book | None = None

    async def search(self, q: str):
        """검색 실행 (디바운스됨)"""
        self.query = q
        self.selected_index = -1

        if len(q) < 2:
            self.results = []
            self.is_open = False
            return

        # QuerySet은 await할 수 없다. async for로 모은다
        self.results = [
            book
            async for book in Book.objects.filter(
                Q(title__icontains=q) |
                Q(author__icontains=q) |
                Q(description__icontains=q)
            )[:10]
        ]
        self.is_open = len(self.results) > 0

    async def navigate(self, direction: int):
        """화살표 키로 결과 탐색"""
        if not self.results:
            self.skip_render()
            return

        max_index = len(self.results) - 1
        if self.selected_index == -1:
            self.selected_index = 0 if direction == 1 else max_index
        else:
            new_index = self.selected_index + direction
            if new_index < 0:
                self.selected_index = max_index
            elif new_index > max_index:
                self.selected_index = 0
            else:
                self.selected_index = new_index

    async def select_result(self, index: int):
        """결과 선택"""
        if 0 <= index < len(self.results):
            self.selected_book = self.results[index]
            self.is_open = False
            self.query = self.selected_book.title

    async def select_current(self):
        """현재 선택 항목 확정 (Enter)"""
        if self.selected_index >= 0:
            await self.select_result(self.selected_index)
        elif len(self.results) == 1:
            await self.select_result(0)

    async def close_dropdown(self):
        """드롭다운 닫기 (Escape)"""
        self.is_open = False
        self.selected_index = -1

    async def clear(self):
        """검색 초기화"""
        self.query = ""
        self.results = []
        self.selected_index = -1
        self.is_open = False
        self.selected_book = None

        # JS로 input 초기화 및 포커스
        await self.push_js(
            JS()
            .set_value(f"#{self.id} input[name=q]", "")
            .focus(f"#{self.id} input[name=q]")
        )

results와 selected_book은 모델 인스턴스를 그대로 담는다. 서명 상태에는 pk(목록)만 실리고, 다시 join할 때 타입 표기를 따라 다시 읽힌다(상세는 Todo 앱).

3. 템플릿#

search/templates/search/live_search.html:

{% load wireview %}

<div {% tag_header %}>
  <div class="search-wrapper">
    <div class="search-input-group">
      <input
        type="text"
        name="q"
        class="search-input"
        placeholder="Search books..."
        value="{{ query }}"
        autocomplete="off"
        {% on 'input.debounce.300' 'search' %}
        {% on 'keydown.key.ArrowDown.prevent' 'navigate' direction=1 %}
        {% on 'keydown.key.ArrowUp.prevent' 'navigate' direction=-1 %}
        {% on 'keydown.key.Enter.prevent' 'select_current' %}
        {% on 'keydown.key.Escape' 'close_dropdown' %}
      />
      {% if query %}
        <button type="button" {% on 'click' 'clear' %}>Clear</button>
      {% endif %}
    </div>

    {% if is_open %}
      <div class="dropdown">
        {% for book in results %}
          <div
            {% class {'dropdown-item': True, 'highlighted': forloop.counter0 == selected_index} %}
            {% on 'click' 'select_result' index=forloop.counter0 %}
          >
            <div class="book-title">{{ book.title }}</div>
            <div class="book-author">by {{ book.author }}</div>
          </div>
        {% endfor %}
      </div>
    {% endif %}
  </div>

  {% if selected_book %}
    <div class="selected-book">
      <h3>{{ selected_book.title }}</h3>
      <p>by {{ selected_book.author }}</p>
    </div>
  {% endif %}

  <div class="keyboard-hint">
    <kbd>&uarr;</kbd> <kbd>&darr;</kbd> Navigate &bull;
    <kbd>Enter</kbd> Select &bull;
    <kbd>Esc</kbd> Close
  </div>
</div>

4. 핵심 개념: 디바운스#

.debounce 수정자#

{% on 'input.debounce.300' 'search' %}

입력 후 300ms 동안 추가 입력이 없을 때만 search 호출합니다. 빠른 타이핑 시 불필요한 서버 요청을 방지합니다.

디바운스 vs 쓰로틀#

  • 디바운스: 마지막 이벤트 후 지정 시간 경과 시 실행
  • 쓰로틀: 지정 시간마다 최대 1회 실행
{% on 'input.debounce.300' 'search' %}   <!-- 입력 멈춘 후 300ms -->
{% on 'scroll.throttle.100' 'on_scroll' %} <!-- 100ms마다 최대 1회 -->

5. JS() 명령#

push_js()로 클라이언트에 JavaScript 명령을 보냅니다:

from wireview import JS

await self.push_js(
    JS()
    .set_value(f"#{self.id} input", "")  # input 값 비우기
    .focus(f"#{self.id} input")           # 포커스 이동
)

주요 JS 명령#

JS().set_value(selector, value)    # input 값 설정
JS().focus(selector)               # 포커스 이동
JS().show(selector)                # 요소 표시
JS().hide(selector)                # 요소 숨김
JS().toggle(selector)              # 토글
JS().add_class(selector, "class")  # 클래스 추가
JS().remove_class(selector, "cls") # 클래스 제거

6. 키보드 내비게이션 패턴#

{% on 'keydown.key.ArrowDown.prevent' 'navigate' direction=1 %}
{% on 'keydown.key.ArrowUp.prevent' 'navigate' direction=-1 %}

.prevent는 기본 동작(스크롤)을 방지합니다.

연습 문제#

  1. 검색 하이라이트: 검색어를 결과에서 강조 표시
  2. 최근 검색어: 최근 검색어 기록 표시
  3. 카테고리 필터: 카테고리별 필터링 추가

다음 단계#