THE DEVLOG

scribbly.

Next.js 블로그 프로젝트

2025.05.21 00:50:37

24일차부터 구현했던 검색 기능과 관련하여 정리한 게시글.

블로그 프로젝트를 시작하게 됐던 이유

  1. 많은 분량의 게시글을 관리하기 힘들어서 -> 이는 chatGPT와 text-embedding을 이용한 군집화로 해결했다.
  2. velog나 github의 검색 기능이 부족해서.

2번 문제를 해결하기 위해 full text search를 구현하기로 하였다.

PostgresSQL Full Text Search

기본적으로 DB에서의 검색 기능은 LIKE '%단어%'로 구현하게 된다. 그리고 이러한 검색은 연산량도 많고 정확도도 떨어진다.

PostgresSQL의 Full Text Search는 게시글을 벡터화하여 검색의 정확도와 성능을 높인다.

tsvector

tsvectorFull 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) 또는 형태소 분석

powerfulpower
runningrun

→ ["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

이렇게 처리되는 이유:

  1. 띄어쓰기 단위가 아니라 단어 내의 형태소 단위로 분리

    • '분석기'는 하나의 의미 단위
    • '중요하다''중요' + '하다'로 나뉘기도 함 (분석기 설정에 따라)
  2. 불용어 제거는 한국어 사전에 등록된 조사, 접속사 등으로 판단

  3. 단어 위치 역시 부여됨

이러한 형태소 분석을 위해서는 형태소 분석을 위한 모듈 (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를 호출하여 게시글 불러오고, 이를 게시글 검색 결과에 띄워주게 된다.

1.00