Astro에서 블로그 검색/필터를 가장 ‘Astro답게’ 구현하는 방법

2026년 1월 6일 오전 1:26

오늘은 Astro를 쓰면서 생겼던 질문들 —
“프레임워크 불가지론은 어떻게 가능한가?”, “섬(Islands) 방식에서 번들은 어떻게 줄어드는가?”,
그리고 최종적으로 **“블로그 글 검색/필터를 어떻게 구현하면 Astro의 의미가 유지되는가?”**를 한 흐름으로 정리했다.

결론은 간단하다:
리스트는 Astro로 정적으로 렌더링하고(HTML-first), 검색/필터는 바닐라 JS로 DOM을 숨김/표시만 하자.
글이 1000개 이하라면 이 방식은 충분히 빠르고, Astro의 장점을 극대화한다.


1) Astro의 “프레임워크 불가지론”은 어떤 의미인가?

Astro가 프레임워크 불가지론(framework-agnostic)이라는 말은:

  • Astro의 기본 렌더링 모델이 React/Vue/Svelte 같은 특정 프레임워크에 종속되지 않고,
  • 필요할 때만 특정 프레임워크 컴포넌트를 섬(island) 형태로 끼워 넣을 수 있다는 의미다.

예를 들어 아래처럼 한 프로젝트에서 다양한 컴포넌트를 “섬”으로 사용할 수 있다:

  • src/components/Button.tsx (React)
  • src/components/Widget.svelte (Svelte)

그런데 이렇게 섞으려면 의존성이 늘지 않나?

맞다. .tsx를 쓰면 react, react-dom과 통합 패키지(@astrojs/react)가 필요하고,
.svelte를 쓰면 svelte와 통합 패키지(@astrojs/svelte)가 필요하다.

하지만 핵심은:

  • 의존성이 늘어나는 것(node_modules)
  • 클라이언트 번들이 늘어나는 것(브라우저로 전달되는 JS)

이 둘은 다르다.

Astro는 기본적으로 HTML을 먼저 만들고, 클라이언트 JS는 필요한 섬에만 붙인다.
즉, 프로젝트 안에 React/Svelte가 공존할 수는 있어도, 브라우저로는 실제로 hydrate 되는 섬에 필요한 런타임만 나갈 수 있다.


2) React 섬은 “전체 DOM”을 diff하나?

질문 요지:

작은 버튼 하나를 React로 섬(hydration)하면
React가 페이지 전체 DOM을 비교(diff)하나?
아니면 그 버튼 subtree만 비교하나?

정답:

  • React는 페이지 전체 DOM을 기준으로 비교하지 않는다.
  • React가 hydrate한 “섬의 루트 컨테이너” 아래 subtree에서만 리렌더/리컨실리에이션이 일어난다.

즉, 버튼 하나면 사실상 버튼 subtree에 한정되어 작동한다.

다만 Svelte vs React의 체감 차이는 보통 diff 방식보다:

  • 초기 로드(JS 런타임 크기)
  • hydration 초기 비용

에서 더 크게 나타나는 경우가 많다.


3) Astro에서 JS 인터랙션은 보통 어떻게 구현하나?

블로그/문서/뉴스 같은 정적 콘텐츠 중심 프로젝트에서는 흔히 이렇게 간다:

✅ 작은 인터랙션

  • 다크모드 토글
  • 코드 Copy 버튼
  • TOC 접기/펼치기
  • 간단한 탭/아코디언

👉 이런 건 바닐라 JS로 처리하는 경우가 많다.

✅ 조금 복잡한 위젯(상태가 얽힘)

  • 검색바 + 결과 리스트
  • 태그 필터 + 정렬
  • 자동완성

👉 이건 섬(island)으로 프레임워크를 올리는 것도 흔한 선택이다.


4) 라우팅: pages/about.astro/about/로 가는 이유

Astro 자체가 강제로 /about/를 강제한다기보단,
정적 배포에서 결과물이 보통 이렇게 나오기 때문이다:

  • dist/about/index.html

정적 서버는 /about를 “파일”로 보려다가, 폴더 구조가 있으면 /about/로 정규화(redirect)하는 경우가 많다.


5) 블로그 글 검색은 Astro vs React 중 뭐가 나은가?

“블로그 글 검색” (Content Collections)이라면 보통 Astro가 더 유리

  • 검색은 페이지 전체가 아니라 상단 섹션 + 리스트 영역만 인터랙티브면 된다.
  • Astro는 HTML-first라서 기본 페이지 성능(SEO, 초기 렌더링)이 좋다.
  • React로 전체를 SPA처럼 만들면 검색은 편해져도 불필요한 JS 비용이 커질 수 있다.

