Weasley - 구독 자동 갱신 Scheduler / Docker
스케쥴러
드디어 1차 프로젝트의 틀이 거의 모두 잡혔다. 유저 관리, 상품 목록, 장바구니 및 구매 관련 View들은 PR이 모두 Merge되었고, 이제 구독만을 남겨두고 있는 상태이다(구독 관련 기능 중에 현재 구독 정보를 가져올 수 있는 GET 메소드는 이미 구현했다).
(2021.12.08 기준)
지금은 프로젝트가 모두 마무리 된 상태이다. 목요일 정도에 스케쥴러를 모두 만들어 테스트에도 성공했기에 포스팅을 하려고 했으나 프론트엔드 배포 문제와 Prettier 에러 등의 문제로 그 쪽에 집중하다가 포스팅을 하지 못하고 넘어갔었다. 이제와서라도 한번 정리하고자 해서 임시저장글에서 꺼내오게 되었다.
계획
스케쥴러를 만들 계획을 할 당시 구독과 관련된 GET메소드가 모두 구현된 상태였다. POST, PATCH, DELETE는 K, Y님이 나눠 진행해주시기로 해서 다른 코드들을 리뷰하고 있었는데 불현듯 한가지 의문점이 들었다. "구독이 있어도 자동으로 갱신이 안되면 그게 무슨 의미가 있는거지...?"
그래서 django서버 외에 백그라운드에서 스케쥴러를 돌리면서 구독의 다음 결제일(next_purchase_date)이 되면 자동으로 구독 중인 상품들을 결제하고 다음 결제일을 구독 주기만큼 미루면 좋겠다는 생각이 들었다. 처음에는 크론(cron)을 이용할 생각을 했었지만 Unix 계열에서만 사용 가능하고 맥의 경우 이런저런 권한 설정이 필요하다는 말을 들어서 파이썬의 라이브러리인 Schedule을 사용하기로 했다. 파이썬 Scheduler는 어플리케이션 레벨에서 동작하고, 크론, 윈도우의 작업 스케줄러와 같은 역할을 하기에 채택해 사용했다.
로직
스케쥴러의 로직 자체는 상당히 단순하다. 아래의 플로우를 살펴보자.
- 다음 결제일이 오늘의 날짜 이전인 구독들을 삭제한다.
(그런 구독이 있으면 다음 결제 주기로 미뤄야하나 생각했으나 계속해서 스케줄러가 돈다는 생각을 했을 때 오늘의 날짜 이전이 다음 결제일인 구독은 잘못 입력된 값/의도적으로 이상한 값을 넘긴 것이라 판단해 이번 프로젝트에서는 삭제하기로 했다) - 다음 결제일이 오늘인 구독들(갱신할 구독들)을 user_id로 Group by하고 그 유저의 구독 상품들의 총 금액(제품 가격 X 수량)을 구한다.
- 갱신할 구독을 가지고 있는 유저를 유저별로 순회하며 다음과 같은 작업을 진행한다.
- 아래의 모든 작업 중간에 데이터베이스 관련 에러(무결성 에러 등)가 발생할 경우 원자성을 가지고 진행된 모든 과정을 롤백하기 위해 트랜잭션을 걸어준다. with transaction.atomic()을 통해 이 구문 아래의 것들을 하나의 트랜잭션으로 만들어 줄 수 있다.
- 만약 유저의 포인트가 유저가 구독한 상품의 총 금액보다 크면 결제를 진행한다.
- 결제하는 과정은 OrderView의 POST메소드와 동일하며 order_items를 cart가 아닌 subscribe에서 가져온다는 차이만 존재한다.
- 결제가 완료되었거나 유저의 포인트가 구독한 상품의 총 금액보다 작으면 다음 결제일을 구독 주기만큼 뒤로 미뤄준다.
- 트랜잭션이 종료된다.
위와 같은 플로우로 진행되는 로직을 구성했으며 raw query를 통해 구현하려고 하다가 시간도 애매하고 django ORM을 좀 더 만져보고 싶어 성능은 포기하고(스케줄러 구현 당시 프로젝트 마감까지 하루~이틀 전이었다) django를 통해 구현해보았다(한 프로젝트 폴더 안에 도커 파일이 두개가 되어버린 슬픈 프로젝트...0).
코드 - 함수 부분
코드를 한번 살펴보면
import os, uuid, datetime, schedule, time
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'weasley.settings')
django.setup()
from django.utils import timezone
from django.db import transaction
from django.db.models import Sum, F
from users.models import User
from shops.models import Subscribe, Order, OrderStatus, OrderItem, OrderItemStatus
def subcribe_check():
now = timezone.now().date()
print(timezone.now())
Subscribe.objects.filter(next_purchase_date__lt=now).delete()
subscribe_users = Subscribe.objects.filter(next_purchase_date=now)\
.values('user_id').annotate(total_price=Sum(F('product__category__price')*F('amount')))
for subscribe_user in subscribe_users:
with transaction.atomic():
user = User.objects.get(id=subscribe_user['user_id'])
subscribes = Subscribe.objects.filter(user=user, next_purchase_date=now)\
.select_related('product', 'product__category', 'address')
if subscribe_user['total_price'] <= user.point:
order = Order(
user = user,
address = subscribes[0].address,
order_number = uuid.uuid4(),
order_status_id = OrderStatus.Status.UNDONE
)
order_items = [
OrderItem(
product = subscribe.product,
order_item_status_id = OrderItemStatus.Status.PRESHIPPIN,
amount = subscribe.amount,
order = order
)
for subscribe in subscribes
]
user.point -= subscribe_user['total_price']
user.save()
order.order_status_id = OrderStatus.Status.DONE
order.save()
OrderItem.objects.bulk_create(order_items)
interval = subscribes[0].interval
subscribes.update(next_purchase_date=F('next_purchase_date')+datetime.timedelta(weeks=interval))
schedule.every().hours.at("00:00").do(subcribe_check)
while True:
schedule.run_pending()
time.sleep(1)
와 같이 구현했다.
코드의 제일 윗부분을 보면
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'weasley.settings')
와 같은 코드가 있다. 이 코드는 manage.py, wsgi.py, asgi.py에 모두 공통적으로 존재하는 코드로, django를 시작할 때 꼭 필요한 환경변수를 Setting 하는 부분이기에 존재한다.
scheduler.py에서도 django ORM을 사용해 스케쥴러를 구현했기에 weasley 프로젝트의 settings 파일의 위치를 가르키는 환경변수를 지정하고, django.setup() 명령어로 django 환경을 load하여 사용하였다.
그 다음 subscribe_check 함수를 살펴보면,
scheduler.py가 구동중인 환경과 상관 없이 Asia/Seoul의 시간대를 기준으로 구독을 필터링하기 위해 timezone.now().date()를 통해 날짜를 가져오고
subscribe_users = Subscribe.objects.filter(next_purchase_date=now)\
.values('user_id').annotate(total_price=Sum(F('product__category__price')*F('amount')))
과 같이 user_id로 Group by한 후 그 이용자가 구독 중인 상품들의 총 금액을 계산해주었다. 위의 ORM문은 아래의 SQL문으로 치환된다.
SELECT `subscribes`.`user_id`, SUM((`categories`.`price` * `subscribes`.`amount`)) AS `total_price`\
FROM `subscribes` INNER JOIN `products` ON (`subscribes`.`product_id` = `products`.`id`)\
INNER JOIN `categories` ON (`products`.`category_id` = `categories`.`id`)\
GROUP BY `subscribes`.`user_id` ORDER BY NULL LIMIT 21; args=()
그 후 결과로 나온 구독 중인 유저별로 반복문을 순회하며 트랜잭션 안에서 구독 중인 상품 구매 / 다음 결제일 갱신을 하고 함수는 종료된다.
코드 - 스케쥴러 부분
위의 함수가 메인 로직을 담고 있는 부분이긴 하지만 스케쥴러를 스케쥴러로써 동작하게 하는 부분은
schedule.every().hours.at("00:00").do(subcribe_check)
while True:
schedule.run_pending()
time.sleep(1)
부분이다. 가장 위의 scheduler.every().hours.at("00:00").do(subscribe_check)
부분은 schedule 라이브러리의 스케줄링 할 동작을 지정해주는 부분으로 코드를 보면 직관적으로 알 수 있듯이 매 시 "00:00"에 subscribe_check라는 작업을 시행하라는 뜻의 코드인 것 같다. 여기서 "00:00"은 매 시를 기준으로 하므로 분, 초를 나타낸다. 즉 이 코드는 매 시 00분 00초에 subscribe_check라는 함수를 실행하라는 뜻이 된다.
그리고 while문 안의 내용은 1초마다(time.sleep(1)) schedule에 저장된 작업의 조건(언제 그 작업을 실행하라고 했는지)을 확인하고 그 조건을 충족하면 schedule에 저장된 작업을 실행하도록 하는 것이다.
스케쥴러가 한 시간에 한 번 동작하도록 한 이유는 본 프로젝트는 기간이 길게 정해지지 않았기에 빠르게 테스트하고 구독이 갱신되는 모습을 보고 보여주고 싶었기 때문이다(프로젝트 마감이 2일 남았는데 하루에 한 번 갱신되는 프로그램을 기다린다...?).
또한 나름의 생각으로는 이상한 값(다음 결제일을 이상하게 넣는)을 넣는 사람이 존재할 수 있었을 것이라고 생각해(세션 스토리지에서 access token을 꺼낼 수 있기도 하고 network탭을 통해 fetch하는 엔드포인트와 메소드 등을 알 수 있었기에) 한 시간에 한 번 그런 값들을 없애고자 했다.
물론 이 두 이유 중 테스트를 빠르게 하고 결과를 확인하고 싶었기 때문이 더 크긴 했다.
스케쥴러 결론
처음에는 스케쥴러, 백그라운드라는 워딩에 약간 어려울 것 같다는 생각을 하기도 했지만 막상 스케쥴러를 구현하고 나니 너무나 간단해서 허무할 정도였다. 물론 내 로직 자체가 어려운 로직도 아니고 효율성을 따지는 코드도 아니었기에(효율을 따지면 ORM을 쓰면..) 간단하게 구현할 것도 있긴 하다. 그래도 이 스케쥴러를 만들면서 한번 더 트랜잭션에 대해 생각해보고, 도커를 도입할 생각도 해보고, 실제 동작하는 것 같은 프로젝트 결과물을 내었으니 아주 만족스러운 녀석이었다. 다음에 또 스케쥴러를 만들 경우가 생기거나 이런 작업을 하게 된다면 raw query로 트랜잭션까지 작성해보고 싶다고 생각했다.
도커
장고 서버를 EC2에 배포하고 싶은 마음이었기에 gunicorn을 통해 장고 서버를 돌리면서 백그라운드에서 스케쥴러를 돌리는 것이 맨 처음의 목표였다. 그러나 그럴 경우 스케쥴러의 문제가 서버까지 꺼지게 만들 수 있겠다고 생각했기에 사용해보고 싶었지만 한번도 사용해 본 적 없는 도커를 사용해보았다.
고등학생 시절부터 beebox를 깔면서 애를 먹어보고 실습해본다고 가성머신만 2개 이상 돌려본 나로서는 참으로 매력적이고 과거의 뻘짓들이 생각나 슬퍼지는 그런 기술이었다. 도커 설치, 초기 설정이나 여러가지 이야기는 후에 내 이해도가 좀 더 올라간 후 작성하고 싶어 이 글에서는 이번 프로젝트에 도커를 적용한 방법이나 문제들에 대해 써보도록 하겠다.
구성
이미지를 두개로 구성해 Dockerfile.web파일을 통해 eslerkang/weasley라는 장고 서버용 이미지를, Dockerfile.schedule이라는 도커파일을 통해 백그라운드 스케쥴러용 이미지를 구성했다. 물론 스케쥴러를 django ORM을 통해 만들었기 때문에 두 이미지 모두에 장고 파일들이 들어간다는게 마음에 걸렸지만 어쩌겠는가... 처음부터 raw query로 하지 않은 내 실책이다.
아래는 두개로 나눠 구성된 도커파일들이다.
# Dockerfile.web
FROM ubuntu:18.04
WORKDIR /usr/src/app
COPY requirements/requirements.txt ./requirements.txt
RUN apt-get update -y \
&& apt-get install software-properties-common -y \
&& add-apt-repository ppa:deadsnakes/ppa -y \
&& apt-get install python3.9 -y \
&& apt-get install python3-pip -y \
&& apt-get install libmysqlclient-dev -y
RUN pip3 install --upgrade pip
RUN pip install -r requirements.txt
COPY . .
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--log-level", "debug", "weasley.wsgi:application"]
위의 파일이 장고 서버를 위한 Dockerfile.web 파일이고,
# Dockerfile.schedule
FROM ubuntu:18.04
WORKDIR /usr/src/app
COPY requirements/requirements.txt ./
RUN apt-get update -y \
&& apt-get install software-properties-common -y\
&& add-apt-repository ppa:deadsnakes/ppa -y\
&& apt-get install python3.9 -y\
&& apt-get install python3-pip -y\
&& apt-get install libmysqlclient-dev -y
RUN pip3 install --upgrade pip
RUN pip install -r requirements.txt
COPY . .
CMD ["python3", "scheduler.py"]
아래의 파일이 스케쥴러를 위한 Dockerfile.schedule 파일이다.
문제점들
처음 도커파일을 구성할때는 FROM python:3.9
로 파이썬 이미지를 기반으로 구성하고자 했다. 그러나 파이썬 이미지를 통해 구성하면 numpy등 내가 사용할 필요가 없는 것들까지 포함되어 980MB라는 끔찍한 용량이 나오게 되었고, 그 문제를 해결하기 위해 FROM ubuntu:18.04
와 같이 우분투 이미지를 기반으로 필요한 패키지들만 설치해 구성하고자 하였다. 도커파일을 처음 만들어보기도 했고, 파이썬 이미지에서 출발해 만들었으면 간단하게 끝났겠지만 3.9를 고집한 과거의 나 때문에 특정 구간에서 헤매기도 했다. 아래의 내용들은 우분투 이미지 기반으로 파이썬 환경을 구성하면서 마주했던 문제점들이다.
- 두 파일 모두 우분투에서 시작해 파이썬 패키지들을 설치해 사용했으므로 apt-get을 많이 사용했는데 apt-get으로 설치시 yes/no를 물어보는 과정에서 어떻게 진행할지 몰라 처음에는 apt-get 아래에
RUN y
와 같이 다소 멍청한 방법을 시도했었으나 -y라는 옵션으로 정말 간단하게(알고 있었잖아...! 대체 왜그런거야...) 해결했다. - software-properties-common를 설치 시 타임존을 고르라는(지역? 타임존?) 문제가 있었는데(-y 같은거 없나..?) 이건
docker builder prune
을 통해 캐싱된 레이어들을 밀어주고 다시 빌드하니 무사히 지나갔다(정확한 발생 원인에 대해서는 후에 찾아봐야겠다). - mysqlclient를 설치하려고 할 때 2.1.0 버전을 찾을 수 없다는 에러가 발생했었다(그런데 선택 가능한 버전 중에 2.1.0을 제시하는 미친놈). 이 문제는 libmysqlclient-dev를 설치해 간단히 해결했다.
- pip install —upgrade pip에서 pip를 인식하지 못하고 pip라는 명령이 없다는 말을 되풀이했다. → 이 경우 pip3 install —upgrade pip로 해결했는데, python3.9를 지칭해 설치함으로써 발생한 상황인 것 같다. python3로는 이런 문제가 발생하지 않는다는 것을 확인했기에 다음부터는 특별한 이유가 없다면 python3을 설치하는 것으로...(이번 프로젝트에서는 로컬에서 구성할때 3.9에 맞춰 설정했기에 혹시나 해서 3.9로 고집해서 설치했었다)
- 마지막 줄의 python scheduler.py가 실행되지 않는 문제가 발생했었다. → python의 환경변수 설정이 되지 않아 python3 scheduler.py를 통해 해결했다.
- 장고 서버 컨테이너를 docker run 했을때 worker timeout이 나는 현상 → RDS의 인바운드 규칙에 내 백앤드 서버를 제외해놓고 실행해 서버가 db에 대한 접근 권한이 없어 들어가지 못해 발생한 문제였다
AWS, Docker를 모두 처음 쓰니 내가 나를 막아두고(보안 그룹 인바운드 규칙) 왜 안되지 하는 웃긴 상황도 있었고, 맨 처음 AWS EC2, RDS를 설정할때 멋도 모르고 오하이오(..) 리전에 설정해 매우 느린 서버로 한동안 테스트하기도 했었다(ssh를 하면 마우스 포인터가 툭툭 끊기는 사람이 있다!?).
도커는...
처음에 도커를 도입하지 않고 서버를 테스트할때는 EC2에 깃헙 레포를 클론받아 배포하는 방식으로 진행해 과정이 귀찮기도 하고 EC2 터미널에서 vim으로 코딩한 적도 있었다(심지어 이 당시가 오하이오에 있을 당시였다... 인내심 칭찬해).
그러나 확실히 도커를 도입하고 도커헙을 통해 이미지를 관리하니 버전 관리도 간편하고 이미지 두개만 풀 받아 사용하면 되었기에 레포 전체를 클론 받거나 그럴 필요가 없어져서 너무 좋았다.
그리고 이번 프로젝트에서는 EC2 1대 위에서 도커 이미지를 두개 돌린것에 불과하지만 다음에는 젠킨스까지 도전해 EC2 여러대, S3, VPC와 로드 밸런서까지 한번 사용해 배포 자동화와 부하 관리까지 해보고 싶다는 생각이 계속해서 들었다(아마존... 나 적금 깬다...?).
오래전부터 사용해보고 싶었던 서비스, 기술이었기도 하고 새로운 도전이라는 사실이 나를 설레게하고 체력적으로 심적으로 힘들어진 프로젝트 막바지를 잘 이겨내 오히려 열정을 더 불태우게 했던 것 같다. 그리고 역시 개발세계는 공부를 하면 할수록 내가 부족하다는 것이 보이고 계속 공부를 할 수 밖에 없도록 만드는 그런 곳인 것 같아 다시 한번 마음을 다잡을 수 있었다. 앞으로도 화이팅해야겠다!! 화이팅!!
Author And Source
이 문제에 관하여(Weasley - 구독 자동 갱신 Scheduler / Docker), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@eslerkang/Weasley-구독-자동-갱신-Scheduler저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)