들어가며
TechFork의 검색은 BM25와 kNN을 결합한 하이브리드 구조로 동작합니다. 기능적으로는 잘 동작했지만, 부하 테스트를 진행하면서 몇 가지 성능 병목이 드러났습니다. 첫 번째 쿼리가 비정상적으로 느린 문제, 순차 실행으로 인한 불필요한 대기 시간, kNN 연산의 CPU 부하, 그리고 스레드 풀 설정이 실제 CPU 코어 수와 맞지 않아 발생하는 경합 문제까지.
이 글에서는 네 가지 최적화를 적용한 순서대로 정리합니다. 각 단계에서 무엇이 문제였고, 어떻게 해결했고, 수치가 어떻게 변했는지를 중심으로 설명합니다.
검색 파이프라인의 전체 구현 구조는 하이브리드 검색 구현 구조 정리 글을 참고해주세요.
실험 환경
- 인프라: Oracle Cloud ARM 인스턴스 (4코어, 24GB RAM)
- 부하 테스트 도구: k6
- 측정 지표: p50, p95, p99, RPS(Requests Per Second), 에러율
- 인덱스 규모: 약 5,800건, 290MB, force merge 후 단일 세그먼트
1. ES 인덱스 준비 최적화 — force merge + store.preload + warmup
문제
검색 파이프라인 자체는 정상적으로 동작했지만, 두 가지 문제가 있었습니다.
첫째, RSS 크롤링으로 문서가 추가·삭제될 때마다 ES 세그먼트가 늘어나, 검색 시 불필요한 비용이 발생하고 있었습니다.
둘째, 서버 기동 직후나 트래픽이 없던 시간대 이후의 첫 번째 검색 요청이 비정상적으로 느렸습니다. kNN 검색에 필요한 HNSW 인덱스 파일(.vec, .vem, 약 400~500MB)이 디스크에만 있어서, 첫 쿼리 시 디스크 I/O가 발생했기 때문입니다. 기존에 쿼리 기반 warmup을 시도했지만, 특정 벡터 공간 근방만 캐시에 올라와서 새로운 키워드로 검색할 때마다 지연이 재발했습니다. 또한 idle 상태가 약 8분 이상 지속되면 OS 페이지 캐시가 정리되면서 다시 느려지는 현상도 확인되었습니다.
해결
Force Merge: RSS 크롤링 배치가 완료된 뒤 force merge를 실행해 세그먼트를 하나로 합칩니다. 세그먼트가 단일화되면 검색 시 세그먼트 간 병합 비용이 사라집니다.
store.preload: index.store.preload: [vec, vem] 설정으로 ES가 인덱스를 열 때 HNSW 파일 전체를 OS 페이지 캐시에 선제 로드하도록 했습니다. 쿼리 기반 warmup과 달리 벡터 공간 전체가 메모리에 올라오기 때문에, 어떤 키워드로 검색하든 첫 쿼리부터 디스크 I/O 없이 동작합니다.
기동 시 warmup 쿼리: 애플리케이션 기동 시 실제 검색과 동일한 경로를 타는 warmup 쿼리를 한 번 실행합니다. 이를 통해 ES 내부의 필드 데이터 캐시까지 채워져서, 실제 사용자의 첫 번째 요청부터 정상적인 응답 시간을 보장할 수 있습니다.
5분 주기 keepalive 쿼리: 트래픽이 없는 시간대에도 캐시가 evict되지 않도록 5분 주기로 더미 kNN 쿼리를 실행합니다. 더미 벡터는 [-1, 1] 범위의 랜덤 float를 사용했는데, zero 벡터는 코사인 유사도가 undefined이기 때문입니다. 평상시 ES CPU 사용률 0.19%로 부하는 무시할 수 있는 수준입니다.
측정 결과
서버 기준 검색 latency를 store.preload 적용 전후로 비교했습니다.
| preload 없음 (1번째) | preload 없음 (2번째) | preload 있음 (1번째) | preload 있음 (2번째) | |
| 키워드 "ai" | 1112ms | 425ms | 775ms | 413ms |
| 키워드 "llm" | 706ms | 505ms | 458ms | 442ms |
첫 번째 쿼리 기준으로 "ai"는 1112ms → 775ms (약 30% 개선), "llm"은 706ms → 458ms (약 35% 개선)입니다. 두 번째 이후 쿼리는 preload 유무와 관계없이 400~500ms로 동일한데, 이는 첫 쿼리 이후에는 어차피 캐시가 채워지기 때문입니다. 즉 store.preload의 핵심 가치는 첫 번째 쿼리의 cold start 문제를 해결하는 데 있습니다.
2. BM25·임베딩 병렬 실행 — CompletableFuture
문제
기존 검색 흐름에서는 검색어의 embedding 벡터를 먼저 생성하고, 그 결과를 받은 뒤에야 BM25 검색과 kNN 검색이 시작되는 순차 구조였습니다. 그런데 BM25 검색은 텍스트 기반 매칭이라 embedding 벡터가 필요하지 않습니다. embedding API 호출이 끝날 때까지 BM25 검색이 불필요하게 대기하고 있었던 셈입니다.
해결
CompletableFuture를 사용해 BM25 검색을 embedding API 호출과 동시에 실행하도록 변경했습니다. embedding이 완료되면 곧바로 kNN 검색을 실행하고, BM25 결과와 kNN 결과가 모두 준비되면 RRF 결합을 수행합니다.
[변경 전 — 순차 실행]
embedding 생성 (100ms) → BM25 검색 → kNN 검색 → RRF 결합
[변경 후 — 병렬 실행]
embedding 생성 (100ms) ──→ kNN 검색 ──┐
BM25 검색 ─────────────────────────────┤→ RRF 결합
측정 결과
평균 검색 latency가 550ms → 450ms (약 18% 감소) 했습니다. embedding API 호출이 약 100ms 소요되는데, 이 시간 동안 BM25 검색이 이미 실행되고 있으므로 그만큼 전체 대기 시간이 줄어든 것입니다.
3. vectorChunkBoost 제거 — kNN 연산 경량화
문제
병렬 실행을 적용한 뒤에도 부하가 올라가면 RPS가 기대만큼 늘지 않았습니다. 원인을 추적해보니 kNN 검색에서 contentChunks.embedding 필드까지 포함해 코사인 유사도를 계산하고 있었고, 이 연산이 CPU를 상당히 소모하고 있었습니다. 4코어 환경에서 ES와 애플리케이션이 CPU를 공유하는 상황이라, kNN 연산이 무거울수록 CPU 경합이 심해지는 구조였습니다.
해결
vectorChunkBoost를 0으로 설정해 kNN 검색 대상에서 chunk embedding을 제외했습니다. 이 결정은 사전에 검색 품질 평가(Phase 3)에서 vector chunk를 제외해도 nDCG, Recall 모두 유의미한 차이가 없다는 근거를 확보해둔 상태였기 때문에 가능했습니다.
판단
별도의 before/after 부하 테스트 수치를 남기지 못한 것은 아쉬운 점입니다. 다만 chunk embedding에 대한 코사인 유사도 계산이 문서 수만큼 반복되는 CPU-intensive 연산이라는 점, 그리고 이후 스레드 풀 튜닝에서 CPU 경합 감소가 핵심 개선 요인이었다는 점을 고려하면, vectorChunkBoost 제거가 CPU 여유를 확보하는 데 기여했다고 보고 있습니다.
4. 스레드 풀 튜닝 — searchAsyncExecutor
문제
병렬 실행을 도입한 뒤 부하 테스트를 진행하면서, 스레드 풀 크기가 성능에 미치는 영향을 확인할 필요가 있었습니다. 기존에는 searchAsyncExecutor의 corePoolSize를 넉넉하게 20으로 설정해 두었는데, 실제 서버는 4코어 ARM 인스턴스입니다. 코어 수 대비 스레드가 과도하게 많으면 CPU 컨텍스트 스위칭 비용이 발생하고, 반대로 너무 적으면 병렬성을 충분히 활용하지 못합니다.
실험
k6로 VU(Virtual Users) 15 기준 부하 테스트를 진행하면서, corePoolSize를 2, 4, 8, 20으로 바꿔가며 비교했습니다. VU 15를 기준으로 잡은 이유는 VU 5~10에서는 모든 설정이 비슷한 성능을 보여 차이가 드러나지 않았기 때문입니다.
| VU | corePoolSize | p50 | p95 | p99 | RPS | 에러율 |
| 5 | 20 (기준치) | 366ms | 522ms | 749ms | 1.73 | 0% |
| 10 | 20 (기준치) | 406ms | 642ms | 858ms | 3.48 | 0% |
| 10 | 8 | 415ms | 654ms | 886ms | 3.40 | 0% |
| 15 | 20 (기준치) | 644ms | 1531ms | 1934ms | 4.54 | 0% |
| 15 | 8 | 641ms | 1250ms | 1530ms | 4.58 | 0% |
| 15 | 4 | 560ms | 999ms | 1200ms | 4.86 | 0% |
| 15 | 2 | 2130ms | 2820ms | 3110ms | 3.18 | 0% |
판단
VU 15 기준으로, corePoolSize=4가 모든 지표에서 가장 좋은 결과를 보였습니다. 기준치(corePoolSize=20) 대비 p95가 1531ms → 999ms로 약 35% 개선되었고, RPS도 4.54 → 4.86으로 소폭 향상되었습니다.
corePoolSize=2는 극적으로 나빠졌습니다. p50이 2130ms로 치솟았고 RPS가 3.18까지 떨어졌는데, 이는 하이브리드 검색이 요청당 BM25와 kNN 두 개의 비동기 작업을 실행하기 때문입니다. corePoolSize=2에서는 동시 요청이 조금만 늘어도 스레드 풀이 포화되어 대기 시간이 급증합니다.
corePoolSize=4가 최적인 이유는 서버의 물리 코어 수와 일치하기 때문입니다. 4코어 머신에서 스레드가 4개이면 각 스레드가 코어 하나를 점유해 컨텍스트 스위칭 없이 실행됩니다. 스레드를 8개, 20개로 늘리면 코어를 놓고 경합이 발생해 오히려 느려집니다. 이는 Brian Goetz의 스레드 풀 사이징 공식(N_threads = N_cpu * (1 + W/C))에서도 CPU-bound 작업(W/C가 작은 경우)의 최적 스레드 수가 CPU 코어 수에 가까워지는 것과 일치합니다.
다만 현재 검색이 embedding API 호출(~100ms)과 ES 쿼리라는 I/O-bound 요소를 포함하고 있음에도 corePoolSize=4가 최적인 것은, 단일 인스턴스에서 ES도 함께 동작하고 있어 실질적으로 CPU가 병목이 되는 환경이기 때문입니다. ES가 별도 노드로 분리되면 애플리케이션 서버의 CPU 여유가 생기므로, 그때는 I/O 대기 시간을 활용할 수 있는 더 큰 풀 사이즈가 유리해질 수 있습니다.
최적화 요약

