원문 캡처 · builders.cortex.io
원문 캡처 · builders.cortex.io

Cortex는 검색 인덱스를 애플리케이션 메모리에 불러오던 기존 구조를 Postgres에서 직접 질의하는 방식으로 바꿨다. 회사 측은 이 변경으로 검색의 긴 지연 구간과 메모리 사용량이 줄었고, 카탈로그 변경 사항도 대부분 몇 초 안에 검색 결과에 반영된다고 밝혔다.

Cortex의 소프트웨어 카탈로그에는 규모와 세분화 정도에 따라 수백 개에서 수백만 개의 항목이 들어간다. 기존 시스템은 Lucene 인덱스를 일정에 따라 생성해 저장한 뒤, 질의할 때 애플리케이션 메모리로 불러와 사용했다. 데이터가 커지면서 최대 1Gi 크기의 인덱스를 불러오는 경우도 생겼고, 이로 인해 메모리 부족 오류와 콜드 스타트 질의의 긴 대기 시간이 발생했다. 인덱스를 정해진 주기에만 갱신해 새 서비스가 검색에 나타나기까지 시간이 걸리는 문제도 있었다.

Postgres에서 검색하는 방식

Cortex는 별도의 검색 시스템이나 임의의 Postgres 확장에 의존하지 않고, 관리형 Postgres 환경에서도 사용할 수 있는 구성을 목표로 삼았다. 회사는 unaccent처럼 Postgres에 포함된 확장은 활용할 수 있지만, 운영 부담이 있거나 RDS·Cloud SQL에서 설치할 수 없는 ElasticSearch와 ParadeDB는 선택하지 않았다고 설명했다.

검색어는 자체 파서가 토큰화하고 구문 트리로 변환한다. 지원 문법은 필드:값, 필드:* 형태의 값 존재 확인, 따옴표로 묶은 구문, 접두어 검색, AND, OR, NOT 또는 -, 괄호다. 연산 우선순위는 NOT, AND, OR 순이며, 비정상적으로 깊거나 항목이 많은 질의는 Postgres가 지나치게 오래 실행하지 않도록 제한한다.

Postgres의 전문 검색 기능에서는 to_tsvector가 텍스트를 정규화된 단어와 위치 정보로 바꾸고, tsquery와 @@ 연산자가 일치 여부를 확인한다. Cortex는 어간 추출을 사용하지 않는 설정을 택했다. 카탈로그에는 이름과 태그, 식별자가 많아 서로 다른 단어를 같은 형태로 합치지 않으려는 선택이다. GIN 인덱스는 검색어가 포함된 후보 행을 찾는 데 쓰고, 필드 지정 검색은 후보 행에 한해 해당 열을 다시 검사한다.

검색 결과의 순위는 완전 일치, 부분 문자열 일치, 전문 검색 관련도, 트라이그램 유사도 순으로 점수를 더하는 방식이다. Cortex는 완전 일치 점수가 부분 문자열 일치보다 항상 높도록 점수 간격을 두며, a OR b 같은 구조화 질의에는 완전·부분 일치 단계를 적용하지 않는다고 밝혔다.

변경 사항 반영과 재사용

카탈로그 검색용 읽기 테이블은 여러 원본 테이블에서 변경 데이터 캡처(CDC)로 갱신한다. Debezium이 Postgres의 쓰기 전 로그(WAL)를 읽어 삽입·수정·삭제 이벤트를 Kafka에 전달하면, 검색 소비자가 이벤트에 해당하는 카탈로그 항목을 다시 구성한다. 이벤트 내용으로 행을 직접 패치하는 대신 원본 테이블의 현재 상태에서 항목을 재구성해 이벤트 순서에 덜 의존하고 재처리를 안전하게 만들었다. 같은 테넌트에서 한 항목이 배치 안에 여러 번 바뀌면 중복을 제거해 한 번만 다시 구성한다.

각 검색 행은 이름, 태그, 소유자, 설명에 서로 다른 가중치를 둔 tsvector를 저장한다. 내용 해시가 기존 행과 같으면 쓰기를 건너뛰어 재시도나 중복 이벤트, 변경 없는 전체 재구축이 불필요한 갱신을 만들지 않도록 한다. 수천 개 서비스에 영향을 주는 그룹 이름 변경처럼 한꺼번에 많은 행을 바꾸는 작업은 실시간 처리 대신 야간 전체 재구축으로 미룬다. 이는 일반적인 갱신의 성능 저하를 피하는 대신 일부 일관성을 양보하는 선택이며, CDC 갱신 실패에 대비하는 안전망 역할도 한다.

Cortex는 카탈로그 외의 점수표, 워크플로, 보고서 등에도 같은 파서와 질의 작성 기능을 적용하도록 SearchSurface를 만들었다. 기능 팀은 검색 대상 테이블과 열, 가중치 등을 지정해 검색을 사용할 수 있다. 이 표면에서는 대개 필드: 연산자를 지원하지 않으며, 인접한 단어를 기본적으로 OR로 처리한다. 사용자가 직접 입력한 AND는 필수 조건으로 유지한다. 이들 테이블은 테넌트당 수백~수천 행이어서 질의 시점에 tsvector를 계산하며, 규모가 커지면 코드 변경 없이 표현식 GIN 인덱스를 추가할 수 있다고 회사는 설명했다.

