우리가 사용하고 있는 웹서버(Apache, iis, Nginx 등)는 동기적이다. 덕분에 브라우저에서 요청을 할 때마다 그에 따른 응답 결과를 서버에서 받아 보내줄 수 있다. 그러나 한번에 수많은 요청이 들어왔을 때는 어떨까? 웹서버는 처리가 끝날 때까지 브라우저에게 결과를 보내주지 못하고 무한한 로딩 시간을 거치게 된다. 이는 웹서비스 전체 사용자의 요청을 한 명의 사용자가 기다려야한다는 말과도 같다.

예를 들어, 익히 알려진 SNS인 인스타그램(Instagram)에서는 1개의 포스트에 100만 개 이상의 좋아요가 달리는 경우가 있다. 이 때 수많은 사용자가 동시다발적으로 좋아요를 누르게 되면 웹서버는 이를 처리하느라 페이지를 보여주지 못할 것이다.

따라서 웹서버가 처리하기에 부담스러운 요청은 비동기 태스크 큐를 통해 처리한다. 즉, 작업이 실행되는 시점과 생성되는 시점, 또는 생성 순서에 상관없이 웹서버에 부담을 주는 작업을 순차적으로 처리하는 과정을 일컫는다. 따라서 웹페이지를 보여주는 것과 사용자의 요청을 분리하여 실행하기 때문에 웹서버를 가볍게 유지할 수 있고 작업이 끝날 때까지 기다리지 않아도 된다.


장고에서 비동기 태스크 큐 사용하기

파이썬으로 비동기 태스크 큐를 사용할 수 있는 툴에는 celery가 있다. celery는 메세지 브로커와 파이썬의 작업 프로세스를 연결해 비동기 작업을 수행하는 시스템을 제공한다.

큐 (Queue)

큐를 해석하면 ‘대기 행렬’, ‘줄을 서서 기다리다’ 라는 의미를 가지고 있다. 작업을 요청 순서대로 쌓아놓고 대기했다가 순차적으로 실행한다는 의미를 가지는 것이다. 그리고 이러한 메세지 큐에 작업을 보내주는 시스템은 브로커라고 부른다.

브로커 (broker)

브로커는 작업들이 보관되어 있는 장소를 말한다. 요청에 따라 보내줘야 할 데이터를 지속적으로 보관할 수 있는 도구라면 무엇이든 가능하다. 장고에서는 RabbitMQRedis를 가장 많이 쓴다. 비동기 작업큐를 처음 사용한다면 Redis로 시작하는 것이 좋다. Redis는 브로커의 용도로 만들어진 것은 아니지만 in-memory 데이터 저장장치이기 때문에 쉽게 사용할 수 있다.

프로듀서 (producer)

나중에 실행될 태스크를 큐에 넣는 코드를 말한다. 브로커와 프로듀서를 혼동할 수도 있는데, 프로듀서는 브로커에게 데이터를 기록해주는 역할을 담당하고 있다. 프로듀서가 보낸 데이터를 브로커가 받아 메세지 큐에 대기시키는 것이다.

워커 (worker)

마지막으로 워커는 태스크를 브로커에서 가져와 실행하는 코드를 말한다. 일반적으로는 하나 이상의 워커가 있으며, 각 워커는 데몬(사용자가 직접 제어하지 않고, 백그라운드에서 돌면서 여러 작업을 하는 프로그램) 형태로 실행되고 관리받는다.


언제 비동기 태스크 큐를 사용하는가?

비동기 태스크 큐를 사용해야하는 이유는 모두 다를 수 있다. 하지만 사용자 경험 측면에서 생각했을 때를 생각하면 판단은 쉬워진다. 특정 코드에서 병목 현상이 나타나거나 페이지 로드에 부담을 줄 것 같은 상황에서 사용하면 된다. 즉, 연산 결과에 시간이 걸리는 작업은 태스크 큐를 사용하는 것이 좋고 사용자에게 바로 결과를 제공해야할 경우에는 태스크 큐를 사용하지 않는 것이 좋다.

(1) 비동기 태스크 큐를 이용하자!

  • 이메일 전송
  • 파일 수정작업(이미지)의 경우
  • 외부 API에서 대량의 데이터를 받아오는 작업
  • 테이블에 대량의 데이터를 추가/수정하는 경우
  • 웹훅(webhook) - 특정 액션 앞뒤로 정해진 일을 하도록 하는 것(글 등록 알림, 외부 메신저 연결 등)
  • 긴 시간이 필요한 연산처리

(2) 비동기 태스크 큐를 이용하지 말자!

  • 사용자 프로필 변경 등의 단순 작업
  • 블로그 및 CMS 엔트리 추가
    • CMS란 저작물 관리 시스템(Content Management System)의 약자로 사진, 음성, 전자문서와 같은 파일을 관리하는 소프트웨어이다. 웹 저작물 관리 시스템은 웹사이트에 웹 저작물을 발행하는 데 필요한 작업들을 수월하게 해준다. 더 읽어보기

트래픽이 중하 정도의 수준인 사이트는 작업내용에 상관없이 사용할 필요가 없다. 반면, 트래픽이 많은 사이트의 경우에는 모든 작업에 대해 태스크 큐를 사용해야 한다.


