Gatsby 블로그 검색 기능 개선 — AND/OR 검색, 관련도 정렬까지

@JavaPark · April 05, 2026 · 9 min read

안녕하세요, 자바파커입니다.

블로그에 검색 기능이 있긴 한데, 정작 원하는 글을 찾기가 어렵습니다.

Gatsby 블로그의 기본 검색은 단순 문자열 매칭이라 태그로 검색도 안 되고, 결과 정렬도 날짜순이라 원하는 글을 찾기 힘들었습니다. Algolia 같은 외부 서비스를 쓸 수도 있지만, 개인 블로그에 과한 느낌이 들어서 직접 개선해봤습니다.


기존 검색의 한계

gatsby-starter-hoodie 테마의 기본 검색 코드를 보면:

const filteredPosts = posts.filter(post => {
  const { title } = post.frontmatter
  const lowerQuery = query.toLocaleLowerCase()

  if (rawMarkdownBody.toLocaleLowerCase().includes(lowerQuery)) return true
  return title.toLocaleLowerCase().includes(lowerQuery)
})

문제점은 명확합니다.

문제 설명
태그 검색 불가 태그가 검색 대상에 없음
관련도 정렬 없음 제목 매칭이든 본문 매칭이든 동일 취급, 날짜순 정렬
복합 검색 불가 여러 키워드로 AND/OR 검색 불가
피드백 부족 어디서 매칭됐는지, 결과가 없을 때 안내 없음

개선 1: 검색 로직 강화

태그 검색 추가 + 관련도 점수

제목, 태그, 본문 각각에 가중치를 부여해서 관련도 순으로 정렬합니다.

const scorePost = (post, term) => {
  let score = 0
  let matchType = null

  // 제목 매칭 (최우선, 100점 + 완전 일치 보너스 50점)
  if (lowerTitle.includes(term)) {
    score += 100
    if (lowerTitle === term) score += 50
    matchType = "title"
  }

  // 태그 매칭 (50점)
  if (lowerTags.some(t => t.includes(term))) {
    score += 50
    if (!matchType) matchType = "tag"
  }

  // 본문 매칭 (10점)
  if (lowerBody.includes(term)) {
    score += 10
    if (!matchType) matchType = "body"
  }

  return { score, matchType }
}

점수 체계를 정리하면:

매칭 위치 점수 이유
제목 완전 일치 150 가장 정확한 결과
제목 부분 일치 100 높은 관련도
태그 일치 50 주제 관련성
본문 일치 10 참고 수준

AND/OR 복합 검색

쉼표와 공백으로 검색 모드를 자동 판별합니다.

const parseQuery = rawQuery => {
  if (trimmed.includes(",")) {
    // 쉼표: OR 검색 — 하나라도 포함되면 결과에 표시
    return { mode: "or", terms: trimmed.split(",").map(t => t.trim()) }
  }

  const terms = trimmed.split(/\s+/)
  if (terms.length > 1) {
    // 공백: AND 검색 — 모든 키워드가 포함된 글만 표시
    return { mode: "and", terms }
  }

  return { mode: "single", terms }
}

사용 예시:

입력 모드 동작
python 단일 python이 포함된 글
python django AND python과 django 모두 포함된 글
python, java OR python 또는 java가 포함된 글

AND 모드에서는 하나라도 매칭되지 않으면 결과에서 제외합니다.

if (parsed.mode === "and") {
  for (const term of parsed.terms) {
    const { score, matchType } = scorePost(post, term)
    if (score === 0) return { ...post, score: 0, matchType: null } // 하나라도 안 맞으면 탈락
    totalScore += score
  }
}

디바운스 적용

타이핑할 때마다 전체 포스트를 필터링하면 비효율적입니다. 200ms 디바운스를 적용해서 타이핑이 멈춘 후에 검색을 실행합니다.

function useDebounce(value, delay) {
  const [debounced, setDebounced] = useState(value)
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay)
    return () => clearTimeout(timer)
  }, [value, delay])
  return debounced
}

const debouncedQuery = useDebounce(query, 200)

개선 2: UI/UX 향상

검색어 하이라이팅

검색 결과에서 매칭된 키워드를 노란색으로 강조 표시합니다. AND/OR 검색 시 모든 키워드가 각각 하이라이팅됩니다.

const highlightText = (text, terms) => {
  const escaped = terms.map(escapeRegExp).join("|")
  const regex = new RegExp(`(${escaped})`, "gi")
  const parts = text.split(regex)
  return parts.map((part, i) =>
    terms.some(t => part.toLowerCase() === t) ? (
      <Highlight key={i}>{part}</Highlight>
    ) : (
      part
    )
  )
}

