FastAPI 의미 기반 검색 API, eBPF로 성능 병목 분석 및 개선점 생각해보기

박은서·2025년 11월 8일

MONITERING

목록 보기
4/4

서비스 느림 의심되는 원인에 대한 모니터링 진행

https://velog.io/@weeeeestern/%EC%BB%A4%EB%84%90-%EB%A0%88%EB%B2%A8%EC%97%90%EC%84%9C-fastapi-%EB%AA%A8%EB%8D%B8-%EC%A7%80%EC%97%B0-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%ED%95%98%EA%B8%B0

  • 로그인 1회
  • 서비스 이용 중, 병목이 몸소 체감됐던 의미기반 검색 api 5회 호출

1. 분석을 통해 파악한 사실 (Insights)

사실 1: 서비스 병목은 I/O 속도가 아닌 "I/O 횟수"이다.

  • Total Events: 975
  • Average Latency: 66us (66 마이크로초)
  • read: 60.38ms (731 calls)
  • write: 4.42ms (244 calls)

eBPF 프로파일러 결과, 개별 시스템 콜의 평균 지연 시간(66us)은 매우 빠르다는 것이 확인되었다.

진짜 문제는 속도가 아니라 압도적으로 많은 호출 횟수이다. read 시스템 콜에 소요된 총 시간 60.38ms는, read 자체가 느려서가 아니라 매우 빠른 read를 731번이나 반복 호출하여 누적된 결과이다.

사실 2: "의미 기반 검색 API"가 병목의 원인이다.

  • 실험: 로그인 1회 + 의미 기반 검색 5회 = 총 6회의 API 호출
  • 결과: read 731회 발생
  • 결론: API 1회 호출당 평균 121회read 시스템 콜이 발생했다. "로그인"보다 "의미 기반 검색" API가 이 오버헤드의 주범일 가능성이 매우 높다.

사실 3: "Fetch-All-Loop" 안티패턴이 확인되었다.

main.py 코드의 의미 기반 검색 로직은 다음과 같이 추정된다.

  1. 사용자 검색어를 벡터로 변환한다.
  2. DB에서 모든 기사의 벡터SELECT한다. (SELECT * FROM article_semantic_vectors)
  3. Python 애플리케이션 메모리로 가져온 모든 벡터for문으로 반복하며, 검색어 벡터와 일일이 코사인 유사도를 계산한다.

read 731회의 정체는 2번 단계에서 발생한다. DB 드라이버가 네트워크 소켓을 통해 DB의 모든 벡터 데이터를 애플리케이션으로 가져오기 위해 731번에 걸쳐 잘게 쪼개어 읽어온 것이다.

이것은 전형적인 "Fetch-All-Loop-in-Python" 안티패턴이며, 데이터가 증가함에 따라 성능이 치명적으로 저하되는 구조이다.


2. 향후 조치 계획 (Actions)

이 분석 결과를 바탕으로 다음과 같은 조치를 취할 수 있다.

조치 1: 유사도 계산 주체를 Python이 아닌 DB로 이전 (근본 해결책)

현재 방식은 기사가 1만 개면 1만 개를, 10만 개면 10만 개를 read해야 하므로 스케일링이 불가능하다.

그리고 현재 사용 중인 표준 MySQL RDSpgvector처럼 벡터 검색에 특화된 인덱스(HNSW, IVF 등)를 지원하지 않는다. 731번의 read를 유발하는 "Fetch-All-Loop"가 느린 근본적인 이유이다.

이 문제를 해결하기 위한 AWS 생태계 내의 조치는 다음과 같다.

  • 대안 1 (DB 마이그레이션): Amazon Aurora (MySQL 호환 에디션) 사용

    • RDS for MySQL이 아닌, Amazon Aurora (MySQL 호환)벡터 스토어 기능을 지원한다.
    • DB를 Aurora로 마이그레이션하면, pgvector와 유사하게 DB가 직접 코사인 유사도를 계산하고 상위 N개만 반환하도록 쿼리를 최적화할 수 있다.
    • 기대 효과: read 731회가 read 10~20회로 줄어들어, 가장 근본적인 병목이 해결된다.
  • 대안 2 (하이브리드): Amazon OpenSearch Service 도입

    • 기존 MySQL RDS는 그대로 유지하면서, 벡터 데이터만 Amazon OpenSearch (Elasticsearch 기반)에 저장한다. OpenSearch는 강력한 k-NN (벡터 검색) 인덱스를 제공한다.
    • 변경 후 흐름:
      1. 검색 API가 호출되면, OpenSearch에 "유사한 기사 ID 10개만 찾아줘"라고 쿼리한다.
      2. OpenSearch가 반환한 ID 10개를 가지고, MySQL RDS에 WHERE article_id IN (id1, id2, ...) 쿼리를 보내 최종 데이터를 가져온다.
    • 기대 효과: DB 마이그레이션 없이 벡터 검색 문제를 해결할 수 있다.

조치 2: 캐시(Cache) 도입

벡터 DB 도입이 당장 어렵다면, DB 마이그레이션이 복잡하다면,read 횟수는 줄일 수 없지만 read 속도를 높이는 차선책을 선택할 수 있다.

  • 해결책1 : Redis 같은 인메모리(In-Memory) 캐시를 도입한다.

  • 변경 후 흐름:

    1. article_semantic_vectors 테이블의 모든 벡터를 Redis에 미리 저장한다.
    2. 검색 API가 호출되면, 느린 MySQL(디스크 I/O)이 아닌 빠른 Redis(메모리 I/O)에서 731번 read를 수행한다.
  • 기대 효과: read 총 시간이 60.38ms에서 10~15ms 정도로 감소할 수 있다.

  • 한계: "Fetch-All-Loop"라는 근본적인 설계 문제는 해결되지 않아, 데이터 증가에 따른 부하는 여전히 존재한다.

  • 해결책2 : Amazon ElastiCache (Redis)를 도입한다.

  • 변경 후 흐름:

    1. article_semantic_vectors 테이블의 모든 벡터를 Redis에 미리 저장해 둔다.
    2. 검색 API가 호출되면, 느린 MySQL RDS(네트워크/디스크 I/O)가 아닌 매우 빠른 Redis(메모리 I/O)에서 731번 read를 수행한다.
  • 기대 효과: read 총 시간이 60.38ms에서 10~15ms 정도로 크게 감소할 수 있다.

  • 한계: "Fetch-All-Loop" 자체는 해결되지 않아, 기사가 수십만 개로 늘어나면 Redis의 메모리 한계와 루프 오버헤드에 부딪힌다.

조치 3: write 호출 최적화 (로그 버퍼링)

write: 4.42ms (244 calls)read에 비해 심각하지 않지만, 이 역시 불필요하게 잦은 호출이다.

  • 원인: API가 호출될 때마다 logging.info(...) 같은 로그를 즉시 파일이나 stdout에 244번 write한 것이다.
  • 해결책: 로깅 라이브러리의 설정을 "버퍼링(Buffering)" 모드로 변경한다.
  • 변경 후 흐름: 로그를 즉시 쓰지 않고, 메모리에 일정 시간 또는 일정량 모았다가 한꺼번에 write한다.
  • 기대 효과: write 244회가 write 5~10회로 줄어들어 불필요한 I/O가 감소한다.

0개의 댓글