전체 검색

글, 태그와 카테고리를 검색하고 바로 이동해요

블로그에 ⌘K를 달았어요 — 검색과 탐색 만들기

글이 서른 편을 넘기니 쓴 사람도 옛 글을 못 찾겠더라고요. ⌘K 팔레트, 한글이 부분 일치해야 하는 이유, 그리고 처음 온 사람을 위한 시리즈 페이지 — 찾아오는 세 가지 문을 만든 이야기예요.

2026. 07. 16. 15:32:229분 읽기프로젝트/arcaC/기술 노트
#rich-text#검색
조회 45
커맨드 팔레트가 떠오른 검색 장면 일러스트

제 블로그에서 옛 글을 못 찾은 사람은 독자가 아니라 저였어요.

글이 서른 편을 넘어가니까 "그거 어디 썼더라"가 시작됐습니다. 새 글을 쓰면서 이전 글을 참조하려는데 목록을 스크롤하고 있더라고요.

쓴 사람이 못 찾으면 독자는 당연히 못 찾죠. 그래서 찾는 방법을 세 가지로 나눠 만들었어요.

누구를 위한 것상태
⌘K 검색뭘 찾는지 아는 사람키워드가 머리에 있음
시리즈 페이지처음 온 사람어디부터 읽을지 모름
태그·카테고리비슷한 걸 더 보려는 사람한 편 읽고 흥미가 생김

첫 번째 문 — ⌘K

검색창을 화면 어딘가에 두는 대신 단축키로 띄우기로 했어요. 이유는 단순합니다. 검색은 화면을 보다가 갑자기 필요해지는 기능이거든요.

검색창이 항상 떠 있으면 자리를 차지하는데, 정작 대부분의 시간엔 안 씁니다. 그런데 필요한 순간엔 즉시 필요하고요.

그래서 평소엔 없고 부르면 즉시 나오는 팔레트로 만들었어요.

검색을 어디서 할까

팔레트를 띄우기로 정하자마자 다음 질문이 왔어요. 실제 검색은 어디서 도나.

방식장점안 맞았던 이유
DB 전문 검색규모가 커져도 버팀한글 형태소 사전 설정이 필요하고, 글 수십 편에는 과함
전체 목록을 내려받아 브라우저에서입력마다 즉시 반응글이 늘수록 첫 다운로드가 무거워짐
입력마다 서버 조회항상 최신한 글자 칠 때마다 요청이 나감
조회는 서버 · 결과는 잠깐 캐시둘의 절충캐시가 낡을 수 있음

마지막을 골랐어요. 그리고 캐시가 낡는 문제는 이미 풀어 둔 게 있었습니다 — 전환 속도 편에서 만든 세대 번호요. 저장이 일어나면 세대가 올라가고, 세대가 다르면 캐시가 통째로 무효가 됩니다.

먼저 만든 게 나중에 재료가 되는 순간이 가끔 있어요. 이번이 그랬습니다.

// 입력마다 요청하지 않습니다
const debounced = debounce(async (query) => {
  if (query.length < MIN_QUERY) return setResults([]);

  const cached = searchCache.getValid(query);   // 세대 검사 포함
  if (cached) return setResults(cached);

  const results = await searchApi.query(query);
  searchCache.set(query, results);
  setResults(results);
}, 120);

디바운스를 120ms로 잡은 건 타이핑 리듬 때문이에요. 이보다 짧으면 글자마다 요청이 나가고, 길면 다 치고 나서 멈칫하는 게 느껴집니다.

그런데 한글 검색이 문제였습니다

영어는 단어 단위로 끊기지만 한글은 안 그래요. 그리고 사람들은 이렇게 검색합니다.

입력찾고 싶은 글단순 검색이면
"프로젝션""CQRS식 콘텐츠 프로젝션"찾아짐
"프로젝"같은 글못 찾음
"콘텐츠 프로"같은 글못 찾음

두 번째 줄이 핵심이에요. 사람은 단어를 끝까지 안 칩니다. 앞 몇 글자만 치고 결과를 봐요.

그리고 한글은 조합 중인 글자도 있습니다. "프로젝ㅅ" 같은 상태요. 이 상태에서도 결과가 나와야 자연스럽거든요.

그래서 부분 일치를 기본으로 두고, 입력이 바뀔 때마다 다시 거릅니다. 정확히 일치하는 것을 위로 올리고요.