매칭 위치 배지

각 검색 결과에 "제목 일치", "태그 일치", "본문 일치" 배지를 색상별로 표시해서 왜 이 글이 검색됐는지 한눈에 파악할 수 있습니다.

배지 색상 의미
제목 일치 노란색 제목에서 키워드를 찾음
태그 일치 보라색 태그에서 키워드를 찾음
본문 일치 파란색 본문에서 키워드를 찾음

본문 매칭 컨텍스트

본문에서 매칭된 경우, 단순 excerpt 대신 매칭된 부분 주변 텍스트를 발췌해서 보여줍니다.

const getMatchContext = (body, terms, contextLen = 80) => {
  const lower = body.toLowerCase()
  let bestIdx = -1
  for (const term of terms) {
    const idx = lower.indexOf(term)
    if (idx !== -1 && (bestIdx === -1 || idx < bestIdx)) bestIdx = idx
  }
  // bestIdx 기준으로 앞뒤 80자씩 발췌
  const start = Math.max(0, bestIdx - contextLen)
  const end = Math.min(body.length, bestIdx + contextLen + 20)
  let snippet = body.slice(start, end).replace(/\n/g, " ").trim()
  if (start > 0) snippet = "..." + snippet
  if (end < body.length) snippet = snippet + "..."
  return snippet
}

검색 전 추천 섹션

검색어를 입력하기 전에는 인기 태그최근 글을 보여줍니다.

  • 인기 태그: 사용 빈도 상위 12개를 칩 형태로 표시. 클릭하면 해당 태그로 바로 검색
  • 최근 글: 최신 5개 포스트를 제목, 카테고리, 날짜와 함께 표시
const popularTags = useMemo(() => {
  const tagCount = {}
  posts.forEach(post => {
    ;(post.frontmatter.tags || []).forEach(tag => {
      tagCount[tag] = (tagCount[tag] || 0) + 1
    })
  })
  return Object.entries(tagCount)
    .sort((a, b) => b[1] - a[1])
    .slice(0, 12)
}, [posts])

기타 UX 개선

  • 초기화 버튼 (X): 검색어를 한 번에 지우고 다시 입력할 수 있는 버튼
  • 검색 결과 카운트: "검색어" 검색 결과 N건 (AND/OR 모드 표시)
  • 빈 결과 안내: 결과가 없을 때 "다른 키워드로 검색해 보세요" 안내
  • 자동 포커스: 검색 페이지 진입 시 검색창에 자동 포커스

전후 비교

항목 Before After
검색 대상 제목 + 본문 제목 + 태그 + 본문
정렬 날짜순 관련도순
복합 검색 불가 AND(공백) / OR(쉼표)
하이라이팅 없음 키워드 강조
매칭 정보 없음 위치 배지 + 컨텍스트
검색 전 화면 전체 글 목록 인기 태그 + 최근 글
입력 최적화 없음 디바운스 200ms

FAQ

외부 검색 서비스(Algolia 등)와 비교하면 어떤가요?

Algolia는 오타 교정(fuzzy matching), 검색어 자동완성, 분석 대시보드 등 강력한 기능을 제공합니다. 하지만 무료 플랜에 제한이 있고, 별도의 인덱싱 설정이 필요합니다. 글이 100개 이하인 개인 블로그라면 클라이언트 사이드 검색으로 충분합니다.

글이 많아지면 성능 문제가 생기지 않나요?

현재 방식은 모든 글의 rawMarkdownBody를 클라이언트에 로드합니다. 글이 수백 개 이상이 되면 초기 로딩 시간이 길어질 수 있습니다. 그때는 검색 인덱스를 별도로 생성하거나 외부 서비스를 도입하는 것이 좋습니다.

AND와 OR를 동시에 사용할 수 있나요?

현재는 쉼표가 있으면 OR, 공백만 있으면 AND로 단일 모드만 지원합니다. (A AND B) OR C 같은 복합 조건은 지원하지 않습니다. 개인 블로그 검색에서는 이 정도면 충분하다고 판단했습니다.


다음 포스팅에서는 GitHub Pages에서 Netlify로 전환한 이유와 과정을 공유합니다. 소스 코드 비공개, 커스텀 도메인 설정까지 다룹니다.

검색 기능 관련해서 추가로 개선하면 좋을 아이디어가 있다면 댓글로 알려주세요!

@JavaPark
AI 시대의 개발자 도구, 실전 경험을 공유합니다