이 글은 누구를 위한 것인가
- 서비스 안에 검색을 붙이는데 프론트엔드 쪽 설계를 정하려는 팀
- 검색 결과가 가끔 이전 쿼리의 것으로 남는 버그를 겪은 개발자
- 검색 결과 페이지를 색인시킬지 말지 결정해야 하는 경우
들어가며
검색 기능의 백엔드는 대체로 검색 엔진에 맡긴다. 남는 문제는 전부 프론트엔드에 있다.
· 타이핑할 때마다 요청을 보낼 것인가
· 결과가 도착 순서와 다르게 오면
· 뒤로가기를 누르면 이전 검색으로 돌아가야 하는가
· 새로고침하면 검색어가 유지되는가
· 결과가 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}`);
}
replaceState와 pushState의 차이가 여기서 결정적이다. 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번이다. 둘 다 검색 엔진을 무엇으로 바꾸든 그대로 남는 문제다.