-
이코에코(Eco²) Scan API 성능 측정 및 시각화이코에코(Eco²) 2025. 12. 8. 04:36



실시간으로 녹아버리는 잔고....
공모전 수상 후 한 2-3일 간은 편히 쉬었다. 32일 골방 개발 + 이틀간 본선 쪽잠 작업의 반동이 꽤 크게 왔다.
이력서를 업데이트하고 관련 포지션에 이리저리 지원한 후, '구축한 모니터링이 아깝다.'는 생각에 성능 테스트를 진행해 봤다.import os from locust import HttpUser, task, between class ScanUser(HttpUser): # 사용자 간 대기 시간 (0.1~0.5초 랜덤 딜레이) -> 매우 공격적인 부하 wait_time = between(0.1, 0.5) @task def scan_classify(self): # 테스트용 이미지 (재활용 마크가 있는 이미지) payload = { "image_url": "https://images.dev.growbin.app/scan/1e89074f111d4727b1f28da647bc7c8e.jpg", "user_input": "" } # 인증 쿠키 설정 (환경변수에서 로드) cookie_val = os.getenv("ACCESS_COOKIE", "") headers = { "Content-Type": "application/json", "Cookie": f"s_access={cookie_val}" } # API 호출 및 결과 확인 with self.client.post("/api/v1/scan/classify", json=payload, headers=headers, catch_response=True) as response: if response.status_code == 200: response.success() elif response.status_code == 429: response.failure("Rate Limit (429)") elif response.status_code >= 500: response.failure(f"Server Error ({response.status_code})") else: response.failure(f"Unexpected Status ({response.status_code})")
대상은 이코에코의 주기능인 Scan, 테스트는 Locust로 진행했다.
간단히 진행했던 사전 테스트에서 제공받은 GPT API 한도를 다 썼기 때문에.. GPT 키는 내돈내산으로 따로 구매해 일괄 교체했다.
구축해 둔 인프라 덕에 OpenAI 키 교체는 원클릭으로 잘 됐다.(Thanks to External Secret Operator)
테스트는 5명의 동시 접속자가 루프를 돌며 지속적으로 scan/classify를 요청하게 구성했다.
API 호출이 쌓여야 유의미한 데이터를 얻을 수 있다고 판단해서 동시접속자 5명을 기준으로 잡고 10-20분가량 무한정 호출하며 데이터를 쌓았다.
@router.post("/classify", response_model=ClassificationResponse) async def classify( payload: ClassificationRequest, service: ScanService = Depends(), token: TokenPayload = Depends(access_token_dependency), ) -> ClassificationResponse: # ... return await service.classify(payload, token.user_id)async def classify( self, payload: ClassificationRequest, user_id: UUID ) -> ClassificationResponse: # ... (생략) # 동기 함수(process_waste_classification)를 별도 스레드에서 실행하여 Non-blocking 처리 pipeline_payload = await asyncio.to_thread( process_waste_classification, prompt_text, image_url, save_result=False, verbose=False, )테스트에 들어가기 전, Scan이 어떤 방식으로 래핑되어 있는지부터 살펴보자.
현재 Scan의 경우 FastAPI의 비동기 엔드포인트를 사용하되, 내부의 무거운 AI 추론 파이프라인(동기 로직)은 asyncio.to_thread를 활용한 Non-blocking 방식으로 처리하여, 단일 프로세스 내에서도 메인 루프 차단 없이 동시성을 확보했다.
api_scan 노드가 t3.medium으로 vCPU가 2개 남짓이기도 하고 기본 스레드 풀 사이즈 제한(2+4)으로 인해 무한정 확장은 어렵지만, I/O Wait이 긴 GPT 호출 특성상 유의미한 처리량 개선이 있다. 최대 병목 구간인 GPT API를 호출동안 서버가 얼지 않는다는 것도 이점으로 작용한다. (Scan이 동작할 때도 헬스체크가 살아있는 등)

