RAG 애플리케이션 성능 테스트: 완전한 엔지니어링 가이드
요약
RAG 애플리케이션의 성능과 품질을 동시에 검증하기 위한 엔지니어링 가이드를 제공합니다. k6를 활용한 지연 시간 부하 테스트와 DeepEval을 이용한 LLM-as-judge 품질 평가를 CI/CD 파이프라인에 통합하는 방법을 다룹니다.
핵심 포인트
- RAG 테스트는 속도(성능)와 답변 품질(정확성)이라는 두 가지 게이트가 필요함
- k6를 사용하여 엔드 투 엔드 지연 시간 및 부하 테스트 수행 가능
- DeepEval을 통해 충실도와 답변 관련성 등 품질 지표 평가
- GitHub Actions를 활용해 성능 및 품질 저하를 CI/CD 단계에서 자동 포착
이 블로그 포스트에서는 속도와 정확성 모두를 다루며 RAG (Retrieval-Augmented Generation, 검색 증강 생성) 애플리케이션을 적절하게 성능 테스트하는 방법과, 회귀(regression)가 프로덕션에 도달하기 전에 포착될 수 있도록 두 가지 요소를 CI/CD 파이프라인에 연결하는 방법을 살펴보겠습니다.
RAG 애플리케이션의 성능 테스트에는 두 가지 별도의 테스트 게이트(testing gates)가 필요합니다. 하나는 속도를 위한 것이고, 다른 하나는 답변 품질을 위한 것입니다. 전통적인 부하 테스트(load testing) 도구는 응답 시간(response times)을 측정하지만, 모델이 빠르게 답변을 내놓더라도 검색된 문서가 아닌 조작된 컨텍스트에 기반하여 사실과 다른 답변을 내놓는 환각 (hallucinations) 현상은 감지할 수 없습니다.
이 가이드는 엔드 투 엔드 지연 시간 (end-to-end latency) 부하 테스트를 위해 k6를 사용하고, LLM-as-judge 접근 방식을 사용하여 충실도 (faithfulness)와 답변 관련성 (answer relevancy)을 평가하기 위해 DeepEval을 사용하는 방법을 보여줍니다. 두 게이트 모두 GitHub Actions CI/CD 파이프라인에 통합되어, 성능이나 출력 품질 중 어느 하나라도 저하되는 회귀 현상이 발생하면 프로덕션에 도달하기 전 모든 풀 리퀘스트 (pull request) 단계에서 자동으로 포착됩니다.
저처럼 JMeter나 k6 배경 지식을 가지고 계신 분이라면, RAG 엔드포인트에 대해 가장 먼저 떠올리는 본능은 아마도 부하 테스트를 실행하여 응답 시간을 확인하는 것일 겁니다. 그것은 절반의 성공일 뿐입니다. RAG 앱은 빠르고 확신에 차 있지만 완전히 틀린 답변을 내놓을 수 있으며, 단순한 부하 테스트로는 이를 절대 알 수 없습니다. 하나가 아닌 두 개의 테스트 영역, 즉 성능과 품질이 필요합니다. 이 가이드는 전체 과정에서 단일 실행 예제인
전통적인 API는 완전한 응답을 반환하며, 사용자는 왕복 시간 (round trip)을 측정합니다. 하지만 RAG 엔드포인트는 답변하기 전에 두 가지 비용이 많이 드는 작업을 수행합니다. 먼저 벡터 스토어 (vector store) 또는 검색 인덱스 (search index)에서 컨텍스트 (context)를 검색(retrieval)하고, 그다음 생성된 응답을 토큰 (token) 단위로 스트리밍 (streaming)합니다. 이 두 번째 부분이 매우 중요합니다. 단일 요청이 몇 초 동안 수백 개의 토큰을 스트리밍할 수 있으므로, 단일 수치로서의 "요청 지속 시간 (request duration)"은 두 가지 매우 다른 문제점을 숨기게 됩니다. 즉, 모델이 답변을 시작할 때까지 얼마나 걸렸는지, 그리고 일단 시작한 후 얼마나 빨리 생성했는지에 대한 문제입니다.
시작은 느리지만 생성은 빠른 시스템은 채팅 UI (chat UI)에서 타이핑을 하는 사용자에게 고장 난 것처럼 느껴집니다. 반대로 시작은 빠르지만 생성은 느린 시스템은 간단한 질문에는 괜찮지만, 긴 문서 요약에는 고통스럽습니다. 이 두 가지를 함께 평균 내는 것은 유용한 정보를 전혀 제공하지 못합니다.
두 가지 테스트 영역: 성능과 품질
저는 RAG 테스트를 동일한 엔드포인트(endpoint)를 대상으로 실행되는 두 개의 별개 게이트(gate)라고 생각합니다.
**성능 (Performance)**은 다음을 답합니다: 얼마나 빠른가, 그리고 부하 (load) 상황에서도 잘 견디는가? 이는 다른 모든 API 부하 테스트와 마찬가지로 k6의 역할이며, 단지 LLM 특화 메트릭 (LLM-specific metrics)이 계층적으로 추가될 뿐입니다.
**품질 (Quality)**은 다음을 답합니다: 답변이 실제로 검색된 내용에 근거(grounded)하고 있는가, 아니면 모델이 무언가를 지어냈는가? 이 지점에서 DeepEval이 등장하여, LLM을 판사 (judge)로 사용하여 모든 응답에 대해 충실도 (faithfulness)와 관련성 (relevancy)을 점수화합니다.
어느 한 쪽의 게이트만으로는 전체 상황을 알 수 없습니다. 환각 (hallucination)을 일으키는 빠른 RAG 앱은 정확하지만 느린 앱보다 나쁘며, 완벽하게 근거를 갖춘 앱이라도 응답에 8초가 걸린다면 정확도와 상관없이 사용자를 잃게 될 것입니다.
실제로 중요한 메트릭 (Metrics)
성능 메트릭 (Performance metrics)
| 메트릭 (Metric) | 알려주는 내용 |
|---|---|
| TTFT (Time to First Token) | 무언가 나타나기 전까지 사용자가 빈 화면을 응시하는 시간 |
| ... |
TTFT는 전체 시스템에서 사용자가 가장 눈에 띄게 인지하는 수치이며, 동시에 기존의 클래식한 부하 테스트 도구들이 분리해내도록 설계되지 않은 메트릭이기도 합니다. 왜냐하면 기존 도구들은 스트림 (streams)이 아닌 원자적 (atomic)인 요청/응답 사이클을 위해 설계되었기 때문입니다.
품질 메트릭 (Quality metrics)
| 메트릭 (Metric) | 의미 (What it tells you) |
|---|---|
| 충실도 (Faithfulness) | 답변이 검색된 컨텍스트 (context)에 근거하고 있는가, 아니면 지어낸 것인가 |
| ... |
이 네 가지 메트릭은 RAG 평가에서 진단적 비중의 대부분을 차지합니다. 충실도 (Faithfulness)와 답변 관련성 (answer relevancy)은 생성 (generation) 측면에 속하며, 컨텍스트 정밀도 (context precision)와 재현율 (recall)은 검색 (retrieval) 측면에 속합니다. 충실도는 낮지만 컨텍스트 재현율이 높다면, 검색기 (retriever)는 제 역할을 다했으나 모델이 이를 무시한 것입니다. 이는 검색 문제가 아니라 프롬프팅 (prompting) 문제입니다. 잘못된 구성 요소를 튜닝하기 전에 이 차이를 아는 것이 중요합니다.
DeepEval을 이용한 환각 (Hallucination) 탐지
여기서 RAGAS 대신 DeepEval을 사용하는 주된 이유는, DeepEval이 평가를 통과/실패 임계값(pass/fail thresholds)을 가진 pytest 테스트 케이스로 취급하기 때문이며, 이는 CI/CD 게이트(gate)에 정확히 필요한 형태입니다. 또한 DeepEval은 판사 모델 (judge model)로서 어떤 LLM이든 수용할 수 있으므로, 우리의 예시 앱이 우연히 Gemini를 사용하더라도 특정 벤더에 종속되지 않습니다.
다음은 우리의 JMeter 문서 보조 도구 (doc-assistant) 예시에 대한 테스트 케이스의 모습입니다:
from deepeval.metrics import FaithfulnessMetric, AnswerRelevancyMetric
from deepeval.test_case import LLMTestCase
from deepeval.models import GeminiModel
...
이를 pytest로 실행하면 다른 테스트와 마찬가지로 통과하거나 실패합니다. 이것이 핵심입니다.
만약 여러분이 깔끔한 TTFT (Time To First Token) 측정 방법을 찾으러 오셨다면, 여기서부터 좌절감을 느끼게 될 것입니다. k6 SSE 확장 프로그램(xk6-sse)이 k6 v2와 호환되지 않기 때문입니다. 이 확장 프로그램은 go.k6.io/k6 v1을 대상으로 하며, 업데이트가 되기 전까지는 k6 v2의 개선된 아키텍처를 사용할 것인지, 아니면 스트리밍 메트릭 (streaming metrics)을 제대로 측정할 수 있는 능력을 선택할 것인지 사이에서 고민해야 합니다.
따라서 동반 리포지토리(companion repo)는 실용적인 방식을 취합니다. k6의 내장 http 모듈을 사용하여 /chat 스트리밍 엔드포인트 대신 /chat/complete 엔드포인트를 테스트하는 것입니다. 커스텀 바이너리도, 확장 프로그램도 필요 없이 표준 k6만 사용합니다. 이 방식의 트레이드오프 (tradeoff)는 진정한 TTFT 측정을 포기해야 한다는 점입니다. /chat/complete는 응답을 반환하기 전에 전체 응답이 완료될 때까지 기다리기 때문입니다. 대신 여러분이 얻는 것은 엔드투엔드 레이턴시 (end-to-end latency)이며, 이는 시스템이 느린지 여부는 알려주지만, 왜 느린지는 알려주지 못한다는 점을 제외하면 여전히 유용합니다.
테스트 코드는 다음과 같습니다:
import http from 'k6/http';
import { Trend, Counter } from 'k6/metrics';
import { check } from 'k6';
...
테스트는 30초 동안 가상 사용자(virtual users)를 0명에서 10명으로 점진적으로 늘리고(ramp up), 1분 동안 유지한 다음, 다시 줄어듭니다(ramp down). 임계값(Thresholds)은 http_req_duration과 커스텀 메트릭인 total_duration_ms 모두에 대해 p95 < 6000ms로 설정되어 있습니다.
언제 다시 SSE로 전환해야 할까요? xk6-sse 리포지토리를 주시하세요. k6 v2 지원이 추가되면, 엔드포인트를 /chat/complete에서 /chat으로 바꾸고 Dockerfile에 SSE 확장 프로그램을 추가하기만 하면 진정한 TTFT 측정이 가능해집니다. 그때까지는 표준 k6를 사용하는 것이 가장 실용적인 경로입니다. 커스텀 빌드 없이 사용할 수 있지만, 스트리밍 동작이 아닌 엔드투엔드 레이턴시를 측정하게 된다는 주의 사항이 있습니다.
동반 리포지토리의 Express 앱에는 두 엔드포인트가 모두 포함되어 있어 준비가 되면 언제든 전환할 수 있습니다:
| 엔드포인트 (Endpoint) | 응답 (Response) | 상태 (Status) |
|---|---|---|
POST /chat | SSE 스트림 (SSE stream) | xk6-sse가 k6 v2를 지원할 때 사용 가능 |
POST /chat/complete | 전체 JSON (Full JSON) | 현재 k6 및 DeepEval에서 사용 중 |
두 게이트를 CI/CD에 연결하기
두 테스트가 로컬에서 모두 실행되면, 이를 GitHub Actions에 연결하는 것은 대부분 단순한 작업(plumbing)입니다. 앱을 시작하고, 상태가 정상(healthy)이 될 때까지 기다린 다음, k6 게이트(gate)와 DeepEval 게이트를 실행하면 됩니다. 두 테스트는 서로 독립적이므로 병렬로 실행합니다.
name: RAG CI
on: [pull_request]
...
두 작업(job) 모두 모든 풀 리퀘스트(pull request)에서 실행됩니다. 응답 시간을 늦추는 PR이나 모델을 조용히 환각(hallucination)하게 만드는 PR 모두, 리뷰어의 눈에 띄거나 프로덕션(production)에 도달하기 전에 동일한 방식으로 포착됩니다.
워크플로우가 통과되려면 GitHub 저장소(repo)에 두 개의 비밀값(secret)을 추가해야 합니다:
| Secret | Value |
|---|---|
GEMINI_API_KEY | https://aistudio.google.com/apikey 에서 발급받은 Gemini API 키 |
FILE_SEARCH_STORE_NAME | setup-store.js에서 확인한 스토어 이름 (형식: fileSearchStores/your-store-id) |
SLO 설정하기
저는 의도적으로 목표로 삼아야 할 단 하나의 보편적인 지연 시간(latency) 수치를 제공하지 않겠습니다. 채팅 스타일의 RAG 애플리케이션을 위한 1초 미만의 목표부터, 더 복잡한 문서 분석을 위한 3~5초의 예산까지 다양한 가이드라인을 보아왔습니다. 여러분에게 적합한 수치는 전적으로 검색 백엔드(retrieval backend), 모델, 그리고 사용자가 실제로 무엇을 하고 있는지에 따라 달라집니다. 먼저 자신의 베이스라인(baseline)을 대상으로 부하 테스트(load test)를 실행한 다음, 블로그 포스트(이 포스트를 포함하여)의 수치가 아닌 해당 베이스라인을 기준으로 임계값(threshold)을 설정하세요.
예제 저장소(repo)는 p95 < 6000ms를 시작점으로 사용하는데, 이는 테스트용 Gemini File Search RAG 앱이 gemini-3.5-flash를 사용하여 10명의 동시 사용자(concurrent users) 환경에서 달성하는 수치이기 때문입니다. 여러분의 결과는 다음 요소들에 따라 크게 달라질 것입니다:
- 모델 선택 (flash vs pro, 실제로 사용되는 컨텍스트 윈도우(context window)의 크기)
- 검색 백엔드 (벡터 DB(vector DB) 쿼리 시간, 검색된 청크(chunk)의 수)
- 문서의 크기와 복잡도
- LLM 제공업체까지의 네트워크 지연 시간(network latency)
정확한 수치와 관계없이 추적해야 할 사항은 다음과 같습니다:
- 중앙값(median)뿐만 아니라 p95 및 p99 지연 시간(latency)을 확인하세요. 사용자들이 불만을 제기하는 것은 바로 꼬리 부분(tail)의 경험입니다.
- 사용자 1명이 아닌, 예상되는 동시성(concurrency)에서의 지연 시간을 측정하세요. RAG 애플리케이션은 검색 병목 현상(retrieval bottlenecks)으로 인해 부하 상황에서 비선형적으로 성능이 저하되는 경우가 많습니다.
- 단일 실행의 통과/실패 여부가 아니라, 시간 경과에 따른 충실도(Faithfulness)와 답변 관련성(answer relevancy)의 추세를 확인하세요. 두 지표 모두 0.75 임계값을 통과하더라도, 0.90에서 0.78로 지속적으로 하락하는 지표는 하나의 신호입니다.
요약 (Wrap-up)
RAG 성능 테스트는 사실 하나의 트렌치코트를 입고 있는 두 가지 학문과 같습니다. 바로 LLM 인식 지표(LLM-aware metrics)를 활용한 고전적인 부하 테스트(load testing)와, 기존의 부하 테스트 도구로는 수행할 수 없었던 LLM-as-judge 방식의 품질 점수 측정입니다. 이 두 가지를 모두 실행하고 두 가지 모두를 통과 기준으로 삼는다면, 속도 테스트만으로는 그냥 지나칠 수 있는 성능 퇴보(regressions)를 잡아낼 수 있을 것입니다.
현재 도구들의 상태가 완벽하지는 않습니다. 예를 들어, k6 v2에서 직접 SSE 클라이언트를 작성하지 않고는 TTFT(Time To First Token)를 측정할 수 없으며, LLM-as-judge 점수 측정에는 고유의 일관성 문제(consistency quirks)가 있습니다. 하지만 이는 프로덕션에 배포하기 전 성능 퇴보를 잡아내기에 충분하며, 이것이 바로 CI/CD 게이트(gate)의 핵심 목적입니다.
전체 작동 애플리케이션, k6 스크립트, DeepEval 테스트, Docker Compose 설정, 그리고 5분 이내에 로컬에서 클론하여 실행할 수 있는 GitHub Actions 워크플로우를 확인하려면 companion GitHub repo를 방문하세요.
즐거운 테스트 되시길 바랍니다!
순수 부하 테스트가 놓친 환각(hallucination) 성능 퇴보를 경험해 보신 적이 있나요? 어떻게 이를 잡아냈는지 X(구 트위터)에서 답글을 남기거나 companion repo에 이슈를 생성하여 알려주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기