Django와 비동기 프로그래밍

  25 Sep 2019


Django와 비동기 프로그래밍


Django와 비동기 프로그래밍

두 달간 진행했던, Sentiment Analysis 웹페이지를 본격적으로 배포하기 위한 준비를 시작하고 있다.
Heroku에 우리의 프로젝트를 올리고, 데이터베이스를 연결하여 유저를 위한 서비스를 추가적으로 준비하고자 한다.
이제 더 안정적이고 효율적인 프로그래밍을 하기 위해, 세부적인 서버와 데이터베이스 등의 대해 공부를 시작한다.

비동기 프로그래밍

현재 우리 프로젝트의 주요한 문제상황은 Manager 객체를 두고, Request가 들어왔을 시 이 객체가 run하게 한다는 점이다.

GUNICORN

1. Django와 함께 사용되는 용도로 Heroku에서 추천되는 HTTP server
2. 다이노에서 여러개의 Python 동시 프로세스를 실행할 수 있는 WSGI 어플리케이션을 위한 순수 Python으로 작성된 HTTP server
3. Gunicorn은 python WSGI HTTP Server이다. 상대적으로 가볍고 빠르다.


WSGI 파일

CGI - Command Gateway Interface의 일종이다.
WSGI는 CGI의 일종으로, Framework의 웹서버이다. Route Web에서는 표준 인터페이스로 개발되었다.
WSFI는 두 종류로 구분할 수 있는데, Nigix나 Apache같은 Server often high profile 웹서버 또는 python script로 짜여진 web app 으로 구분할 수 있다.
서버는 보통 web app에 있는 정보, callback 함수 등을 실행한다. request는 앱단에서 실행되며, response는 callback 함수를 이용해 서버로 되보내진다.

CALLBACK 함: 무엇인가 객체한테 시키고, 그 일이 끝날 때까지 기다리는 것이 아닌(동기적 프로그래밍), 일이 끝나면 다시 나를 호출하는 비동기적 방식의 함수로 사용된다.

WSGI Server는 많은 request를 다룰 수 있도록 설계되었다. framework들은 스스로 수천개의 request를 실행하고 최고의 방법으로 처리할 수 있도록 설계되어 있지 않다.
Django의 경우, 단순히 manage.py runserver로 배포하면 안된다는 뜻이다.
WSGI는 python web의 개발 속도를 올려준다.

Concurrency Problem

Make sure the resource we are working on is not altered while we are working on it.
우리가 어떠한 작업을 하는 동시에, 다른 작업을 하지 않도록! - 동시성 제어를 해야한다.

1. Pessimistic Approach

Lock the resource exclusively unitil you are finished with it.

  • 관계형 데이터베이스들은 Lock을 관리하고 일관성을 유지하는 것에 좋다.
  • 데이터베이스는 데이터를 접근하는 가장 낮은 단계이다.
    가장 낮은 단계에서 lock을 거는 것은 다른 프로세스들로부터 데이터의 변형을 지켜낸다. (DB직접 접근, cron job-명령이나 스크립트를 지정한 시간/날짜에 자동으로 실행하게 해주는 프로그램, cleanup tasks ..)
  • Django 어플리케이션은 다수 프로세스를 실행시킬 수 있다. - Workers.
  • 어플리케이션 단계에서 Lock을 유지하는 것은 많은 불필요한 작업을 요구한다.

Django 어플리케이션에서 객체의 Lock을 걸기 위해선, select_for_update()를 사용해야 한다.

def deposit(cls, id, amount):
   with transaction.atomic():
       account = (
           cls.objects
           .select_for_update()
           .get(id=id)
       )
       account.balance += amount
       account.save()
    return account
  1. 데이터베이스에 트랜잭션이 끝날 때까지 객체에 Lock을 걸기 위해서, select_for_update()을 쿼리셋에 건다.
  2. 데이터베이스 row에 Lock을 거는 것은 데이터베이스 트랜잭션을 요구한다. 트랜잭션 영역에 transaction.atomic()이란 Django 데코레이터를 사용한다.
  3. Instance method 대신에 Class method를 사용한다. Lock을 얻기위해서 데이터베이스에 Lock을 설정해야한다. 이를 위해서 데이터베이스에서 객체를 가져와야 하고, 자체적으로 조작할 때, 객체가 이미 페치되었으며 Lock상태에 대한 보증이 없다.
  4. 모든 계좌의 operation들은 데이터베이스의 트랜잭션과 함께 수행된다.

기다리고 싶지 않을 때?

A가 release될 때까지 B가 기다렸다가 수행되는데, 기다리는 것을 대신하여 오류를 내는 코드를 작성하고 싶다면 select_for_update(nowait=True) 라는 코드를 추가적으로 적으면 된다.

