지금까지 알던 기술 스택으로는 감당되지 않는 대규모 트래픽을 안정적으로 처리하는 것은 어려운 도전이지만, 동시에 개발자로서 가장 가슴 뛰는 경험이기도 합니다. 알파리뷰는 기존의 순수 Python 서버 환경에 Rust 생태계를 전략적으로 도입함으로써, 일평균 1.8억 건의 HTTP 요청을 처리하는 API 서버의 개수는 줄이면서도 시스템 안정성은 비약적으로 높이는 성과를 만들어냈습니다.
이 글에서는 대규모 트래픽 환경에서 마주한 문제를 구조화하고, 병목의 근본 원인을 파악하기 위한 실험 과정, 그리고 새로운 아키텍처 도입에 따른 최종 성과까지의 전 과정을 공유하고자 합니다.
1. 대규모 트래픽 문제 구조화하기
프로덕트 마켓 핏(PMF)을 찾는 과정에서 민첩하게 개발된 시스템은, 트래픽이 급증하는 성장 궤도에 오르면서 그동안 숨어있던 병목들을 드러내기 시작합니다. 치솟는 CPU 사용량, 간헐적인 메모리 누수, 비효율적인 쿼리, 캐싱의 한계 등 문제의 현상과 원인은 애플리케이션 코드부터 클라우드 인프라까지 전반에 걸쳐 나타납니다.
그렇다고 초기 빠른 템포로 개발한 시스템이 잘못되었다는 것은 아닙니다. 오히려 그 민첩함 덕분에 대규모 트래픽을 감당해야 하는 지금의 비즈니스 단계까지 성장할 수 있었던 것이죠. 이제 우리에게 남은 과제는 현재의 성장 단계에 걸맞은, 대규모 트래픽에도 흔들림 없는 시스템으로 진화시키는 것입니다.
1-1. 문제 구조화하기
이를 위해 가장 먼저 해야 할 일은 현재 시스템을 분해하고, 트래픽 스파이크 시 발생하는 문제를 명확히 정의하는 것입니다. 인프라와 애플리케이션의 성능 문제를 진단할 때 널리 쓰이는 USE 방법론(Utilization: 사용률, Saturation: 포화도, Errors: 에러)을 차용하여 현재의 상황을 구조화해 보았습니다.
| 문제 현상 (USE 기준) | 발생 위치 | 파생되는 문제 및 비즈니스 임팩트 |
|---|---|---|
| Utilization (사용률): API 서버의 CPU 사용률이 비정상적으로 높고, 잦은 스파이크가 발생한다. | 애플리케이션 서버 (ECS/EC2 등) | 서버의 잦은 수평 확장(Scale-out)을 유발하여 클라우드 인프라 비용을 급격히 증가시킨다. |
| Errors (에러): ELB(Load Balancer) 단에서 502/504 에러가 간헐적으로 발생하며, 트래픽 양에 비례해 증가한다. | 클라우드 인프라 ↔ 애플리케이션 서버 구간 | API 신뢰도를 하락시킨다. 특히 고객사의 대규모 캠페인 집행 시 리뷰 정보가 노출되지 않는 치명적인 문제를 야기한다. |
| Saturation (포화도): 네트워크 커넥션 및 요청 대기열(Queue)이 가득 차 요청 처리가 지연된다. | OS 커널 및 Web Server 프레임워크 | 시스템 리소스(CPU/Memory)는 남아있는데도 새로운 요청을 받지 못해 서비스 장애로 이어진다. |
문제 구조화 과정을 통해 해당 문제를 해결했을 때의 비즈니스 임팩트(비용 감소, 서비스 신뢰도)를 명확히 하여 동료들을 설득할 수 있었습니다. 그리고 우리 알파리뷰 개발 조직이 딥다이브해야 하는 문제 구간이 명확해져서 실제로 문제를 해결할 수 있다는 자신감이 생겼습니다.
1-2. 문제 현상 파고들기
그렇다면 이제부터는 딥하게 파고들 시간입니다. 왜 이렇게 CPU 사용량이 높았을까요? API 서버가 CPU를 과도하게 사용하는 원인으로는 보통 다음과 같은 가설을 세울 수 있습니다.
- 네트워크 연결 개수 폭증에 따른 OS 커널의 컨텍스트 스위칭(Context Switching) 오버헤드
- 대용량 데이터의 직렬화(Serialization) 및 역직렬화에 따른 애플리케이션 레벨의 연산 부하
- Python의 가비지 컬렉션(GC) 동작 빈도 증가
다행히 알파리뷰 팀은 Datadog을 이용 중이어서 가설을 검증하기 위해 많은 시간을 할애할 필요가 없었습니다. Datadog APM Profiling을 통해 애플리케이션 내부의 CPU 사용량을 상세히 추적할 수 있었고, 프로파일링 결과 실제 비즈니스 로직보다는 Worker 프로세스가 요청을 스케줄링하고 처리하는 과정 자체에서 막대한 CPU를 소모하고 있음이 확인되었습니다.
더욱 흥미로운 점은 Datadog APM에 전혀 잡히지 않는 502(Bad Gateway)와 504(Gateway Timeout) 에러들이 존재했다는 것입니다. 이는 곧 요청이 Python 애플리케이션 코드를 실행하기 위해 Datadog Agent의 관측 범위 내로 진입하기도 전에, 웹 서버(WSGI/ASGI)나 OS 커널의 Queue 단계에서 이미 Drop 되고 있다는 것을 의미했습니다.
2. 문제 해결하기
결국 문제 현상의 원인은 Python 웹 서버에서 사용하는 Worker이며, 문제 구간은 백엔드 애플리케이션이 HTTP 요청을 처리하기 이전 단계라는 것이 명확해졌습니다. 이제는 문제를 해결하기 위한 조사와 실질적인 행동이 필요한 순간입니다.
2-1. 문제 해결을 위한 이론 조사
APM 바깥에서 발생하는 502/504 에러의 본질을 이해하려면, 애플리케이션 코드를 넘어서 HTTP 통신의 하부 구조와 대기열 이론(Queueing Theory)을 살펴봐야 합니다.
트래픽 스파이크가 발생할 때 AWS ALB(Application Load Balancer)를 거쳐 백엔드 컨테이너로 쏟아지는 요청들은 애플리케이션에 도달하기 전부터 이미 치열한 병목을 겪게 됩니다.
커널 수준의 대기열(TCP 3-Way Handshake)
HTTP는 TCP 연결 위에서 동작합니다. 클라이언트(혹은 로드밸런서)의 요청이 웹 서버 애플리케이션에 도달하기 전, OS 커널 수준에서는 두 단계의 대기열(Queue)을 거칩니다.
- SYN Queue: 클라이언트가 SYN 패킷을 보내면 커널은 이 큐에 연결 정보를 저장하고 SYN-ACK를 반환합니다.
- Accept Queue: 클라이언트가 최종 ACK를 보내면 연결이 Accept Queue로 이동합니다. 웹 서버가
accept()시스템 콜을 호출하여 이 연결을 가져가기 전까지 머무는 공간입니다. (우리가 흔히 웹 서버나 OS를 튜닝할 때 말하는 Backlog 크기가 바로 이 큐의 최대 허용량을 의미합니다.)

