# 2026년 Django 비동기 뷰와 ASGI: 성능과 면접 질문 > 2026년 Django 비동기 뷰와 ASGI 심층 분석: 내부 동작 원리, 배포할 서버 선택, 비동기 ORM과 SynchronousOnlyOperation 함정, 그리고 면접 질문까지 다룹니다. - Published: 2026-07-03 - Updated: 2026-07-07 - Author: SharpSkill - Tags: django, async, asgi, python, performance - Reading time: 10 min --- Django의 비동기 뷰는 각 커넥션마다 thread를 하나씩 할당하지 않고도 단일 worker가 수백 개의 I/O 바운드 요청을 동시에 처리할 수 있게 합니다. 다만 뷰에서 모든 네트워크 호출에 이르기까지 코드 경로가 계속 비동기로 유지될 때만 그렇습니다. Django 3.1이 `async def` 뷰를 도입하고 Django 4.1이 비동기 ORM 인터페이스를 추가한 이후로, 이 프레임워크는 신뢰할 만한 ASGI 플랫폼으로 자리 잡았습니다. 그럼에도 대부분의 프로덕션 배포는 여전히 WSGI에서 실행되며 그 동시성을 그대로 방치합니다. 이 글은 비동기 뷰가 ASGI에서 어떻게 동작하는지, 2026년에 어떤 서버를 운영해야 하는지, `SynchronousOnlyOperation`을 발생시키는 ORM 함정은 무엇인지, 그리고 비동기 Django를 실제로 출시해 본 엔지니어와 글로만 접한 엔지니어를 가르는 면접 질문은 무엇인지 설명합니다. > **비동기가 실제로 도움이 되는 경우** > > 비동기 뷰는 요청이 대부분의 시간을 스스로 제어할 수 없는 I/O를 기다리는 데 쓸 때 효과를 냅니다. 서드파티 HTTP API, 느린 다운스트림 서비스, 오래 유지되는 스트리밍 응답 등이 그런 경우입니다. CPU 바운드 작업이나 빠른 로컬 데이터베이스 쿼리에는 Gunicorn worker를 더 투입한 동기 뷰가 보통 더 빠르고 추론하기도 훨씬 간단합니다. ## Django 비동기 뷰가 ASGI에서 동작하는 방식 WSGI는 근본적으로 동기 방식입니다. 처리 중인 각 요청이 시작부터 끝까지 worker thread나 프로세스를 하나씩 점유하므로, 느린 요청 50개를 처리하려면 worker 50개가 필요합니다. ASGI는 이 모델을 event loop로 대체합니다. event loop는 단일 worker에서 여러 요청을 번갈아 처리하며, 코루틴이 I/O를 await할 때마다 그 코루틴을 일시 중단했다가 데이터가 도착하면 재개합니다. [Django 공식 비동기 문서](https://docs.djangoproject.com/en/5.2/topics/async/)는 이 공존을 가능하게 하는 두 개의 어댑터를 설명합니다. ASGI에서 Django는 `async def` 뷰를 loop 위에서 네이티브로 실행하고 동기 뷰는 `sync_to_async`로 threadpool에 밀어 넣습니다. WSGI에서는 비동기 뷰를 `async_to_sync`로 감싸 완료될 때까지 실행하며, 모든 동시성 이점을 버립니다. 실질적인 결과는 냉정합니다. WSGI 서버에 배포된 `async def` 뷰는 더 느린 동기 뷰처럼 동작합니다. 동시성은 오직 ASGI 서버에서만 실현됩니다. 외부 API를 호출하는 최소한의 비동기 뷰는 동기 버전과 거의 똑같아 보이며, 차이는 `await`에 집약되어 있습니다. ```python # views.py import httpx from django.http import JsonResponse async def dashboard(request): # ASGI에서 이 코루틴은 event loop 위에서 실행되므로, httpx가 # 네트워크 왕복을 await하는 동안 worker는 다른 요청을 자유롭게 처리합니다. async with httpx.AsyncClient(timeout=5.0) as client: response = await client.get("https://api.example.com/metrics") return JsonResponse(response.json()) ``` 여기서 얻는 이득은 단일 요청이 더 빨리 끝나는 것이 아닙니다. 벽시계 시간(wall-clock time)은 동일합니다. 이득은 `await`가 진행되는 동안 worker가 차단되지 않는다는 데 있습니다. 그래서 프로세스 수는 그대로인데도 동시 부하에서의 처리량(throughput)이 올라갑니다. 같은 모델은 WSGI에서는 다루기 껄끄러운 응답 패턴도 열어 줍니다. Django 4.2부터 `StreamingHttpResponse`는 async generator를 받으므로, 뷰는 업스트림 소스가 데이터를 생성하는 대로 청크를 내보낼 수 있습니다. 이는 커넥션이 수 초간 열려 있는 Server-Sent Events나 프록시된 LLM 토큰 스트림에 적합합니다. WebSocket 같은 지속적 양방향 프로토콜은 여전히 일반 HTTP 뷰가 아니라 Django Channels를 거치지만, 둘 다 동일한 ASGI 기반 위에 놓여 있습니다. 따라서 비동기 뷰를 채택한 프로젝트는 나중에 실시간 기능을 위한 인프라 비용을 이미 치른 셈입니다. ## 2026년 Django ASGI 서버 선택 Django는 `asgi.py`에 ASGI 애플리케이션 객체를 제공하지만, 그 자체로 실행되지는 않습니다. 전용 ASGI 서버가 event loop를 구동합니다. [ASGI 명세](https://asgi.readthedocs.io/en/latest/)는 이 서버들이 모두 구현하는 인터페이스를 정의하며, 그래서 이들은 프로토콜 수준에서 서로 교체 가능하고 주로 성능과 프로토콜 커버리지에서만 차이가 납니다. | 서버 | 구현 언어 | 가장 적합한 용도 | |--------|----------------|----------| | Uvicorn | Python (uvloop) | 일반적인 ASGI, HTTP/1.1, 흔한 기본 선택 | | Daphne | Python | Django Channels, WebSocket, HTTP/2 | | Granian | Rust | 최고 수준의 순수 처리량, HTTP/1과 HTTP/2 | | Hypercorn | Python | HTTP/2와 HTTP/3(QUIC) | 2026년의 대부분의 배포에서는 Gunicorn worker 클래스로 실행하는 [Uvicorn](https://www.uvicorn.org/)이 여전히 믿을 만한 선택입니다. Gunicorn이 프로세스 수명 주기를 관리하고 죽은 worker를 재시작하며 시그널을 처리하는 동안, Uvicorn은 각 worker 안에서 event loop를 구동합니다. 업그레이드에서 걸려 넘어지기 쉬운 마이그레이션 세부 사항이 하나 있습니다. Uvicorn 0.30부터 Gunicorn worker 클래스가 Uvicorn 코어에서 별도의 `uvicorn-worker` 패키지로 옮겨져 import 경로가 바뀌었습니다. ```bash # 먼저 worker 패키지를 설치합니다: pip install uvicorn-worker gunicorn myproject.asgi:application \ --workers 4 \ --worker-class uvicorn_worker.UvicornWorker \ --bind 0.0.0.0:8000 ``` 최대 처리량을 추구하는 팀은 점점 더 Granian으로 눈을 돌립니다. Granian은 Python 쪽 HTTP 파싱 오버헤드를 제거하는 Rust 기반 서버입니다. 어느 쪽을 선택하든, 이 배포 과정이야말로 비동기가 의도적으로 구성했을 때만 프로덕션에 나타나는 이유입니다. ASGI 전용 설정은 [Django 배포 면접 질문](/technologies/django/interview-questions/django-deployment) 모듈에서 다루는 운영상의 주의와 맥을 같이합니다. ## 비동기 ORM과 SynchronousOnlyOperation 함정 Django 4.1은 ORM 전반에 비동기 쿼리 메서드를 추가했습니다. `aget`, `acreate`, `asave`, `adelete`, `acount`, `aexists`, `aaggregate`, 그리고 `async for`를 통한 순회가 그것입니다. 이들은 비동기 친화적인 표면을 제공하지만, 2026년까지 남아 있는 결정적인 단서가 하나 있습니다. 그 아래의 데이터베이스 계층은 네이티브 비동기가 아니라는 점입니다. psycopg3를 쓰더라도 Django는 쿼리를 threadpool에서 실행하고 이를 async 래퍼로 노출합니다. 그래서 비동기 ORM은 진정한 종단 간(end-to-end) 비동기 데이터베이스 I/O를 제공하지는 못한 채, 편의성을 높이고 loop 차단을 피하게 해 줍니다. 가장 흔한 실패 유형은 비동기 뷰 안에서 동기 ORM 메서드를 직접 호출하는 것입니다. Django는 비동기 컨텍스트를 감지하고 이를 거부하며 `SynchronousOnlyOperation`을 발생시킵니다. 차단성 호출이 그 worker를 공유하는 다른 모든 요청을 위해 event loop를 얼려 버리는 것을 막기 위해서입니다. > **SynchronousOnlyOperation은 버그가 아니라 기능** > > 이 예외는 버그가 아니라 가드레일입니다. 이를 잠재우려고 `DJANGO_ALLOW_ASYNC_UNSAFE=true`에 손을 대면, 비동기가 없애려던 바로 그 차단 동작을 다시 끌어들이게 됩니다. 올바른 해결책은 비동기 ORM 메서드(`get` 대신 `aget`)를 쓰거나, 비동기 대응물이 없는 코드를 `sync_to_async`로 명시적으로 감싸는 것입니다. 비동기 쿼리 메서드가 동기 쌍둥이를 대체하고 나면 비동기 뷰는 깔끔하게 읽힙니다. 아래 패턴은 event loop를 한 번도 벗어나지 않고 행 수를 세고 처음 열 개의 레코드를 스트리밍합니다. ```python # views.py from django.http import JsonResponse from .models import Article async def latest_articles(request): # acount()와 async 순회는 ORM을 감싼 async 안전 래퍼입니다. count = await Article.objects.acount() titles = [] async for article in Article.objects.order_by("-created_at")[:10]: titles.append(article.title) return JsonResponse({"count": count, "titles": titles}) ``` 어떤 코드에는 비동기 대응물이 없습니다. 동기 호출만 제공하는 서드파티 SDK나, 다시 작성할 가치가 없는 촘촘한 ORM 순회 체인이 그렇습니다. 이를 [asgiref 라이브러리](https://github.com/django/asgiref)의 `sync_to_async`로 감싸면 작업이 thread로 넘어가고 loop는 계속 응답성을 유지합니다. `thread_sensitive=True` 인자가 안전한 기본값인 이유는, 감싼 모든 호출을 하나의 공유 executor thread로 라우팅하여, 호출이 임의의 thread로 흩어지면 깨질 데이터베이스 커넥션과 트랜잭션 상태를 보존하기 때문입니다. ```python # views.py from asgiref.sync import sync_to_async from .services import build_report # 동기 함수 async def report_view(request): # thread_sensitive=True는 감싼 호출을 하나의 공유 thread에 유지하여, # 경계를 넘나드는 동안 커넥션과 트랜잭션 상태를 보존합니다. report = await sync_to_async(build_report, thread_sensitive=True)(request.user.id) return JsonResponse(report) ``` ## Django 비동기 성능: 동시성이 이기는 순간 Django 비동기 성능은 순수 속도가 아니라 I/O 중첩에 관한 이야기입니다. 가장 분명한 이득은 `asyncio.gather`로 독립적인 네트워크 호출을 팬아웃할 때 나옵니다. 각각 200ms인 외부 요청 세 개는 순차 실행하면 대략 600ms가 들지만, 동시 실행하면 약 200ms 만에 완료됩니다. await들이 같은 loop 위에서 중첩되기 때문입니다. ```python # views.py import asyncio import httpx from django.http import JsonResponse async def aggregate(request): async with httpx.AsyncClient(timeout=5.0) as client: # 독립적인 세 호출을 하나씩 차례로가 아니라 동시에 실행합니다. weather, prices, news = await asyncio.gather( client.get("https://api.example.com/weather"), client.get("https://api.example.com/prices"), client.get("https://api.example.com/news"), ) return JsonResponse({ "weather": weather.json(), "prices": prices.json(), "news": news.json(), }) ``` 반대 경우도 그만큼 중요합니다. 빠른 로컬 쿼리 하나만 실행하는 뷰는 비동기에서 아무것도 얻지 못하며 오히려 손해를 보는 경우가 많습니다. 이제 모든 요청이 event loop 스케줄링에 더해 동기 데이터베이스 드라이버로 들어가는 threadpool 홉 비용까지 치르기 때문입니다. 비동기는 또한 모든 sync/async 경계에서 더 미묘한 세금을 물립니다. 동기 middleware와 비동기 middleware를 섞으면 Django는 전환할 때마다 loop와 thread 사이를 오가야 하고, 응답 한 번에 그 경계를 여러 번 넘나드는 요청은 실질적인 지연(latency)을 쌓게 됩니다. middleware 스택을 전부 비동기로, 또는 전부 동기로 유지하면 이러한 전환이 사라집니다. 이 결정은 최신 문법에 대한 선호가 아니라 워크로드의 형태로 귀결됩니다. | 워크로드 | 더 나은 기본 선택 | |----------|----------------| | 요청당 여러 개의 느린 외부 API 호출 | `asyncio.gather`를 쓰는 비동기 뷰 | | 오래 유지되는 스트리밍 또는 Server-Sent Events | async generator를 쓰는 비동기 뷰 | | 빠른 데이터베이스 쿼리 하나, 가벼운 CPU 부하 | 동기 뷰, Gunicorn worker 증설 | | CPU 바운드 렌더링 또는 연산 | 동기 뷰, 태스크 큐로 오프로드 | 진짜 오래 걸리는 작업에는 비동기 뷰가 전적으로 잘못된 도구입니다. 몇 분짜리 작업을 하려고 요청을 열어 두는 것은 동시성 모델과 무관하게 커넥션을 낭비합니다. 그런 작업은 백그라운드 worker의 몫이며, 이 트레이드오프는 [Django의 Celery 비동기 태스크](/blog/django/django-celery-async-tasks-interview-2026) 가이드에서 다룹니다. 도입은 직관이 아니라 측정을 따라야 합니다. 비동기를 정당화하는 정직한 방법은 Locust나 k6 같은 도구로 현실적인 동시성 아래 특정 엔드포인트를 부하 테스트하여, N개의 worker 위 동기 버전과 동일한 하드웨어 위 비동기 버전을 비교하는 것입니다. 지연이 큰 단일 외부 호출이 지배하는 엔드포인트는 비동기 쪽에 유리하게 큰 격차를 보입니다. 반면 빠른 쿼리가 지배하는 엔드포인트는 threadpool 홉까지 계산하고 나면 비동기가 근소하게 뒤처지는 경우가 잦습니다. 요청을 먼저 프로파일링하면 병목이 정말 I/O인지도 드러납니다. 인덱스 없는 쿼리 때문에 느린 뷰에는 비동기가 아무런 도움이 되지 않는데, 이 문제는 [Django ORM 쿼리 최적화](/blog/django/django-orm-optimizing-queries) 안내에서 다룹니다. ## 2026년 Django 비동기 면접 질문 Django 비동기 면접은 지원자가 모델을 이해하는지 아니면 키워드만 알아보는지를 파고듭니다. 아래 질문들은 2026년 시니어 면접에서 실제로 묻는 내용을 반영하며, 각 질문에는 경험 많은 엔지니어가 내놓는 답을 함께 실었습니다. **비동기 뷰가 ASGI가 아니라 WSGI에서 실행되면 무엇이 달라지나요?** WSGI에서 Django는 코루틴을 `async_to_sync`로 감싸 요청 thread에서 완료될 때까지 실행합니다. 그래서 동시성 이점이 전혀 없는 더 느린 동기 뷰처럼 동작합니다. event loop를 구동하는 ASGI 서버만이 `await` 동안 worker가 다른 요청을 처리하게 해 줍니다. **비동기 뷰 안에서 `Article.objects.get(pk=1)`은 왜 `SynchronousOnlyOperation`을 발생시키나요?** 동기 ORM 호출은 event loop를 차단하여 그 worker의 다른 모든 요청을 멈춰 세우기 때문입니다. Django는 비동기 컨텍스트를 감지하고 이를 거부합니다. 해결책은 비동기 메서드 `await Article.objects.aget(pk=1)`이거나, 비동기 대응물이 없는 코드에는 `sync_to_async`입니다. **Django의 비동기 ORM은 정말로 논블로킹인가요?** 아닙니다. 비동기 메서드는 편의를 위한 래퍼입니다. 데이터베이스 드라이버가 종단 간 네이티브 비동기 실행에 통합되어 있지 않기 때문에, 그 아래의 쿼리는 여전히 threadpool에서 실행됩니다. 이점은 loop를 자유롭게 유지하는 것이지, 드라이버 수준의 병렬 데이터베이스 I/O가 아닙니다. **Celery 같은 태스크 큐 대신 비동기 뷰를 골라야 하는 때는 언제인가요?** 비동기 뷰는 응답 전에 반드시 끝나야 하는 요청 범위의 I/O 동시성에 적합합니다. 여러 API를 집계하는 경우가 그렇습니다. 요청보다 오래 사는 작업, 재시도가 필요한 작업, 몇 분간 실행되는 작업은 Celery나 Django의 백그라운드 태스크의 몫입니다. 그런 작업을 위해 HTTP 커넥션을 열어 두는 것은 서버 자원을 낭비하기 때문입니다. **동기 middleware와 비동기 middleware를 섞는 비용은 무엇인가요?** 동기 컴포넌트와 비동기 컴포넌트 사이의 각 전환은 Django가 `sync_to_async`나 `async_to_sync`를 통해 event loop와 thread 사이를 오가게 만듭니다. 그 경계를 반복해서 넘나드는 요청은 누적 오버헤드를 치르므로, 뒤섞인 스택보다 균일한 middleware 스택이 더 나은 성능을 냅니다. ## 결론 - 비동기 뷰는 ASGI 서버에서만 동시성을 제공합니다. 같은 뷰가 WSGI에서는 `async_to_sync`를 거쳐 동기로 실행됩니다. - Uvicorn은 Gunicorn 아래에서 `uvicorn-worker` 패키지로 배포하고, 최대 처리량이 필요하면 Granian을 배포하며, ASGI 설정은 명시적인 배포 단계로 다뤄야 합니다. - 요청이 여러 개의 느린 외부 I/O 호출을 기다릴 때 비동기 뷰를 선택하고, 빠른 쿼리나 CPU 바운드 엔드포인트는 worker를 늘려 동기로 유지하는 것이 좋습니다. - 비동기 뷰 안에서는 `aget`, `acreate`, `async for`를 사용하되, ORM이 여전히 네이티브 비동기가 아니라 threadpool에서 쿼리를 실행한다는 점을 기억해야 합니다. - `SynchronousOnlyOperation`은 가드레일로 취급해, 검사를 비활성화하지 말고 비동기 메서드나 `sync_to_async(thread_sensitive=True)`로 해결해야 합니다. - 경계를 넘을 때마다 thread 전환 페널티를 물지 않으려면 middleware 스택을 전부 동기로 또는 전부 비동기로 유지해야 합니다. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/django/django-async-views-asgi-2026