최근 FastAPI를 사용하여 백엔드를 구축하고 LLM(Large Language Model)을 연동하는 사례가 늘고 있습니다. 하지만 실제 운영 환경(Production)에서 Gunicorn과 함께 배포할 때, 워커(Worker) 수 설정이나 오토스케일링 전략에 대해 고민하게 되는 경우가 많습니다.

오늘은 Gunicorn 구동 방식, 비동기 처리의 한계, 그리고 한정된 리소스에서의 최적화 전략에 대해 심도 있게 다뤄보겠습니다.


1. Gunicorn + FastAPI(4 Workers)의 내부 동작 원리

Gunicorn으로 FastAPI를 구동하고 워커를 4개로 설정했다면, 내부에서는 어떤 일이 일어날까요?

  • Process 구조: 부모 프로세스(Master) 1개가 존재하고, 자식 프로세스(Worker) 4개가 생성됩니다. 각 워커는 독립된 메모리 공간을 가집니다.
  • Event Loop:워커 내부에는 Uvicorn이 동작하며, 각각 독립적인 비동기 이벤트 루프(Event Loop) 가 돌아갑니다.
  • 요청 처리:
    1. 사용자 요청이 들어오면 Gunicorn Master가 이를 4개의 워커 중 하나에게 전달합니다.
    2. 해당 워커의 이벤트 루프가 요청을 받습니다.
    3. FastAPI가 async def로 작성되어 있다면, I/O 작업(DB 조회, 외부 API 호출 등)을 만날 때 대기하지 않고 다른 요청을 처리합니다.
    4. 즉, 4개의 워커가 동시에 4개의 요청만 처리하는 것이 아닙니다. I/O 대기 시간이 길다면, 이론상 수천 개의 요청을 동시에 '수용(Concurrency)'할 수 있습니다.

2. 비동기의 한계와 CPU, 그리고 LLM

비동기의 한계는 CPU 성능인가?

그렇습니다. Python은 GIL(Global Interpreter Lock) 때문에, 하나의 프로세스(워커)는 한 번에 하나의 CPU 연산만 수행할 수 있습니다.

  • I/O Bound 작업: 비동기 처리에 최적화되어 있습니다. (대기 시간 동안 딴짓 가능)
  • CPU Bound 작업: (이미지 처리, 복잡한 수학 연산 등) CPU가 바쁘게 일하는 동안 이벤트 루프가 멈춥니다. 이 경우 다른 요청들이 처리되지 못하고 밀리게 됩니다.

LLM 호출은 어떤 작업인가?

LLM 호출(OpenAI API, Claude 등)은 전형적인 I/O Bound 작업입니다.

  • 내 서버가 계산하는 것이 아니라, 외부 서버에 요청을 보내고 응답을 기다리는 것이기 때문입니다.
  • 따라서 FastAPI의 비동기 처리가 빛을 발하는 영역이며, 워커가 응답을 기다리는 동안 다른 유저의 요청을 받을 수 있습니다.

LLM Streaming 방식은 문제가 없을까?

문제없습니다. 오히려 권장됩니다.

  • 스트리밍은 HTTP 연결을 길게 유지(Long-polling 유사)하며 데이터를 조각조각 받습니다.
  • 이 또한 I/O 대기 상태이므로, 이벤트 루프를 차단(Block)하지 않습니다. 단, 연결이 오래 유지되므로 동시 접속자 수(Connection Limit) 관리는 필요할 수 있습니다.

3. 오토스케일링(Autoscaling)은 언제 필요한가?

"비동기니까 오토스케일링 필요 없는 거 아냐?"

아닙니다. 비동기가 효율적이긴 하지만 만능은 아닙니다. 오토스케일링은 다음 두 가지 상황에서 필수적입니다.

  1. CPU 자원 고갈 (CPU Bound): 로직 내에 데이터 파싱이나 연산이 많아 CPU 사용량이 100%를 치면, 워커를 늘려도 소용없고 서버(Node/Pod) 자체를 늘려야 합니다.
  2. 동시 접속 한계 (Memory/Socket): 수만 명의 유저가 동시에 접속하면, I/O 작업이라도 메모리 부족이나 포트 고갈이 발생할 수 있습니다.

결론: "버벅인다"는 현상이 발생했을 때, CPU 사용률이 낮다면 워커 설정을, CPU 사용률이 높다면 오토스케일링(Scale-out)을 고려해야 합니다.


4. 실전 시나리오: 16코어 VM에서의 최적화 전략

가장 고민이 되는 부분입니다. 16코어라는 좋은 자원이 있지만, FE, BE, DB, Redis, GitLab Runner, Log Server가 한 지붕 아래 살고 있습니다.

리소스 분배 계산

이 상황에서 무턱대고 BE 워커를 늘리면 Context Switching(문맥 교환) 비용 때문에 오히려 성능이 떨어집니다.

  • 배경 소음(Noisy Neighbors) 고려:
    • RDB & Redis: 최소 2~4 코어 점유 예상
    • GitLab Runner: 빌드 도중 CPU를 엄청나게 잡아먹음 (가변적)
    • Log Server/FE: 1~2 코어
    • 남는 자원: 안정적으로 BE가 쓸 수 있는 건 약 6~8 코어 남짓입니다.

적정 워커 수 추천

공식(Gunicorn docs)은 (2 x 코어 수) + 1이지만, 이는 전용 서버일 때 이야기입니다. 다양한 서비스가 혼재된 상황에서는 보수적으로 접근해야 합니다.

  • 추천 설정: 4 ~ 6 Workers
  • 이유: 워커가 너무 많으면 GitLab Runner가 돌 때 CPU 쟁탈전이 벌어져 전체 서비스가 느려집니다. 차라리 적은 워커가 이벤트 루프를 부지런히 돌리는 것이 낫습니다.

5. 아키텍처 결정: 워커 증설 vs 컨테이너 증설

1개의 VM 안에서 리소스를 늘리는 방법은 두 가지가 있습니다.

구분 워커 수 늘리기 (Gunicorn workers) 컨테이너 수 늘리기 (Replica)
방식 1개 컨테이너 안에서 프로세스 생성 Docker 컨테이너 자체를 여러 개 띄움
메모리 효율적 (일부 자원 공유) 비효율적 (OS/Runtime 오버헤드 중복)
관리 Gunicorn 설정 파일 수정 Port Mapping 관리 복잡 (Nginx 등 앞단 필요)
권장 VM 내부라면 이 방식 권장 Kubernetes 환경이라면 이 방식 권장

결론: 현재와 같은 단일 VM 환경에서는 Gunicorn 워커 수를 조절하는 것이 훨씬 효율적입니다. 컨테이너를 여러 개 띄우면 불필요한 오버헤드만 증가하고 포트 관리만 복잡해집니다.


요약 및 Action Plan

  1. 진단: 현재 "버벅임"의 원인이 CPU인지(모니터링 툴 확인), 외부 API(LLM) 응답 지연인지 확인하세요.
  2. 설정: 16코어 통합 환경이라면 Gunicorn 워커는 4~6개로 유지하세요. 무작정 늘리면 독이 됩니다.
  3. LLM: 스트리밍 방식은 비동기 서버에 아주 적합합니다. 안심하고 사용하세요.
  4. 스케일링: CPU 사용량이 여유로운데 느리다면 워커를 1~2개 늘려보고, CPU가 터질 것 같다면 VM 스펙을 올리거나 서버를 분리(Scale-out)해야 합니다.
  • 네이버 블러그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 카카오스토리 공유하기