탐구 생활/개발 탐구

Django WSGI 최적화 - Granian 과 ARM64(Graviton)

개발프로브 2025. 12. 2. 22:43

이 글은 최근 Django WSGI 를 다루게 된 백엔드 개발자가 문제를 인식하는 과정, 실험을 통해 데이터를 확보하고 실제 성능과 비용을 최적화한 경험을 다룹니다.

 

TL;DR

  • 공식문서를 따르는게 아닌 실제 운영 환경에 맞춰 파라미터를 튜닝하고, linux/amd64 를 linux/arm64 로 교체하여 Req/s 지표를 2배 높일 수 있었습니다.
  • Gunicorn, gthread 기반 HTTP 서버를 Granian 으로 교체하여 부하 상황에서 발생하던 502 에러를 방지했습니다.

1. 문제 정의 및 가설 수립

"왜 우리 서비스만 Task가 70%나 더 많을까?" AWS 콘솔을 모니터링하던 중, 타 프로덕트 대비 비정상적으로 높은 ECS Task 수와 일일 최대 6만 건에 달하는 502 Bad Gateway 에러를 발견했습니다. 분석 결과, Python Worker(WSGI)의 부하로 인한 CPU Spike가 오토스케일링(Horizontal Scaling)과 에러의 주원인임을 확인했습니다.

 

이에 따라 HTTP Server(WSGI)의 세팅 최적화 또는 교체가 CPU 효율을 높이고 비용 및 에러율을 낮출 것이라는 가설을 세웠습니다.


2. 실험 설계

문제의 원인을 명확히 격리하고 최적값을 찾기 위해 변인을 통제하여 총 4단계의 실험을 설계했습니다.

2.1 변수 설정

구분 항목 내용
통제 변수(고정값) 하드웨어 스펙 동일한 CPU 및 RAM 할당
부하 테스트 시나리오 • 단기 부하: 100 Users, 1분 (3개 Endpoint: Fast/Mid/Slow)
• 중기 부하: 100 Users, 10분 (지속성 검증)
독립 변수(변경값) 워커 구성 Worker 개수, Thread 개수 조정
서버 엔진 Gunicorn(기존) vs Granian(신규)
운영체제 Linux OS 종류 변경
종속 변수(측정값) 성능 지표 CPU Spike, RPS(Req/s), Error Rate, Task Count
왜 Granian 인가?
Granian 은 Rust 를 기반으로하는 Python HTTP Server 입니다.
그들이 제시하는 성능 벤치마크에 따르면 WSGI 에서 gunicorn 대비 20배 빠른 성능을 보인다고 합니다.
또한 개인적으로는 Python 이 아니기 때문에 GC 로 인한 jitter 현상이 없을 것으로 기대도 됩니다.

2.2 실험 단계

최적의 구성을 찾기 위해 아래 순서로 실험을 진행합니다.

  1. Worker/Thread 튜닝: 기존 환경에서 파라미터 튜닝만으로 개선 가능한지 확인 (단기 부하)
  2. HTTP Server 교체: Gunicorn을 대체할 서버 테스트 (단기 부하)
  3. OS 변경: 운영체제 레벨의 오버헤드 확인 (단기 부하)
  4. 최종 검증: 도출된 최적 조합으로 장시간 안정성 테스트 (중기 부하)

3. 실험 결과 데이터 및 해석

총 4단계의 실험을 통해 Worker 튜닝, HTTP Server 교체, 그리고 아키텍처 변경이 성능에 미치는 영향을 분석했습니다. 모든 데이터는 동일한 하드웨어 스펙에서 측정되었습니다.

3.1  Gunicorn 워커/스레드 튜닝 (단기 부하)

먼저 기존 Gunicorn 환경에서 파라미터 튜닝만으로 개선이 가능한지 확인했습니다. CPU 경합(Context Switching)을 줄이기 위해 워커와 스레드 수를 줄이는 접근을 시도했습니다.

 

기존 Worker / Thread 세팅Gunicorn 공식문서에서 추천하는 규칙을 따르고 있습니다.

"Generally we recommend(2x$num_cores)+1as the number of workers to start off with" 이 규칙을 해석해서 대부분의 국내 Python 개발자들은 아래의 세팅을 따르는 중입니다.

import multiprocessing
workers = multiprocessing.cpu_count() * 2 + 1

 

