목록으로

Django의 모델단에 Async 사용하기

낱글
2024. 7. 29. AM 12:04:34
Django엔 비동기(Async) 동작이 아주 조금씩 조심스레 적용되고 있습니다. 특히 데이터베이스 계층엔 더 점진적으로 적용하고 있지요. 그래서 비동기를 사용하지 않는 기존 구현 방식엔 변화가 없으며, 부분 부분 비동기를 적용하며 구현해가기 좋아요.
Django에 비동기를 적용하는 아주 쉽고 안전한 방법은 Django View 계층 위주로 비동기를 적용하는 거예요. 기존 View 함수의 함수 정의 예약어 앞에 async 예약어를 덧붙여 함수를 코루틴(coroutine)으로 변경하기만 하면 되죠.
하지만 비동기 동작이 정말 필요한 계층은 매우 빈번하게 I/O가 일어나는 데이터베이스 계층입니다. 그리고 Django의 데이터베이스 계층에서 비동기 동작을 사용하며 여러 시행착오를 겪게 됩니다. 이번 컨텐츠에서는 가장 빈번하게 접하는 시행착오를 살펴보고, 우회 방법이 아닌 정확한 문제 대응 방법을 알아보겠습니다.

모델 매니저의 비동기 API 사용

데이터 생성, acreate()

모델 매니저엔 create() API가 있습니다. 데이터베이스에 데이터를 생성하는 API이지요.
이 API의 비동기 버전은 API 이름 앞에 접두사 a를 쓴 것입니다.
create() 뿐만 아니라 데이터베이스 I/O 수행(평가, evaluate)이 일어나는 API들도 접두사 a가 API 이름 앞에 붙어있어요.

데이터 복수 개를 비동기로 가져오기

동기식으로 데이터베이스에서 모든 데이터를 가져오려면 all()을 사용합니다.
그렇다면 비동기로 가져오려면 a 접두사를 붙이면 되겠군요.
하지만 aall()이라는 메서드는 없습니다. aall() 메서드 뿐만 아니라 afilter(), aorder_by()도 없는데, 이들은 QuerySet 객체를 반환한다는 공통점이 있습니다. QuerySet 객체는 실제 데이터베이스에 접속해 데이터를 가져와 담은 객체가 아니라 데이터베이스에 보낼 질의(Query)를 집합(Set)한 객체예요. 실제로 데이터베이스에 접근해야 하기 전까지는 QuerySet 객체를 만들고, 이런 API는 동기식으로 동작합니다. 이런 동작을 지연 평가(lazy evaluation)라고 합니다.
그럼 실제로 데이터를 비동기로 가져오려면 어떻게 해야 할까요? 질의 집합은 데이터베이스에 접근하지 않아도 무방하지만, 질의에 해당하는 데이터의 특정 위치(색인)에 접근하거나 개수를 세거나 순환하려면 데이터베이스에 접근해야 합니다.

관계형 데이터 가져오기

Django ORM은 QuerySet 개체를 반환하는 API를 비롯해 여러 곳에서 이러한 지연 평가(lazy evaluation) 동작을 합니다. 될 수 있는 한 데이터베이스에 접근하지 않고 기다렸다가 실제로 데이터베이스에 접근해야 할 때 데이터베이스에 접근하는 구현이 곳곳에 있지요.
관계형 데이터를 가져오는 동작이 한 예입니다.
이 상태에서 product 객체의 속성인(attribute) category에는 아직 데이터가 할당되어 있지 않습니다.
이와 같이 print() 함수로 category 속성을 출력하려 하면, 데이터베이스에서 Category 데이터를 가져옵니다.
여기까지는 까다로운 점이 없습니다.

데이터베이스 지연 평가를 비동기로 처리하기

비동기 맥락에서는 처리할 수 없는 요청이다?