다음은 Scan의 핵심인 분류/매칭 파이프라인이다.
Scan은 Rule-based retrieval 파이프라인을 따라 동작하며 이미지 Classification, Answer에서 별개의 모델을 사용한다.
모델은 GPT-5.1로 동일하나, 굳이 두 파트로 모델을 나눴던 이유는 아래와 같다. (AI 파트 분의 입장이다.)
1. Context가 과적재되어 LLM의 성능이 감소하지 않기 위함
2. 토큰 비용 절감
이를 전제로 파이프라인 스텝을 재정리하면 아래와 같다.
1. Classification: 분리배출 누리집 정보를 바탕으로 만든 계층적 분류 체계(YAML)를 모델에 주입해 이미지를 분류 (Vision Model)
2. Rule-based retrieval: 분류 결과를 키로 활용해 JSON 기반 지식 베이스에서 배출 방법을 검색, 별도의 외부 DB없이 로컬 메모리(Dictionary)를 사용
3. Disposal-Answer: 시스템 프롬프트로 부정 제약 조건을 명시해 LLM이 주입된 컨택스트 내에서 근거 기반 생성(Grounding)을 하도록 강제 (Answer Model)
default. Fail-fast: 정의된 분류 체계에 속하지 않는 입력은 즉시 파이프라인을 이탈
추가로 Character 매칭(/api/v1/internal/character...)이 존재하니 Scan API가 수행하는 단계는 총 4단계다.

