사이트 내 검색 화면 구현 — URL 상태, 하이라이트, 그리고 빈 결과

프론트엔드

검색 UIURL 상태자동완성하이라이트접근성

이 글은 누구를 위한 것인가

  • 서비스 안에 검색을 붙이는데 프론트엔드 쪽 설계를 정하려는 팀
  • 검색 결과가 가끔 이전 쿼리의 것으로 남는 버그를 겪은 개발자
  • 검색 결과 페이지를 색인시킬지 말지 결정해야 하는 경우

들어가며

검색 기능의 백엔드는 대체로 검색 엔진에 맡긴다. 남는 문제는 전부 프론트엔드에 있다.

  · 타이핑할 때마다 요청을 보낼 것인가
  · 결과가 도착 순서와 다르게 오면
  · 뒤로가기를 누르면 이전 검색으로 돌아가야 하는가
  · 새로고침하면 검색어가 유지되는가
  · 결과가 0건일 때 무엇을 보여줄 것인가
  · 이 페이지를 검색엔진이 색인해도 되는가

이 여섯 개는 검색 엔진을 무엇으로 쓰든 똑같이 나온다. 이 글은 그 목록이다.


1. 검색어는 URL에 있어야 한다

가장 먼저 정할 것은 검색 상태를 어디에 두는가다.

[컴포넌트 상태만]           [URL 쿼리]

  useState('')                ?q=캔버스&page=2
  · 새로고침하면 사라짐        · 새로고침해도 유지
  · 공유 불가                  · 링크 공유 가능
  · 뒤로가기 동작 안 함        · 브라우저 히스토리와 일치

검색어는 URL에 둔다. 예외가 거의 없다. 검색 결과를 공유하거나 북마크하는 건 사용자가 당연히 기대하는 동작이다.

다만 타이핑 한 글자마다 히스토리에 쌓이면 안 된다.

// 타이핑 중 — 주소만 갱신, 히스토리 항목은 만들지 않음
function onType(next: string) {
  setQuery(next);
  const sp = new URLSearchParams(location.search);
  next ? sp.set('q', next) : sp.delete('q');
  sp.delete('page');                       // 검색어가 바뀌면 1페이지로
  window.history.replaceState(null, '', `${location.pathname}?${sp}`);
}

// 확정(엔터·결과 클릭) — 히스토리에 남김
function onSubmit(q: string) {
  const sp = new URLSearchParams({ q });
  window.history.pushState(null, '', `${location.pathname}?${sp}`);
}

replaceStatepushState의 차이가 여기서 결정적이다. replaceState는 현재 항목을 덮어쓰므로 뒤로가기 스택이 오염되지 않는다. 타이핑마다 pushState를 하면 뒤로가기를 20번 눌러야 이전 화면으로 나간다.


2. 요청 경쟁 상태 — 가장 흔한 버그

입력할 때마다 요청을 보내면, 응답이 보낸 순서대로 오지 않는다.

  t=0    "캔"   요청 A 발송
  t=100  "캔버"  요청 B 발송
  t=250  요청 B 응답 도착 → 화면에 "캔버" 결과
  t=400  요청 A 응답 도착 → 화면이 "캔" 결과로 덮임   ← 버그

사용자에게는 "입력한 것과 다른 결과가 나온다"로 보이고, 재현이 불규칙해서 원인을 찾기 어렵다.

해법은 이전 요청을 취소하는 것이다.

const controllerRef = useRef<AbortController>();

async function search(q: string) {
  controllerRef.current?.abort();                  // 이전 요청 취소
  const ctrl = new AbortController();
  controllerRef.current = ctrl;

  try {
    const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
      signal: ctrl.signal,
    });
    setResults(await res.json());
  } catch (e) {
    if ((e as Error).name !== 'AbortError') throw e;  // 취소는 정상 흐름
  }
}

취소만으로 부족한 경우(취소가 서버에 도달하기 전 응답이 오는 등)를 대비해 순번 검사를 함께 두면 확실하다.

const seq = useRef(0);

async function search(q: string) {
  const my = ++seq.current;
  const data = await fetchResults(q);
  if (my !== seq.current) return;         // 더 최신 요청이 있으면 버림
  setResults(data);
}

