분명히 비동기로 짰는데 왜 느려지는 거지. RAG 서버 운영하면서 가장 많이 했던 생각이 이거다. FastAPI는 처음부터 async를 전제로 설계된 프레임워크다. 동시성 처리에 강하다는 게 거의 상식처럼 통한다. 그런데 실제로 RAG 파이프라인을 얹어서 운영해보면, “비동기로 썼다”는 사실 자체가 성능을 보장해주지 않는다는 걸 몸으로 배운다.
이 글은 내가 운영하던 RAG API가 동시 요청 몇 개만 들어와도 응답 시간이 급격히 늘어지던 문제를 추적한 과정이다. 결론부터 말하면 코드는 비동기였는데, 그 안에 동기 코드가 숨어 있었다. 이 글이 비슷한 증상을 겪는 사람들에게 어디부터 들여다봐야 할지 힌트가 됐으면 한다.
증상, 동시 요청 두세 개부터 응답이 느려졌다
처음 문제를 인지한 건 모니터링 대시보드에서였다. 단일 요청에서는 응답 시간이 평균 1.2초 정도로 안정적이었다. 그런데 동시 요청이 3개만 들어와도 평균 응답 시간이 4초, 5초로 튀었다. 요청이 5개를 넘으면 일부 요청은 10초 가까이 걸렸다.
FastAPI는 ASGI 기반이라 동시성에 강하다고 알고 있었기 때문에, 처음엔 외부 LLM API 호출이나 임베딩 모델 호출이 느려진 거라고 짐작했다. 그런데 LLM API 자체의 응답 시간을 따로 측정해보니 정상이었다. 문제는 다른 곳에 있었다.
첫 번째 의심, 임베딩 함수가 동기 함수였다
코드를 다시 열어서 RAG 파이프라인의 흐름을 처음부터 따라가봤다. 사용자 질문이 들어오면 임베딩을 만들고, 그 임베딩으로 벡터 검색을 하고, 검색된 컨텍스트를 LLM에 넘겨서 답을 생성하는 구조였다.
엔드포인트 함수 자체는 async def로 선언돼 있었다. 그런데 그 안에서 호출하던 임베딩 생성 함수가 일반 def로 짜인 동기 함수였다. 임베딩 모델을 로컬에서 직접 추론하는 구조였는데, 이 추론 코드가 동기 방식으로 GPU를 호출하고 있었다.
async def 안에서 동기 함수를 그냥 await 없이 호출하면, 그 함수가 실행되는 동안 이벤트 루프 전체가 블로킹된다. 다른 요청이 그 시간 동안 아무것도 처리되지 못하고 대기한다. 동시 요청이 늘어날수록 이 블로킹이 누적되면서 응답 시간이 기하급수적으로 늘어난 거였다. 비동기 프레임워크를 쓰고 있다는 사실이, 코드 내부에 동기 블로킹이 없다는 걸 보장해주지 않는다는 걸 이때 제대로 체감했다.
해결은 두 가지 방향으로 했다. 임베딩 추론을 별도 스레드풀로 넘겨서 이벤트 루프를 막지 않게 했고, 자주 호출되는 짧은 텍스트는 비동기 호출이 가능한 외부 임베딩 API로 옮겼다. 이렇게 바꾸고 나서 동시 요청 처리 시간이 확연히 안정됐다.
두 번째 함정, DB 커넥션 풀이 너무 작았다
임베딩 문제를 고치고도 약간의 지연은 남아 있었다. 이번엔 PostgreSQL 쪽을 들여다봤다. pgvector로 벡터 검색을 하던 구조였는데, 커넥션 풀 설정이 기본값 그대로였다. 동시 요청이 풀 크기를 넘어서면 다음 요청은 커넥션이 반환될 때까지 대기열에서 기다려야 한다.
이게 의외로 자주 놓치는 부분이다. 로컬에서 테스트할 때는 동시 요청이 거의 없으니까 기본 풀 크기로도 문제가 안 보인다. 그런데 운영 환경에서 트래픽이 몰리면 풀 크기가 바로 병목이 된다. 풀 크기를 늘리고, 커넥션을 오래 들고 있는 쿼리가 있는지 다시 점검했다. 검색 쿼리 하나가 끝나면 바로 커넥션을 반환하도록 트랜잭션 범위를 좁힌 것도 도움이 됐다.
세 번째, 컨텍스트 윈도우 구성 단계에서 생긴 숨은 동기 작업
세 번째로 찾은 건 좀 의외였다. 검색된 문서들을 LLM 프롬프트에 넣기 전에, 토큰 수를 세서 컨텍스트 윈도우에 맞게 자르는 전처리 단계가 있었다. 이 토큰 카운팅 로직이 매 요청마다 토크나이저를 새로 로드하는 방식으로 짜여 있었다. 토크나이저 로딩 자체가 가벼운 작업이 아니었고, 이게 매 요청마다 반복되면서 누적 지연을 만들고 있었다.
해결은 단순했다. 토크나이저 인스턴스를 애플리케이션 시작 시점에 한 번만 로드해서 전역으로 재사용하도록 바꿨다. FastAPI의 lifespan 이벤트를 활용해서 서버가 뜰 때 모델과 토크나이저를 미리 로드해두는 방식으로 정리했다. 이렇게 하니 요청마다 반복되던 초기화 비용이 통째로 사라졌다.
디버깅하면서 깨달은 것! 비동기는 도구일 뿐, 설계는 내 책임
이 세 가지를 다 고치고 나서 다시 부하 테스트를 했다. 동시 요청 10개에서도 응답 시간이 크게 늘지 않았다. 그런데 이 과정을 거치면서 든 생각은, FastAPI가 비동기를 지원한다는 것과 내 애플리케이션이 실제로 비동기로 동작하는 것 사이에는 큰 간극이 있다는 거였다.
RAG 파이프라인은 외부 API 호출, DB 쿼리, 모델 추론, 전처리까지 여러 단계가 얽혀 있다. 이 중 단 하나라도 동기 블로킹 코드가 섞여 있으면, 전체 파이프라인의 동시성이 무너진다. async def로 함수를 선언했다는 것만으로는 아무것도 보장되지 않는다. 그 안에서 호출하는 모든 것이 실제로 비동기인지, 아니면 동기 코드가 숨어서 이벤트 루프를 막고 있는지를 하나씩 확인해야 한다.
비슷한 문제 겪고 있다면 체크해볼 순서
내가 겪은 순서를 그대로 공유하면, 가장 먼저 동시 요청을 인위적으로 만들어서 부하 테스트를 해보는 게 첫 단추다. 단일 요청만 테스트하면 이 문제는 절대 안 보인다.
그다음 순서로는 임베딩이나 모델 추론처럼 무거운 연산이 async def 함수 안에서 await 없이 직접 호출되고 있는지를 확인한다. 그다음은 DB 커넥션 풀 크기와 트랜잭션이 커넥션을 오래 들고 있는지를 본다. 마지막으로 매 요청마다 반복되는 초기화 작업이 있는지, 그게 애플리케이션 시작 시점으로 옮길 수 있는 작업인지를 점검한다.
이 네 가지를 순서대로 짚어보면 대부분의 “비동기인데 왜 막히지” 문제는 잡힌다. 적어도 내 경우는 그랬다. RAG 시스템이 점점 복잡해질수록 이런 숨은 동기 블로킹이 어디 하나쯔음 끼어 있을 가능성이 높다. 코드를 async로 짰다는 자체보다, 그 안의 모든 호출이 실제로 비동기 흐름을 따라가고 있는지를 의심하는 습관이 결국 더 중요하다.
모니터링을 먼저 세팅해야 하는 이유
이 문제를 겪고 나서 바꾼 운영 습관이 하나 있다. 이제는 새 RAG 엔드포인트를 배포하기 전에 단계별 레이턴시를 따로 측정하는 로깅을 먼저 깐다. 임베딩 생성에 몇 밀리초, 벡터 검색에 몇 밀리초, LLM 호출에 몇 밀리초가 걸렸는지를 요청마다 구간별로 찍어두는 것이다.
이게 왜 중요하냐면, 전체 응답 시간만 보고 있으면 어느 구간이 문제인지 추측만 하게 된다. 내가 처음에 LLM API를 의심했던 것도 결국 추측이었다. 구간별 로깅이 있었다면 임베딩 단계에서 시간이 튄다는 걸 훨씬 빨리 알아챘을 거다. 지금은 구간별 레이턴시를 Prometheus로 모아서 대시보드에 띄워두고, 특정 구간이 평소보다 늘어나면 바로 알림이 오게 만들어뒀다. 사후 디버깅보다 사전에 이상 징후를 잡는 쪽이 훨씬 마음이 편하다.
또 하나는 부하 테스트를 운영 배포 전 체크리스트에 정식으로 넣은 것이다. 이전에는 기능이 의도대로 동작하는지만 확인하고 배포했는데, 이제는 동시 요청 5개, 10개, 20개 시나리오를 각각 돌려보고 응답 시간 분포를 확인한 다음에 배포한다. 단일 요청 테스트만으로는 절대 보이지 않는 문제들이 이 단계에서 걸러진다.
돌이켜보면 이 디버깅 과정 자체가 RAG 시스템을 운영한다는 게 모델 성능이나 검색 품질만의 문제가 아니라는 걸 다시 확인시켜준 경험이었다. 결국 그 위에 얹히는 서버 코드의 동시성 설계가 받쳐주지 못하면, 좋은 검색 결과도 사용자에게 느리게 전달될 뿐이다. RAG 파이프라인을 잘 만드는 것과, 그 파이프라인을 잘 서빙하는 것은 완전히 다른 역량이라는 걸 이번 디버깅에서 제대로 배웠다.