태스크 큐 소프트웨어

장고에서 비동기 태스크 큐를 사용하려는 경우에는 3가지 중에 1가지를 선택하면 된다.

셀러리 (celery)

앞서 언급한 셀러리의 경우 장고의 표준으로 가장 많이 사용하는 시스템이다. 대용량 데이터도 처리가 수월하며 기능이 풍부하다는 장점이 있는 반면 세팅이 까다롭고 트래픽이 적은 사이트의 경우에는 오히려 낭비적인 측면도 있다.

따라서 태스크 관리가 복잡한 대용량 프로젝트에 사용하는 것이 좋다.

레디스 큐 (redis queue)

레디스를 기반으로 셀러리에 비해 적은 메모리를 사용한다. 그렇지만 역시 대용량에도 무리없이 사용할 수 있다. 셀러리에 비해서는 기능이 적지만 세팅이 비교적 쉽다. 저장소를 레디스로만 사용할 수 있다는 특징이 있다.

django-backend-tasks

세팅이 매우 쉽고 이용이 가장 간편하다. 장고의 ORM을 백엔드로 이용하기 때문에 대용량을 처리하기에는 무리가 있다. 또한 하나의 워커만 사용하기 때문에 여러 개의 워커가 필요하다면 셀러리나 레디스를 사용하는 것이 좋다. 보통 django-backend-tasks를 사용하는 경우는 주기적으로 일어나야하는 일괄처리(batch) 작업이 사용되는 소규모 프로젝트가 있다.

종합해보면 용량에 상관없이 레디스 큐를 사용하는 것을 추천한다. 그러나 셀러리를 사용할 수 있다면 프로젝트 규모가 커질 것을 대비하여 셀러리를 사용하는 것이 좋다.


태스크는 쉽고 단순하게

태스크 큐는 사용자가 볼 수 없는 코드나 몸집이 불어나기 쉽다. 하지만 태스크 또한 각 프로세스 당 메모리와 리소스를 사용하는 작업이기 때문에 사이트에 영향을 줄 수도 있다.

뷰를 작성하는 방식처럼 쉽고 단순하게 작성하는 것이 좋다. 아래의 예시는 인스타그램에서 포스트 총 갯수에 대한 좋아요 수를 세어 화면에 표시해주는 태스크 함수이다.

### tasks.py

@app.task
def task_update_post_like_count(post_pk):
    # 셀러리에서 장고 프로젝트를 임포트하기전에 실행하므로
    # 함수 내부에서 필요한 모델을 import해준다.
    from .models.post import Post
    post = Post.objects.get(pk=post_pk)
    
    # 모델의 메서드를 불러오는 방식으로 task 함수를 간단하게 표현
    post.calc_like_count()
    return post.like_count

살펴보면 실제 연산을 처리하는 함수인 calc_like_count()는 모델의 인스턴스 메서드로 처리하였다. 태스크 코드를 함수 안으로 넣어 해당 함수를 호출하는 방식으로 사용하면 코드를 쉽게 디버깅할 수 있을 뿐만 아니라 재사용 측면에서도 장점을 지닌다.


비동기 메시지 큐 관리

JSON화 가능한 값만 태스크 함수에 전달하자

태스크 함수에 복잡한 객체를 인자로 사용할 경우 시간과 메모리를 더 많이 사용할 수도 있다. 위의 예시처럼 객체를 넘겨줘야할 경우에는 프라이머리 키(primary key)객체의 구분자를 함께 보내 최신 데이터를 뽑아오는 방식으로 사용할 수 있다.

이외의 경우에는 정수, 부동소수점, 문자열, 튜플, 딕셔너리, 리스트 등의 자료형만 사용하는 것이 좋다.


태스크와 워커를 모니터링하자

태스크 함수를 디버깅하려면 서버가 동작하고 있는지, 작업이 순차적으로 실행되는지, 태스크가 죽지는 않았는지 살펴볼 필요가 있다. 태스크와 워커를 시각적으로 모니터링 해주는 패키지가 있으니 사용하고 있는 소프트웨어에 따라 사용하면 된다. (아래 링크 참조)

장고 프로젝트에 설치하고 장고의 어드민 페이지에서 관리를 할 수도 있다.


에러 핸들링을 이용하자

태스크 자체의 문제가 아닌 서드파티 패키지나 네트워크 이상으로 에러가 발생하는 경우 다음 값을 세팅해두면 에러가 자연스럽게 복구되는데 도움을 준다.

  • 태스크에 대한 최대 재시도 횟수
# 설정에서 값을 지정
broker_connection_max_retries=100

# 해당 값이 0 or None이면 영원히 재시도할 것이다.
# 기본값은 100이다.
  • 재시도 전 지연시간 (적어도 10초 이상)

지연시간은 점진적으로 간격을 키워주는 것이 좋다. 계속 같은 시간의 요청이 들어오면 실패 원인을 찾는데 오히려 더 많은 시간을 쏟을 수도 있다.

참고 자료

Two Scoops of Django 25장 - 비동기 태스크 큐 (p.293 - 300)


Julia Hwang

디발자를 꿈꾸는 웹개발자의 블로그입니다.