Search for a command to run...

LogHub는 현재(2026년 3월) 기준, 검색 기능을 구현하기 위해 PostgreSQL의 다국어 FTS 확장 기능인 PGroonga를 사용하고 있습니다.
최근 웹 서핑 중, PGroonga와 비슷한 목적을 가진 프로젝트 ParadeDB를 우연히 발견하게 되었습니다. 두 프로젝트 모두 PostgresSQL의 확장 기능 형태로 동작하며, 애플리케이션에서 별도의 검색엔진(예: ElasticSearch)을 운영하지 않고도 강력한 FTS 기능을 제공하게 도와줍니다. 하지만 사용하는 라이브러리나 내부 구조 및 활용 방식에는 꽤 차이가 있었습니다.
따라서 이번 아티클에서는 PGroonga와 ParadeDB를 살펴보고, 한국어 Full-Text Search 관점에서 어떤 차이가 있는지 비교해보고자 합니다.
단순 정리표는 다음과 같습니다:
| 구분 | PGroonga | ParadeDB |
|---|---|---|
| 내부 엔진 | Groonga | Tantivy |
| 토크나이저 | Mecab/Mecab-ko | Lindera |
| 스코어 알고리즘 | TF | BM25 |
| 스코어 함수 사용 | 비교적 장황 | 비교적 간편 |
| 커스텀 동의어 사전 | 지원 | 미지원 |
PGroonga는 내부엔진으로 Groonga를 사용합니다. 이는 C로 작성되었으며, 향후 설명할 TF 키워드 검색 알고리즘을 사용합니다.
ParadeDB는 내부엔진으로 Tantivy를 사용합니다. 이는 Rust로 작성되었으며, 향후 설명할 BM25 키워드 검색 알고리즘을 사용합니다.
PGroonga는 기본적으로 한국어 토크나이저를 지원하지 않습니다. 따라서 이번 아티클에서는 Mecab-ko 토크나이저와 mecab-ko-dic 사전을 추가 설치하여 사용하는 방식으로 진행했습니다.
이는 PGroonga가 기본적으로 지원하는 Mecab 일본어 토크나이저와 Mecab-ko 한국어 토크나이저가 인터페이스가 동일하다는 점을 이용한 트릭을 사용하는 방식이며, 자세한 내용은 PGroonga에서 한국어 토크나이저 사용하기에서 확인하실 수 있습니다.
반면, ParadeDB는 기본적으로 한국어 토크나이저를 지원합니다. 내부적으로는 Rust로 작성된 Lindera 토크나이저를 사용하며, 이는 사전만 제공되면 CJK(Chinese, Japanese, Korean) 언어를 모두 처리할 수 있는 구조로 설계되어 있습니다. ParadeDB에서는 한국어 사전으로 Mecab-ko-dic을 기반으로 한 fork 사전인 KoDic 을 사용합니다.
FTS에서 Scoring(스코어링) 이란 관련성 점수 계산입니다. 스코어링 알고리즘은 여러 종류가 있으며, 기본적으로 PGroonga의 내부 엔진 Groonga는 TF 알고리즘을 사용하고, ParadeDB의 내부 엔진 Tantivy는 BM25 알고리즘을 사용합니다.
IDF : 전체 문서에서 얼마나 희귀한 단어인지avgDL : 평균 문서 길이
Groonga는 BM25가 제공되지 않으며, 이는 개발자가 직접 TF + 필드별 가중치를 통해 커스터마이징하도록 하는 의도적인 설계입니다.
또한 일반적으로 TF가 BM25보다 속도가 빠릅니다.
관련성 점수를 계산하는 함수의 호출 방식은 ParadeDB 쪽이 더 단순한 편입니다.
PGroonga의 관련성 점수 계산 함수인 pgroonga_score를 사용할 때는 ctid, tableoid를 매개변수로 전달해야 합니다. 이 값들은 PostgreSQL의 내부 식별자이기 때문에 일반적인 ORM 환경에서는 직접 다루기 번거로운 경우가 있습니다.
반면 ParadeDB의 관련성 점수 계산 함수인 pdb.score는 훨씬 단순합니다. ParadeDB 인덱스를 생성할 때 지정한 고유 식별자(key_field)만 전달하면 되기 때문입니다. 따라서 ORM 기반 애플리케이션에서도 별도의 내부 필드를 노출하거나 추가하지 않고 비교적 쉽게 사용할 수 있습니다.
PGroonga는 Groonga 쿼리 문법을 지원합니다. 이는 구글의 검색 양식과 유사한 형태이며, 사용자의 복잡한 쿼리를 애플리케이션에서 별도의 추가적인 구현 없이 바로 지원할 수 있습니다. 또한 PGroonga는 고급 검색 함수(pgroonga_condition)로 필드별 가중치를 비교적 간단하게 설정할 수 있으며, 쿼리 확장 함수(pgroonga_query_expand)를 사용해 커스텀 동의어 사전도 구축할 수 있습니다.
반면, ParadeDB는 Groonga 쿼리 문법 대신 Tantivy 문법을 지원하며, 커스텀 동의어 사전 기능은 지원하지 않습니다.
참고로 ParadeDB는 현재(2026년 3월) 기준
0.21.x버전이며, 아직 공식 릴리즈 상태가 아닙니다.
Docker를 활용해서 각각 분리된 환경에서 테스트할 수 있도록 했습니다.
services:
my-pgroonga:
container_name: my-pgroonga
image: ghcr.io/loghub-me/postgres:0.2.0
shm_size: 1g
volumes:
- ./pgroonga/init/:/docker-entrypoint-initdb.d/
env_file: .env.postgres
my-paradedb:
container_name: my-paradedb
image: paradedb/paradedb:latest
shm_size: 1g
volumes:
- ./paradedb/init/:/docker-entrypoint-initdb.d/
env_file: .env.postgres
PGroonga의 경우 기본적으로 한국어 토크나이저를 지원하지 않기에, 한국어 토크나이저를 사용할 수 있도록 커스텀된 이미지를 사용해야 합니다. 따라서 LogHub의 Postgres 이미지가 PGroonga + Mecab-ko 환경을 사용하기에, ghcr.io/loghub-me/postgres:0.2.0 이미지를 사용했습니다.
ParadeDB 같은 경우에는 기본적으로 한글 토크나이저가 탑재되어 있기 때문에 공식 Docker 이미지를 그대로 사용했습니다.
.env 파일에는 단순 username, password가 설정되어 있습니다.
PGroonga의 예제 테이블 생성
\c postgres
CREATE EXTENSION IF NOT EXISTS pgroonga;
CREATE TABLE IF NOT EXISTS public.posts (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
title text NOT NULL,
content text NOT NULL, -- for search
topics_flat text NOT NULL DEFAULT '' -- for search
);
CREATE INDEX IF NOT EXISTS posts_fts_idx ON public.posts
USING pgroonga (
(ARRAY [title, content, topics_flat]))
WITH (
tokenizer='TokenMecab',
normalizer='NormalizerAuto'
);
pgroonga 확장 기능을 활성화합니다.posts 예제 테이블을 생성합니다.posts 테이블에 FTS 검색용 인덱스를 사용합니다.
ParadeDB의 예제 테이블 생성
\c postgres
CREATE EXTENSION IF NOT EXISTS pg_search;
CREATE TABLE IF NOT EXISTS public.posts (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
title text NOT NULL,
content text NOT NULL, -- for search
topics_flat text NOT NULL DEFAULT '' -- for search
);
CREATE INDEX IF NOT EXISTS posts_fts_idx ON public.posts
USING bm25 (
id,
(title::pdb.lindera(korean, 'trim=true', 'remove_short=2')),
(content::pdb.lindera(korean, 'trim=true', 'remove_short=2')),
(topics_flat::pdb.simple)
)
WITH (key_field = 'id');
pg_search(ParadeDB) 확장 기능을 활성화합니다.posts 예제 테이블을 생성합니다.posts 테이블에 FTS 검색용 인덱스를 사용합니다.
벤치마크 테스트를 위해 사용할 시드 데이터 DDL을 만들기 위해, 한국어 텍스트를 지원하면서 비교적 간단하게 사용할 수 있는 가짜 데이터 생성 도구인 Faker.js를 사용해 봤습니다.
Faker.js는 다양한 가짜 데이터를 생성하기 위한 라이브러리로, 간단히 테스트용 텍스트 데이터를 생성할 수 있으며, 이를 활용해 간단한 시드 데이터를 생성하는 스크립트를 작성해봤습니다.
import fs from 'fs';
import { fakerKO as faker } from '@faker-js/faker';
const NUM_ROWS = 1_000_000; // 생성할 더미 데이터 개수
const OUTPUT_FILE = '02-dml.sql';
const techPool = [
{ key: 'java', name: 'Java' },
{ key: 'cpp', name: 'C++' },
{ key: 'python', name: 'Python' },
{ key: 'javascript', name: 'JavaScript' },
{ key: 'react', name: 'React' },
{ key: 'spring', name: 'Spring' },
{ key: 'nodejs', name: 'Node.js' },
{ key: 'postgres', name: 'PostgreSQL' },
{ key: 'aws', name: 'AWS' },
{ key: 'docker', name: 'Docker' }
];
let sqlContent = ``;
for (let i = 0; i < NUM_ROWS; i++) {
const title = faker.lorem.sentence();
const content = faker.lorem.paragraphs(3);
const selectedTopics = faker.helpers.arrayElements(techPool, { min: 1, max: 3 });
const topicsFlat = selectedTopics
.map(topic => `${topic.key},${topic.name}`)
.join(':');
const safeTitle = title.replace(/'/g, "''");
const safeContent = content.replace(/'/g, "''");
const safeTopics = topicsFlat.replace(/'/g, "''");
sqlContent += `INSERT INTO public.posts (title, content, topics_flat) VALUES ('${safeTitle}', '${safeContent}', '${safeTopics}');\n`;
}
fs.writeFileSync(OUTPUT_FILE, sqlContent, 'utf-8');
벤치마크가 진행된 환경은 다음과 같습니다:
shm_size : 1g다음은 벤치마크에 사용된 쿼리입니다:
PGroonga와 ParadeDB는 문법이 다르기 때문에, 완전히 동일한 결과를 내는 쿼리는 한정적이었습니다.
-- PGroonga 쿼리
SELECT id, title
FROM public.posts
WHERE ARRAY[title, content, topics_flat] &@~ pgroonga_condition(
query => ?1,
weights => ARRAY[5, 2, 3],
index_name => 'posts_fts_idx'
)
ORDER BY pgroonga_score(tableoid, ctid) DESC;
-- ParadeDB 쿼리
SELECT id, title
FROM public.posts
WHERE (
title &&& ?1::pdb.boost(5)
OR content &&& ?1::pdb.boost(2)
OR topics_flat &&& ?1::pdb.boost(3)
)
ORDER BY pdb.score(id) DESC;
두 쿼리 모두, 특정 쿼리를 AND 연산 기준으로 포함하고 있는 지에 대한 조건을 가지며, 관성성 점수를 내림차순으로 정렬하여 결과를 반환합니다.
다음은 동일한 쿼리를 1,000회 반복 수행한 warm cache 상태의 평균값 결과입니다:
| 구분 | PGroonga + Mecab-ko | ParadeDB + Lindera |
|---|---|---|
| FTS 지연속도 | 881ms | 1112ms |
| 동시 INSERT 지연속도 | 1531 ms | 254ms |
| 동시 INSERT TPS | 6.53 | 39.30 |
pg_bench툴을 사용하여 측정했습니다.
일반적으로 PGroonga + Mecab-ko가 평균 FTS 조회 쿼리의 속도는 약 1.3배 정도 더 빠른 결과를 보여줬으며, 쓰기(인덱스 갱신) 성능은 ParadeDB가 6배 빠른 속도로 압도적이었습니다.
다음으로 인덱스 크기를 비교하려고 해봤지만, ParadeDB는 pg_relation_size로 인덱스 크기가 확인되는 반면, PGroonga는 내부적으로 Groonga의 별도 스토리지를 사용하기 때문에 크기를 확인하기 어려웠습니다. 관련 이슈 :(
분명 위의 벤치마크의 두 쿼리에 동일한 가중치를 주고 관련성 점수를 계산했지만, 정렬 결과 굉장히 다르게 출력되었으며, 심지어는 일부 row는 누락된 경우도 있었습니다.
따라서, Linedra 토크나이저가 제대로 동작하고 있는 지에 대한 의문이 발생하였고, 직접 토크나이저의 결과를 확인해봤습니다.
SELECT array_agg(token->>'value') AS tokens
FROM json_array_elements(
(pgroonga_command(
'tokenize TokenMecab "계승/발전과 범죄에 의무교육은 범죄에 장기 판결이 조약과 기능을 범죄를 청구할 수 있다."'
)::json)->1
) AS token;
-- {계승,/,발전,과,범죄,에,의무,교육,은,범죄,에,장기,판결,이,조약,과,기능,을,범죄,를,청구,할,수,있,다,.}
SELECT '계승/발전과 범죄에 의무교육은 범죄에 장기 판결이 조약과 기능을 범죄를 청구할 수 있다.'::pdb.lindera(korean)::text[] AS tokens;
-- {계승,발전,과,범죄,에,의무,교육,은,범죄,에,장기,판결,이,조약,과,기능,을,범죄,를,청구,할,수,있,다}
토크나이저에 문제가 없는 것은 확인했으며, Tantivy 문법도 사용해 쿼리를 해봤지만 결과는 같았습니다.
이는 PGroonga와 ParadeDB의 기능적 차이->스코어링 파트에서 잠깐 소개 되었던, 스코어링 알고리즘의 차이로 생긴 문제였습니다. PGroonga는 단순 TF를 통해 검색어의 등장 횟수만 카운트하지만, ParadeDB는 BM25를 사용하기에 더 다양한 변수들을 고려해 계산된 결괏값이었습니다.
따라서, PGroonga와 ParadeDB는 사용할 애플리케이션의 특성에 따라 알맞게 선택하면 될 것 같습니다.
이번 글에서는 PostgreSQL 기반 Full-Text Search 확장인 PGroonga와 ParadeDB를 간단하게 비교해보았습니다.
결론적으로, LogHub 프로젝트에 ParadeDB를 실험적으로 도입해 볼 계획입니다. 벤치마크에서 확인한 압도적으로 낮은 인덱스 갱신 비용(동시 쓰기 성능)이 가장 큰 이유였으며, ORM 환경에서 다루기 까다로운 ctid, tableoid 같은 필드를 걷어낼 수 있다는 점도 좋았습니다.
다만, 커스텀 동의어 사전 및 Groonga 쿼리 문법은 사용하지 못하게 되는 트레이드오프도 발생할 것으로 예상되며, 정식 릴리즈가 아니기에 실험적 환경에서 먼저 테스트를 진행할 예정합니다.
정리하자면, Rust 기반 강력한 Tantivy 검색엔진을 PostgreSQL 내부에서 직접 사용할 수 있다는 점에서 상당히 매력적이며, 더 정교하고 높은 품질의 검색 결과를 낼 수 있는 BM25 키워드 검색 알고리즘을 제공한다는 점에서 대단하고 동의합니다.
로그인 후 댓글을 작성할 수 있습니다.