신규 Worker / Thread 세팅은 vCPU 개수와 Worker 개수를 일치시키고, Thread 개수는 Worker * 2 를 따르도록 했습니다.

3.1.1 실험 데이터

구분 설정 (Worker / Thread) RPS (Req/s) CPU Usage Error Rate 비고
대조군 Gunicorn (기존 세팅) 35.6 71.9% 3.33% 기존 운영 세팅
실험군 Gunicorn (신규 세팅) 46.4 63.6% 0.76% 최적 효율

3.1.2 해석, 클라우드 환경의 vCPU 가 갖는 특수성

워커와 스레드 개수를 줄였음에도 불구하고 RPS는 약 30% 증가(35.6 → 46.4)하고, 에러율은 1/4 수준(3.33% → 0.76%)으로 대폭 감소했습니다. 이는 과도한 Thread 생성으로 인한 Context Switching 오버헤드가 성능 저하의 주원인이었음을 시사합니다. 

 

하지만 이상합니다. 어째서 Gunicorn 공식문서 추천 세팅이 더 비효율적인걸까요? 공식문서의 "Obviously, your particular hardware and application are going to affect the optimal number of workers." 부분에 더 주의를 기울여야 했습니다.

 

현재 Django Application 은 Bear Metal 에서 돌아가는게 아니라 Docker 에서, 그것도 AWS ECS Fargate 의 vCPU 라는 가상의 CPU 를 이용하고 있습니다. 이런 특수한 상황을 고려한 실험이 필요했습니다.

 

다행입니다. 첫번째 실험으로 상당한 인사이트를 얻었습니다.


3.2  HTTP Server 교체 (Gunicorn vs Granian)

Gunicorn 최적화(실험군 A) 상태에서도 간헐적인 에러(0.76%)가 잔존했습니다. 이를 근본적으로 해결하기 위해 Rust 기반의 Granian을 도입하여 비교 실험을 진행했습니다.

3.2.1 실험 데이터

서버 설정 (Worker / Thread) RPS (Req/s) CPU Usage Error Rate
Gunicorn (기존 세팅) 46.4 63.6% 0.76%
Granian (신규 세팅) 40.2 69.3% 0.00%
Granian (기존 세팅) 41.8 92.1% 0.00%

3.2.2 해석, Python GIL 의 제약에 따른 502 에러

Granian은 Gunicorn 대비 RPS가 소폭 낮거나 비슷했지만, 가장 중요한 지표인 에러율이 0% (Zero Error)를 기록했습니다.

 

1) 502 에러가 바생하는 매커니즘

HTTP 요청이 들어오면 OS 레벨에서는 다음과 같은 과정이 일어납니다.

sync_queue, accept_queue 구조(https://blog.cloudflare.com/syn-packet-handling-in-the-wild/#the-tale-of-two-queues)

  1. SYN Backlog: 클라이언트의 요청(SYN)이 들어오면 커널 큐에 쌓입니다.
  2. Accept Queue: TCP 3-way Handshake가 완료되면 연결이 성립되어 Accept Queue로 이동합니다.
  3. Application Accept: 애플리케이션(Gunicorn 등)이 accept() 시스템 콜을 통해 큐에서 연결을 가져가야 합니다.

이 과정에서 문제의 핵심은 애플리케이션이 바빠서 Accept Queue를 제때 비우지 못하면 큐가 가득 차게 되고, 커널은 들어오는 연결을 거부(RST)하는 현상을 서버 앞단의 ALB가 "서버가 죽었다"고 판단하여 502 Bad Gateway를 반환하는 것으로 보입니다.

 

2) Gunicorn 의 한계: Python 의 GIL 과 Accept Loop

Gunicorn, 특히 gthread 모드의 치명적인 단점은 연결 수락 과정이 Python 런타임 내부에 갇혀 있다는 점입니다.

  • 동기적 Accept: Gunicorn 워커는 Python 루프 안에서 accept()를 호출합니다.
  • GIL 병목 (Bottleneck): 멀티 스레드를 써도 Python의 GIL 때문에 한 번에 하나의 스레드만 실행됩니다.
  • 기아 상태 (Starvation): 요청이 폭주하면 워커 스레드들은 비즈니스 로직 처리와 I/O 대기로 바빠집니다. 이 와중에 GIL 획득 경쟁에서 밀리면 accept()를 호출할 기회조차 얻지 못합니다.

