다양한 서비스가 결합된 대규모 플랫폼에서는 사용자들이 원하는 정보를 빠르고 정확하게 찾을 수 있도록 지원하는 검색 기능이 매우 중요합니다. 저희 팀은 여러 서비스들이 공통으로 사용할 수 있는 검색 플랫폼을 만들고, 다양한 서비스의 검색 기능을 안정적으로 지원하는 역할을 담당하고 있습니다.
이를 위해서는 가장 먼저 각 서비스의 데이터를 검색 엔진으로 가져와 색인(Indexing)하는 과정이 필요한데요, 이 글에서는 기존의 색인 파이프라인(Indexing Pipeline)을 운영하면서 마주했던 문제점들과, 이를 해결하기 위해 어떻게 파이프라인을 개선해 나갔는지 그 여정을 공유하고자 합니다.
저희가 운영하던 색인 파이프라인은 크게 두 가지 방식으로 동작했습니다. 첫째는 데이터 변경 사항을 실시간으로 반영하기 위한 온라인 색인(Online Indexing)이고, 둘째는 데이터 보정이나 사전 데이터 변경 등을 위해 하루에 한 번 전체 데이터를 다시 색인하는 오프라인 색인(Offline Indexing)입니다. 이 두 가지 파이프라인을 운영하면서 다음과 같은 문제점들에 직면하게 되었습니다.
위에서 언급한 4가지 문제를 해결하기 위해, 우리는 다음과 같은 명확한 목표를 세웠습니다.
생산성 높은 시스템을 만들기 위해서는, 반복적인 작업을 자동화하고 개발자가 핵심 비즈니스 로직에만 집중할 수 있도록 해야 합니다. 이를 위해 우리는 다음과 같은 원칙을 세우고, 설정 기반의 인터페이스를 설계했습니다.
원칙:
YAML 기반 설정 인터페이스:
(코드 블록: YAML 설정 인터페이스 예시)
# 예시: 데이터 소스 및 색인 관련 설정을 정의하는 YAML 파일
version: 1
name: my_service_datastore
storage: datastore
sources:
- table: my_service_table_v1
primary_key: id
schema: dbschema/my_service_table.yaml # 데이터 스키마 파일 경로
scan:
type: time
target_field: updated_at # 증분 색인을 위한 시간 기준 필드
ttl_interval_ms: 86400000 # 데이터 보존 기간 (예: 1일)
subscribe_message:
- topic: event.my_service.data_changed # 실시간 변경 이벤트를 수신할 Kafka 토픽
transform: my_custom_transform # 해당 이벤트를 처리할 변환 로직 이름
auto_indexing:
- option: simple
active_conditions: # 검색 엔진에 색인될 데이터의 조건
- deleted_at is null
# ... (배포, 리소스, 변환 파라미터 등 기타 설정)
(코드 블록: 자동 생성된 데이터 모델 코드 예시)
// Code generated by indexer/message. DO NOT EDIT.
// source: message/schema/my_service_datastore.yaml
package my_service_datastore
import (
"github.com/my-company/search-indexer/message/entity"
"time"
)
type MyServiceTableV1 struct {
ID int64 `db:"id" json:"id"`
Title *string `db:"title" json:"title"`
Content *string `db:"content" json:"content"`
UpdatedAt *time.Time `db:"updated_at" json:"updated_at"`
// ...
}
이러한 설정 기반 자동화 덕분에, 새로운 서비스를 검색 플랫폼에 연동하는 작업이 매우 간소화되었고, Airflow와 같은 워크플로우 관리 도구에 오프라인 색인 파이프라인(DAG)이 자동으로 생성되도록 연동하여 생산성을 극대화할 수 있었습니다.
운영 DB(Online DB)는 실시간으로 상태가 변하고, 직접적인 접근은 장애 포인트를 증가시킵니다. 또한, 매일 풀 스캔을 수행하는 것은 비용과 부하 측면에서 매우 비효율적입니다. 우리는 이 문제를 해결하기 위해 오프라인 스토리지(Offline Storage)를 중간 저장소로 활용하는 아키텍처를 도입했습니다.
updated_at
과 같은 시간 기준 필드를 통해 더 최신 데이터를 우선적으로 반영하고 머지(Merge)하여 데이터의 정합성을 유지합니다.이러한 구조를 통해 운영 DB에 대한 직접적인 의존성을 제거하고, 안정적이고 정제된 오프라인 데이터를 기반으로 색인 작업을 수행함으로써 파이프라인 전체의 안정성을 크게 향상시킬 수 있었습니다.
하루에 한 번, 수억 건의 데이터를 오프라인 스토리지에서 가져와 풀 색인하는 과정은 여전히 비용과 시간 측면에서 부담이었습니다. BigQuery와 같은 오프라인 스토리지는 파일 시스템 기반이므로, 특정 범위를 지정하여 조회하더라도 내부적으로는 풀 스캔(Full Scan)이 발생하여 비용이 많이 청구될 수 있습니다.
이러한 파티셔닝 전략을 통해, 각 프로세서는 전체 데이터가 아닌 일부 데이터만 처리하면 되므로 병렬 처리 효율이 극대화되었습니다. 그 결과, 하루에 수억 건의 데이터를 색인하는 데 걸리는 시간이 1~2시간 내외로 단축되었고, 쿼리 비용 또한 수만 원 수준으로 크게 절감하는 효과를 얻었습니다. 이 방식은 Apache Spark와 같은 대규모 분산 처리 프레임워크가 데이터를 처리하는 방식과 매우 유사하여, 저희 접근 방식에 대한 기술적 확신을 가질 수 있었습니다.
실시간 색인을 위해 사용하는 Kafka에는 특정 서비스로부터 초당 수천 건 이상의 이벤트가 쏟아져 들어오는 경우가 있습니다. 만약 이러한 이벤트를 하나씩 개별적으로 처리한다면, Kafka Lag가 발생하거나 DB 및 검색 엔진에 급격한 부하가 발생하여 다른 서비스에까지 영향을 미칠 수 있습니다.
(코드 블록: 스트리밍 윈도우 처리 로직 의사 코드)
// 예시: 스트리밍 파이프라인에서 TimeWindow를 적용하는 로직
streams := kafka.NewStream(topic, config.KafkaConsumer).
WindowTime(2000 * time.Millisecond). // 2초의 Tumbling Window 설정
Transform( ... ). // 개별 메시지 전처리
Process(func(ctx context.Context, window *TimeWindow) error {
// window.Messages()를 통해 2초간 모인 메시지 배치를 가져와
// 한 번에 변환하고 Bulk Indexing 수행
batchTransformAndIndex(ctx, window.Messages())
return nil
}, ...)
이러한 스트리밍 배치 처리 방식을 통해, 개별 이벤트 처리의 오버헤드를 크게 줄이고 DB 및 검색 엔진으로의 쓰기 작업을 최적화할 수 있었습니다. 그 결과, 소규모의 자원(예: 1.5 core Pod 8대)만으로도 초당 수만 건의 이벤트를 무리 없이 안정적으로 처리하는 고가용성 시스템을 구축할 수 있었습니다.
지금까지 검색 플랫폼의 색인 파이프라인이 가진 문제점들을 해결하고, 안정성과 생산성을 높이기 위해 고민하고 개선했던 과정들을 공유했습니다.
서비스가 성장할수록 더 많은 데이터를 더 효율적으로 처리할 수 있는 구조는 필수적입니다. 저희는 여기서 멈추지 않고, Transform 로직 자동 생성, 벡터 임베딩 및 LLM을 활용한 시맨틱 검색, 모델 서빙(Inference) 파이프라인 통합, 지속적인 테스트 및 모니터링 강화 등 더욱 편리하고 신뢰할 수 있는 검색 환경을 제공하기 위해 다양한 시도를 계속하고 있습니다.
이 글이 대규모 데이터 처리와 검색 시스템 아키텍처에 대해 고민하는 분들께 작은 영감이나마 드릴 수 있었기를 바랍니다. 긴 글 읽어주셔서 감사합니다.