저는 현재 1인 개발자로 두 개의 프로덕트를 운영하고 있습니다. 하나는 현장에서 견적을 내는 기술자들을 위한 현장 견적 앱이고, 다른 하나는 구인구직, 중고 거래, 실시간 메시징을 갖춘 커뮤니티 마켓플레이스 플랫폼입니다.
프로덕트도 다르고 사용자도 다르지만, 기술 스택은 거의 동일합니다. 그리고 둘 다 Fly.io에서 가장 저렴한 티어인 shared-cpu-1x 머신에서만 돌아갑니다. 이 제약은 의도적인 것입니다. 피크 부하에 맞춰 서버를 크게 잡는 대신, 작은 머신 한 대에서 뽑아낼 수 있는 성능을 최대한 끌어올리고, 정확히 어디서 무너지는지 측정한 다음, 그 측정된 한계를 기준으로 Fly의 오토스케일러가 머신을 추가하고 제거하도록 맡깁니다. 이 글에서는 이를 가능하게 하는 최적화들, 임계값의 근거가 된 부하 테스트 수치, 그리고 Fly.io에서 스케일 업/다운이 실제로 어떻게 동작하는지 다룹니다.
철학: 머신 한 대를 측정하고, 그다음 곱하기#
두 앱 모두 스케일링 전략은 같은 세 단계입니다.
- 작은 머신 한 대를 합리적인 수준에서 최대한 빠르게 만든다.
- 무너질 때까지 부하 테스트를 하고, 지연 시간이 급격히 나빠지는 동시성 지점을 기록한다.
- 그 지점 직전에 두 번째 머신이 시작되도록 Fly 오토스케일러를 설정하고, 트래픽이 줄면 유휴 머신을 정지시킨다.
그러면 용량은 머신 수에 거의 선형으로 비례해서 늘어나고, 기본 비용은 앱당 작은 VM 한 대입니다. 아래의 모든 내용은 이 세 단계 중 하나를 위한 것입니다.
작은 머신 한 대를 빠르게 만들기#
Granian: API는 WSGI, WebSocket은 uvloop ASGI#
두 백엔드 모두 Django 6 + Django REST Framework입니다. API는 Rust 기반 서버인 Granian이 서빙하지만, ASGI가 아니라 블로킹 스레드 풀을 쓰는 WSGI로 돌립니다.
granian --interface wsgi project.wsgi:application \
--workers 1 --blocking-threads 16 \
--workers-max-rss 1400 \
--workers-kill-timeout 15 --respawn-failed-workers처음에는 --interface asgi --loop uvloop로 시작했는데, 잘못된 선택이었습니다. 이 앱들의 모든 HTTP 뷰는 동기(sync)이고, Django의 ASGI 핸들러는 동기 뷰를 thread_sensitive로—워커당 한 번에 하나씩—실행합니다. 그래서 ASGI는 워커당 동시 요청을 사실상 하나로 묶어놓으면서도, asyncio/uvloop/Channels 스택 전체를 그냥 메모리에 올려두기만 했습니다. --blocking-threads를 쓰는 WSGI는 같은 동기 뷰를 진짜 스레드 풀에서 돌립니다. 워커 1개 × 스레드 16개 = I/O 바운드 요청(DB 읽기, S3 프리사인) 16개 동시 처리를, 비동기 스택보다 낮은 상주 메모리로 해냅니다.
왜 워커 하나일까요? VM이 shared-cpu-1x—코어 하나입니다. 두 번째 워커는 코어 하나에서 파이썬을 병렬로 돌릴 수 없지만(GIL + 코어 1개), 상주하는 Django 프로세스 하나를 통째로 더 잡아먹습니다. Granian이 바로 이 점을 경고합니다. 워커 하나에 스레드 16개가 I/O 바운드 작업에서 1 vCPU 머신의 정직한 한계치입니다. --workers-max-rss 1400은 워커가 메모리 상한을 넘기면 재생성하므로, 폭주하는 PDF나 이미지 작업이 머신을 OOM으로 끌고 가는 대신 재활용됩니다.
WebSocket은 자체 Granian 프로세스로 빠졌습니다. uvloop와 --ws를 쓰는 ASGI 앱이고, 장수명 커넥션을 위해 워커 하나로 돌립니다. 동기 API에는 ASGI 경로가 전혀 필요 없습니다. 뷰가 소켓으로 푸시하려 할 때는 Redis를 통해 async_to_sync(channel_layer.group_send)를 호출하므로, 메시지는 여전히 WebSocket 프로세스로 건너갑니다. 서버 두 개가 각자 진짜 잘하는 인터페이스를 돌리는 셈입니다. 로컬 Docker도 같은 WSGI 스레드 풀로 돌리기 때문에, 테스트할 때 제 노트북이 프로덕션 머신 한 대와 근사합니다.
핫 패스에는 Rust#
Python 핫 패스에 Rust 기반 대체재가 있으면 무조건 씁니다.
- orjson (
drf-orjson-renderer경유) — 모든 API 응답이 표준 라이브러리 JSON 인코더 대신 Rust로 직렬화됩니다. - blurhash-rs — 업로드 시점에 이미지 플레이스홀더를 계산합니다.
- uuid-utils — Rust 기반 UUID 생성.
- psycopg 3 (binary) — C로 가속된 Postgres 드라이버.
이것들은 아키텍처를 바꾸지 않습니다. 두 번째 머신이 필요해지기 전까지 vCPU 하나가 해낼 수 있는 한계치를 끌어올릴 뿐입니다.
숨어 있는 지연 비용 없애기#
두 코드베이스를 통틀어 가장 큰 성능 개선은 알고리즘이 아니라 커넥션 재사용이었습니다. Granian의 스레드 풀에서는 새 스레드마다 boto3 S3 클라이언트를 새로 만들고 있었고, 사진이나 PDF를 반환하는 모든 엔드포인트가 그 비용을 지불하고 있었습니다. 커스텀 SharedConnectionS3Storage 클래스가 클라이언트를 프로세스 단위로 캐싱하도록 바꾸자:
새 스레드에서의 서명된 URL 생성이 ~150ms에서 ~1ms로 줄었습니다.
데이터베이스에도 같은 교훈이 적용됩니다. Fly의 Postgres는 PgBouncer 뒤에 있으므로 앱은 conn_max_age=0으로 두고 커넥션 재사용을 풀러에 맡기며, 오래된 SSL 커넥션에 워커가 매달리지 않도록 connect_timeout을 5초로 잡아 빠르게 실패시킵니다.
진짜 병목이 있는 곳에 캐싱하기#
마켓플레이스를 부하 테스트하면서 내재화할 만한 사실을 발견했습니다. 한계는 CPU가 아니라 Postgres 커넥션이었습니다. 둘러보기 트래픽은 압도적으로 익명이고 사용자 간에 동일한데, 부하가 걸리면 그 동일한 목록 쿼리들이 CPU는 놀고 있는 동안 커넥션 풀을 포화시킵니다.
해결책은 익명 목록 페이지를 위한 Redis 캐시입니다. 무효화는 네임스페이스별 버전 카운터로 처리합니다. 쓰기가 발생하면 카운터 하나만 올리면 되고(O(1), 키 관리 불필요), 해당 리소스의 캐시된 페이지 전체가 즉시 무효화됩니다. 짧은 TTL이 놓친 것들을 보완합니다. 둘러보기 페이지를 Redis에서 바로 서빙하면 진짜 병목이 제거되고, 작은 머신은 커넥션을 로그인 트래픽에 쓸 수 있게 됩니다.
브로커 없는 백그라운드 작업#
두 앱 모두 Celery 대신 Django의 데이터베이스 기반 태스크 프레임워크인 django-tasks-db를 씁니다. 별도의 db_worker 프로세스가 데이터베이스를 폴링하며 이미지 처리, PDF 생성, 기계 번역, 리마인더 같은 작업을 처리합니다. 브로커도 없고, 크기를 정하고 모니터링해야 할 추가 서비스도 없으며, API 프로세스는 무거운 작업에 절대 블로킹되지 않습니다. 심지어 워커는 PgBouncer를 우회해 Postgres에 직접 연결하는데, 오래 걸리는 작업은 트랜잭션 풀링과 잘 맞지 않기 때문입니다.
메모리: jemalloc과 스왑 쿠션#
이미지와 PDF 처리는 정상 상태 기준의 메모리 산정으로는 예측할 수 없는 스파이크를 만듭니다. 저렴한 완화책 두 가지: Docker 이미지에 jemalloc을 프리로드해 단편화를 줄이고, 모든 머신에 스왑을 둡니다(API 머신 2GB, WebSocket 머신 1GB). 스파이크가 나면 프로세스가 OOM으로 죽는 대신 디스크로 스왑됩니다. 스왑은 성능 기능이 아니라, 메모리 할당을 작게 유지할 수 있게 해주는 보험입니다.
부하 테스트: 무릎 지점 찾기#
임계값은 측정된 것일 때만 방어할 수 있습니다. 프로젝트마다 자체 하네스가 있습니다.
- 견적 앱에는 동시성 레벨을 훑으며 RPS, p50/p95/p99, 에러율을 보고하는 커스텀 Python 스트레스 하네스(
scripts/stress-test.py)가 있고, 목록 엔드포인트가 실제 데이터 볼륨을 상대로 쿼리되도록 수백 개의 현실적인 작업 데이터를 생성하는 관리 명령이 있습니다. - 마켓플레이스는 Locust로 30초 단위 스텝을 밟아 50 → 500 사용자까지 올리며 포화 지점을 찾고, 실제 둘러보기 위주 트래픽에 맞춘 엔드포인트 가중치를 쓰며, 별도의 k6 스위트가 p95 < 2초 임계값으로 프로덕션을 상대로 가상 사용자 5 → 50을 램프합니다.
견적 앱 스윕의 대표적인 수치입니다(Granian 워커 1개 × 블로킹 스레드 16개, 개발 머신의 Docker):
| 엔드포인트 | 동시성 | RPS | p95 | 에러 |
|---|---|---|---|---|
| health | 25 | 602 | 52ms | 0% |
| 작업 목록 | 25 | 309 | 260ms | 0% |
| 고객 목록 | 25 | 357 | 61ms | 0% |
| 작업 목록 | 50 | 309 | 347ms | 0% |
그리고 다른 모든 것을 결정하는 숫자는 shared-cpu-1x 머신 한 대에서 마켓플레이스를 부하 테스트한 결과에서 나옵니다. 기존 ASGI 빌드에서는 이게 단단한 벽이었습니다. 동기 뷰가 사실상 슬롯 3개 정도로 직렬화되는 바람에, p95가 동시 요청 ~5개까지 300ms 미만을 유지하다가 모든 게 thread_sensitive 락 뒤에 줄 서면서 ~8–10개를 지나면 붕괴했습니다. 이 무릎 지점을 바깥으로 밀어낸 것이 바로 API를 WSGI 스레드 풀로 옮긴 일입니다—머신당 실질 동시 슬롯이 ~3개가 아니라 16개가 됐고, 이 새 천장이 오토스케일링 설정 전체의 근거입니다.
Fly.io에서 스케일링이 동작하는 방식#
Fly의 모델은 단순합니다. 앱은 프록시 뒤에 있는 동일한 머신들의 집합이고, 프록시가 머신별 부하를 셉니다. 선언하는 것은 숫자 두 개입니다.
[http_service.concurrency]
type = 'requests'
soft_limit = 12 # 이를 넘으면 다른 머신을 시작
hard_limit = 24 # 이를 넘으면 큐잉 대신 부하를 차단
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 1스케일 업: 한 머신의 진행 중(in-flight) 요청이 soft limit을 넘으면 프록시가 정지된 머신을 시작하고 새 트래픽을 그쪽으로 보냅니다. WSGI 스레드 풀 덕에 머신마다 실질 동시 슬롯이 16개이므로 soft limit은 12입니다—풀의 약 75%로, 사용자가 수 초짜리 지연을 겪은 뒤가 아니라 스레드에 아직 여유가 있을 때 두 번째 머신이 시작됩니다. hard limit 24(풀의 1.5배)는 서킷 브레이커입니다. 짧은 버스트는 스레드에서 큐잉되고, 그 너머에서는 머신 하나가 녹아내리게 두는 대신 프록시가 부하를 차단합니다. (기존 ASGI 빌드는 실질 슬롯이 ~3개여서 8/20이었습니다. 풀을 넓힌 덕에 두 숫자 모두 올릴 수 있었습니다.)
스케일 다운: 트래픽이 줄면 Fly가 유휴 머신을 자동으로 정지시키고, min_machines_running = 1까지 내려갑니다. 정지된 머신은 컴퓨팅 비용이 들지 않고(루트 파일시스템 스토리지만 청구), 프록시가 다시 필요로 하면 1초도 안 되어 콜드 스타트합니다. 각 앱의 기본 비용은 말 그대로 작은 VM 한 대이고, 버스트 용량은 항상 대기 중입니다.
한 가지 미묘한 포인트: 동시성 타입이 connections가 아니라 requests입니다. 현대 클라이언트는 keep-alive 커넥션을 계속 열어 두기 때문에, 커넥션을 세면 실제 부하에서도 수치가 거의 움직이지 않아 오토스케일러가 트리거되지 않습니다. 진행 중 요청을 세야 실제 작업량이 추적됩니다.
WebSocket은 다른 축으로 스케일된다#
마켓플레이스는 WebSocket 서버를 같은 Docker 이미지를 공유하는 두 번째 Fly 앱으로 분리합니다. API 앱은 WSGI로 워커 1개 × 블로킹 스레드 16개를 돌리며 요청을 셉니다(soft limit 12). WebSocket 앱은 512MB 머신에서 ASGI로 uvloop와 함께 워커 1개를 돌리며 커넥션을 셉니다.
[http_service.concurrency]
type = 'connections'
soft_limit = 1500
hard_limit = 2000오래 유지되는 소켓은 열어 두는 비용은 거의 없지만 슬롯을 몇 시간씩 차지하고, HTTP 요청은 슬롯을 밀리초 단위로 차지합니다. 같은 오토스케일링 신호로 둘 다 감당할 수는 없습니다. 분리하면 각 플릿이 자신에게 정직한 지표로 스케일되고, 유휴 WebSocket 용량은 2GB가 아니라 512MB만 차지합니다. Channels 쪽에서는 Redis 레이어가 capacity: 1500과 60초 메시지 만료로 돌아가고, 30초마다 소켓 keepalive를 보냅니다. Fly의 Redis 프록시가 Channels가 의존하는 장수명 BRPOP 소켓을 유휴 상태로 간주해 끊어버리기 때문입니다.
컴퓨팅을 엣지와 클라이언트로 오프로드하기#
가장 저렴한 서버 작업은 서버가 아예 하지 않는 작업입니다.
- 정적 파일과 미디어는 Fly의 S3 호환 스토리지인 Tigris에 있고, 비공개 미디어는 서명된 URL로 CDN을 통해 서빙됩니다. 이미지 바이트는 Django 머신을 전혀 거치지 않습니다.
- AI 추론은 사용자의 폰에서 돌아갑니다. 두 앱 모두 llama.rn으로 로컬 LLM을 내장합니다(마켓플레이스는 양자화된 4B 모델 ~2.7GB를 스트리밍하고, 견적 앱은 데이터 레이어에 툴 콜링을 연결해 어시스턴트가 작업을 생성·수정할 수 있습니다). 서버 측 추론 비용은 0이고, 오프라인에서도 동작합니다.
- 오프라인 우선 모바일(Expo SDK 57, 기기에서는 SQLite, 웹에서는 Dexie, 재시도가 있는 동기화 큐) 덕분에 앱은 키 입력마다 수다스러운 요청으로 API를 두들기는 대신, 쓰기를 배치로 모으고 통신 음영 지역을 견딥니다.
나머지 스택, 간단히#
두 앱 모두 Firebase Auth와 WebAuthn 패스키를 쓰고, 커스텀 미들웨어가 업그레이드 완료 전에 WebSocket 커넥션을 인증합니다. 프로덕션은 Postgres, 개발은 SQLite. 관리자 화면은 django-unfold, 감사 추적은 django-simple-history. 툴링은 uv와 ruff. 모바일은 React Native 0.86 / React 19 / TypeScript에 Expo Router, Gluestack UI v3, Tailwind 4이고, OTA 업데이트는 자체 호스팅 Expo Updates, 구독은 RevenueCat입니다. 품질 게이트는 백엔드에 pytest + mypy + bandit, 클라이언트에 Jest + Detox + Playwright.
가져가서 쓸 만한 것#
이식 가능한 아이디어는 특정 라이브러리가 아니라 루프 자체입니다. 저렴한 머신 한 대를 진짜로 빠르게 만들고(Rust 핫 패스, 커넥션 재사용, 진짜 병목 앞의 캐시, CDN과 클라이언트로 오프로드), 정확히 어느 동시성에서 무너지는지 찾을 때까지 부하 테스트를 하고, 오토스케일러의 soft limit을 그 무릎 지점에 놓고, 유휴 머신은 정지시키고, 최소 플릿은 1로 유지합니다. 프로덕션 앱 두 개, 실시간 기능, AI 어시스턴트 — 그런데 평상시 청구서는 플랫폼에서 가장 저렴한 VM 두어 대 값입니다.