(형태소 분석 같은 건 안 넣었어요. 글 수십 편 규모에서는 과했습니다.)

그래서 판정을 이렇게 했어요. 한글에서 부분 일치가 기본이 되어야 하는 이유가 여기 있습니다.

// 비교 전에 양쪽을 같은 모양으로 만듭니다
const normalize = (s) =>
  s.normalize("NFC")        // 자모 분리된 입력을 합칩니다
   .toLowerCase()
   .replace(/\s+/g, "");    // 공백 차이를 무시

// "프로젝" 으로 "프로젝션" 이 걸려야 합니다
const matches = (text, query) =>
  normalize(text).includes(normalize(query));

첫 줄의 NFC가 은근히 중요해요. 같은 "가"라도 완성형 한 글자로 들어올 수도, 자모 두 개로 들어올 수도 있거든요. 특히 맥에서 복사한 텍스트가 그렇습니다. 정규화를 안 하면 눈에 똑같이 보이는 글자가 안 걸려요.

공백을 지운 것도 실전 대응이에요. "리치 텍스트"를 "리치텍스트"로 치는 사람이 절반쯤 됩니다.

형태소 분석 같은 건 안 넣었어요. 글 수십 편 규모에서는 과했고, 사전을 관리해야 하는 순간 유지비가 기능값을 넘습니다.

무엇을 검색 대상으로 넣을까

제목만 검색하면 부족하고, 본문 전체를 넣으면 결과가 시끄러워져요.

그래서 이렇게 정리했습니다.

대상포함 여부이유
제목포함제일 강한 신호
요약포함제목이 은유적일 때 보완
태그포함#태그로 바로 이동하고 싶은 경우
카테고리포함묶음 단위 탐색
본문 전체제외결과가 흐려짐

태그를 넣은 게 의외로 유용했어요. 검색 결과에 태그가 섞여 나오면 "이 주제로 묶인 글 전체"로 바로 갈 수 있거든요. 검색이 탐색으로 이어지는 거죠.

그리고 결과 종류가 섞이니까 무엇인지 구분이 필요했어요. 그래서 항목마다 작은 라벨을 붙였습니다. 글인지, 태그인지, 카테고리인지.

그리고 부분 일치만 하면 결과 순서가 뒤죽박죽이 돼요. 그래서 점수를 매겼습니다.

// 같은 "걸림"이라도 값이 다릅니다
function score(item, query) {
  const t = normalize(item.title);
  const q = normalize(query);

  if (t === q)            return 100;   // 제목이 정확히 일치
  if (t.startsWith(q))    return 80;    // 제목이 그 말로 시작
  if (t.includes(q))      return 60;    // 제목 중간에 포함
  if (item.tags.some(tag => normalize(tag).includes(q))) return 40;
  if (normalize(item.excerpt).includes(q))               return 20;
  return 0;
}
가중치를 안 주면생기는 일
제목과 본문이 동점제목에 그 말이 있는 글이 아래로 밀림
시작 일치와 중간 일치가 동점"프로젝"을 쳤는데 "리액트 프로젝션"이 위로
태그와 제목이 동점태그로 걸린 글이 제목으로 걸린 글보다 위로

두 번째 줄이 실제로 제일 거슬렸어요. 앞 글자를 치는 사람은 그 말로 시작하는 걸 찾는 중이거든요.

그리고 점수가 같으면 최신 글을 위로 올립니다. 동점 처리 규칙이 없으면 순서가 매번 달라져서, 같은 검색어를 두 번 쳤을 때 결과가 흔들려요.

선택 동작을 한 곳으로 모았습니다

여기서 코드가 지저분해질 뻔했어요. 결과 종류마다 눌렀을 때 갈 곳이 다르잖아요.

// 이렇게 갈 수도 있었지만
if (item.kind === "post") navigate(`/posts/${item.slug}`);
else if (item.kind === "tag") navigate(`/posts/tags/${item.slug}`);
else if (item.kind === "folder") navigate(`/posts/folders/${item.slug}`);

이 분기가 여러 군데 생기면 새 종류를 추가할 때마다 전부 찾아 고쳐야 해요. 키보드 선택, 마우스 클릭, 엔터 키가 각자 분기를 갖게 되고요.