Cortex가 공개한 운영 결과

Cortex는 기존 검색 엔진을 사용한 한 달과 마지막 테넌트 이전 후 한 달의 운영 데이터를 비교했다. 아래 수치는 회사가 공개한 내부 검색 및 공개 API 검색의 지연 시간이다.

구분백분위이전이후변화
내부 검색p500.16초0.14초1.1배 빨라짐
내부 검색p750.40초0.28초1.4배 빨라짐
내부 검색p953.33초1.68초2배 빨라짐
내부 검색p9917.8초5.0초3.6배 빨라짐
공개 API 검색p500.63초0.30초2.1배 빨라짐
공개 API 검색p750.98초0.58초1.7배 빨라짐
공개 API 검색p9513.3초1.54초8.6배 빨라짐
공개 API 검색p9921.2초3.35초6.3배 빨라짐

회사에 따르면 검색을 제공하는 애플리케이션의 가비지 컬렉션 후 상주 힙은 약 절반으로 줄었고, 전체 메모리 사용량은 약 40% 감소했다. 기존 인덱스는 변경 사항 반영에 최소 5분이 걸렸으며 일부 필드는 다음 야간 전체 빌드까지 갱신되지 않았다. 새 구조에서는 수정 사항의 검색 반영 시간이 p50 1.5초, p99 9.7초였고 삽입은 각각 1.4초와 3.3초였다. 대량 배치 작업이 데이터베이스에 한꺼번에 기록될 때는 짧은 처리 지연이 생길 수 있다.

Cortex의 DRIVE 프레임워크와 주간 운영 우수성 검토는 기존 검색의 서비스 수준 목표와 버그 보고를 문제를 파악한 계기였다. 이 사례는 Postgres만 사용할 수 있는 환경에서 검색을 운영해야 하는 팀이 데이터 규모, 확장 설치 가능 여부, 검색 최신성 요구를 함께 고려할 때 참고할 수 있다. Cortex는 질의 작성에 jOOQ를 사용했다고 밝혔다.

부록: 원문 예시

text
sealed interface SearchExpr {
 data class Term(val field: String?, val value: String, val prefix: Boolean = false) : SearchExpr

 data class And(val children: List<SearchExpr>, val explicit: Boolean = false) : SearchExpr

 data class Or(val children: List<SearchExpr>) : SearchExpr

 data class Not(val child: SearchExpr) : SearchExpr

 data class Presence(val field: String) : SearchExpr
}
text
private fun parseOr(): SearchExpr {
 val first = parseAnd()
 val parts = mutableListOf(first)
 while (peek() is Token.Or) {
 advance()
 parts.add(parseAnd())
 }
 return if (parts.size == 1) parts[0] else SearchExpr.Or(parts)
}
text
And([And([a, b], explicit=false), c], explicit=true)
text
SELECT to_tsvector('simple_nostop', 'The Heart of Gold');
-- 'gold':4 'heart':2
text
CREATE TABLE search_index (
 id bigint NOT NULL,
 name text,
 description text,
 owner_text text,
 tag_text text,
 content_hash text,
 search_vector tsvector NOT NULL
);
text
fun build(e: SearchExpr): Condition = when (e) {
 is SearchExpr.Term -> term(e)
 is SearchExpr.Presence -> presence(e.field)
 is SearchExpr.And -> DSL.and(e.children.map(::build))
 is SearchExpr.Or -> DSL.or(e.children.map(::build))
 is SearchExpr.Not -> DSL.not(build(e.child))
}
text
WHERE search_vector @@ phraseto_tsquery('heart of gold') -- GIN index: fast, finds candidates
 AND to_tsvector(name) @@ phraseto_tsquery('heart of gold') -- recheck: runs only on candidates
text
CASE WHEN lower(name) = lower(:q) THEN 20000 ELSE 0 END -- exact
+ CASE WHEN name ILIKE '%' || :q || '%' THEN 5000 ELSE 0 END -- substring
+ ts_rank_cd(search_vector, query) * 100 -- full-text relevance
+ similarity(name, :q) * 10 -- trigram closeness
text
search_vector =
 setweight(to_tsvector(name), 'A')
 || setweight(to_tsvector(tag_text), 'A')
 || setweight(to_tsvector(owner_text), 'B')
 || setweight(to_tsvector(description), 'C')
text
val SCORECARD_SEARCH = SearchSurface(
 table = SCORECARDS,
 id = SCORECARDS.ID,
 documentColumns = listOf(SCORECARDS.NAME, SCORECARDS.TAG, SCORECARDS.DESCRIPTION),
 boostWeights = mapOf(SCORECARDS.NAME to 10_000, SCORECARDS.TAG to 10_000),
)