24일차부터 구현했던 검색 기능과 관련하여 정리한 게시글.
블로그 프로젝트를 시작하게 됐던 이유
- 많은 분량의 게시글을 관리하기 힘들어서 -> 이는 chatGPT와 text-embedding을 이용한 군집화로 해결했다.
- velog나 github의 검색 기능이 부족해서.
2번 문제를 해결하기 위해 full text search를 구현하기로 하였다.
PostgresSQL Full Text Search
기본적으로 DB에서의 검색 기능은 LIKE '%단어%'
로 구현하게 된다. 그리고 이러한 검색은 연산량도 많고 정확도도 떨어진다.
PostgresSQL의 Full Text Search는 게시글을 벡터화하여 검색의 정확도와 성능을 높인다.
tsvector
tsvector
는 Full Text Search를 위한 색인 가능한 형태의 데이터 타입이다.
- 입력 문자열을 토큰화(tokenize) 한 뒤
- 불용어(stop word) 제거
- 어간 추출(stemming) 또는 형태소 분석(morphological analysis)
- 정규화(normalization)
- 그 결과를
(단어:위치)
형태로 정리한 자료구조다.
SELECT to_tsvector('english', 'ChatGPT is a powerful AI assistant.');
-- 결과: 'ai':5 'assistant':6 'chatgpt':1 'power':4
tsvector의 작동 과정
1단계. Tokenize
문장을 단어 단위로 나눈다 (공백/구두점 기준)
"ChatGPT is a powerful AI assistant"
→ ["ChatGPT", "is", "a", "powerful", "AI", "assistant"]
2단계. Normalize
모두 소문자로 변환, 구두점 제거
→ ["chatgpt", "is", "a", "powerful", "ai", "assistant"]
3단계. Stopword 제거 (불용어 제거)
해당 언어의 사전에 등록된 의미 없는 단어 제거 (ex: a, the, is, and)
→ ["chatgpt", "powerful", "ai", "assistant"]
4단계. 어간 추출 (Stemming) 또는 형태소 분석
powerful
→ power
running
→ run
→ ["chatgpt", "power", "ai", "assistant"]
5단계. GIN(Generalized Inverted Index)
일반적인 인덱스의 예: 'chatgpt':1 'power':4 'ai':5 'assistant':6
'chatgpt':1 'power':4 'ai':5 'assistant':6
GIN을 이용한 인덱싱의 예
문서 1: "chatgpt is amazing"
문서 2: "chatgpt and ai"
문서 3: "I love ai"
chatgpt → [1, 2]
ai → [2, 3]
amazing → [1]
love → [3]
즉 단어와 그 단어를 포함하고 있는 문서를 역으로 인덱싱한다. 이를 통해 빠르게 단어 기반 검색을 진행할 수 있다.
한국어에서 tsvector 이용시 유의사항
english
, simple
등의 설정은 어근 추출 기반이지만, korean
은 **형태소 분석기(morphological analyzer)**를 사용하여야 한다.
예:
SELECT to_tsvector('korean', '한국어 형태소분석기는 정말 중요하다.');
→ 결과 예시:
'한국어':1 '형태소':2 '분석기':3 '중요하다':4
이렇게 처리되는 이유:
-
띄어쓰기 단위가 아니라 단어 내의 형태소 단위로 분리
'분석기'
는 하나의 의미 단위'중요하다'
는'중요'
+'하다'
로 나뉘기도 함 (분석기 설정에 따라)
-
불용어 제거는 한국어 사전에 등록된 조사, 접속사 등으로 판단
-
단어 위치 역시 부여됨
이러한 형태소 분석을 위해서는 형태소 분석을 위한 모듈 (e.g PGroonga)를 설치해야 하는데, Supabase에서는 형태소 분석기를 지원하지 않는다.
따라서 슈퍼베이스에서는 아래와 같이 작동하게 된다.
예:
SELECT to_tsvector('simple', '한국어 형태소분석기는 정말 중요하다.');
→ 결과 예시:
'한국어':1 '형태소분석기는':2 '정말':3 '중요하다':4
이를 수정하려면 Route Handler에서 node-open-korean-text
등을 통해 형태소를 분석한 후 tsvector로 저장하는 과정을 거쳐야 할 것으로 보인다.
일단 현재로서는 기본 simple 벡터도 원하는 수준으로는 작동하여 simple vector로 FTS를 적용하였다.
구현
TSVECTOR 적용
-- Supabase SQL Editor에서 실행
alter table posts add column if not exists tsv tsvector;
-- 기존 데이터의 tsv 초기화
update posts set tsv =
setweight(to_tsvector(coalesce(title, '')), 'A') ||
setweight(to_tsvector(coalesce(body, '')), 'B');
-- tsvector 자동 업데이트 트리거 생성
create function posts_tsv_trigger() returns trigger as $$
begin
new.tsv :=
setweight(to_tsvector(coalesce(new.title, '')), 'A') ||
setweight(to_tsvector(coalesce(new.body, '')), 'B');
return new;
end
$$ language plpgsql;
create trigger tsv_update before insert or update
on posts for each row execute procedure posts_tsv_trigger();
위와 같이 게시글을 생성할 때에 tsvector를 생성하여 테이블에 적용하는 trigger function을 만든다.
Full Text Search RPC 적용
CREATE OR REPLACE FUNCTION search_posts_with_snippet(
search_text text,
page int,
page_size int
)
RETURNS TABLE (
id uuid,
title text,
short_description text,
thumbnail text,
released_at timestamptz, -- 여기 timestamptz로 변경!
url_slug text,
tags jsonb[], -- 여기 json → jsonb[]로 명확히 수정!
snippet text
)
LANGUAGE plpgsql AS $$
BEGIN
RETURN QUERY
SELECT
v.id,
v.title,
v.short_description,
v.thumbnail,
v.released_at,
v.url_slug,
v.tags, -- 이미 배열 형태로 정의됨
-- 검색어 없으면 snippet을 null로, 있으면 실제 snippet을 생성!
CASE
WHEN search_text = '' THEN NULL
ELSE ts_headline(
v.body,
plainto_tsquery(search_text),
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,FragmentDelimiter=...,MaxWords=20,MinWords=5'
)
END AS snippet
FROM posts_with_tags_summaries v
WHERE (v.is_private IS NULL OR v.is_private IS FALSE)
AND (search_text = '' OR v.tsv @@ plainto_tsquery(search_text))
ORDER BY v.released_at DESC
OFFSET (page * page_size)
LIMIT page_size;
END;
$$;
Supabase RPC의 반환값은 Supabase CLI의 자동 타입 생성이 잘 안먹히는 경우가 많다. 그래서 아래와 같이 타입을 명시적으로 지정하고, 클라이언트에서 사용하는 것이 좋다.
RETURNS TABLE (
id uuid,
title text,
short_description text,
thumbnail text,
released_at timestamptz, -- 여기 timestamptz로 변경!
url_slug text,
tags jsonb[], -- 여기 json → jsonb[]로 명확히 수정!
snippet text
)
jsonb[]
타입은 자바스크립트 클라이언트에서 배열처럼 사용할 수 있다. supabase에서는 tags를 Json이라는 타입으로 인식한다
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
하지만 jsonb[]
로 명확하게 타입을 지정해주었기에, 클라이언트에서는 아래와 같이 객체의 배열로 as를 지정하여 쓰면 된다.tags as { id: string; name: string; }[]
한편 snippet이라는 결과값은 아래와 같다.
ELSE ts_headline(
v.body,
plainto_tsquery(search_text),
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,FragmentDelimiter=...,MaxWords=20,MinWords=5'
)
END AS snippet
검색 키워드가 있는 경우에, 검색 키워드와 일치하는 부분을 <mark>
태그로 감싼다.
페이지 구현
main/app/(app-shell)/posts/search/page.tsx
먼저 페이지는 posts/page를 재활용 한다. posts/page는 게시글 목록을 보여주는 화면인데, 최신 게시글이 캐싱되어 있다.
반면 main/app/(app-shell)/posts/search/page.tsx는 searchParams를 전달받아 렌더링하며, 따라서 캐싱이 되지 않는다.
해당 페이지에서 supabase RPC를 호출하여 게시글 불러오고, 이를 게시글 검색 결과에 띄워주게 된다.