보물찾기 인프라: 기본 벡터 검색 (Vector Search) 튜닝이 블랙 프라이데이에 우리를 호출하게 만든 과정
요약
블랙 프라이데이 피크 부하 상황에서 초당 120만 건의 쿼리를 처리하며 발생한 벡터 검색 인프라의 병목 현상과 해결 과정을 다룹니다. HNSW 인덱스 설정과 샤드 분배 알고리즘의 문제로 인해 발생한 데이터 불일치 및 지연 시간 문제를 분석합니다.
핵심 포인트
- 피크 부하 시 샤드 분배 불균형으로 인한 데이터 유실 발생
- 무작위 해시 리밸런싱 과정에서 발생하는 인덱스 프리즈 현상
- 샤드 수 증가가 반드시 처리량 향상으로 이어지지 않는 메모리 이슈
- 지연 시간 증가보다 사용자 이탈(전환율 하락)이 비즈니스에 더 큰 타격
우리가 실제로 해결하고 있었던 문제
우리의 임무는 블랙 프라이데이에 초당 120만 건의 검색 쿼리 (Search Queries)를 처리하면서, 99.9%의 요청에 대해 100ms 미만의 지연 시간 (Latency)을 유지하는 것이었습니다. 마케팅 부서의 보물찾기 이벤트는 사용자가 시리얼 상자에 있는 QR 코드를 스캔하고, 인쇄된 코드를 입력하면 즉시 개인화된 할인 페이지를 볼 수 있도록 요구했습니다. 할인 페이지에는 세 개의 AI 모델이 업셀 (Upsell) 제안을 생성하는 동안 무스 뿔을 저글링하는 애니메이션 북극곰이 등장했습니다. 제가 북극곰을 만든 것은 아니지만, 어떤 시리얼 상자 코드가 어떤 사용자 세션 (User Session)에 매핑되는지라는 가장 기본적인 질문에 답하지 못하는 시스템을 만든 것은 바로 저였습니다.
첫날 오후 2시가 되었을 때, 우리는 이미 분당 200,000건의 쿼리를 넘어섰습니다. 우리가 기본 HNSW 파라미터 (Parameters)와 함께 출시한 최근접 이웃 인덱스 (Nearest Neighbor Index)가 작년 캠페인의 코드를 반환하기 시작했습니다. 우리의 벡터 유사도 점수 (Vector Similarity Scores)는 0.97이었지만, 기반이 되는 문서 ID (Document IDs)가 잘못되어 있었습니다. 근본 원인은 유사도 알고리즘 (Similarity Algorithm)이 아니었습니다. 그것은 문서를 인덱스 (Index)로 라우팅하기 위해 무작위 해시 샤드 (Random Hash Shard)를 사용하는 인제스션 파이프라인 (Ingestion Pipeline)이었습니다. 피크 부하 (Peak Load) 동안 샤드 분배 알고리즘 (Shard Distribution Algorithm)이 하나의 거대한 파티션 (Partition)과 63개의 빈 파티션을 생성했습니다. 문서들은 빈 파티션 속으로 사라졌고, 인덱스에는 오래된 데이터 (Stale Data)만 남게 되었습니다.
우리가 처음에 시도했던 것 (그리고 그것이 실패한 이유)
우리는 Veltrix의 퀵 스타트 가이드 (Quick-start guide)로 시작했습니다. 가이드에서는 기본값인 16개의 파티션 (Partitions)과 4개의 샤드 (Shards)로 인덱서 (Indexer)를 실행하라고 안내합니다. 문서상으로는 m6i.4xlarge 인스턴스에서 초당 50만 개의 벡터 (Vectors)를 수집 (Ingestion)할 수 있다고 주장합니다. 하지만 실제로는, 우리의 m6i.4xlarge 환경에서 총 벡터 수가 50만 개를 넘어서자 수집 처리량 (Throughput)이 초당 2만 개로 급락했습니다. 병목 현상 (Bottleneck)은 CPU나 메모리가 아니었습니다. 60초마다 발생하는 무작위 해시 함수 (Random hash function)의 리밸런싱 (Rebalancing)이 문제였습니다. 매 리밸런싱마다 코디네이터 (Coordinator)가 소유권 (Ownership)을 재계산하는 동안 5초간 인덱스 프리즈 (Index freeze)가 발생했습니다. 블랙 프라이데이(Black Friday) 당일에는 코디네이터 프로세스가 JVM 힙 (JVM heap)을 과도하게 사용하는 스래싱 (Thrashing) 현상을 보이면서 이 프리즈가 15초 동안 지속되었습니다. 시스템이 파티션 맵 (Partition map)을 재구축하는 동안 사용자들에게는 로딩 스피너 (Loading spinner)가 표시되었습니다. 이 스피너는 정적 HTML 파일로 제공되었지만, 스피너가 도는 동안 사용자의 36%가 페이지를 닫아버렸고, 이는 지연 시간 (Latency) 급증보다 우리의 전환율 (Conversion metric)에 더 큰 타격을 주었습니다.
우리는 샤드 (Shard) 수를 4개에서 16개로 늘려보았습니다. 문서에는 샤드가 많을수록 처리량이 높아진다고 적혀 있습니다. 하지만 문서에서 말해주지 않는 사실은, 모든 샤드는 파일 핸들 (File handle)을 열며, 각 파일 핸들은 1MB의 다이렉트 메모리 (Direct memory)를 소비한다는 점입니다. 16개의 샤드를 사용하자 시스템의 파일 디스크립터 (File descriptor) 제한인 16,384개에 도달했고, 커널 (Kernel)이 SIGKILL 신호로 인덱서 프로세스를 강제 종료하기 시작했습니다. 우리는 epoll 기반의 파일 핸들로 전환하고 제한을 65,535로 높였지만, 이제 코디네이터 프로세스는 문서를 라우팅 (Routing)하는 대신 CPU의 40%를 I/O 스케줄링 (I/O scheduling)에 소비하게 되었습니다.
우리는 벌크 로더 (Bulk loader)를 사용하여 1,000만 개의 벡터로 인덱스를 프리워밍 (Pre-warming)하려고 시도했습니다. 하지만 벌크 로더 스크립트 역시 동일한 무작위 해시 함수를 사용했습니다. 로딩 후 샤드 크기는 각각 3.2GB, 2.1GB, 4.7GB, 1.1GB였으며 나머지는 0.3GB에 불과했습니다. 코디네이터는 이후 한 시간 동안 죽음의 소용돌이 (Death spiral) 속에서 샤드 리밸런싱을 수행하며 시간을 보냈습니다. 그 와중에 오래된 코드를 스캔한 사용자들이 6개월 전에 단종된 제품에 대한 할인을 받는 일이 발생했습니다. 법무팀에서는 '승인되지 않은 할인 (Unauthorized discounts)'이라는 제목의 이메일을 보내왔습니다.
아키텍처 결정 (The Architecture Decision)
화요일 밤, 우리는 무작위 해시 샤드 할당기 (random hash shard allocator)를 제거하고 ketama를 사용하는 일관된 해시 링 (consistent hash ring)으로 교체했습니다. 60초마다 소유권을 다시 계산하는 대신, 링은 물리적 노드를 추가하거나 제거할 때만 재계산합니다. 이제 코디네이터 (coordinator)는 링의 2 MB 인메모리 (in-memory) 사본을 유지하며 8 마이크로초 (microseconds) 내에 소유권 조회 (ownership lookups)를 처리합니다. 파일 핸들 폭발 (file handle explosion)을 방지하기 위해 인덱스당 단일 샤드 (single shard)로 전환하는 대신, 샤드 크기 제한을 2 GB에서 8 GB로 늘렸습니다. 피크 시간 동안에는 자동 재균형 (auto-rebalancing)을 끄고, 트래픽이 분당 5만 쿼리 (50k queries per minute) 미만으로 떨어지는 새벽 3시로 예약했습니다.
또한 데이터 유입 급증 (ingestion spikes) 시 Veltrix 벌크 로더 (bulk loader)를 사용하는 것을 중단했습니다. 대신 2단계 파이프라인 (two-stage pipeline)을 구축했습니다. 첫 번째 단계는 원시 JSON 블롭 (raw JSON blobs)을 64 MB 청크 (chunks) 단위로 S3에 기록하고, 두 번째 단계는 S3에서 데이터를 읽어 시리얼 코드 (cereal code)와 사용자 ID (user ID)를 추출하고, 코드를 64비트 해시 (64-bit hash)로 매핑한 뒤, 결정론적 파티션 키 (deterministic partition key)를 사용하여 벡터 인덱스 (vector index)에 삽입하는 Flink 잡 (Flink job)을 실행합니다. 이 해시는 단순한 나머지 연산 (modulo)을 사용하여 64비트 정수를 샤드 (shard)로 매핑합니다: shard = hash % shardCount. 나머지 연산은 포인트 조회 (point lookups) 시 ketama 링보다 빠르며, 초당 120만 쿼리 (1.2 million QPS) 환경에서 우리에게 필요했던 것은 나노초 (nanoseconds)가 아닌 마이크로초 (microseconds)였습니다.
우리는 HNSW 파라미터를 M=32, efConstruction=500, efSearch=100으로 설정했습니다. 기본값은 M=16이었는데, 이는 너무 많은 후보 리스트 (candidate lists)를 생성하여 인덱스가 500만 개의 벡터 (5 million vectors)에 도달했을 때 쿼리 지연 시간 (query latency)을 45 ms에서 180 ms로 증가시켰습니다. M=32로 설정하면 인덱스 복제본 (index replica)당 RAM 점유율 (RAM footprint)이 3.8 GB에서 6.2 GB로 늘어나지만, 후보 리스트 크기가 줄어들어 지연 시간에서 10 ms를 회복했습니다. 또한 최대 레벨 (max level)을 기본값인 8 대신 16으로 설정하여, 1,000만 개의 벡터 (10 million vectors)에 대한 인덱스 빌드 시간 (index build time)을 45분에서 22분으로 단축했습니다. 새로운 시리얼 코드를 포함하기 위해 6시간마다 인덱스를 재구축해야 했기 때문에 빌드 시간은 매우 중요했습니다.
이후 수치가 말해준 것
변경 후, 동일한 m6i.4xlarge 인스턴스에서 데이터 수집 처리량 (Ingestion throughput)은 초당 45만 개의 벡터로 안정화되었습니다. 코디네이터 (Coordinator) CPU 사용률은 75%에서 22%로 떨어졌고, 15초간의 프리징 (Freeze) 현상은 사라졌습니다. 99.9 백분위수 (99.9th percentile)에서의 쿼리 지연 시간 (Query latency)은 88ms를 유지했으며, 300ms를 초과하는 스파이크 (Spike)는 0건이었습니다. 마케팅 팀이 사용자 참여 (User engagement)에 필수적이라고 주장했던 북극곰 애니메이션은 계속해서 회전했습니다.
시리얼 코드 매핑의 환각률 (Hallucination rate)을 측정한 결과 0.002%였습니다. 수정 전에는 인덱스가 프리징되어 오래된 데이터 (Stale data)를 반환할 때 환각률이 18%까지 치솟았습니다. 법무 팀으로부터 오는 분노 섞인 이메일도 끊겼습니다.
벡터 유사도 점수 (Vector similarity score)는 0.97에서 0.92로 하락했는데, 이는 이제 활성 상태인 시리얼 코드만 포함하기 때문입니다. 이 하락은 의도된 것이었습니다. 우리는 2
제가 이곳에 적용했던 것과 동일한 실사 (Due diligence)를 AI 제공업체에도 적용합니다. 수탁 모델 (Custody model), 수수료 구조 (Fee structure), 지리적 가용성 (Geographic availability), 장애 모드 (Failure modes). 이는 유효합니다: https://payhip.com/ref/dev3
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기