Astro 블로그에 검색 기능 구현하기: search.json.ts의 비밀
Astro로 만든 블로그에 검색 기능을 추가해 보았다.
이번 포스팅에서는 React 와 Tailwind CSS 를 활용한 가벼운 클라이언트 사이드 검색 구현법과 함께, 이 모든 걸 가능하게 해주는 search.json.ts 파일의 작동 원리를 깊이 파헤쳐 본다.
1. 검색 데이터의 심장, search.json.ts 완벽 이해하기
Astro는 기본적으로 정적 사이트 생성(SSG) 방식을 사용한다. 따라서 사용자가 검색을 요청할 때마다 백엔드 서버의 데이터베이스를 뒤지는 것은 Astro의 철학과 맞지 않는다. 대신, 블로그를 빌드할 때 검색용 데이터를 미리 만들어두고 브라우저에서 이를 필터링하는 방식을 사용한다.
이때 가장 핵심이 되는 파일이 바로 search.json.ts 이다.
Astro의 특별한 API 엔드포인트 규칙
Astro는 src/pages/ 폴더 안의 구조를 그대로 웹사이트의 주소(URL)로 만든다. 이때 파일 확장자가 .ts 나 .js 이면서 내부에 GET, POST 등의 HTTP 메서드 함수를 export 하면, Astro는 이를 일반적인 HTML 페이지가 아니라 API 엔드포인트 로 인식한다.
GET 함수는 언제, 어떻게 실행될까?
그렇다면 코드 안의 GET 함수는 언제 실행되어 데이터를 가져오는 걸까? 블로그가 배포되는 방식(SSG vs SSR)에 따라 확연히 달라진다.
-
빌드 시점 (SSG 모드 - 기본 블로그 방식) 대부분의 Astro 블로그는 이 방식을 사용한다. 터미널에서
npm run build를 실행하여 배포를 준비할 때, Astro는 프로젝트의 모든 파일을 훑는다.search.json.ts를 발견하면 그 즉시GET함수를 딱 한 번 실행 한다. 그 결과물을 바탕으로 진짜 물리적인 파일인search.json을 생성하여 빌드 결과물인dist/폴더에 구워낸다. 즉, 웹사이트가 배포된 후에는 무거운 함수가 다시 실행되는 것이 아니라, 이미 만들어진 가벼운 JSON 파일만 방문자에게 제공된다. -
개발 모드 또는 SSR 모드 로컬 환경에서
npm run dev로 띄워놓았거나, 서버 사이드 렌더링(SSR)을 사용 중이라면 이야기가 다르다. 브라우저 주소창에https://내블로그/search.json을 입력하거나 컴포넌트에서fetch를 요청할 때마다 실시간으로GET함수가 실행되어 데이터를 반환한다.
search.json.ts 작성하기
원리를 이해했으니, 실제로 데이터를 만들어 줄 코드를 작성해 보자.
// src/pages/search.json.ts
import { getCollection } from 'astro:content';
export async function GET() {
// 1. Astro의 Content Collections에서 모든 블로그 데이터를 가져온다.
const blogEntries = await getCollection('blog');
// 2. 본문을 제외하고 제목, 요약, 카테고리, 태그 등 검색에 '꼭 필요한 데이터'만 추출한다.
const searchList = blogEntries.map((entry) => ({
slug: entry.slug,
title: entry.data.title,
description: entry.data.description,
category: entry.data.category,
tags: entry.data.tags || [],
}));
// 3. 브라우저가 인식할 수 있도록 JSON 형식으로 응답을 보낸다.
return new Response(JSON.stringify(searchList), {
headers: { 'Content-Type': 'application/json' },
});
}
이렇게 하면 사용자가 브라우저에서 검색창에 타이핑을 시작할 때, 서버에 무리를 주지 않고 단 한 번의 요청 만으로 모든 검색 데이터를 빠르게 가져올 수 있다.
2. React 컴포넌트로 검색창 만들기 (핵심 로직)
데이터를 준비했으니, 화면에 보여질 검색창 UI와 필터링 로직을 담당할 React 컴포넌트를 만든다. 코드가 길어질 수 있으니, 스타일링과 렌더링 부분을 최소화하여 핵심 로직만 정리했다.
// src/components/Search.tsx
import { useState, useEffect } from 'react';
// ... (Post, SearchProps 타입 정의 생략) ...
export default function Search({ currentCategory }: SearchProps) {
const [query, setQuery] = useState('');
const [posts, setPosts] = useState<Post[]>([]);
const [results, setResults] = useState<Post[]>([]);
// 1. 컴포넌트 마운트 시 만들어둔 JSON 데이터를 한 번만 가져온다.
useEffect(() => {
const fetchPosts = async () => {
const response = await fetch('/search.json');
setPosts(await response.json());
};
fetchPosts();
}, []);
// 2. 검색어(query)나 카테고리가 변경될 때마다 결과를 필터링한다.
useEffect(() => {
if (!query.trim()) return setResults([]);
const lowerQuery = query.toLowerCase();
const isAllCategory = currentCategory?.toLowerCase() === 'all';
// 카테고리 필터링
let filteredPosts = (currentCategory && !isAllCategory)
? posts.filter(post => post.category === currentCategory)
: posts;
// 제목, 요약, 태그 기반 검색어 필터링
const finalResults = filteredPosts.filter(post =>
post.title.toLowerCase().includes(lowerQuery) ||
post.description?.toLowerCase().includes(lowerQuery) ||
post.tags?.some(tag => tag.toLowerCase().includes(lowerQuery))
);
setResults(finalResults);
}, [query, posts, currentCategory]); // posts가 비동기로 채워지므로 의존성 배열에 반드시 포함해야 한다.
return (
<div className="relative w-full max-w-md mx-auto mb-8">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="검색어를 입력하세요..."
className="w-full px-4 py-2 border rounded-lg" // Tailwind 클래스 생략
/>
{/* 검색 결과 목록 렌더링 */}
{results.length > 0 && (
<ul className="absolute z-10 w-full mt-2 bg-white border rounded-lg shadow-lg">
{results.map((post) => (
<li key={post.slug} className="p-3 border-b">
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
)}
</div>
);
}
3. 카테고리 페이지에 컴포넌트 적용하기
마지막으로 Astro 페이지 템플릿에 컴포넌트를 적용한다. Astro에서 React를 브라우저에서 실행되도록 만들려면 반드시 client:load 지시어를 추가해야 한다.
---
// src/pages/category/[category].astro
import Layout from "../../../layouts/Layout.astro";
import PostList from "../../../components/PostList.astro";
import Search from "../../../components/Search.tsx";
const { category } = Astro.params;
const { posts } = Astro.props;
---
<Layout title=`${category} | Blog`>
{/* client:load 지시어로 즉시 상호작용 가능하게 만듦 */}
<Search client:load currentCategory={category} />
<PostList posts={posts} />
</Layout>
이렇게 하면 전체 검색뿐만 아니라, 특정 카테고리 내에서만 작동하는 빠른 검색창이 완성된다.
이 포스팅은 AI의 도움을 받아 초안을 작성하고, 직접 검수 및 편집한 글입니다.