metrics 수집에 앞서 사전작업이 필요했다. 설명했듯 Scan은 동기로 묶인 AI 파이프라인 3스텝 + Character 매칭 1스텝을 거친다.
단일 Latency 지표만으로는 병목 구간을 식별하기 어렵기에 Observability를 강화했다.
ScanService 내에서 asyncio.to_thread로 실행되는 AI 파이프라인을 논리적 단계(Step)로 분리하고, 각 구간별 실행 시간을 0.1초 단위로 측정하도록 커스텀 Histogram 메트릭(scan_pipeline_step_duration_seconds)을 도입했다.
Metric 스텝은 아래 4단계로 분류했다.- Vision Processing (step="vision"): OpenAI GPT-4o Vision 모델을 호출하여 이미지를 분석하고 JSON으로 파싱하는 구간.
- Rule-based Retrieval (step="rag"): 분류 결과를 Key로 활용하여 메모리 내 지식 베이스(JSON/YAML)에서 배출 규정을 검색하는 구간.
- Answer Generation (step="answer"): 검색된 규정과 사용자 질문을 결합하여 LLM이 최종 답변 텍스트를 생성하는 구간.
- Character Reward Matching (step="reward_match"): 분리배출 성공 여부를 판단한 후, 내부 Character API를 호출하여 보상 지급 가능 여부를 확인하는 구간.
측정한 지표는 p99, Avg Latency, Success Rate다.
이제 의도별로 적용한 prometheus 쿼리와 시각화된 메트릭을 살펴보자.
1. API P99 Latency (상위 1% 응답 속도)
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{service="scan-api", method="POST", path="/api/v1/scan/classify"}[5m])) by (le))
전체 요청 중 가장 느린 1%에 해당하는 요청들이 얼마나 걸렸는지 보여준다.평균이 3초라도 P99가 15초라면, 100명 중 1명은 15초를 기다리게 되므로 사용자 경험(UX) 개선의 핵심 지표다.
살펴볼 수 있듯 최악을 가정했을 때 12.5s에서 25s까지 요청이 늘어진다.
2. API 평균 Latency (Average Latency)
rate(http_request_duration_seconds_sum{service="scan-api", method="POST", path="/api/v1/scan/classify"}[5m]) / rate(http_request_duration_seconds_count{service="scan-api", method="POST", path="/api/v1/scan/classify"}[5m])
(전체 처리 시간의 합) ÷ (전체 요청 수)를 계산하여 구한 평균 처리 시간이다.
Scan API의 전반적인 처리 속도를 나타낸다. 대략 8-11초 사이로 P99와 가장 격차가 많이 벌어지는 구간은 14s까지 차이가 난다.
3. 단계별 평균 소요 시간 (Step-by-Step Average Duration)
Vision(Image Classification), 응답 시간이 3-4.5초 사이로 은근 길지 않은 모습이다. 
Rule-based retrieval, 단순 Dictionary 매칭이기에 응답시간이 굉장히 빠른 걸 확인할 수 있다. 
Answer(Disposal-answer), Answer Prompt에 맞게 답변을 생성하는 과정에서 오히려 많은 시간을 사용한다. 
Character Matching, 내부 로직으로 DB RW를 포함해도 0.03 - 0.045 사이의 처리시간을 보여준다. rate(scan_pipeline_step_duration_seconds_sum[1m]) / rate(scan_pipeline_step_duration_seconds_count[1m])
추가한 커스텀 메트릭을 사용하여 vision, rule-based retrieval, answer, character 각 단계별로 평균 시간을 보여준다.
어디가 병목인가?를 찾는 결정적인 쿼리다. 그래프가 Stacked 형태라면 전체 시간 중 어느 색깔(단계)이 가장 큰 비중을 차지하는지 한눈에 볼 수 있다. 의외로 Vision이 아닌 Answer의 처리 시간이 가장 긴 걸 확인할 수 있다.
4. 단계별 P99 Latency
Vision p99 
Rule-based retrieval p99 
answer p99 
Character Reward Matching p99 histogram_quantile(0.99, sum(rate(scan_pipeline_step_duration_seconds_bucket[5m])) by (le, step))
각 단계별로 상위 1%의 느린 시간을 보여준다. 특정 단계가 가끔씩 튀는 현상(Spike)을 잡아낼 때 유용하다.
단계별 P99 Latency(vision, rag, answer)와 전체 API P99 응답 시간을 비교한 결과, 전체 Latency의 추세와 스파이크 패턴이 answer 단계(LLM 답변 생성)의 응답 시간과 강한 상관관계를 보이는 것을 확인할 수 있다.
동일한 GPT-5.1 모델을 사용함에도, Vision보다 JSON 분류 답변 생성에서 안정성이 떨어지고, 시간이 많이 소요되는 게 인상적이다. 비율로 따지면 vision:answer = 1:2 정도의 응답 소요 시간을 가진다.
5. API 성공률 (Success Rate %)
sum(rate(http_requests_total{service="scan-api", method="POST", path="/api/v1/scan/classify", status_code!~"5.*"}[5m])) / sum(rate(http_requests_total{service="scan-api", method="POST", path="/api/v1/scan/classify"}[5m])) * 100
(성공한 요청 수) ÷ (전체 요청 수) × 100으로 측정했다. 여기서 성공은 5xx(서버 에러)가 아닌 모든 요청을 의미한다.
앞서 설명했듯 가용 스레드 6개인 환경에서 동시 접속자 5명을 가정하고 테스트했기에 성공률이 100%인 걸 확인할 수 있다.
더 robust한 환경을 만들 수도 있겠지만.. 내 계좌가 녹을 수도 있기에 정상 사이클 내로 들어오도록 테스트를 세팅했다.
6. CPU / Memory 사용량
CPU 사용량 (Cores) 
로컬 메모리 사용량 (GB) # cpu sum(rate(container_cpu_usage_seconds_total{namespace="scan", container="scan-api"}[1m])) # memory sum(container_memory_working_set_bytes{namespace="scan", container="scan-api"}) / 1024 / 1024 / 1024
CPU와 메모리 사용량은 낮은 수준으로 부하가 발생한 환경에서도 컴퓨팅 리소스로 인한 제약은 발생하지 않는다.
Max를 기준으로도 CPU는 2%, 메모리는 7% 정도만 소모 중이다.
이정도 사용량이면 API 노드에 Envoy Sidecar를 추가로 붙여도 운용에 무리가 없어보인다.
Auth에서 쿠키 인증을 헤더로 변경하는 작업이 예정된 상태인데 이 때 Istio 도입을 시도해 보기 좋은 근거가 된다.
위 지표를 종합하면 아래와 같다.- 측정 환경
- 파이프라인 단계별(vision, rag, answer) Custom Metric 도입
- FastAPI 비동기 엔드포인트 + asyncio.to_thread (Non-blocking 동시성 확보)
- Prometheus Histogram Bucket 세분화 (0.1초 단위 정밀 측정)
- 데이터 관측 결과
- 전체 P99 Latency: 약 12~15초 (부하 시 최대 25초까지 Spike 발생)
- 병목 구간 식별: 전체 응답 시간 그래프(Total P99)가 answer 단계(LLM 답변 생성) P99 그래프와 거의 동일한 추세로 움직임을 확인.
- 상관관계: vision(이미지 분석)이나 rule-based retrieval은 상대적으로 안정적인 반면, answer 단계가 전체 수행 시간의 약 70~80%를 점유하며 지연을 주도함.
- asyncio.to_thread로 서버 멈춤은 막았지만, 개별 요청 처리 시간 자체를 줄여주지는 못함. LLM API의 응답 속도(Vision, Answer)가 곧 Scan API의 Latency가 되는 구조.
- 컴퓨터 리소스 점유율은 미미.
실제 서버 로직과 GPT API 호출 로직의 수행시간이 수치상으로 100배가량 차이가 나는 상황으로 GPT 응답시간에 따라 Scan의 성능이 좌우된다. 이런 결과를 예측 못한 건 아니나 Vision과 Answer의 차이를 보면 주입되는 프롬프트를 수정해 처리시간을 줄일 여지는 있어 보인다. 이미지가 직접 삽입되는 Vision보다 Answer의 응답시간이 배로 긴 건 놀랍다. 둘의 차이는 생성 토큰양에 있지 않을까.
Vision은 이미지를 입력받고, 물체를 인식한 후 대분류/중분류 답변만 출력한다. 역할이 객체인식 + 분류로 상대적으로 가볍다.
반면 Answer의 경우, lite_rag(배출 규정 전체 텍스트)를 입력받고, 사용자에게 전달될 모든 Output JSON을 출력한다.
JSON엔 user_answer + 단계별 절차 + insufficiences가 포함되며, Vision에 비해 상대적으로 역할이 중첩된 상태다.
LLM은 입력에 비해 출력에 소모되는 리소스(시간, 비용, GPT 모델의 연산)가 크기 때문에, 경량화를 한다면 Answer의 역할을 분할하고 출력 토큰을 경량화하는 방향으로 진행이 되어야할 듯 싶다. 현재는 총 응답 시간이 평균 10초에서 최대 22초까지 소요되기에 현재 스레드 세팅(2+4)으론 다량의 동시 접속에 취약하다.