6) 내가 원하는 UI: “상단 검색/필터 → 하단 포스트 리스트가 즉시 변경”

스크린샷처럼:

  • 상단에 검색바/필터
  • 아래엔 날짜 그룹 헤더 + 글 리스트
  • 검색/태그/날짜 필터가 바뀌면 아래 리스트가 즉시 바뀌어야 함

여기서 흔한 고민:

그럼 검색 섹션 + 리스트를 통째로 React 섬으로 만들면
Astro의 의미가 퇴색되는 거 아닌가?

✅ 맞다. 그래서 “Astro답게” 하려면 리스트는 Astro로 유지한다.

권장 구조(오늘의 결론):

  1. 리스트(포스트 카드/날짜 그룹)는 Astro가 정적으로 렌더링
  2. 검색/필터는 바닐라 JS로 이벤트 처리
  3. 필터 결과는 DOM에서 hidden 토글로 표시/숨김

이 방식이면:

  • JS 없이도 리스트는 초기부터 보임 (HTML-first 유지)
  • 클라이언트 JS는 필터 컨트롤만 담당
  • 글 1000개 이하라면 충분히 빠름

7) 성능: 글 1000개 이하라면 DOM 필터링 괜찮나?

결론: 대부분 충분히 괜찮다.
다만 중요한 건 “DOM 업데이트 비용”을 최소화하는 것.

필수 최적화 포인트

  • 검색 입력은 debounce(80~150ms)
  • DOM 업데이트는 el.hidden = true/false 또는 class 토글로 최소화
  • dataset을 매번 읽지 말고, 초기에 {date, tags[], searchText, el} 형태로 메모리 캐싱
  • 날짜 그룹 섹션도 “보이는 글이 0개면” 같이 숨기기

8) 최종 구현: SearchBar.astro + 정적 리스트 + 바닐라 JS 필터링

설계 개요

  • index.astro에서:
    • getCollection("blog")로 글 로드
    • 날짜별 그룹 렌더링
    • 각 글 카드에 data-date, data-tags, data-search를 심는다
  • SearchBar.astro에서:
    • 검색 입력, 태그 필터, 캘린더 UI 렌더
    • 페이지의 [data-post-item]를 찾고 메모리 캐싱
    • 필터 변경 시 applyFilters() 실행 → hidden 토글

필터 데이터 구조 예시

각 글 카드에 박는 데이터:

  • data-date="YYYY-MM-DD"
  • data-tags="tag1,tag2,tag3"
  • data-search="title description tags..." (lower-case)

이걸로:

  • 텍스트 검색: includes
  • 태그 필터: AND/OR 조건
  • 날짜 필터: YYYY-MM-DD 매칭

로직 Flow 요약

✅ 서버(Astro)

  1. 글 목록 로드 → 정렬/그룹핑
  2. HTML로 리스트 렌더링(SEO/초기 렌더링 강점)
  3. 각 글/그룹에 data-* 주입
  4. 태그 목록, 글 있는 날짜 목록을 SearchBar.astro props로 전달

✅ 클라이언트(JS)

  1. DOM에서 글 카드들을 찾고 캐싱
  2. 검색/태그/날짜 UI 이벤트 연결
  3. 필터 변경 시 applyFilters()
  4. 매칭 안 되는 글은 hidden=true
  5. 날짜 그룹 섹션도 내부에 보이는 글 없으면 숨김
  6. 결과 카운트 갱신

마무리

Astro로 블로그를 만들 때 검색/필터는 “무조건 프레임워크 섬”으로 만들 필요가 없다.
특히 글 1000개 이하라면:

  • 리스트는 Astro가 정적으로 렌더링
  • 검색/필터는 바닐라 JS로 DOM 숨김/표시

이 방식이 가장 Astro의 철학(HTML-first, 최소 JS)을 살리면서도 충분히 좋은 UX를 만든다.


다음에 개선할 수 있는 확장 아이디어

  • 필터 패널을 드로어/모달로 변경
  • URL 쿼리(?q=&tags=&date=)와 동기화 (공유/뒤로가기 강화)
  • 본문까지 진짜 풀텍스트 검색이 필요하면 JSON 인덱스 + 클라이언트 검색(Fuse/MiniSearch/Pagefind)로 확장