대규모 검색을 위한 색인 파이프라인 개선기: 안정성과 생산성을 잡다

이동휘·2025년 6월 10일
0

매일매일 블로그

목록 보기
25/49

다양한 서비스가 결합된 대규모 플랫폼에서는 사용자들이 원하는 정보를 빠르고 정확하게 찾을 수 있도록 지원하는 검색 기능이 매우 중요합니다. 저희 팀은 여러 서비스들이 공통으로 사용할 수 있는 검색 플랫폼을 만들고, 다양한 서비스의 검색 기능을 안정적으로 지원하는 역할을 담당하고 있습니다.

이를 위해서는 가장 먼저 각 서비스의 데이터를 검색 엔진으로 가져와 색인(Indexing)하는 과정이 필요한데요, 이 글에서는 기존의 색인 파이프라인(Indexing Pipeline)을 운영하면서 마주했던 문제점들과, 이를 해결하기 위해 어떻게 파이프라인을 개선해 나갔는지 그 여정을 공유하고자 합니다.


1. 기존 색인 파이프라인의 문제점: 우리가 마주한 4가지 도전 과제

저희가 운영하던 색인 파이프라인은 크게 두 가지 방식으로 동작했습니다. 첫째는 데이터 변경 사항을 실시간으로 반영하기 위한 온라인 색인(Online Indexing)이고, 둘째는 데이터 보정이나 사전 데이터 변경 등을 위해 하루에 한 번 전체 데이터를 다시 색인하는 오프라인 색인(Offline Indexing)입니다. 이 두 가지 파이프라인을 운영하면서 다음과 같은 문제점들에 직면하게 되었습니다.

  • 생산성 (Productivity): 데이터를 가져와 검색 엔진에 맞게 가공하고 색인하는 로직의 패턴은 대부분 유사했지만, 새로운 서비스가 추가될 때마다 해당 서비스만을 위한 별도의 색인 로직을 구현해야 했습니다. 이는 서비스가 늘어날수록 관리 포인트가 기하급수적으로 증가하는 결과를 낳았습니다.
  • 의존성 (Dependency): 색인 파이프라인이 각 서비스의 운영 데이터베이스(Online DB)에 직접 연결하여 데이터를 가져오는 구조였습니다. 이로 인해 색인 파이프라인은 각 서비스 DB에 매우 강한 의존성을 가지게 되었고, 서비스 DB의 스키마 변경이나 장애 발생 시 색인 파이프라인도 직접적인 영향을 받는 취약한 구조였습니다.
  • 비용 (Cost): 매일 수행되는 오프라인 풀 색인(Full Indexing) 작업은 각 서비스의 운영 DB 전체를 스캔(Full Scan)해야 했습니다. 이는 운영 DB에 상당한 부하를 주었고, 이 부하를 감당하기 위해 서비스팀에서는 색인 파이프라인만을 위한 별도의 복제 DB(Replica DB)를 만들어 주어야 하는 부담과 비용이 발생했습니다.
  • 가시성 (Visibility): 어떤 데이터 필드가 어떤 로직을 거쳐 어떻게 색인되는지, 특정 데이터가 왜 필터링되었는지 등을 파악하려면 매번 담당 개발자가 작성한 코드를 일일이 열어 분석해야 했습니다. 이는 파이프라인의 동작을 이해하고 디버깅하는 데 많은 시간을 소모하게 만들었습니다.

2. 새로운 색인 파이프라인을 향한 여정: 4가지 개선 목표 설정

위에서 언급한 4가지 문제를 해결하기 위해, 우리는 다음과 같은 명확한 목표를 세웠습니다.

  1. 생산성 향상: 설정 기반(Configuration-driven)의 인터페이스를 제공하고 작업을 자동화하여, 누구나 최소한의 노력으로 새로운 데이터 소스를 연동할 수 있는 생산성 높은 시스템을 만든다.
  2. 의존성 완화: 외부 서비스의 운영 DB에 대한 직접적인 의존성을 낮춰, 상태 변경이나 장애 발생에도 안전하고 독립적인 색인 파이프라인을 만든다.
  3. 비용 절감: 매일 수행되는 풀 색인의 부담을 줄이고, DB 리소스 및 인프라 비용을 최적화한다.
  4. 고가용성 확보: 대량의 이벤트가 쏟아지는 상황에서도 안정적으로 동작하는 고가용성 시스템을 만든다.

3. 문제 해결 과정: 어떻게 파이프라인을 개선했는가?