그래서 선택 처리를 한 함수로 모았습니다. 어떤 방식으로 골랐든 같은 곳을 지나가요. 종류가 늘어나도 고칠 곳은 한 군데입니다.

선택 처리를 모은 모양이 이래요.

// 종류마다 갈 곳이 다르지만, 지나가는 문은 하나입니다
const DESTINATION = {
  post:     (item) => `/posts/${item.slug}`,
  tag:      (item) => `/posts/tags/${item.slug}`,
  category: (item) => `/posts/folders/${item.path}`,
  series:   (item) => `/series/${item.slug}`,
};

function onSelect(item) {
  const to = DESTINATION[item.kind];
  if (!to) throw new Error(`알 수 없는 결과 종류: ${item.kind}`);

  close();
  navigate(to(item));
}

// 키보드 Enter · 마우스 클릭 · 터치 — 전부 이 함수로 옵니다

마지막 줄이 목적이에요. 입력 수단마다 분기를 따로 두면 "마우스로는 되는데 키보드로는 안 되는" 버그가 반드시 생깁니다. 그리고 그런 버그는 만든 사람이 제일 늦게 발견해요. 저는 주로 키보드를 쓰니까요.

그리고 모르는 종류가 오면 조용히 넘어가지 않고 던집니다. 새 종류를 추가하고 목적지를 안 적으면 개발 중에 바로 걸리게요.

두 번째 문 — 시리즈 페이지

검색은 찾을 게 있는 사람을 위한 거예요. 그런데 처음 온 사람은 뭘 찾을지 모릅니다.

그 사람에게 최신 글 목록을 보여주면 어떻게 될까요. 20편짜리 연재의 17편을 먼저 만나게 됩니다. 앞뒤 맥락 없이요.

그래서 "순서대로 읽는 묶음"을 별도 페이지로 만들었어요. 시리즈마다 몇 편인지, 어떤 이야기인지, 어디서 시작하는지가 보이게요.

그리고 글 안에도 시리즈 내비게이션을 넣었습니다. 지금 몇 편을 읽고 있는지, 다음은 뭔지.

(이 페이지가 나중에 큰 값을 했어요. 시리즈를 5개에서 13개로 재편할 때, 독자가 볼 화면이 이미 있었거든요.)

세 번째 문 — 태그와 카테고리

마지막은 한 편 읽고 나서 "비슷한 거 더"가 생긴 사람이에요.

여기서 축을 두 개로 나눴습니다.

카테고리(폴더)태그
성격이 글이 어디 소속인가이 글이 무엇에 관한가
개수글마다 하나글마다 여러 개
구조계층 있음평평함
프로젝트 / arcaC / 아키텍처#성능 #캐시 #SSR

하나로 합치고 싶은 유혹이 계속 있었어요. 그런데 성격이 달라서 합치면 둘 다 이상해집니다. 카테고리를 여러 개 달면 "소속"이 아니게 되고, 태그에 계층을 넣으면 관리가 무거워지거든요.

키보드만으로 끝나야 합니다

⌘K로 여는 팔레트를 만들면 따라오는 기대가 있어요. 손이 키보드를 안 떠나는 것.

그래서 이런 것들이 다 필요했습니다.

동작놓치기 쉬운 것
↑↓결과 이동목록 끝에서 순환할지 멈출지
Enter선택조합 중인 한글이 확정되는 Enter와 구분
Esc닫기열려 있는 다른 것이 있으면 그것부터
⌘K (다시)토글이미 열려 있으면 입력창 재포커스

두 번째 줄이 한글 환경의 함정이에요. "프로젝션"을 치다가 Enter를 누르면, 그게 글자 조합을 끝내는 Enter인지 선택하는 Enter인지 구분해야 합니다. 안 그러면 첫 결과로 튀어버려요.

마지막 줄은 사소해 보이는데 체감이 커요. 검색하다가 다시 ⌘K를 누르는 건 대개 "처음부터 다시 치겠다"는 뜻이거든요.

키보드 처리에서 제일 까다로웠던 건 한글 조합이었어요.

// IME 조합 중인지 추적합니다
let composing = false;
input.addEventListener("compositionstart", () => { composing = true; });
input.addEventListener("compositionend",   () => { composing = false; });

