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

제 블로그에서 옛 글을 못 찾은 사람은 독자가 아니라 저였어요.
글이 서른 편을 넘어가니까 "그거 어디 썼더라"가 시작됐습니다. 새 글을 쓰면서 이전 글을 참조하려는데 목록을 스크롤하고 있더라고요.
쓴 사람이 못 찾으면 독자는 당연히 못 찾죠. 그래서 찾는 방법을 세 가지로 나눠 만들었어요.
| 문 | 누구를 위한 것 | 상태 |
|---|---|---|
| ⌘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"] 로 좁힘
// 결과가 항상 일치합니다여기서 흔한 실수가 링크 팝오버용 조회를 따로 만드는 거예요. 요구가 조금 다르니까요. 그러면 검색에서는 나오는데 링크에서는 안 나오는 글이 생기고, 그 차이를 아무도 설명 못 하게 됩니다.
기능이 아니라 조회를 재사용하면, 화면이 달라도 결과가 일관돼요. 다르게 보여주고 싶으면 화면에서 좁히면 됩니다.
만들고 나서 안 것
세 문을 다 만들고 보니 공통점이 있었어요. 전부 독자가 어떤 상태인지에서 출발했다는 거예요.
찾을 게 명확한 사람, 아무것도 모르는 사람, 방금 흥미가 생긴 사람. 기능이 아니라 상태로 나누니까 무엇을 만들지가 자동으로 정해졌습니다.
반대로 기능부터 생각했다면 "검색 기능 하나"로 끝났을 거예요. 그리고 처음 온 사람은 여전히 길을 잃었겠죠.
탐색 기능은 무엇을 찾느냐가 아니라, 찾는 사람이 어떤 상태냐로 나뉘더라고요.
찾아오는 문은 열렸어요. 그런데 이 블로그에는 아직 독자의 흔적이 안 남고 있었습니다. 그 이야기는 "독자가 생겼다" 시리즈에 있어요.


