본문으로 건너뛰기
뒤로
Database

섹션8 실습 1: Django ASGI에서 idle 커넥션 재사용 확인

앞선 Django ASGI 커넥션 수명 설명을 실제 세션으로 확인한다. Django는 일반적인 웹 요청별 커넥션 풀을 제공하지 않는다. 그렇다면 ASGI 서버에서 요청이 끝난 뒤 idle로 남은 DB 커넥션을 다음 요청이 재사용할까? 이 실습은 Django 4.2.30, PostgreSQL 17, Gunicorn 22와 uvicorn-worker를 사용해 확인한다. DB 별칭 세 개는 모두 격리된 PostgreSQL 하나를 가리키고, 각 커넥션에 별칭 이름을 application_name으로 기록한다.

실행 방식과 패키지 버전은 실제로 사용했던 ASGI 서버 조합에 맞췄다. 서비스 코드와 접속 정보는 사용하지 않고, 동작을 관찰하기 위한 최소 Django 앱으로 바꿨다.

ASGI 서버와 최소 Django 앱

세 DB 별칭 모두 같은 실험 DB를 가리킨다. 기본 조건은 CONN_MAX_AGE=600이다. 0은 설정 변경의 효과를 비교하기 위해 별도로 실행한다.

docker network create asgi-db-lab
docker run -d --name asgi-db-lab-db \
  --network asgi-db-lab --network-alias db \
  -e POSTGRES_USER=lab -e POSTGRES_PASSWORD=lab -e POSTGRES_DB=lab \
  postgres:17
# settings.py
SECRET_KEY = "lab"
DEBUG = False
ROOT_URLCONF = "urls"
ALLOWED_HOSTS = ["*"]
INSTALLED_APPS = []
MIDDLEWARE = []
ASGI_APPLICATION = "asgi.application"

def database(alias):
    return {
        "ENGINE": "django_prometheus.db.backends.postgresql",
        "NAME": "lab", "USER": "lab", "PASSWORD": "lab",
        "HOST": "db", "PORT": 5432,
        "CONN_MAX_AGE": 600,
        "OPTIONS": {"application_name": f"asgi-lab-{alias}"},
    }

DATABASES = {
    "default": database("default"),
    "default_read": database("default_read"),
    "secondary_read": database("secondary_read"),
}
# asgi.py
import os
from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings")
application = get_asgi_application()

동기 Django 뷰 하나가 각 별칭으로 쿼리해 PostgreSQL backend PID를 돌려준다. delay를 주면 SQL 실행은 끝났지만 HTTP 요청은 아직 진행 중인 구간도 만들 수 있다.

# urls.py
import threading
import time
from django.db import connections
from django.http import JsonResponse
from django.urls import path

ALIASES = ("default", "default_read", "secondary_read")

def index(request):
    pids = {}
    for alias in ALIASES:
        with connections[alias].cursor() as cursor:
            cursor.execute("select pg_backend_pid()")
            pids[alias] = cursor.fetchone()[0]
    time.sleep(float(request.GET.get("delay", "0")))
    return JsonResponse({
        "pids": pids,
        "thread_id": threading.get_ident(),
        "thread_name": threading.current_thread().name,
    })

urlpatterns = [path("", index)]

앱 컨테이너는 Gunicorn의 Uvicorn worker 한 개로 실행한다. 패키지 설치는 한 번만 하고 요청 생성 스크립트를 실행한다.

pip install Django==4.2.30 asgiref==3.9.1 django-prometheus==2.5.0 \
  psycopg2-binary==2.9.9 gunicorn==22.0.0 uvicorn==0.49.0 uvicorn-worker==0.4.0

gunicorn asgi:application -k uvicorn_worker.UvicornWorker \
  --bind 0.0.0.0:8000 --workers 1

부하 스크립트는 HTTP 요청 10개를 차례로 보낸 뒤, 8개를 동시에 보낸다. 각 응답에서 받은 PID를 별칭별로 출력한다.

import concurrent.futures
import json
import urllib.request

ALIASES = ("default", "default_read", "secondary_read")

def get(url):
    with urllib.request.urlopen(url, timeout=30) as response:
        return json.load(response)

sequential = [get("http://127.0.0.1:8000/") for _ in range(10)]
print("sequential:", {a: [r["pids"][a] for r in sequential] for a in ALIASES})

with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
    concurrent = list(executor.map(
        get, ["http://127.0.0.1:8000/?delay=0.5"] * 8
    ))
print("concurrent:", {a: sorted({r["pids"][a] for r in concurrent}) for a in ALIASES})

실제 ASGI 설정인 CONN_MAX_AGE=600에서 커넥션을 관찰한다

기본 설정의 CONN_MAX_AGE=600으로 실행하고, 순차 요청과 동시 요청을 보낸다. 요청이 끝난 다음 PostgreSQL에서 실험 세션을 조회한다.

select application_name, state, count(*)
from pg_stat_activity
where datname = 'lab' and application_name like 'asgi-lab-%'
group by application_name, state
order by application_name, state;