총 정리
| 최적화 | 측정 환경 | 핵심 개선 |
| force merge + store.preload + warmup | 서버 latency (단일 쿼리) | 첫 쿼리 cold start 30~35% 개선 |
| CompletableFuture 병렬 실행 | 서버 latency (단일 쿼리) | 평균 latency 550ms → 450ms (18% 감소) |
| vectorChunkBoost 제거 | 별도 수치 없음 | CPU 부하 감소 (kNN 코사인 유사도 연산 축소) |
| corePoolSize 4 튜닝 | k6 부하 테스트 (VU 15) | p95 1,531ms → 999ms (35% 개선) |
돌아보며
네 가지 최적화를 진행하면서 공통적으로 느낀 점이 있습니다.
첫째, 측정 없이는 최적화할 수 없습니다. store.preload의 효과는 "첫 번째 쿼리"에서만 나타나고, 스레드 풀 튜닝의 효과는 "VU 15 이상"에서만 드러났습니다. 특정 조건에서만 발현되는 병목은 체계적인 부하 테스트 없이는 발견하기 어렵습니다.
둘째, 이론적 최적값과 실측 최적값은 다를 수 있습니다. Brian Goetz 공식에 따르면 I/O-bound 작업에서는 코어 수보다 많은 스레드가 유리한데, 실제로는 ES와 CPU를 공유하는 환경이라 코어 수와 동일한 4가 최적이었습니다. 공식은 출발점을 잡아주지만, 최종 결정은 실측이 해야 합니다.
셋째, 최적화의 순서가 중요합니다. store.preload로 cold start를 해결한 뒤에야 병렬 실행의 효과를 정확히 측정할 수 있었고, 병렬 실행을 도입한 뒤에야 스레드 풀 크기가 성능에 미치는 영향이 명확해졌습니다. 앞 단계의 노이즈가 제거되어야 다음 단계의 신호가 보입니다.
넷째, 변인 통제의 아쉬움이 남습니다. 이번 최적화에서는 각 단계마다 측정 환경이 달랐습니다. preload는 서버 단일 쿼리로, 병렬 실행도 서버 latency로, 스레드 풀 튜닝만 k6 부하 테스트로 측정했고, 사용한 쿼리도 단계마다 동일하지 않았습니다. 그 결과 네 단계 점진적 개선의 전체 흐름을 하나의 일관된 기준으로 보여주지 못했습니다. 다음에 이런 종류의 최적화를 진행한다면, 동일한 쿼리 세트와 동일한 부하 테스트 조건을 매 단계마다 적용해서 각 변경의 기여를 누적적으로 추적할 수 있도록 설계할 계획입니다.
'프로젝트 > Techfork' 카테고리의 다른 글
| 추천 품질을 4단계 실험으로 개선한 과정 — 임베딩 가중치부터 MMR Lambda까지 (0) | 2026.03.30 |
|---|---|
| 하이브리드 검색 구현 구조 정리 — BM25 + kNN + RRF + 개인화 리랭킹 (0) | 2026.03.25 |
| 검색 품질을 5단계 실험으로 개선한 과정 — 필드 가중치부터 쿼리 구조까지 (0) | 2026.03.25 |
| 검색 평가용 Ground Truth 구축 정리 (0) | 2026.03.25 |
| [26/02/24] 오늘의 개발 일지 - JWT 인증 필터에 Redis 캐싱 도입 (0) | 2026.03.02 |