Account에서 입출금을 하려고 할 때, 내 계좌의 이름을 바꾸고 싶을때 입출금 도중에 내 계좌의 이름이 바뀌는 것을 막으려 한다.
이럴 경우엔 select_related() 도 함께 사용하면, 해당 객체와 관련된 객체도 잠긴다.
PostgreSQL 또는 Oracle을 사용하는 경우, Django 2.0의 새로운 기능으로 인해 관련된 객체를 업데이트하는 것이 문제가 되지 않는다. 해당 Django 2.0 버전에서는 select_for_update에 Lock을 걸고 싶은 다른 테이블을 명시적으로 나타내는 of 옵션을 제공한다.

1. Optimistic Approach

위에서 설명한 Pessimistic 접근 방법과는 다르게, Optimistic 접근 방식은 객체에 Lock을 거는 것을 요구하지 않는다.
이 방법은 collision이 흔하지 않은 것으로 가정하고, 객체가 업데이트될 때, 변경사항이 없는지 확인해야 한다.

1. 객체의 변경사항을 추적하기 위해서, 열을 추가한다.
version = models.IntegerField(
    default=0,
)
2. 그리고 우리가 객체를 업데이트할 때, 버젼을 확인하여 변하지 않았다는 것을 확인한다.
  1. 우리는 Class method가 아닌 instance 함수를 이용하여 직접 operate한다.
  2. 객체가 업데이트될 때마다, version이 증가되는 것에 의존한다.
  3. 우리는 버젼이 바뀌지 않았을 때만 업데이트를 진행한다. 우리는 객체가 변형되지 않았을 때, 업데이트된 객체를 fetch한다. 객체가 변형되어서 query가 0을 리턴하게 되면 해당 객체를 업데이트 시키지 않는다.
  4. Django는 업데이트된 행 수를 반환한다.
  • Pessimistic과 달리, 접근 방식에는 추가 분야와 많은 훈련이 필요하다. 훈련 문제를 극복하는 방법은 동작을 추상화하는 것이다. django-fsm은 위에서 설명한 버전 필드를 사용하여 Optimistic Lock을 구현합니다.

Pessimistic V.S. Optimistic

  1. 객체에 동시 업데이트가 많은 경우, 비관적 접근 방식을 사용하는 것이 좋다.
  2. ORM 외부에서(DB에서 직접), 업데이트가 발생하는 경우 비관적 접근 방식이 더 안전하다.
  3. 매소드에 원격 API호출 또는 OS 호출과 같은 부작용이 있는 경우, 안전해야합니다. 시간이 오래 걸릴 수 있는 지 여부 체크

Celery

celery란 비동기 태스크 큐를 python에서 사용할 수 있는 모듈이다. 기존 우리의 프로젝트는 무거운 연산과 오래 걸리는 작업의 경우, 서버가 처리할 때까지 사용자가 기다려야 하는 방식이었다. 원래도 이를 해결하기로 했었지만, Heroku에 프로젝트를 도입하고 나서 히로쿠 서버에서 30초 제한이 걸려있어서 이를 해결하기 위해 Celery를 도입하고자 한다. 우리는 Celery를 이용하여, 오래 걸리는 작업을 비동기적으로 처리함으로 사용자가 해당 작업을 기다리지 않고 다른 작업을 진행할 수 있도록 사용자 측면에서 속도개선을 유도하기 위해 비동기 태스크 큐를 사용하기로 하였다.

우리의 Flow는 django 앱에서 요청받은 작업을 database에 기록한다. 각 작업당 Task ID를 놀고있는 Worker에게 넘겨서 Worker가 비동기적으로 이를 수행한다.

Celery는 python으로 작성된 분산 메시지 전달을 기반으로 한 비동기 작업큐로 Worker의 한 종류이다. 비동기로 처리하고자 하는 함수위에 @app.task 또는 @shared_task 라는 decorator를 붙여주면, 해당 함수의 API CALL이 들어오는 순간 Queue에 해당 작업이 할당됩니다.

Celery Routing

Celery Task들을 routing 해주는 것에 대해 설명하겠다. 이것을 통해서, A task는 A worker로 돌리고, B task는 B worker로 돌리게 해주면서, A task에 일이 몰려도 B는 B대로 일을 할 수 있다.

from tasks import slow_task, quick_task

for i in range(10, 100):
	slow_task.delay(i)

quick_task.delay(100)

위와 같은 코드를 실행하게 되면, slow_task 90개가 끝나야 그제서야 quick_task를 실행할 수 있다.

반면,

app.conf.task_queues = (
	Queue('slow_tasks', routing_key='slow.#'),
	Queue('quick_tasks', routing_key='quick.#'),
)


@app.task
def slow_task(x):
	time.sleep(x)
	return x


@app.task
def quick_task(x):
	return x

이렇게 각 Task Queue를 만들어서, routing_key로 routing해주고,

  for i in range(10, 100):
  	slow_task.apply_async(args=[i], queue='slow_tasks')

  quick_task.apply_async(args=[100], queue='quick_tasks')

해당 코드를 실행시키면, A는 A대로, B는 B대로 실행 시킬 수 있다.

...