저는 현재 1인 개발자로 두 개의 프로덕트를 운영하고 있습니다. 하나는 현장에서 견적을 내는 기술자들을 위한 현장 견적 앱이고, 다른 하나는 구인구직, 중고 거래, 실시간 메시징을 갖춘 커뮤니티 마켓플레이스 플랫폼입니다.
프로덕트도 다르고 사용자도 다르지만, 기술 스택은 거의 동일합니다. 그리고 둘 다 Fly.io에서 가장 저렴한 티어인 shared-cpu-1x 머신에서만 돌아갑니다. 이 제약은 의도적인 것입니다. 피크 부하에 맞춰 서버를 크게 잡는 대신, 작은 머신 한 대에서 뽑아낼 수 있는 성능을 최대한 끌어올리고, 정확히 어디서 무너지는지 측정한 다음, 그 측정된 한계를 기준으로 Fly의 오토스케일러가 머신을 추가하고 제거하도록 맡깁니다. 이 글에서는 이를 가능하게 하는 최적화들, 임계값의 근거가 된 부하 테스트 수치, 그리고 Fly.io에서 스케일 업/다운이 실제로 어떻게 동작하는지 다룹니다.
철학: 머신 한 대를 측정하고, 그다음 곱하기#
두 앱 모두 스케일링 전략은 같은 세 단계입니다.
- 작은 머신 한 대를 합리적인 수준에서 최대한 빠르게 만든다.
- 무너질 때까지 부하 테스트를 하고, 지연 시간이 급격히 나빠지는 동시성 지점을 기록한다.
- 그 지점 직전에 두 번째 머신이 시작되도록 Fly 오토스케일러를 설정하고, 트래픽이 줄면 유휴 머신을 정지시킨다.
그러면 용량은 머신 수에 거의 선형으로 비례해서 늘어나고, 기본 비용은 앱당 작은 VM 한 대입니다. 아래의 모든 내용은 이 세 단계 중 하나를 위한 것입니다.
작은 머신 한 대를 빠르게 만들기#
Granian과 uvloop#
두 백엔드 모두 Django 6 + Django REST Framework이며, Rust 기반 ASGI 서버인 Granian이 uvloop 이벤트 루프와 함께 서빙합니다.
granian --interface asgi project.asgi:application \
--workers 3 --loop uvloop --ws \
--workers-kill-timeout 15 --respawn-failed-workers워커 3개는 1 vCPU 공유 머신의 최적점입니다. I/O 대기를 겹치기에는 충분하고, CPU를 쥐어짜서 스래싱을 일으킬 정도는 아닙니다. Granian은 HTTP/1.1, HTTP/2, WebSocket을 하나의 프로세스에서 처리하므로 메모리를 잡아먹는 별도의 프록시 레이어가 없습니다. 로컬 Docker도 같은 워커 3개로 돌리기 때문에, 테스트할 때 제 노트북이 프로덕션 머신 한 대와 근사합니다.
핫 패스에는 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 워커 3개, 개발 머신의 Docker):
| 엔드포인트 | 동시성 | RPS | p95 | 에러 |
|---|---|---|---|---|
| health | 25 | 602 | 52ms | 0% |
| 작업 목록 | 25 | 309 | 260ms | 0% |
| 고객 목록 | 25 | 357 | 61ms | 0% |
| 작업 목록 | 50 | 309 | 347ms | 0% |
그리고 다른 모든 것을 결정하는 숫자, shared-cpu-1x 머신 한 대에서 마켓플레이스를 부하 테스트한 결과: p95는 동시 요청 ~5개까지 300ms 미만을 유지하다가 ~8–10개를 지나면 붕괴합니다. 처리량이 떨어지고 지연 시간이 수 초 단위로 치솟습니다. 이 무릎 지점이 오토스케일링 설정 전체의 근거입니다.
Fly.io에서 스케일링이 동작하는 방식#
Fly의 모델은 단순합니다. 앱은 프록시 뒤에 있는 동일한 머신들의 집합이고, 프록시가 머신별 부하를 셉니다. 선언하는 것은 숫자 두 개입니다.
[http_service.concurrency]
type = 'requests'
soft_limit = 8 # 이를 넘으면 다른 머신을 시작
hard_limit = 20 # 이를 넘으면 큐잉 대신 부하를 차단
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 1스케일 업: 한 머신의 진행 중(in-flight) 요청이 soft limit을 넘으면 프록시가 정지된 머신을 시작하고 새 트래픽을 그쪽으로 보냅니다. 부하 테스트에서 동시 요청 8–10개에서 붕괴가 확인됐으므로 soft limit은 8입니다. 사용자가 이미 수 초짜리 지연을 겪은 뒤가 아니라, 정확히 무릎 지점에서 두 번째 머신이 시작됩니다. hard limit 20은 서킷 브레이커입니다. 그 너머에서는 머신 하나가 녹아내리게 두는 대신 프록시가 부하를 차단합니다.
스케일 다운: 트래픽이 줄면 Fly가 유휴 머신을 자동으로 정지시키고, min_machines_running = 1까지 내려갑니다. 정지된 머신은 컴퓨팅 비용이 들지 않고(루트 파일시스템 스토리지만 청구), 프록시가 다시 필요로 하면 1초도 안 되어 콜드 스타트합니다. 각 앱의 기본 비용은 말 그대로 작은 VM 한 대이고, 버스트 용량은 항상 대기 중입니다.
한 가지 미묘한 포인트: 동시성 타입이 connections가 아니라 requests입니다. 현대 클라이언트는 keep-alive 커넥션을 계속 열어 두기 때문에, 커넥션을 세면 실제 부하에서도 수치가 거의 움직이지 않아 오토스케일러가 트리거되지 않습니다. 진행 중 요청을 세야 실제 작업량이 추적됩니다.
WebSocket은 다른 축으로 스케일된다#
마켓플레이스는 WebSocket 서버를 같은 Docker 이미지를 공유하는 두 번째 Fly 앱으로 분리합니다. API 앱은 워커 3개로 요청을 셉니다(soft limit 8). WebSocket 앱은 512MB 머신에서 워커 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 두어 대 값입니다.