1) 설정 기반 인터페이스로 생산성 높이기: "코딩 대신 설정으로!"

생산성 높은 시스템을 만들기 위해서는, 반복적인 작업을 자동화하고 개발자가 핵심 비즈니스 로직에만 집중할 수 있도록 해야 합니다. 이를 위해 우리는 다음과 같은 원칙을 세우고, 설정 기반의 인터페이스를 설계했습니다.

  • 원칙:

    • 인터페이스에 명시된 동작만 수행되어야 하며, 숨겨진 마법 같은 로직은 배제한다.
    • 모든 데이터는 명확한 스키마를 기반으로 안전하게 처리되어야 한다.
    • 데이터의 입력(Input)과 출력(Output) 사이에는 커스텀 비즈니스 로직(Transform)을 자유롭게 삽입할 수 있어야 한다.
    • 플랫폼의 핵심 로직은 데이터 처리 외의 부가적인 작업에 관여하지 않아야 한다.
  • YAML 기반 설정 인터페이스:

    • 개발자가 쉽게 이해하고 작성할 수 있도록 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 Generation)을 통한 안정성 확보:
    • "모든 데이터는 스키마를 가져야 한다"는 원칙을 지키기 위해, 위 YAML 설정 파일에 정의된 스키마 정보를 바탕으로 데이터 모델(DTO/VO) 코드를 자동으로 생성하는 방식을 채택했습니다.
    • 이를 통해 컴파일 시점에 데이터 타입 안정성을 확보하고, 온라인 이벤트 데이터와 오프라인 배치 데이터를 동일한 스키마 구조로 일관되게 처리할 수 있었습니다.

(코드 블록: 자동 생성된 데이터 모델 코드 예시)

// 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"`
    // ...
}
  • 자유로운 데이터 변환 로직(Transform) 삽입:
    • 외부에서 수신한 메시지(예: Kafka 이벤트)를 검색 엔진에 적재하기 전에, 개발자가 자유롭게 비즈니스 로직을 추가하여 데이터를 가공할 수 있도록 Transform 인터페이스를 제공했습니다.
    • 개발자는 이 인터페이스를 구현하여 Kafka 메시지를 파싱하거나, 다른 데이터를 조합하거나, 특정 필드를 추가/제거하는 등 커스텀 변환 로직을 손쉽게 작성할 수 있습니다.

이러한 설정 기반 자동화 덕분에, 새로운 서비스를 검색 플랫폼에 연동하는 작업이 매우 간소화되었고, Airflow와 같은 워크플로우 관리 도구에 오프라인 색인 파이프라인(DAG)이 자동으로 생성되도록 연동하여 생산성을 극대화할 수 있었습니다.

2) 외부 DB 의존성 낮추기: "안전지대, 오프라인 스토리지를 활용하자!"

운영 DB(Online DB)는 실시간으로 상태가 변하고, 직접적인 접근은 장애 포인트를 증가시킵니다. 또한, 매일 풀 스캔을 수행하는 것은 비용과 부하 측면에서 매우 비효율적입니다. 우리는 이 문제를 해결하기 위해 오프라인 스토리지(Offline Storage)를 중간 저장소로 활용하는 아키텍처를 도입했습니다.

  • 아키텍처 변경:
    1. 중간 저장소로 오프라인 스토리지 활용: 운영 DB 대신, 모든 서비스의 데이터가 주기적으로 적재되는 BigQuery와 같은 오프라인 스토리지를 주 데이터 소스로 사용하기로 결정했습니다.
    2. 초기 데이터 적재 (Backfill): 새로운 데이터 소스를 연동할 때, 먼저 오프라인 스토리지의 전체 데이터를 가져와 검색 엔진에 초기 풀 색인(Backfill)을 수행합니다.
    3. 변경 사항 반영 (CDC 및 이벤트 기반): 운영 DB에서 발생하는 데이터 변경 사항은 상태 변경 이벤트(WAL - Write-Ahead Log 기반 또는 애플리케이션 레벨 이벤트) 형태로 Kafka와 같은 메시지 큐로 발행됩니다.
      • 이때, 이벤트 순서 보장이나 유실 문제를 완화하기 위해 이벤트 메시지에는 변경된 데이터의 ID만 포함시킵니다.
      • 별도의 CDC 스트리밍 애플리케이션이 이 ID를 기준으로 운영 DB에서 최신 데이터를 다시 조회하고, 스키마에 맞춰 변환한 후 오프라인 스토리지에 안정적으로 적재합니다.
    4. 데이터 정합성 확보: 오프라인 배치 색인 시, 오프라인 스토리지의 데이터와 스트리밍으로 수집된 최신 데이터를 비교하여, updated_at과 같은 시간 기준 필드를 통해 더 최신 데이터를 우선적으로 반영하고 머지(Merge)하여 데이터의 정합성을 유지합니다.