까다로운 건 우리가 예상하지 못한 시기에 데이터베이스 지연 평가가 비동기로 수행되는 걸 처리하는 것입니다. 예를 들게요. 먼저 Django View 함수(코루틴)부터 보겠습니다.
이번엔 product-detail.html 템플릿 파일을 보겠습니다.
동작시켜보면 Python으로 Django 내장 웹서버를 구동한 터미널에 오류가 출력되어 있습니다.
이 오류 상황을 해결하는 방법을 웹에서 찾아보면 우회 방법을 주로 안내합니다. settings.py 파일 안에 다음 코드를 추가하는 거죠.
운영체제 셸에 "true" 문자열을 값으로 하는 DJANGO_ALLOW_ASYNC_UNSAFE 환경 변수를 만드는 건데, 환경 변수명이 수상쩍습니다. Django가 안전하지 않은 비동기 처리를 허용하도록 한다니. 데이터베이스 처리에 대해 발생한 비동기 오류인데, 안전하지 않은 동작을 허용하라니 찝찝합니다. 우선 데이터 I/O에 대해 안전하지 않은 비동기 처리를 허용한다면 자칫 데이터 유실이 일어날 수 있습니다. 그리고 이렇게 환경 변수로 동작을 제어하는 설정은 도구(Django)의 정책에 따라 사라지거나 사용하지 않는 걸 권하기도 하고요.

동기 함수 안에서 비동기 동작을 수행하기

Django가 일으킨 오류의 메시지를 보니 비동기 맥락에서 우리가 요청한 작업을 호출할 수 없으니 쓰레드를 쓰거나 sync_to_async를 쓰라고 합니다(You cannot call this from an async context - use a thread or sync_to_async). 오류를 추적해보면 오류가 일어난 실행 지점이 표시됩니다. product-detail.html 템플릿 파일 안에 오류가 있다는 걸 예상할 수 있습니다.
이 템플릿 파일을 출력하는 건 render() 함수입니다. 이 함수는 동기식으로 실행하고 있고요.
Django는 동기 함수 안에서 비동기 동작이 실행되면 오류가 발생합니다. render() 함수를 실행하는 Django View는 비동기 함수, 즉 코루틴이니 비동기 맥락 상에서 동작하는 것 아닌가 생각이 들지만, 템플릿 안에서 지연 평가로 Category 데이터를 가져오고, 이 템플릿은 동기 동작입니다.
이 문제에 대처하는 방법은 Django 오류 메시지에 나와있으니 그대로 대응하겠습니다.
sync_to_async() 함수로 render() 함수를 비동기로 동작하게 만들어서 수행한 거예요. 인자는 함수이고, sync_to_async()가 반환하는 객체에 실제 실행할 함수에 전달할 인자를 전달하며 실행하면 됩니다.

Django는 비동기 맥락에서 DB 커서를 생성하는 걸 허용하지 않는다

모델 매니저가 지연 평가 동작을 템플릿 렌더링(render()) 중에 수행하면 새로운 데이터베이스 커서를 만듭니다.
Django의 코드를 보면, cursor 프로퍼티에 접근할 때 사용 가능한 커서가 있으면 사용하고 없으면 커서를 만듭니다. 그런데 이 프로퍼티를 보면 @async_unsafe라고 정식되어 있지요. async_unsafe() 장식자, 즉, 함수를 보면 다음과 같이 Docstring이 정의되어 있습니다.
비동기 맥락 중에 지정한(장식한) 함수에 접근을 시도하면 오류를 일으킨다고 합니다. 구현 코드는 다음과 같습니다.
get_running_loop() 함수로 현재 돌아가고 있는 이벤트 루프가 있는지 확인해서, 있으면 비동기 맥락 상에서 동작하는 것이니 SynchronousOnlyOperation 예외 오류를 일으킵니다. 예외 오류명을 보니 동기식으로 실행해야 한다는 걸 알 수 있어요. 현재 돌아가고 있는 이벤트 루프가 없으면 RuntimeError 예외 오류가 발생하는데, 이에 예외 처리(try, except)하여 함수를 동기식으로 수행합니다.
정리하면, Django는 데이터베이스 커서를 동기식으로 만들어야 하는 겁니다.

Django의 동기식 동작과 비동기식 동작 구조