input.addEventListener("keydown", (e) => {
  if (e.isComposing || composing) return;   // 조합 중 Enter 는 무시

  if (e.key === "Enter")     select(activeIndex);
  if (e.key === "ArrowDown") move(+1);
  if (e.key === "ArrowUp")   move(-1);
  if (e.key === "Escape")    close();
});

"프로젝션"을 치다가 Enter를 누르면, 그게 글자 조합을 끝내는 Enter인지 결과를 고르는 Enter인지 구분해야 해요. 안 걸러내면 조합을 끝내려는 순간 첫 결과로 튀어버립니다.

동작빠뜨리면
↑ ↓결과 이동마우스를 잡아야 함
Enter선택 (조합 중이면 무시)한글 입력 중에 엉뚱한 글로 이동
Esc닫기팔레트에 갇힘
⌘K 다시입력 비우고 다시 열기지우고 치는 두 동작이 필요

마지막 줄은 사소해 보이는데 체감이 커요. 검색하다가 다시 ⌘K를 누르는 건 대개 "처음부터 다시 치겠다"는 뜻이거든요.

결과가 없을 때가 제일 중요합니다

검색 기능에서 제일 자주 보게 되는 화면이 뭘까요. 저는 결과 없음이라고 봅니다. 오타를 내거나, 없는 걸 찾거나, 아직 덜 쳤을 때요.

그런데 대부분의 검색 UI가 여기에 제일 무성의해요. "결과가 없습니다" 한 줄이면 사용자는 막다른 길에 섭니다.

그래서 결과가 없을 때도 갈 곳을 뒀어요. 카테고리 목록이나 시리즈로요. 검색이 실패해도 탐색은 이어지게요.

(막다른 길을 만들지 않는 게 탐색 설계의 절반이더라고요.)

그리고 이 기능이 나중에 재사용됐어요

검색을 만들고 얼마 뒤에 내부 링크 기능을 만들었는데, 거기서 "글을 골라야 하는" 상황이 나왔습니다.

그때 검색 조회를 그대로 썼어요. 링크 팝오버에서 글을 찾는 것과 ⌘K로 글을 찾는 것은 화면만 다르지 같은 일이거든요.

기능이 아니라 조회를 재사용하면, 화면이 달라도 결과가 일관됩니다. 검색에서 안 나오는 글이 링크 팝오버에서는 나오는 일이 없어요.

재사용이 실제로 어떤 모양이었냐면, 화면이 아니라 조회를 공유한 거예요.

// 조회 하나
searchApi.query(text, { kinds: ["post", "tag", "category", "series"] })

// 화면 둘
<CommandPalette  />   // ⌘K — 전체 종류를 다 보여줌
<LinkPopover     />   // 내부 링크 — kinds: ["post"] 로 좁힘

// 결과가 항상 일치합니다

여기서 흔한 실수가 링크 팝오버용 조회를 따로 만드는 거예요. 요구가 조금 다르니까요. 그러면 검색에서는 나오는데 링크에서는 안 나오는 글이 생기고, 그 차이를 아무도 설명 못 하게 됩니다.

기능이 아니라 조회를 재사용하면, 화면이 달라도 결과가 일관돼요. 다르게 보여주고 싶으면 화면에서 좁히면 됩니다.

만들고 나서 안 것

세 문을 다 만들고 보니 공통점이 있었어요. 전부 독자가 어떤 상태인지에서 출발했다는 거예요.

찾을 게 명확한 사람, 아무것도 모르는 사람, 방금 흥미가 생긴 사람. 기능이 아니라 상태로 나누니까 무엇을 만들지가 자동으로 정해졌습니다.

반대로 기능부터 생각했다면 "검색 기능 하나"로 끝났을 거예요. 그리고 처음 온 사람은 여전히 길을 잃었겠죠.

탐색 기능은 무엇을 찾느냐가 아니라, 찾는 사람이 어떤 상태냐로 나뉘더라고요.


찾아오는 문은 열렸어요. 그런데 이 블로그에는 아직 독자의 흔적이 안 남고 있었습니다. 그 이야기는 "독자가 생겼다" 시리즈에 있어요.

시리즈 이어읽기

찾아오게 만들기

2 / 2

이야기 나누기

반응을 남기고 새 글 소식을 받아보세요.

댓글0
서로를 존중하는 대화를 남겨주세요.

첫 댓글을 남겨 대화를 시작해보세요.

새 글 구독
새 글이 공개되면 이메일로 알려드려요.