이 글을 읽으면 알 수 있는 것
- RDS 프리티어(db.t3.micro)의 콜드 스타트가 발생하는 메커니즘
- Docker bridge vs host 네트워크 모드가 EC2↔RDS 레이턴시에 미치는 영향
- gunicorn timeout과 워커 수 튜닝의 실전 기준
- CONN_MAX_AGE와 헬스체크로 콜드 스타트를 완화하는 방법
- “무료” 인프라의 트레이드오프를 사전에 파악하는 관점
배경: 마이그레이션 성공, 그리고 다음 날 아침
Railway에서 AWS 프리티어로 마이그레이션을 마친 날, 모든 것이 잘 동작했습니다. EC2(t3.micro) + RDS(db.t3.micro), 월 비용 $0. 검색도 되고, ETL도 돌고, SSL도 붙었습니다.
다음 날 새벽, 검색이 타임아웃으로 죽기 시작했습니다. 코드는 한 줄도 안 바꿨는데.
장애 타임라인
3/16 새벽 — 최초 발견
gunicorn 로그에 [CRITICAL] WORKER TIMEOUT 메시지가 쏟아지고 있었습니다.
[2026-03-16 03:12:47 +0000] [CRITICAL] WORKER TIMEOUT (pid:28)
[2026-03-16 03:12:47 +0000] [WARNING] Worker with pid 28 was terminated due to signal 9
기본 타임아웃 30초를 초과. 검색 API가 응답을 못 돌려주고 있었습니다. 낮에는 괜찮다가, 트래픽이 없는 새벽~아침 시간대에 집중적으로 발생.
1차 대응: 워커 수 축소 + 타임아웃 확대
가설: t3.micro(vCPU 2, RAM 1GB)에서 gunicorn 워커 3개는 과부하. 워커끼리 메모리를 나눠 쓰다가 응답이 느려지는 것 아닐까.
# 변경 전
CMD ["gunicorn", "config.wsgi", "-w", "3", "-b", "0.0.0.0:8000", "--timeout", "30"]
# 변경 후
CMD ["gunicorn", "config.wsgi", "-w", "2", "-b", "0.0.0.0:8000", "--timeout", "120"]
워커 수 공식: gunicorn 공식 문서는 (2 × CPU) + 1을 권장합니다. t3.micro는 vCPU 2개이므로 5개가 공식이지만, 프리티어 메모리(1GB)를 고려하면 2개가 현실적 상한. Django 앱 하나가 약 150~200MB를 차지하므로, 워커 3개면 OS + Docker 오버헤드와 합쳐 메모리가 빠듯합니다.
결과: 타임아웃 빈도가 줄었지만, 완전히 사라지진 않았습니다. 새벽에 오랜 유휴 후 첫 요청에서 여전히 120초를 초과하는 케이스 발생.
2차 대응: Docker bridge → host 네트워크 전환
1차 대응 후에도 타임아웃이 남아서, 네트워크 경로를 의심했습니다. EC2 위에 Docker 컨테이너가 올라가고, 컨테이너 안에서 RDS에 접속하는 구조입니다.
bridge 모드의 문제
Docker 기본 네트워크 모드인 bridge에서는 컨테이너가 가상 네트워크(docker0)를 거쳐 외부 통신합니다.
[컨테이너] → docker0 bridge → NAT → EC2 eth0 → VPC → RDS
이 중간 단계마다 미세한 레이턴시가 추가됩니다. 정상 상태에서는 무시할 수준이지만, RDS가 콜드 스타트 상태일 때 TCP 핸드셰이크 + SSL 협상 + PostgreSQL 인증 전체가 느려지면 이 레이턴시가 누적됩니다.
host 모드로 전환
# docker-compose.yml
services:
web:
# 변경 전
# ports:
# - "8000:8000"
# 변경 후
network_mode: "host"
host 모드에서는 컨테이너가 EC2의 네트워크 스택을 직접 사용합니다.
[컨테이너] → EC2 eth0 → VPC → RDS
NAT 변환 계층이 사라지고, 포트 매핑도 불필요합니다. 단, 포트 충돌을 직접 관리해야 하는 트레이드오프가 있습니다. EC2에서 단일 서비스만 돌리는 구성이라면 host 모드가 단순하고 빠릅니다.
| 항목 | bridge | host |
|---|---|---|
| 네트워크 격리 | 있음 | 없음 |
| 포트 매핑 | 필요 (-p 8000:8000) | 불필요 |
| NAT 오버헤드 | 있음 | 없음 |
| 멀티 컨테이너 | 적합 | 포트 충돌 주의 |
| EC2 단일 서비스 | 불필요한 복잡성 | 권장 |
결과: 정상 상태에서의 응답 시간이 체감될 정도로 개선. 하지만 장시간 유휴 후 첫 요청 타임아웃은 여전히 발생.
원인 분석: t3.micro의 버스터블 성능 모델
여기서 문제의 본질을 파악했습니다. 코드도, 네트워크도 아닌 RDS 인스턴스 자체의 동작 방식이 원인이었습니다.
CPU 크레딧 시스템
t3 계열 인스턴스는 버스터블(Burstable) 성능 모델을 사용합니다. 상시 고성능이 아니라, 기본 성능(baseline)을 제공하되 크레딧이 있을 때만 버스트 가능.
db.t3.micro 스펙:
- vCPU: 2
- 메모리: 1 GB
- 기본 CPU 성능: 10% ← 핵심
- 시간당 CPU 크레딧 획득: 12
- 최대 크레딧 잔고: 288
**기본 성능 10%**가 의미하는 것: 두 개의 vCPU를 합산해서 전체 성능의 10%만 상시 사용 가능. 나머지는 크레딧을 소진하며 버스트. 트래픽이 없는 시간대에는 크레딧이 쌓이지만, 동시에 AWS 내부적으로 유휴 자원 회수가 일어납니다.
콜드 스타트의 실체
RDS 프리티어의 “콜드 스타트”는 공식 문서에 명시된 개념이 아닙니다. 하지만 실측으로 확인되는 현상입니다.
정상 상태 (트래픽 있음):
DB 연결 수립: ~50ms
단순 쿼리: ~5ms
콜드 상태 (6시간+ 유휴 후):
DB 연결 수립: 5~30초
단순 쿼리: 1~5초
추정되는 원인:
- 스토리지 I/O 워밍업: EBS gp2의 버스트 크레딧이 소진 상태에서 재개
- 버퍼 풀 비움: 1GB 메모리에서 PostgreSQL shared_buffers가 최소 상태로 축소
- 연결 핸들러 재초기화: 유휴 연결 정리 후 새 연결 수립에 전체 핸드셰이크 필요
이 세 가지가 겹치면, 첫 요청의 전체 응답 시간이 30초를 넘기고, 최악의 경우 120초를 초과합니다.
3차 대응: 타임아웃 300초 — 최악을 커버하는 설정
CMD ["gunicorn", "config.wsgi", "-w", "2", "-b", "0.0.0.0:8000", "--timeout", "300"]
타임아웃을 300초(5분)로 확대. 일반적인 웹 서비스에서는 비상식적인 수치지만, 프리티어 콜드 스타트의 최악 케이스를 커버해야 했습니다.
왜 300초인가:
- 실측 최악 케이스: RDS 첫 연결 ~30초 + 첫 쿼리 ~5초 + GIN 인덱스 워밍업 ~60초
- 안전 마진 3배 적용
- 콜드 스타트 후 두 번째 요청부터는 정상(~100ms)이므로, 첫 요청만 버티면 됨
gunicorn 타임아웃의 동작 원리:
# gunicorn이 워커를 감시하는 방식 (단순화)
while True:
for worker in workers:
if time.time() - worker.last_heartbeat > timeout:
os.kill(worker.pid, signal.SIGKILL) # 강제 종료
spawn_new_worker()
워커가 요청을 처리하는 동안 마스터 프로세스에 하트비트를 보냅니다. 타임아웃 시간 내에 하트비트가 없으면 워커를 죽이고 새로 생성합니다. 타임아웃이 너무 짧으면 정상적인 느린 요청도 죽이고, 너무 길면 행(hang)된 워커를 방치합니다.
| 환경 | 권장 timeout |
|---|---|
| 일반 SaaS (유료 인프라) | 30초 |
| 프리티어 + 상시 트래픽 | 60초 |
| 프리티어 + 간헐적 트래픽 | 120~300초 |
결과: 타임아웃 완전 해소. 콜드 스타트 후 첫 요청이 최대 90초 걸리더라도 워커가 살아남아서 응답을 반환합니다.
재발 방지: 콜드 스타트 자체를 줄이기
타임아웃을 늘리는 건 대증 요법입니다. 근본적으로는 콜드 스타트가 발생하는 빈도와 강도를 줄여야 합니다.
1. CONN_MAX_AGE: DB 연결 재사용
# settings.py
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'HOST': os.environ.get('DB_HOST'),
# ...
'CONN_MAX_AGE': None, # 연결을 영구 유지
'CONN_HEALTH_CHECKS': True, # 사용 전 연결 상태 확인
}
}
Django 기본값 CONN_MAX_AGE=0은 매 요청마다 새 DB 연결을 생성하고 폐기합니다. 프리티어 RDS에서 연결 수립은 비싸기 때문에(50ms~30초), 연결을 재사용하면 콜드 스타트의 영향을 첫 연결 한 번으로 국한할 수 있습니다.
CONN_HEALTH_CHECKS=True는 Django 4.1에서 추가된 옵션으로, 재사용 전에 연결이 살아있는지 확인합니다. 유휴 시간이 길어 서버 쪽에서 연결을 끊었을 때 에러 대신 자동 재연결합니다.
2. 헬스체크 엔드포인트
# views.py
from django.http import JsonResponse
from django.db import connection
def health_check(request):
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
return JsonResponse({"status": "ok", "db": "connected"})
except Exception as e:
return JsonResponse({"status": "error", "db": str(e)}, status=503)
외부 모니터링(UptimeRobot 등)에서 5분 간격으로 이 엔드포인트를 호출하면, DB 연결이 완전히 식기 전에 주기적으로 깨워줍니다. 완전한 해결은 아니지만, 콜드 스타트의 깊이를 얕게 유지하는 효과가 있습니다.
# 헬스체크 없을 때
[트래픽] ████░░░░░░░░░░░░░░████ ← 6시간 공백 → 심한 콜드 스타트
# 5분 간격 헬스체크
[트래픽] ████·····················████ ← 연결 유지 → 가벼운 워밍업만
전체 대응 요약
| 단계 | 가설 | 조치 | 결과 |
|---|---|---|---|
| 1차 | 워커 과부하 | workers 3→2, timeout 30→120s | 빈도 감소, 미해결 |
| 2차 | 네트워크 레이턴시 | Docker bridge→host | 정상 시 개선, 콜드 시 미해결 |
| 3차 | 타임아웃 부족 | timeout 120→300s | 타임아웃 해소 |
| 사후 | 콜드 스타트 완화 | CONN_MAX_AGE + 헬스체크 | 재발 빈도 최소화 |
교훈
- “무료”에는 명세서가 있다. RDS 프리티어는 비용이 $0이지만, 성능 보장도 $0이다. 버스터블 성능 모델, CPU 크레딧 한도, 콜드 스타트 — 이 트레이드오프를 알고 쓰는 것과 모르고 쓰는 것은 완전히 다르다.
- 타임아웃은 “기대 응답 시간”이 아니라 “최악 응답 시간”에 맞춘다. 프리티어에서 30초 타임아웃은 정상 시에는 여유롭지만, 콜드 스타트 한 번에 전부 깨진다.
- Docker 네트워크 모드는 상황에 맞게 선택한다. EC2 단일 컨테이너에서 bridge는 불필요한 레이어. host가 단순하고 빠르다.
- 장애는 레이어를 하나씩 벗겨야 한다. 애플리케이션(gunicorn) → 네트워크(Docker) → 인프라(RDS 콜드 스타트). 한 번에 전부 바꾸면 뭐가 효과 있었는지 모른다.