Django는 아직(5.0.x 버전 기준) 데이터베이스단은 동기식 실행으로 처리됩니다.
비동기 동작은 모델 매니저(ORM)의 API 계층과 Django View 계층까지만 지원합니다. 데이터베이스 백엔드 계층 코드를 보면 곳곳에 async_unsafe() 표식이 장식되어 있습니다.

sync_to_async() 가 동작하는 원리

그렇다면 sync_to_async()는 어떤 원리로 동기식으로 동작해야 하는 데이터베이스 작업을 비동기 맥락에서 수행되도록 하는 걸까요? sync_to_async()가 반환하는 SyncToAsync 객체의 구현 코드를 보겠습니다.
loop는 이벤트 루프 객체인데, 이 객체의 run_in_executor() 메서드는 asyncio의 이벤트 루프에서 동작하는 비동기 작업이 아닌, 별도의 스레드에서 실행되는 동기 작업을 처리합니다. 왜냐하면 동기식 함수가 이벤트 루프를 틀어막을(blocking) 수 있기 때문이죠. 여기서 별도의 스레드란 메인 스레드를 의미합니다.
이제 render() 함수를 sync_to_async()를 이용해 동기식 함수를 비동기 맥락에서 실행한 원리가 보입니다. render() 함수를 메인 스레드에서 동작하게 해놓고, 수행 중이던(product_detail() 코루틴) 비동기 맥락이 끝나길 기다렸다가 실행이 끝나서 이벤트 루프가 닫히면, 비로소 메인 스레드에서 render() 함수를 실행하는 겁니다. 그래서 async_unsafe()를 문제없이 통과하는 것이고요.

템플릿이 아닌 Django View 계층에서 처리하기

템플릿 렌더링 중에 데이터베이스 비동기 동작이 발생하여 오류가 발생하면 디버깅하기 번거롭습니다. 그래서 저는 종종 지연 평가가 일어나는 부분을 Django View 계층으로 가져오곤 합니다.
이런 식으로 지연 평가를 Django View단에서 일으키는 거지요. 하지만, product.category는 메서드가 아니므로 await 을 사용할 수 없습니다. 앞서 Django가 비동기를 처리하는 원리를 떠올리면 방법이 보입니다.
조금 싱겁지요? sync_to_async()에 호출 가능한 동기식 객체, 간단히 말해 함수를 전달하는 건데, product.category는 메서드가 아니므로 람다로 감싸준 것입니다.
이 방법은 request 객체에 있는 User 객체에 접근할 때 유용합니다.

데이터를 가져올 때 함께 가져오기

sync_to_async()를 사용하지 않고도 SynchronousOnlyOperation 예외 오류가 발생하지 않도록 할 수 있습니다. 이 글에서 예로 드는 상황은 관계형 데이터를 지연 평가해서 가져오는 것인데, 이 경우는 데이터를 가져올 때 함께 데이터를 가져오는 겁니다.
이렇게 하면 Product 데이터를 가져올 때 데이터베이스의 Join을 걸어 Category 데이터도 함께 가져오므로 product.category로 접근한다고 해서 지연 평가가 일어나지 않습니다.
가져올 관계형 데이터가 다수인 경우엔 prefetch_related()를 사용하면 됩니다.

정리

Django는 아직(2024년 8월, 5.0 버전 기준) 데이터베이스 작업은 동기식으로 처리합니다. 비동기로 데이터베이스를 처리하지 않도록 해놔서 비동기 맥락 중에 데이터베이스 작업을 시도하면 SynchronousOnlyOperation 예외 오류를 일으키지요. 비동기 맥락은 동작하고 있는 이벤트 루프가 있는지 여부로 판단합니다. 따라서 비동기 동작 안에서 데이터베이스 작업을 하려면 이벤트 루프를 종료되길 기다렸다가 종료되고나서 수행하면 됩니다. 이 동작을 처리해주는 것이 sync_to_async() 함수입니다. Django 모델 매니저에 있는 비동기 API는 바로 sync_to_async() 함수를 대신 사용해주는 구현체고요.
토이스토리 2기 모집 중!
푸딩캠프 뉴스레터를 구독하면 학습과 성장, 기술에 관해 요약된 컨텐츠를 매주 편하게 받아보실 수 있습니다.