이러한 구조를 통해 운영 DB에 대한 직접적인 의존성을 제거하고, 안정적이고 정제된 오프라인 데이터를 기반으로 색인 작업을 수행함으로써 파이프라인 전체의 안정성을 크게 향상시킬 수 있었습니다.

3) 풀 색인 부담 낮추고 비용 절감하기: "파티셔닝으로 똑똑하게 읽자!"

하루에 한 번, 수억 건의 데이터를 오프라인 스토리지에서 가져와 풀 색인하는 과정은 여전히 비용과 시간 측면에서 부담이었습니다. BigQuery와 같은 오프라인 스토리지는 파일 시스템 기반이므로, 특정 범위를 지정하여 조회하더라도 내부적으로는 풀 스캔(Full Scan)이 발생하여 비용이 많이 청구될 수 있습니다.

  • 해결책: 파티셔닝(Partitioning) 및 분산 처리
    1. 임시 파티션 테이블 생성: 오프라인 색인 작업 시작 전에, 원본 데이터 테이블을 파티셔닝 규칙(예: 해시, 범위 등)에 따라 여러 개의 작은 임시 파티션 테이블로 분할하는 전처리 단계를 추가했습니다.
    2. 파티션 기반 분산 처리: 색인 파이프라인의 각 분산된 프로세서(Worker)는 자신에게 할당된 파티션 테이블의 데이터만 읽어서 처리하도록 했습니다.

이러한 파티셔닝 전략을 통해, 각 프로세서는 전체 데이터가 아닌 일부 데이터만 처리하면 되므로 병렬 처리 효율이 극대화되었습니다. 그 결과, 하루에 수억 건의 데이터를 색인하는 데 걸리는 시간이 1~2시간 내외로 단축되었고, 쿼리 비용 또한 수만 원 수준으로 크게 절감하는 효과를 얻었습니다. 이 방식은 Apache Spark와 같은 대규모 분산 처리 프레임워크가 데이터를 처리하는 방식과 매우 유사하여, 저희 접근 방식에 대한 기술적 확신을 가질 수 있었습니다.

4) 이벤트 폭증에도 안전한 고가용성 시스템 만들기: "스트리밍 배치로 부하를 제어하자!"

실시간 색인을 위해 사용하는 Kafka에는 특정 서비스로부터 초당 수천 건 이상의 이벤트가 쏟아져 들어오는 경우가 있습니다. 만약 이러한 이벤트를 하나씩 개별적으로 처리한다면, Kafka Lag가 발생하거나 DB 및 검색 엔진에 급격한 부하가 발생하여 다른 서비스에까지 영향을 미칠 수 있습니다.

  • 해결책: 스트리밍 배치 처리 (Streaming Batch Processing) 도입
    • Tumbling Time Window 적용: 특정 시간 간격(예: 2초) 동안 들어온 Kafka 이벤트를 하나의 배치(Batch)로 묶어서 한 번에 처리하는 Tumbling Time Window 방식을 도입했습니다.
    • StateStore 활용: 각 스트리밍 처리 인스턴스는 로컬 상태 저장소(StateStore)를 활용하여 윈도우 내의 이벤트들을 효율적으로 버퍼링하고 관리합니다.
    • 배치 변환 및 색인: 설정된 시간 윈도우가 닫히면, 모아둔 이벤트 배치를 한 번에 변환하고, 검색 엔진에 벌크(Bulk) API를 사용하여 색인합니다.

(코드 블록: 스트리밍 윈도우 처리 로직 의사 코드)

// 예시: 스트리밍 파이프라인에서 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) 파이프라인 통합, 지속적인 테스트 및 모니터링 강화 등 더욱 편리하고 신뢰할 수 있는 검색 환경을 제공하기 위해 다양한 시도를 계속하고 있습니다.

이 글이 대규모 데이터 처리와 검색 시스템 아키텍처에 대해 고민하는 분들께 작은 영감이나마 드릴 수 있었기를 바랍니다. 긴 글 읽어주셔서 감사합니다.

0개의 댓글