결과 — 순차 요청 10개와 동시 요청 8개가 모두 성공했다. 순차 요청마다 별칭별 backend PID가 달랐고, 이어진 동시 요청도 새로운 backend PID들을 사용했다. 요청 뒤에는 여러 커넥션이 idle로 남았다.

순차 요청 결과 — 같은 ASGI worker에 요청을 차례로 보냈는데도 별칭마다 backend PID가 열 개씩 달랐다. 예를 들어 default의 결과는 다음과 같았다.

default: [356, 359, 362, 365, 368, 371, 374, 377, 380, 383]
default_read: [357, 360, 363, 366, 369, 372, 375, 378, 381, 384]
secondary_read: [358, 361, 364, 367, 370, 373, 376, 379, 382, 385]

이어서 8개 요청을 동시에 보내자 각 별칭에서 다시 8개의 backend PID가 관찰됐다. pg_stat_activity에는 순차·동시 요청에서 만들어진 커넥션이 idle로 남았다.

application_name        | state | count
-------------------------+-------+------
asgi-lab-default        | idle  |    10
asgi-lab-default_read   | idle  |    10
asgi-lab-secondary_read | idle  |     9

결과는 “idle 커넥션이 다른 요청에 할당될 때까지 기다리는 공용 풀”과 달랐다. SQL이 끝나면 PostgreSQL 관점에서는 세션이 idle이지만, 다음 Django ASGI 요청은 그 커넥션의 backend PID를 재사용하지 않았다. 같은 프로세스에서 요청이 순차 처리돼도 새 PID가 나왔고, 동시 요청 뒤에는 여러 idle 세션이 남았다.

PostgreSQL요청 2의 동기 실행기요청 1의 동기 실행기ASGI workerHTTP 클라이언트PostgreSQL요청 2의 동기 실행기요청 1의 동기 실행기ASGI workerHTTP 클라이언트커넥션은 열린 채 idle이전 idle 커넥션은 그대로 남음요청 1동기 뷰 실행default 커넥션으로 쿼리PID 356 결과응답다음 요청동기 뷰 실행새 커넥션으로 쿼리새 PID 결과

결과 해석

CONN_MAX_AGE는 커넥션의 최대 유지 시간이지 커넥션 수 제한이나 커넥션 풀 크기가 아니다. 이 재현에서는 600이므로 요청 후 idle 상태로 남은 커넥션을 즉시 닫지 않았다. 하지만 다음 ASGI 요청은 그 세션을 빌려 쓰지 않았고, 별도의 backend PID로 쿼리했다. 따라서 요청 수가 누적되면 유지 기간이 지나거나 커넥션 정리 시점이 올 때까지 idle 세션이 늘 수 있다.

응답에 포함한 OS thread ID는 연속 요청에서 같게 보일 때도 있었다. 하지만 thread 이름은 ThreadPoolExecutor-19_0, ThreadPoolExecutor-20_0처럼 달라졌고 DB backend PID도 매번 달랐다. OS가 종료된 스레드의 숫자 ID를 재사용할 수 있으므로, thread ID 하나만으로 같은 실행기가 DB 커넥션을 재사용했다고 판단하면 안 된다.

별도 설정 변경의 비교 실험 — CONN_MAX_AGE=0

이제 settings.py의 CONN_MAX_AGE를 0으로 바꾸고 앱을 재시작한 뒤 같은 요청을 보낸다. 요청 종료 시 커넥션 정리가 어떻게 달라지는지 비교한다.

결과 — 순차 요청 10개와 동시 요청 8개가 모두 성공했다. 요청이 끝난 뒤 pg_stat_activity 조회는 0 rows를 반환했다. 이 최소 재현에서는 CONN_MAX_AGE=0 설정으로 요청 후 실험용 DB 세션이 남지 않았다.

이 결과만으로 실제 개발 환경의 커넥션 고갈이 해결됐을 것이라고 결론 내릴 수 없다. 실제 서비스의 미들웨어, 요청 경로, 프로세스 수와 DB 별칭 사용 패턴이 재현 앱과 같다는 보장이 없기 때문이다. 또 CONN_MAX_AGE=0은 매 요청에서 커넥션을 닫는 설정이므로 물리 커넥션을 새로 만드는 비용이 든다. 지속 커넥션이 필요하다면 ASGI 지원이 명시된 풀링 방식이나 외부 풀러를 별도로 평가해야 한다.

확인한 것

설정요청 완료 후 PostgreSQL 세션다음 순차 요청의 backend PID
실제 ASGI 조건인 CONN_MAX_AGE=600여러 idle 세션이 남음앞 요청의 PID 재사용을 관찰하지 못함
비교 조건인 CONN_MAX_AGE=0요청 뒤 실험 세션 0개매 요청에서 새 커넥션 후 종료

여기서 확인한 범위는 Django 4.2.30, 동기 뷰를 Gunicorn uvicorn-worker로 실행한 재현이다. 다른 Django/asgiref 버전, async 뷰, 드라이버, DB 풀 설정은 별도 실험이 필요하다.


조회 —



댓글