Number of users: 10, Ramp-up: 1, RPS 0.7 
Number of users: 100, Ramp-up: 5, RPS 2.9 

폭증하는 504 GATEWAY TIMEOUT과 Vision p99
추가 테스트 결과 동시 접속 10명, 응답시간+0.3초 간격 요청은 안정적으로 처리한다. 그렇지만 동시 접속 100명으로 진행하면 얼마 안가 504 GATEWAY TIMEOUT으로 요청이 드랍된다 (이코에코 ALB 타임아웃 설정이 60초인 상태다.)
동시 가용 인원이 6명, 평균 처리시간이 10초이니 100명만 되도 94 / 6 * 10s = 약 150s 가량의 딜레이가 생긴다.
추가로 파이썬은 GIL이라는 이슈가 있다. 한 스레드가 인터프리터를 점유하면 다른 스레드가 접근하지 못한다.
asyncio.to_thread를 사용하여 AI 파이프라인을 별도 스레드로 격리했지만, GIL로 인한 CPU 처리 병목과 제한된 스레드 풀 크기로 인해 요청이 몰릴 경우 대기열에서 지연되는 현상을 해소하지 못했다. (p99 - avg = 최대 14s)
대부분의 병목인 GPT I/O 동안에는 스레드 점유가 풀리긴 하지만.. 단순히 스레드 풀 크기를 늘려 CPU 경합을 심화시키는 것보다는 MQ를 두고 task_id로 큐잉하는 방안이 아무래도 실효성을 보기 좋을 듯싶다.'이코에코(Eco²)' 카테고리의 다른 글
이코에코(Eco²) 백엔드/인프라 코드 품질 분석기 도입 (1) 2025.12.20 [Dec.20.2025] 이코에코(Eco²) 백엔드/인프라 디자인 패턴 (0) 2025.12.20 [Dec.19.2025] 이코에코(Eco2) 백엔드/인프라 오픈소스 사용 현황 (0) 2025.12.19 이코에코(Eco²) Event Driven Architecture 전환 로드맵 (0) 2025.12.17 이코에코(Eco²) Auth Offloading: ext-authz 서버 개발기 (Go, gRPC) (0) 2025.12.13