블로그에 글이 몇 개 없을 때는 카테고리만으로도 원하는 글을 찾을 수 있습니다.
하지만 글이 조금씩 쌓이기 시작하면 검색 기능이 필요해집니다.
검색을 만든다고 하면 먼저 검색 엔진이나 클라이언트 라이브러리를 떠올릴 수도 있습니다.
Fuse.js를 붙일 수도 있고, 글 전체를 JSON으로 만들어 브라우저에서 검색할 수도 있습니다. 규모가 더 커지면 별도의 검색 서비스도 선택지가 됩니다.
하지만 지금 Fresh Notes의 글은 모두 서버에서 읽을 수 있는 Markdown 파일입니다.
이미 전체 글 목록을 가져오는 getPosts()도 만들어져 있습니다.
그렇다면 현재 규모에서는 검색을 위해 새로운 시스템을 추가할 이유가 크지 않습니다.
이번에는 기존 Markdown 데이터를 그대로 이용해 다음처럼 동작하는 검색을 만들어보겠습니다.
/search?q=fresh
↓
Fresh Server
↓
getPosts()
↓
제목 / 설명 / 태그 / 카테고리 검색
↓
관련도 순 정렬
↓
HTML Response검색 기능 자체에는 별도의 클라이언트 JavaScript가 필요하지 않습니다.
검색할 데이터는 이미 가지고 있습니다
앞선 글에서 Markdown 글 하나를 다음과 같은 데이터로 변환했습니다.
export interface PostSummary {
slug: string;
meta: PostMeta;
readingTime: number;
}각 글의 Front Matter에는 다음 정보가 있습니다.
title: "Fresh 2.3 Zero JS를 직접 확인해봤다"
description: "Fresh 2.3에서 Island가 없는 페이지를 확인합니다."
category: Fresh
tags:
- Fresh
- Zero JS
- Islands검색에 필요한 데이터가 이미 대부분 들어 있습니다.
처음에는 다음 네 가지를 대상으로 검색하기로 했습니다.
title
description
category
tags본문 전체 검색은 일단 제외했습니다.
글이 아직 많지 않은 상황에서는 제목과 메타데이터만으로도 충분하고, 검색 결과가 지나치게 넓어지는 것도 막을 수 있기 때문입니다.
검색 로직은 route와 분리합니다
검색 코드는 utils/search.ts에 만들었습니다.
먼저 검색 결과에 점수를 하나 추가합니다.
import { getPosts, type PostSummary } from "./posts.ts";
export interface SearchResult extends PostSummary {
score: number;
}score는 검색 결과의 관련도를 나타냅니다.
예를 들어 검색어가 제목에 들어간 글과 description 끝부분에 한 번 등장한 글을 같은 순서로 보여주고 싶지는 않습니다.
그래서 일치하는 위치에 따라 점수를 다르게 주기로 했습니다.
검색어를 먼저 정규화합니다
사용자가 입력한 검색어와 저장된 글 정보를 바로 비교하면 사소한 차이 때문에 검색 결과가 달라질 수 있습니다.
그래서 비교 전에 문자열을 정리합니다.
function normalize(value: string) {
return value
.normalize("NFKC")
.toLowerCase()
.trim();
}여기서는 세 가지 작업을 합니다.
normalize("NFKC")
→ Unicode 표현 정규화
toLowerCase()
→ 대소문자 차이 제거
trim()
→ 앞뒤 공백 제거예를 들어:
Fresh
fresh
FRESH는 모두 같은 검색어로 취급됩니다.
기술 블로그는 영어 키워드가 자주 등장하기 때문에 대소문자를 무시하는 것만으로도 검색이 훨씬 자연스러워집니다.
여러 단어도 검색할 수 있게 합니다
검색어 하나만 처리할 수도 있지만 실제로는 이런 검색도 자주 하게 됩니다.
fresh vite
zero js
islands architecture그래서 검색어를 공백 기준으로 나눕니다.
const keywords = query
.split(/\s+/)
.filter(Boolean);예를 들어:
"zero js"는:
["zero", "js"];가 됩니다.
현재 검색에서는 입력한 단어가 모두 존재하는 글만 결과에 포함하기로 했습니다.
const searchableText = [
title,
description,
category,
...tags,
].join(" ");
const matchesAll = keywords.every(
(keyword) => searchableText.includes(keyword),
);그래서:
fresh vite라고 검색했다면 fresh만 들어 있는 글은 제외되고, 두 단어가 모두 포함된 글만
남습니다.
현재처럼 글의 주제가 명확한 기술 블로그에서는 이 방식이 결과를 꽤 깔끔하게 만들어줍니다.
모든 일치 결과를 같은 순서로 보여주지는 않습니다
이제 검색 결과에 점수를 줍니다.
현재 기준은 다음처럼 잡았습니다.
제목 완전 일치 100
제목 시작 일치 70
제목 포함 50
태그 완전 일치 40
태그 부분 일치 25
카테고리 완전 일치 20
description 포함 15
카테고리 부분 일치 10코드로는 다음과 같습니다.
let score = 0;
for (const keyword of keywords) {
if (title === keyword) {
score += 100;
} else if (title.startsWith(keyword)) {
score += 70;
} else if (title.includes(keyword)) {
score += 50;
}
if (
tags.some((tag) => tag === keyword)
) {
score += 40;
} else if (
tags.some((tag) => tag.includes(keyword))
) {
score += 25;
}
if (
description.includes(keyword)
) {
score += 15;
}
if (category === keyword) {
score += 20;
} else if (
category.includes(keyword)
) {
score += 10;
}
}이 점수가 절대적인 검색 알고리즘은 아닙니다.
하지만 블로그에서는 꽤 합리적인 기준입니다.
독자가 Fresh를 검색했다면 제목에서 Fresh를 직접 다루는 글이 먼저 나오고,
단순히 description에 한 번 언급된 글은 그 뒤에 나오는 편이 자연스럽습니다.
완성된 검색 함수
전체 흐름을 합치면 다음과 같습니다.
export async function searchPosts(
rawQuery: string,
): Promise<SearchResult[]> {
const query = normalize(rawQuery);
if (!query) {
return [];
}
const keywords = query
.split(/\s+/)
.filter(Boolean);
const posts = await getPosts();
return posts
.map((post) => {
const title = normalize(post.meta.title);
const description = normalize(post.meta.description);
const category = normalize(post.meta.category);
const tags = post.meta.tags.map(normalize);
const searchableText = [
title,
description,
category,
...tags,
].join(" ");
const matchesAll = keywords.every((keyword) =>
searchableText.includes(keyword)
);
if (!matchesAll) {
return {
...post,
score: 0,
};
}
let score = 0;
for (const keyword of keywords) {
if (title === keyword) {
score += 100;
} else if (
title.startsWith(keyword)
) {
score += 70;
} else if (
title.includes(keyword)
) {
score += 50;
}
if (
tags.some(
(tag) => tag === keyword,
)
) {
score += 40;
} else if (
tags.some((tag) => tag.includes(keyword))
) {
score += 25;
}
if (
description.includes(keyword)
) {
score += 15;
}
if (category === keyword) {
score += 20;
} else if (
category.includes(keyword)
) {
score += 10;
}
}
return {
...post,
score,
};
})
.filter((post) => post.score > 0)
.sort((a, b) => {
if (b.score !== a.score) {
return b.score - a.score;
}
return (
new Date(b.meta.date).getTime() -
new Date(a.meta.date).getTime()
);
});
}점수가 높은 글을 먼저 보여주고, 점수가 같다면 최신 글을 먼저 보여줍니다.
검색 URL은 GET 방식으로 만들었습니다
검색 페이지는:
routes/search.tsx에 만들었습니다.
검색 폼은 특별할 것이 없습니다.
<form
action="/search"
method="get"
>
<input
type="search"
name="q"
placeholder="Fresh, Deno, Vite..."
/>
<button type="submit">
Search
</button>
</form>;JavaScript event handler가 없습니다.
브라우저의 기본 form 동작을 그대로 사용합니다.
예를 들어 fresh를 입력하고 검색하면 브라우저는:
/search?q=fresh로 이동합니다.
zero js라면:
/search?q=zero%20js가 됩니다.
이 방식의 장점 중 하나는 검색 결과 URL을 그대로 복사할 수 있다는 것입니다.
뒤로 가기나 새로고침도 자연스럽게 동작합니다.
검색 상태를 별도로 클라이언트 state에 저장할 필요도 없습니다.
Fresh route에서 검색어를 가져옵니다
서버에서는 URL의 query parameter를 읽습니다.
export const handler = define.handlers({
async GET(ctx) {
const query = ctx.url.searchParams
.get("q")
?.trim() ?? "";
const results = query ? await searchPosts(query) : [];
return {
data: {
query,
results,
},
};
},
});흐름은 다음과 같습니다.
GET /search?q=fresh
↓
ctx.url.searchParams
↓
"fresh"
↓
searchPosts("fresh")
↓
검색 결과그 결과를 Fresh가 서버에서 HTML로 렌더링합니다.
검색을 실행하기 위해 브라우저에서 별도의 검색 라이브러리가 동작할 필요가 없습니다.
검색 결과는 세 가지 상태로 나눴습니다
검색 페이지에는 크게 세 가지 상태가 있습니다.
아직 아무것도 검색하지 않은 상태
/search에서는 단순히 안내 문구를 보여줍니다.
<p>
검색어를 입력해 주세요.
</p>;결과가 없는 상태
/search?q=없는검색어라면:
<p>
“{query}”에 대한 검색 결과가 없습니다.
</p>;를 보여줍니다.
결과가 있는 상태
검색 결과가 존재하면:
<p>
“{query}” 검색 결과
{results.length}개
</p>;와 함께 글 목록을 출력합니다.
각 결과에는:
날짜
카테고리
Reading Time
제목
Description
Tags를 보여주도록 했습니다.
기존 홈과 카테고리에서 사용하던 데이터 그대로입니다.
검색 결과 페이지는 색인하지 않게 했습니다
검색 페이지에는 한 가지 SEO 설정을 추가했습니다.
<meta
name="robots"
content="noindex, follow"
/>;검색 결과는 query에 따라 사실상 무한히 많은 URL을 만들 수 있습니다.
/search?q=fresh
/search?q=vite
/search?q=deno
/search?q=zero
/search?q=test
...이 페이지들을 각각 검색엔진 결과에 노출할 이유는 없습니다.
실제로 검색엔진이 찾아야 하는 것은 검색 결과 페이지가 아니라 그 안에 있는 원본 글입니다.
그래서:
noindex
→ 검색 결과 페이지 자체는 색인하지 않음
follow
→ 검색 결과에 있는 글 링크는 따라갈 수 있음으로 설정했습니다.
Header의 Search도 이제 실제 페이지로 연결됩니다
처음 블로그 디자인을 만들었을 때 Header에는 이미 Search가 있었습니다.
하지만 당시에는 UI만 있고 기능은 구현하지 않은 상태였습니다.
이제 단순한 링크로 연결할 수 있습니다.
<a href="/search">
Search
</a>;흐름은 아주 단순합니다.
Header
↓
Search
↓
/search
↓
form submit
↓
/search?q=fresh
↓
Fresh Server
↓
검색 결과 HTML검색을 만들었지만 새로운 Island는 하나도 추가하지 않았습니다.
검색 기능 전체에 JavaScript가 꼭 필요한 것은 아닙니다
검색이라고 하면 자동완성이나 입력 즉시 결과가 바뀌는 UI를 먼저 떠올릴 수도 있습니다.
그런 기능에는 클라이언트 JavaScript가 필요합니다.
하지만 검색 자체에는 반드시 JavaScript가 필요한 것은 아닙니다.
이번 기능에 필요한 것은 사실 다음뿐입니다.
HTML form
GET request
Server search
HTML response웹의 기본 동작만으로 충분합니다.
Fresh의 Server-first 방식과도 잘 맞습니다.
물론 현재 사이트에는 ThemeToggle Island가 있기 때문에 페이지 전체가 항상 Zero JS라는 뜻은 아닙니다.
중요한 것은 검색 기능 자체를 위해 추가적인 클라이언트 JavaScript를 만들지 않았다는 점입니다.
검색 라이브러리를 사용하지 않은 이유
현재 검색 알고리즘은 상당히 단순합니다.
글이 수천 개가 된다면 문제가 될 수 있습니다.
매 요청마다 전체 Markdown 파일을 읽고 비교하는 방식은 데이터가 커질수록 효율이 떨어집니다.
또 다음 기능이 필요해질 수도 있습니다.
오타 허용
유사어
형태소 분석
본문 전문 검색
검색어 강조
자동완성
검색 통계그때는 다른 선택을 고려할 수 있습니다.
하지만 현재 Fresh Notes에는 아직 글이 많지 않습니다.
몇십 개의 Markdown 파일을 검색하기 위해 별도의 검색 서버부터 운영하는 것은 오히려 구조를 복잡하게 만듭니다.
현재 규모에서는:
기존 Markdown
+
getPosts()
+
간단한 scoring정도면 충분합니다.
필요해졌을 때 바꾸면 됩니다.
다음 단계에서는 캐시를 생각해볼 수 있습니다
현재 getPosts()는 파일 시스템에서 Markdown을 읽습니다.
검색 요청이 들어올 때마다 이를 다시 읽는 구조라면 글이 많아졌을 때는 최적화할 여지가 있습니다.
예를 들어 서버 시작이나 첫 요청에서 검색용 index를 만들어 메모리에 보관할 수도 있습니다.
Markdown
↓
검색용 Index 생성
↓
Memory
↓
검색 요청또는 build 단계에서 검색 데이터를 별도 파일로 만들어둘 수도 있습니다.
하지만 이것도 지금 당장 필요한 작업은 아닙니다.
성능 문제를 예상해서 미리 복잡한 시스템을 만드는 것보다 실제 문제가 생겼을 때 구조를 확장하는 편이 현재 블로그에는 더 적합합니다.
단순한 검색이 오히려 Fresh 구조를 잘 보여줍니다
이번 검색 기능에서 새로 추가한 것은 많지 않습니다.
utils/search.ts
routes/search.tsx
Header의 /search 링크나머지는 이미 가지고 있던 것을 재사용했습니다.
Markdown Front Matter
getPosts()
formatDate()
Fresh route
서버 렌더링앞선 글에서 Markdown 콘텐츠 시스템을 한 번 만들어둔 덕분입니다.
검색용 데이터베이스를 새로 만들지 않았고, 검색을 위해 모든 글 데이터를 브라우저로 내려보내지도 않았습니다.
사용자가 검색하면 서버가 필요한 결과를 계산하고 그 결과를 HTML로 돌려줍니다.
Fresh에서 계속 등장했던 질문을 검색에서도 그대로 적용할 수 있었습니다.
이 기능이 꼭 브라우저에서 실행되어야 하는가?
이번 검색의 답은 아니다였습니다.
그래서 일단 서버에 두었습니다.
나중에 검색 자동완성이나 ⌘K 검색창이 필요해지면 그 부분만 Island로 확장하면
됩니다.
Server-first에서 시작하고 필요한 부분만 브라우저로 보내는 Fresh의 방식이 작은 기능에서도 꽤 자연스럽게 이어집니다.
다음 글에서는 Fresh의 UI 계층인 Preact를 조금 더 자세히 살펴보겠습니다.
Fresh에서 Preact가 어디에 사용되고, React와 무엇이 비슷하고 다른지, 그리고 Fresh 2의 Islands와 어떤 관계를 가지는지 실제 프로젝트를 기준으로 정리해보겠습니다.
참고 자료
- Fresh 공식 문서, File Routing
- Fresh 공식 문서, Architecture
- Fresh 공식 문서, Islands
- Deno Web APIs, URLSearchParams