개발 · 2026. 8. 8.6

블로그 포스팅 검색기능 개선 작업

devlog-porklog(2/5)

  1. 1.태그 기능 개발 일지
  2. 2.블로그 포스팅 검색기능 개선 작업
  3. 3.관리자용 통계 화면 개발일지
  4. 4.E2E 테스트 적용기
  5. 5.데이빗또, 이건 기초적인 블로그 기능이다. (예약발행 및 비공개 기능 추가)
목차

왜 필요했나

태그 기능을 배포하고 나니 바로 다음 문제가 보였다. 검색·태그·카테고리가 서로 배타적이었다. 검색 중이면 태그·카테고리는 무시되고, 태그로 필터링 중이면 검색어를 넣을 수 없었다. 우선순위 방식(검색 > 태그 > 카테고리)으로 짜여 있었기 때문이다.

당장 아쉬운 조합이 있었다. "개발 카테고리에서 react 관련 글만" 같은 건 지금 구조로는 불가능했다. 검색 범위도 제목뿐이라, 본문에만 키워드가 있으면 못 찾았다. README에 남겨둔 "본문 전문 검색"도 아직 손대지 않은 상태였다.

설계: 넓힐 것과 그대로 둘 것

결정할 지점이 세 개였다. 검색 범위, 필터 조합 방식, 검색 엔진.

검색 범위는 제목+본문으로 넓히기로 했다. 태그명까지 검색어 매칭 대상에 넣는 안도 잠깐 고민했는데, 태그는 이미 ?tag= 필터로 따로 걸 수 있고 검색어와 동시에 적용되게 만들 것이므로, 검색창에서까지 태그명을 잡아줄 실익이 크지 않다고 봤다. 범위는 최소로 갔다.

필터 조합은 배타적 우선순위를 버리고 AND로 바꿨다. 사실 이 방향은 기존 코드에 이미 흔적이 있었다. 페이지네이션의 hrefForPage는 애초부터 q, tag, category를 전부 유지하도록 짜여 있었다 — 원래 설계 의도는 조합이었는데, whereClause만 배타적으로 남아 있었던 셈이다. 그 간극을 메꾸는 작업이었다.

검색 엔진은 ILIKE 부분일치를 그대로 두기로 했다. Postgres 기본 full-text search로 바꾸면 관련도 순 정렬이 가능해지지만, 형태소 분석이 없는 tsvector라 한글에서는 오히려 지금의 부분일치보다 검색 품질이 떨어질 수 있다. 제대로 하려면 pg_bigm 같은 확장을 설치해야 하는데, 개인 블로그 검색 개선 치고는 인프라 변경이 과했다. 글 양이 늘어 성능이 체감되면 그때 트라이그램 GIN 인덱스 정도만 얹기로 하고 지금은 보류했다.

구현: 세 단계

1. whereClause를 배타적 삼항 연산자에서 AND 조합으로.

const conditions = [
  isSearching
    ? or(ilike(posts.title, `%${query}%`), ilike(posts.content, `%${query}%`))
    : undefined,
  activeTag ? arrayContains(posts.tags, [activeTag]) : undefined,
  activeCategory ? eq(posts.category, activeCategory) : undefined,
].filter((c): c is SQL => c !== undefined);
 
const whereClause: SQL | undefined =
  conditions.length > 0 ? and(...conditions) : undefined;

검색은 제목 또는 본문에 매칭되면 인정(or), 그 결과를 태그·카테고리 조건과 다시 and로 묶는 구조다.

2. 결과 안내 문구를 조합 기준으로.

기존엔 "검색 결과"와 "태그 게시글" 문구가 서로 배타적으로 하나만 떴다. 활성 필터를 배열로 모아 이어 붙이는 방식으로 바꿨다.

const activeFilterLabels: string[] = [];
if (isSearching) activeFilterLabels.push(`'${query}' 검색`);
if (activeTag) activeFilterLabels.push(`태그 '${activeTag}'`);
if (activeCategory) activeFilterLabels.push(categoryLabel(activeCategory));

'리액트' 검색 · 태그 'frontend' · 개발 결과 3건 처럼 조합해서 보여준다.

3. 검색창 placeholder를 "제목 검색"에서 "검색"으로. 범위가 본문까지 넓어졌으니 라벨도 맞춰야 했다.

트러블슈팅: 로직은 고쳤는데 실제로는 조합이 안 됐다

whereClause를 고치고 테스트해보니, 여전히 검색 중에 태그를 누르면 검색어가 날아갔다. 로직은 맞는데 결과가 안 맞으니 UI 쪽을 의심했다.

원인은 진입점 세 곳이었다. 검색창 제출은 router.push(/?q=...), 태그 뱃지 링크는 /?tag=..., 카테고리 탭 링크는 /?category=... — 셋 다 자기 파라미터 하나만 새로 세팅하고 나머지는 그냥 버리는 코드였다. 유일하게 페이지네이션의 hrefForPage만 세 파라미터를 전부 보존하고 있었는데, 거기 남아 있던 게 원래 의도였던 셈이다.

그래서 세 진입점을 모두 "기존 파라미터를 보존하며 자기 값만 갈아끼우는" 방식으로 통일했다. 검색창은 useSearchParams로 현재 tag/category를 읽어와 유지하고, 필터가 바뀌니 page는 지운다.

const params = new URLSearchParams(searchParams);
if (trimmed) params.set("q", trimmed); else params.delete("q");
params.delete("page");

태그 뱃지와 카테고리 탭은 클라이언트 훅 없이도 이미 서버 컴포넌트(page.tsx)가 현재 q/tag/category를 들고 있어서, href 빌더 함수에 그대로 넘겨주는 걸로 해결했다. 로직만 고치고 진입점을 안 건드렸으면 "동시 적용"은 URL을 직접 편집하는 사람만 쓸 수 있는 죽은 기능이 될 뻔했다.

남은 것

Postgres FTS는 한글 형태소 분석 미지원 때문에 보류했다. 글이 늘어 ILIKE 스캔이 느려지면 그때 GIN 트라이그램 인덱스만 먼저 검토할 생각이다.

실시간 검색(입력 중 디바운스 조회)이나 자동완성도 아직 없다. 태그 자동완성을 뺀 것과 같은 이유로, 지금 규모에선 과한 기능이라고 판단했다. 검색어 하이라이팅도 마찬가지로 다음으로 미뤘다.