디바운스는 요청 수를 줄이는 것이지 이 문제를 해결하지 않는다. 디바운스를 걸어도 느린 네트워크에서는 순서가 뒤집힌다. 둘 다 필요하다.

  debounce 200~300ms   →  요청 수 감소
  abort + 순번 검사    →  순서 보장

3. 결과 하이라이트 — 서버에서 받되 안전하게

검색어가 결과의 어디에 있는지 표시하면 사용자가 훑는 속도가 빨라진다.

  ❌ 클라이언트에서 문자열 치환
     · 형태소 분석 결과와 어긋남 ("캔버스"로 찾았는데 "캔버"만 하이라이트)
     · 대소문자·정규화 처리를 다시 구현해야 함

  ✅ 검색 엔진의 하이라이트 기능 사용
     · 실제 매칭된 토큰 위치를 알려줌

Elasticsearch의 전문 검색을 비롯한 대부분의 엔진이 하이라이트 결과를 제공한다. 문제는 그게 HTML 조각으로 온다는 점이다.

{
  "highlight": {
    "content": ["자유 배치 <em>캔버스</em>에서 카드를 …"]
  }
}

이걸 그대로 dangerouslySetInnerHTML에 넣으면 XSS다. 원본 콘텐츠에 스크립트가 섞여 있으면 그대로 실행된다.

// 안전한 처리 — 태그를 파싱해 React 요소로 변환
function renderHighlight(fragment: string) {
  const parts = fragment.split(/(<em>|<\/em>)/);
  const out: React.ReactNode[] = [];
  let on = false;
  for (const p of parts) {
    if (p === '<em>') { on = true; continue; }
    if (p === '</em>') { on = false; continue; }
    if (!p) continue;
    out.push(on ? <mark key={out.length}>{p}</mark> : p);   // 텍스트로 삽입 = 이스케이프됨
  }
  return out;
}

<mark>를 쓰는 것이 의미상 맞다. MDN의 <mark> 문서에 정의된 대로 "참조 목적으로 표시된 부분"을 나타내며, 보조기술도 이를 인식한다. <span class="highlight">는 시각 효과만 준다.

검색 엔진의 하이라이트 태그를 <em> 같은 흔한 태그 대신 충돌하지 않는 문자열로 설정하면 파싱이 더 안전하다.

{ "highlight": { "pre_tags": [""], "post_tags": [""] } }

4. 빈 결과 — 세 종류를 구분한다

"결과 없음"은 하나가 아니다.

[A] 검색어가 없음 (초기 상태)
    → 인기 검색어, 추천 콘텐츠를 보여준다

[B] 검색했지만 0건
    → 오타 교정 제안, 필터 해제, 유사 검색어

[C] 필터 때문에 0건
    → 어느 필터를 풀면 몇 건인지 제시

A를 빈 화면으로 두는 것이 가장 흔한 낭비다. 검색창에 들어온 사용자는 이미 무언가를 찾고 있으므로, 그 자리에 진입점을 제공하면 검색어를 입력하지 않고도 목적지에 도달한다.

디렉터리형 서비스의 검색 화면이 이 패턴을 쓴다. Linkme의 검색 화면은 검색어를 넣기 전에 "지금 인기 있는 페이지"를 먼저 보여주는데, 검색어를 떠올리지 못한 사용자에게 예시를 주는 역할을 한다. 빈 검색창 앞에서 이탈하는 비율을 줄이는 가장 저렴한 방법이다.

B의 처리는 구체적일수록 좋다.

  '캔버스 에디토' 검색 결과가 없습니다

  이렇게 찾아보세요
    · '캔버스 에디터'로 검색      ← 오타 교정
    · '캔버스'만으로 검색 (24건)  ← 범위 확대, 건수 표시
    · 전체 카테고리에서 검색 (31건)

건수를 함께 보여주는 것이 핵심이다. 선택지에 결과 수가 없으면 사용자는 또 헛수고할까 봐 누르지 않는다.


5. 자동완성 — 키보드가 먼저다

자동완성 목록은 마우스로만 쓰는 게 아니다.

[필수 키보드 동작]

  ↓ / ↑     후보 이동 (끝에서 순환할지 멈출지 정할 것)
  Enter     선택
  Esc       목록 닫기 (입력값은 유지)
  Tab       목록 닫고 다음 요소로

Esc에서 입력값까지 지우면 안 된다. 사용자는 목록만 닫으려 한 것인데 타이핑한 게 사라지면 다시 쳐야 한다.