이러한 영향으로 CPU가 남아도는데도(20~60% 구간), accept()를 못 해서 큐가 터지고 502 에러가 발생합니다.

 

3) Grarnian 의 한계 돌파: GIL-Free Accept

Granian이 에러율 0%를 달성한 비결은 연결 수락과 HTTP 파싱을 Python 외부(Rust)로 끄집어낸 데 있습니다.

  • Rust 기반 런타임: Granian은 내부적으로 Rust 기반의 고성능 네트워크 라이브러리인 Hyper와 Tokio를 사용합니다. Python 로직이 아무리 바빠도, Rust 레이어는 GIL의 영향을 받지 않고 즉각적으로 연결을 수락(Accept)하여 내부 큐에 안전하게 쌓아둡니다.
  • 지연된 위임: 연결이 안전하게 확보된 후에야 Python 비동기 태스크로 변환하여 애플리케이션에 전달합니다.

Granian 환경에서는 Python 처리가 늦어져 "응답 지연"이 발생할 수는 있어도, 연결 자체가 거부되어 "502 에러"가 발생하는 상황은 원천적으로 차단되는 것으로 보입니다.

 


3.3 아키텍처 변경 (amd64/X86 vs arm64/Graviton)

비용 효율과 기본 성능 향상을 위해 AWS Fargate의 아키텍처를 기존 Intel(x86) 기반에서 arm64(Graviton)로 변경하여 테스트했습니다. arm64 는 클라우드 환경에서 더 비용효율적이며 성능도 더 좋다고 알려져 있습니다.

3.3.1 실험 데이터

아키텍처서버 / 설정RPS (Req/s)CPU UsageError Rate성능 향상

아키텍처 서버 / 설정 RPS (Req/s) CPU Usage Error Rate 성능 향상
amd64 Gunicorn(신규 세팅) 46.4 63.6% 0.76% -
arm64 Gunicorn (신규 세팅) 67.6 80.6% 0.40% +45%
arm64 Granian (신규 세팅) 67.7 59.1% 0.00% +46%

 

도출된 최적의 조합(Arm64 + Granian)이 장시간 부하에서도 안정성을 유지하는지 검증했습니다. (10분 테스트)

대상 RPS (Req/s) CPU Usage Eror Rate
Gunicorn (최적값) 69.1 87.9% 0.67%
Granian (최적값) 66.2 86.0% 0.00%


3.3.2 해석, vCPU 하이퍼스레딩 vs 물리 코어

아키텍처 변경만으로 RPS가 40% 이상 폭발적으로 증가했습니다. 특히 Arm64 환경에서 Granian은 더 적은 CPU 자원(59.1%)을 사용하면서도 최고 수준의 처리량을 보여주었습니다. Python과 Rust 모두 Arm 아키텍처에서 효율적인 퍼포먼스를 보였습니다. 

10분간의 지속적인 부하 상황에서 Gunicorn은 여전히 0.67%의 502 에러가 발생했으나, Granian은 단 한 건의 에러도 발생시키지 않았습니다. 이를 통해 'arm64 + Granian' 조합이 성능과 안정성 두 마리 토끼를 잡는 최적의 솔루션임을 확신할 수 있었습니다.

 

왜 CPU 를 변경하는 것 만으로 이렇게 큰 성능 향상이 나타났을까요? 이는 amd64 대비 arm64 CPU 의 메모리 대역폭이 더 큰 것 등 다양한 영향이 있겠지만 amd64 와  arm64 에서 vCPU 의 실체가 다르기 때문에 나타난 현상입니다.

amd64 에서는 vCPU 두개가 하나의 물리 CPU 코어를 공유하지만 arm64에서의 vCPU와 물리 CPU 코어가 1대1로 매핑됩니다. 이러한 특징은 Python Applicaiton 에서 더 크리티컬하게 작용했을 것으로 보이는데요, GIL 제약으로 실제 활용 가능한 CPU 코어개수가 요청 처리량에 직접적으로 작용하기 때문입니다.


참고

gunicorn 공식문서: https://docs.gunicorn.org/en/latest/design.html#how-many-workers

garnian 벤치마크: https://github.com/emmett-framework/granian/blob/master/benchmarks/vs.md 
cloudflare SYN packet: https://blog.cloudflare.com/syn-packet-handling-in-the-wild/#the-tale-of-two-queues