트래픽이 폭주하여 애플리케이션의 처리 속도가 유입 속도를 따라가지 못하면, 이 Accept Queue가 순식간에 꽉 차버립니다. 커널은 새로 들어오는 TCP 연결을 Drop하게 되고, 앞단의 로드밸런서는 백엔드와 연결조차 맺지 못해 결국 502/504 에러를 발생시킵니다.
대기열 이론으로 본 웹 서버 아키텍처 모델
위의 내용에 따르면 웹 서버가 Accept Queue에 있는 요청을 빠르게 처리하면 안정성 문제는 해결이 될 것 같습니다. 그럼 웹 서버 대수를 늘리면 모두 해결될 것 같습니다. 이미 업계에서는 “유휴자원전략”이라는 이름으로 널리 사용되는 방식이기도 하니까요. 알파리뷰 팀도 당장에는 안정성을 위해 이 전략을 채택했습니다.
하지만 B2B SaaS에서 비효율적인 서버 운영으로 발생하는 비용은 결국 고객에게 전가되기 마련입니다. 이를 막기 위해 알파리뷰 팀은 안정성과 비용을 모두 잡을 수 있는 최적해를 찾기로 결정했습니다.
우선 웹 서버가 대기열을 비워내는 방식에 집중했습니다. 서버의 처리 아키텍처는 수학적인 대기열 모델로 설명할 수 있습니다. 시스템의 안정성은 유입률($\lambda$)과 대기 시간($W$)의 관계를 설명하는 리틀의 법칙($L = \lambda W$)과, 서버의 이용률($\rho$)이 100%에 근접할수록 대기 시간이 기하급수적으로 치솟는다는 킹맨 공식(Kingman's formula)에 의해 지배받습니다.
- Thread-per-Request 모델 (M/M/c 모델): 전통적인 웹 서버(Apache, Tomcat, WSGI 등) 방식입니다. 정해진 개수(c) 의 Worker 스레드가 Accept Queue에서 요청을 하나씩 꺼내 처리합니다.

- 비동기 Event Loop 모델 (M/M/1/K 모델): Nginx, Node.js, ASGI 기반 서버의 방식입니다. 단일 혹은 소수의 스레드가 I/O 요청을 OS 커널(epoll, kqueue)에 위임하고 즉시 다음 요청을 받습니다.

2-2. 문제 해결하기: Python 생태계 속의 Rust
당시 알파리뷰는 Django와 Gunicorn으로 구성되어 있었습니다. 전형적인 WSGI 패턴이므로 Thread-per-Request 모델임이 명확했습니다.
이러한 WSGI 환경에서는 대규모 트래픽이 발생할 때 (특히 HTTP/1.1 Keep-Alive 옵션을 사용할 경우) 클라이언트가 다음 요청을 보내지 않더라도 스레드를 계속 점유합니다. 워커 스레드의 수는 한정되어 있어, 들어오지 못하고 밀린 요청들은 OS 커널의 Accept Queue(Listen Backlog)에 고스란히 쌓이게 됩니다.
트래픽 스파이크로 초당 요청($\lambda$)이 급증하는 상황에서, Python 워커들이 대용량 데이터의 직렬화/역직렬화 연산과 GIL의 제약으로 인해 요청당 처리 시간($W$)이 늘어나면, 결과적으로 Accept Queue($L$)는 순식간에 포화 상태에 이릅니다.
큐가 가득 찬 상태에서 새로 들어오는 패킷들은 OS 수준에서 버려지거나 타임아웃을 발생시키며, 이로 인해 애플리케이션 서버의 CPU 사용량이 급증하고 로드밸런서(ELB)는 백엔드와 연결을 맺지 못해 502/504 에러를 뱉어내고 있었던 것입니다. 즉, Python 이벤트 루프만으로는 거대하게 밀려드는 I/O 파도를 앞단에서 버텨낼 수 없었습니다.
그렇다면 FastAPI로 전환해서 ASGI 생태계로 이전하여 동시 수용 가능한 대기열 크기를 극대화하는 전략을 취하면 될까요? 훌륭한 옵션이지만, 오랜 레거시가 쌓여있는 알파리뷰에게는 너무 많은 시간이 필요한 작업이 될 게 뻔했습니다. 그래서 알파리뷰 팀은 WSGI 상태는 유지하면서 Worker를 최적화할 수 있는 방법을 찾았습니다.

답은 놀랍게도 Python 외부, 바로 Rust에 있었습니다.
Rust는 가비지 컬렉터(GC) 없이 메모리 안전성을 보장하며, 예측 불가능한 GC Pause 현상이 없어 대용량 트래픽 환경에서도 CPU 자원을 극도로 안정적으로 사용합니다. 사실 알파리뷰의 개발 환경에는 이미 Rust가 깊숙이 들어와 있었습니다. 패키지 및 워크스페이스 관리에 사용하는 uv, 그리고 코드 린팅을 책임지는 ruff 모두 Rust로 작성되어 기존 도구들을 압도하는 속도를 보여주고 있습니다.
"무겁고 반복적인 연산은 Rust가 처리하고, 개발자는 Python의 우아한 인터페이스만 누린다."
이 생태계의 철학을 웹 서버 런타임에도 적용한 프로젝트가 바로 Granian입니다. Granian은 소켓 관리, TCP 연결 수립, HTTP 헤더 파싱 등 가장 고비용의 네트워크 I/O 작업을 Rust 런타임에서 초고속으로 처리합니다. 앞단에서 트래픽의 충격을 Rust가 모두 흡수한 뒤, 실행 준비가 완벽히 끝난 데이터만 Python 애플리케이션으로 넘겨주기 때문에 큐의 포화 상태를 근본적으로 방지하고 Python 워커의 CPU 병목을 해소합니다.
2-3. 실증적인 벤치마크
이론상 Gunicorn을 Granian으로 교체하면 CPU 사용량과 502/504 에러를 모두 줄일 수 있을 것처럼 보입니다. 하지만, 실제 환경에서는 어떻게 될지 모르는 일입니다. 그래서 벤치마크 데이터를 확인하고, 실제 AWS 환경에서 동작 중인 알파리뷰 서버에서 얼마나 효율적으로 동작하는지 확인이 필요했습니다.
Granian에서 공식적으로 제시하는 WSGI 환경 벤치마크 데이터는 다음과 같습니다.
(출처: https://github.com/emmett-framework/granian/blob/master/benchmarks/vs.md)
| Server | Total requests | RPS | avg latency | max latency |
|---|---|---|---|---|
| Granian Wsgi get 10KB (c64) | 1258541 | 125824 | 0.507ms | 25.507ms |
| Granian Wsgi echo 10KB (iter) (c64) | 1081157 | 108084 | 0.589ms | 34.131ms |
| Gunicorn Gthread get 10KB (c64) | 91704 | 9175 | 6.963ms | 39.026ms |
| Gunicorn Gthread echo 10KB (iter) (c64) | 67395 | 6744 | 9.479ms | 27.452ms |
| Gunicorn Gevent get 10KB (c64) | 117588 | 11762 | 5.427ms | 352.505ms |
| Gunicorn Gevent echo 10KB (iter) (c64) | 82634 | 8268 | 7.203ms | 1190.073ms |
| Uwsgi get 10KB (c64) | 122002 | 12204 | 5.233ms | 36.54ms |
| Uwsgi echo 10KB (iter) (c64) | 93322 | 9336 | 6.842ms | 37.711ms |
하지만 벤치마크 데이터가 그대로 우리 프로덕트에 적용된다고 맹신할 수는 없습니다. 환경과 Usecase가 다르기 때문입니다. 그래서 AWS EC2, ECS 환경에서 CPU, RAM, worker, thread, blocking-thread, event-loop 등의 변수를 변경해가며 우리에게 맞는 최적의 세팅값을 찾으려 했습니다.
| 인프라 환경 | 웹 서버 (워커 설정) | 초당 처리량 (TPS) | 최대 CPU 사용률 | 에러율 |
|---|---|---|---|---|
| EC2 (2CPU, 8GB) | Gunicorn (w:4, t:10) | 33.8 | 20.60% | 9.82% |
| Granian (w:4, t:4) | 26.62 | 25.00% | 0.00% | |
| ECS Fargate (x86_64) | Gunicorn (w:4, t:10) | 35.63 | 71.90% | 3.33% |
| Granian (w:2, t:2) | 40.21 | 69.30% | 0.00% | |
| ECS Fargate (ARM64) | Gunicorn (w:2, t:4) | 67.59 | 80.60% | 1.40% |
| Granian (w:2, t:2) | 67.69 | 55.10% | 0.00% |
Granian 공식 문서에서 제시한 것만큼 드라마틱한 TPS(초당 처리량) 개선이 있지는 않았습니다. 어찌 보면 당연한 일입니다. 실제 프로덕션 레벨의 웹 애플리케이션에서 TPS를 가장 크게 좌우하는 건 DB 쿼리 성능과 캐싱 전략이기 때문입니다.
하지만 이 실험을 통해 두 가지 중요한 사실을 알아냈습니다.
- 첫째, Gunicorn 공식 문서에서 권장하는 워커 개수(
CPU core * 2 + 1)가 알파리뷰의 부하 특성에는 잘 맞지 않는다는 점. - 둘째, 알파리뷰에게 있어서 Granian의 도입은 단순한 TPS 개선이 아니라, 극단적인 트래픽 스파이크 상황에서 CPU의 안정성을 확보하고 에러율(0.00%)을 완벽하게 방어하는 핵심 조치라는 점이었습니다.
TPS가 비약적으로 상승하지 않은 점은 아쉬웠지만, 알파리뷰 서버에 Granian을 적용하고 Worker와 Thread 개수를 튜닝한 결과, 502/504 에러 발생률을 99% 감소시켰으며, API 요청을 처리하기 위한 서버 대수를 40% 절감하는 놀라운 성과를 만들어낼 수 있었습니다.
3. 회고하면서
이 최적화 작업을 진행한 지 벌써 1년이 다 되어갑니다. 적용했을 당시에는 Granian 2.5.x 버전이었는데 이 글을 쓰는 지금은 Granian 2.8.x 버전이 출시되어 있습니다. 그동안 많은 최적화가 진행되었고, WSGI, ASGI뿐만 아니라 RSGI라는 새로운 개념도 훨씬 더 안정적으로 정착한 것으로 보입니다.
이 문제를 풀면서 배운 가장 큰 교훈은, 공식 문서에서 제시하는 숫자들은 일반적인(Average) 환경에서의 '가이드라인'일 뿐이며, 실제로 우리 운영 환경에 기술을 도입하기에 앞서 실증적인 검증이 반드시 필요하다는 점입니다.
- Gunicorn을 세팅할 때 공식 문서의 기준값을 맹신하고 적용했지만, 결과적으로 더 낮은 TPS와 높은 에러율을 유발하고 있었음이 벤치마크를 통해 밝혀졌습니다.
- 다양한 아키텍처 환경에서 테스트를 거친 덕분에, 그동안 X86_64 아키텍처를 이용 중이던 ECS 환경을 뛰어난 가성비의 ARM64로 안전하게 교체할 수 있는 확실한 근거 데이터도 확보할 수 있었습니다.
결국 소프트웨어 공학은 이론적 기반도 중요하지만, 가설을 세우고 눈으로 직접 데이터를 확인하며 우리 프로덕트에 맞게 파인 튜닝(Fine-Tuning)해 나가는 과정이 반드시 동반되어야 함을 다시금 느낍니다.
'탐구 생활 > Python' 카테고리의 다른 글
| Python asyncio에 대한 탐구 (0) | 2025.03.23 |
|---|---|
| Python의 Type System (0) | 2025.03.20 |
| python 사용자 지정 불변 객체를 만드는 3가지 방법 (0) | 2024.11.17 |
| Python 가변 객체와 불변 객체 (0) | 2024.11.17 |