접근성 마크업은 ARIA combobox 패턴을 따른다.

<input
  role="combobox"
  aria-expanded="true"
  aria-controls="sug-list"
  aria-activedescendant="sug-2"
  aria-autocomplete="list"
/>
<ul id="sug-list" role="listbox">
  <li id="sug-1" role="option" aria-selected="false">캔버스 에디터</li>
  <li id="sug-2" role="option" aria-selected="true">캔버스 좌표계</li>
</ul>

포커스는 입력창에 두고 aria-activedescendant로 활성 항목을 가리키는 것이 요령이다. 실제 포커스를 목록 항목으로 옮기면 타이핑이 끊긴다.

[모바일 추가 고려]

  · 가상 키보드가 목록을 가리지 않는지 (visualViewport로 높이 계산)
  · 항목 높이 44px 이상
  · 자동완성 요청은 데이터 사용량을 고려해 디바운스를 길게 (400ms)

6. 검색 결과 페이지를 색인시킬 것인가

기본 답은 아니오다.

[검색 결과 페이지를 색인시키면]

  · 조합이 무한 (?q=아무거나)
  · 대부분 얇거나 0건인 페이지
  · 크롤 예산을 대량 소모
  · 사용자가 검색 결과에서 또 검색 결과로 이동 — 나쁜 경험
export const metadata: Metadata = {
  robots: { index: false, follow: true },
};

follow: true는 유지한다. 색인은 안 하되 결과에 걸린 링크는 따라가게 해야, 검색을 통해서만 도달 가능한 콘텐츠가 고립되지 않는다.

예외가 하나 있다. 검색어가 아니라 미리 정한 주제 목록이라면 그건 검색 결과가 아니라 카테고리 페이지다.

  /search?q=캔버스            → noindex   (사용자 입력)
  /topics/canvas-editor       → index     (편집자가 만든 큐레이션)

둘을 URL 형태로 구분해두면 정책이 단순해진다. 인기 검색어를 카테고리 페이지로 승격하는 흐름을 만들면, 검색 수요를 색인 가능한 자산으로 바꿀 수 있다.


7. 성능 — 체감을 결정하는 것

[검색 화면의 체감 지연 구간]

  입력 → 요청 발송        디바운스 (200~300ms)
  요청 → 응답             네트워크 + 엔진
  응답 → 화면 갱신        렌더링

  → 총합이 500ms를 넘으면 "느리다"고 느낀다

디바운스를 줄이면 요청이 늘고, 늘리면 느려진다. 중간을 찾는 대신 구조를 바꾸는 방법이 있다.

  · 이전 결과를 남긴 채 새 결과를 덮어쓴다 (빈 화면을 보여주지 않기)
  · 로딩 표시는 200ms 이상 걸릴 때만 (짧은 응답에 스피너가 번쩍이는 것 방지)
  · 최근 검색 결과를 클라이언트에 캐시 (뒤로가기 시 즉시 표시)
  · 첫 화면 분량만 렌더하고 나머지는 스크롤 시

첫 번째가 가장 효과가 크다. 검색어를 고칠 때마다 화면이 비었다 채워지면 실제보다 훨씬 느리게 느껴진다.

// 이전 결과를 유지한 채 갱신
const [results, setResults] = useState<Item[]>([]);
const [pending, setPending] = useState(false);

// 로딩 중에도 results는 그대로 두고, 흐리게 처리
<ul style={{ opacity: pending ? 0.6 : 1 }}>...</ul>

8. 정리

  1. 검색어는 URL에 — 타이핑은 replaceState, 확정은 pushState
  2. abort + 순번 검사로 경쟁 상태 차단 (디바운스로는 부족)
  3. 하이라이트는 엔진에서 받되 파싱해서 <mark>로 (innerHTML 금지)
  4. 빈 결과는 세 종류 — 초기 상태에 진입점을 둔다
  5. 자동완성은 키보드 먼저, aria-activedescendant 사용
  6. 검색 결과는 noindex, follow — 큐레이션 페이지와 URL로 구분
  7. 갱신 시 이전 결과를 지우지 않는다

버그로 가장 많이 이어지는 건 2번이고, 체감 개선이 가장 큰 건 7번이다. 둘 다 검색 엔진을 무엇으로 바꾸든 그대로 남는 문제다.