파이썬의 대표 웹 프레임워크인 Django는 대부분 beginner 수준의 자료가 많다.

그래서 ORM을 어떻게 적절하게 사용하는지, 테스트 코드는 어떻게 작성하는지 등 예제가 많이 없다.

해서 내가 만든 예제를 공유하고자 한다.  

아래의 소스코드에는 docker-compose를 이용한 배포 방법도 나와 있으니 clone해서 코드를 실행해보길 권한다.

 

 

 

프로젝트 소개

gym 프로젝트 소개 및 기능 정의

  • 회원권을 구매하여 크레딧을 생성합니다.
  • 크레딧으로 등록한 레슨을 예약합니다.
  • 나의 예약 및 크레딧 잔고를 확인합니다.

기술적 소개

  • django 테스트
  • python type hint
  • docker-compose를 활용한 배포
  • 스웨거를 통한 api 확인
  • 적절한 orm 활용법

기본 정보

  • 주소 : http://localhost:8000(개발), http://localhost(배포)
  • swagger path : http://localhost:8000/swagger/
  • 기본 관리자 정보 : username : admin password: admin
  • 개발 서버 실행 순서(개발 서버 sqlite 사용)
    1. python manage.py migrate
    1. python manage.py create_default_user
    1. python manage.py runserver
  • 배포 서버 실행 : docker-compose up
  • 필자는 Mac amd를 사용해 docker-compose.yml 파일 platform: linux/amd64를 설정 했으나 장비에 따라 해당 문구 삭제 필요

API 사용 설명

  1. 스웨거 접속 : http://localhost:8000/swagger/
  2. 우측 상단 Django login : admin/admin
  3. 예약을 위한 수업을 생성해 주세요
  4. 크레딧 구매를 위한 가격정책(회원권)을 생성해 주세요.
  5. 크레딧을 구매합니다.
  6. 수업을 예약합니다

 

소스코드

https://github.com/seunwoolee/gym-backend

 

왜 쓰는가?

 

인스타그램과 같은 피드기능을 만드려한다. 하지만 피드에는 다양한 타입을 지원해야한다.

여기서 말하는 다양한 타입이란 인스타그램의 일반적인 게시물(사진, 글)뿐만 아니라 게임, 장소 등등을 포함한다.

어떻게하면 효율적으로 피드 기능을 만들 수 있을까?

 

 

각자 만들기

 

게시물(Post), 게임(Game), 장소(Location) 이렇게 3개의 타입이 Feed에 있어야 하며, 늘 최신순으로 display되어야 한다는 요구사항이 들어 왔다고 가정하자.

이때 가장 처음 생각할 수 있는 방법은 DB(Django Model)에서 Post, Game, Location을 각각 만드는 방법이다.

가장 쉽게 생각할 수 있지만 이 방법을 따르면 우선 최소 3번의 쿼리를 해야하며, Union 또는 코드 상에서 합쳐줘야 하는 번거로움이 있다.

또한 Feed는 마우스 스크롤을 내리면 새로운 Feed를 계속 불러와야하니 개발을 하기도 상당히 번거롭다.

해서 내가 추천하는 방법은 Django Proxy를 이용하는 방법이다. 물론 장,단점이 있지만 메인 기능이 Feed라면 생각 해볼만한 옵션이다.

 

 

Django Proxy를 활용한 Feed 다형성 구현

 

Java JPA ORM에서는 이를 Single Table Strategy라 부른다. 특징으로는 하나의 테이블(Feed)에 필드를 몰아서 생성한다.

DB 정규화의 문제, 특히 null 값 허용을 많이 해야하는 단점이 있지만, 공통 부분 재사용, 다형성 등 장점 또한 많다. 코드를 보며 자세히 살펴보자.

 

 

사용 예제 코드

Feed는 Game, Post의 부모 클래스이며, 실제 DB로 사용되는 모델이다.

Game과 Post는 코드상의 가상의 클래스이며, 실제 DB Table은 가지지 않는다.

Feed의 필드 및 메소드를 간략히 소개하겠다.

필드

  • dtype : 해당 Feed의 타입이 무엇인지(Post, Game, Team) 중 하나
  • title, body : Post 전용 필드
  • game_name, game_location : Game 전용 필드
  • joined_users : Game, Team 공통 필드이며 참가자를 의미한다.
  • like_count : Post, Game, Team의 공통 필드, 해당 피드의 좋아요 개수를 의미한다.

메소드

  • get_queryset : Post, Game Manager에 정의된 TYPE을 참조해 TYPE의 클래스만 필터링해 가져온다.
  • create : Post, Game Manager에 정의된 TYPE을 참조해 해당 클래스 생성

나머지 자세한 사용법은 TEST 코드를 참조하자.

from typing import Optional

from django.contrib.auth.models import User
from django.db import models
from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _


class FeedType(models.TextChoices):
    POST = 'post', _('포스트')
    GAME = 'game', _('게임')
    TEAM = 'team', _('팀')


class Feed(models.Model):
    class FeedManager(models.Manager):
        def get_queryset(self):
            queryset: QuerySet = super().get_queryset()
            dtype: Optional[str] = getattr(self, 'TYPE', None)
            if dtype is not None:
                queryset = queryset.filter(dtype=dtype)
            return queryset

        def create(self, **kwargs):
            return super().create(**kwargs, dtype=self.TYPE)

    title = models.CharField(max_length=128)
    body = models.TextField()
    dtype = models.CharField(
        max_length=10,
        choices=FeedType.choices,
        default=FeedType.POST,
    )
    like_count = models.PositiveIntegerField(default=0)
    joined_users = models.ManyToManyField(User, blank=True)
    game_name = models.CharField(max_length=128)
    game_location = models.CharField(max_length=128)
    room_name = models.CharField(max_length=128)

    objects = FeedManager()

    def add(self, user: User):
        self.joined_users.add(user)

    def like(self):
        self.like_count += 1
        self.save()


class Post(Feed):
    class PostManager(Feed.FeedManager):
        TYPE = FeedType.POST

    objects = PostManager()

    class Meta:
        proxy = True

    def __str__(self):
        return f'{self.title}'


class Game(Feed):
    class GameManager(Feed.FeedManager):
        TYPE = FeedType.GAME

    objects = GameManager()

    class Meta:
        proxy = True

    def __str__(self):
        return f'{self.game_name}'

 

테스트 코드
from django.contrib.auth.models import User
from django.test import TestCase

from core.models import Game, Feed, Post


class GameTest(TestCase):
    def test_model(self):
        user = User.objects.create(username="a")
        user.save()
        game: Game = Game.objects.create(game_name="한게임", game_location="서울")
        self.assertEqual(game.game_name, "한게임")
        self.assertEqual(game.dtype, "game")
        self.assertEqual(game.dtype.label, "게임")

        post: Post = Post.objects.create(title="title", body="body")
        self.assertEqual(post.title, "title")
        self.assertEqual(post.body, "body")
        self.assertEqual(Feed.objects.count(), 2)
        self.assertEqual(Game.objects.count(), 1)
        self.assertEqual(Post.objects.count(), 1)

        # feed(game) add test
        game.add(user)
        self.assertEqual(game.joined_users.count(), 1)

        # feed(game) like test
        game.like()
        game.like()
        self.assertEqual(game.like_count, 2)

 

소스코드 : https://github.com/seunwoolee/Django-Proxy-Model

Poll Model(투표 예제)

 

아래와 같은 투표 요구사항이 있을때 DB 모델링과 쿼리 최적화를 하려 한다.

 

 DB 모델

 

1. User의 썸네일을 나타내기 위해 Image라는 모델을 만들었다.

2. 하나의 투표(Poll)은 여러개의 질문(PollQuestion)을 가진다.

3. 하나의 질문에 여러개의 투표(PollVote)를 할 수 있다.

# models.py
class Image(models.Model):
    image_url = models.CharField(max_length=128)


class User(AbstractUser):
    objects = UserManager()
    image = models.OneToOneField('Image', related_name='image', blank=True, null=True, on_delete=models.CASCADE)


class PollQuestion(models.Model):
    poll = models.ForeignKey(
        'Poll',
        verbose_name="투표ID",
        related_name='questions',
        on_delete=models.CASCADE)
    content = models.CharField(max_length=512)
    ordering = models.PositiveIntegerField(default=1)

    def __str__(self):
        return f'[{self.id}]{self.content}'


class PollVote(models.Model):
    poll = models.ForeignKey(
        'Poll', verbose_name="투표ID", related_name='poll_votes', on_delete=models.CASCADE)
    question = models.ForeignKey(
        'PollQuestion', verbose_name="질문", related_name='question_votes', on_delete=models.CASCADE)
    owner = models.ForeignKey(
        User, on_delete=models.CASCADE, related_name='my_votes', verbose_name="투표자")

    def __str__(self):
        return f'투표자[{self.owner.first_name}]: {self.question}'


class Poll(models.Model):
    owner = models.ForeignKey(User, on_delete=models.CASCADE)
    head = models.CharField(max_length=50)
    message = models.CharField(max_length=128)
    end_time = models.DateTimeField(null=True, blank=True)
    is_anonymous_vote = models.BooleanField(verbose_name="익명투표", default=False)
    is_multiple_vote = models.BooleanField(verbose_name="복수투표", default=False)

 

투표 시리얼라이저

위 그림처럼 투표를 한 화면에 모두 보여주려면 Poll, Question, Vote를 모두 보여줘야한다.

그래서 PollDetailSerializer를 만들었다.

해당 시리얼라이저는 상당히 많은 Serializer를 nested(내포)하고 있으며, poll instance 또는 poll queryset을 생성자로 던지면 마법 처럼 알아서 직렬화를 해준다.

이 마법은 내부적으로 상당히 많은 문제가 발생 시키는데 이를 Lazy Loding으로 인한 부작용, N+1 Problem이라 부른다. 

poll: Poll = Poll.objects.first()
serializer = PollDetailSerializer(poll)
print(serializer.data)

polls: QuerySet[Poll] = Poll.objects.all()
serializer = PollDetailSerializer(polls, many=True)
print(serializer.data)
class ImageSerializer(serializers.ModelSerializer):
    class Meta:
        model = Image
        fields = '__all__'


class UserSerializer(serializers.ModelSerializer):
    image = ImageSerializer()

    class Meta:
        model = User
        fields = ('id', 'first_name', 'username', 'image',)


class PollVoteSerializer(serializers.ModelSerializer):
    owner = UserSerializer()

    class Meta:
        model = PollVote
        exclude = ('poll', 'question',)


class PollQuestionSerializer(serializers.ModelSerializer):
    question_votes = PollVoteSerializer(many=True)

    class Meta:
        model = PollQuestion
        exclude = ('poll',)


class PollDetailSerializer(serializers.ModelSerializer):
    questions = PollQuestionSerializer(many=True)
    owner = UserSerializer(read_only=True, )

    class Meta:
        model = Poll
        fields = ('id', 'owner', 'head', 'message', 'questions', 'end_time',
                  'is_anonymous_vote','is_multiple_vote',)

 

투표 테스트

간단한 투표 생성 및 확인 테스트를 해보겠다.

하나의 투표를 생성하고 직렬화 해봤다.

    def test_poll_create(self):
        end_time: datetime = datetime.datetime.now() + datetime.timedelta(hours=0, minutes=300, seconds=0)
        poll: Poll = Poll.objects.create(
            head='목요일 회식 가능??',
            owner=self.user,
            end_time=end_time)

        yes: PollQuestion = PollQuestion.objects.create(poll=poll, content="네")
        no: PollQuestion = PollQuestion.objects.create(poll=poll, content="아니오")

        PollVote.objects.create(poll=poll, question=yes, owner=self.user)
        PollVote.objects.create(poll=poll, question=no, owner=self.user2)

        self.assertEqual(poll.questions.all().count(), 2)
        self.assertEqual(poll.poll_votes.all().count(), 2)
        
        serializer = PollDetailSerializer(poll)
        self.assertEqual(len(serializer.data['questions']), 2)
        self.assertEqual(len(serializer.data['questions'][0]['question_votes']), 1)
        self.assertEqual(len(serializer.data['questions'][1]['question_votes']), 1)

 

이제 여러개의 투표를 생성해보고 Serializer를 돌려보자.

(필자는 귀찮음을 싫어해 factory boy를 사용했다.)

import datetime

import factory
from dateutil.tz import UTC
from django.db import connection
from django.db.models import QuerySet
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from factory.fuzzy import FuzzyDateTime

from poll.models import Poll, PollQuestion, PollVote, User, Image
from poll.serializers import PollDetailSerializer


class UserFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = User

    first_name = factory.Sequence(lambda n: "Agent %03d" % n)
    username = factory.Sequence(lambda n: "Agent %03d" % n)
    password = factory.Sequence(lambda n: "Agent %03d" % n)


class PollFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Poll

    head = factory.Sequence(lambda n: "Agent %d" % n)
    owner = factory.SubFactory(UserFactory)
    end_time = FuzzyDateTime(datetime.datetime(2023, 1, 1, tzinfo=UTC), datetime.datetime(2024, 1, 1, tzinfo=UTC))


class PollTest(TestCase):
    def setUp(self):
        self.user: User = User.objects.create(**self.create_user())
        self.user.image = Image.objects.create(image_url='www.image.com')
        self.user.save()

        user2 = self.create_user()
        user2['username'] = 'user2'
        self.user2: User = User.objects.create(**user2)
        self.user2.image = Image.objects.create(image_url='www.image.com')
        self.user2.save()

    def create_user(self) -> dict:
        return {
            "username": "leemoney93",
            "password": "mememememe",
            "first_name": "lee",
            "last_name": "money",
        }

    def create_poll(self):
        poll: Poll = PollFactory.create()
        q1 = PollQuestion.objects.create(poll=poll, content="yes")
        q2 = PollQuestion.objects.create(poll=poll, content="no")
        PollVote.objects.create(poll=poll, question=q1, owner=self.user)
        PollVote.objects.create(poll=poll, question=q2, owner=self.user2)

    def test_poll_create2(self):
        for i in range(6):
            self.create_poll()

        with CaptureQueriesContext(connection) as num_queries:
            polls: QuerySet[Poll] = Poll.objects.all()
            serializer = PollDetailSerializer(polls, many=True)
            self.assertEqual(len(serializer.data), 6)

        print(len(num_queries.captured_queries))
        print(num_queries.captured_queries)

test_poll_create2 테스트 함수를 보면 6개의 투표들이 잘 직렬화된걸 알 수 있다.

DRF(Django Rest Framework)가 마법을 부려서 알아서 잘 해줬다.

하지만! num_queries를 이용해 얼마나 많은 쿼리가 실행 됐는지 봐라.

무려 49번이나 실행됐다. 기절할 노릇이다. 고작 6개의 투표들을 직렬화 했을때 이정돈데 10개가 되면 도대체 몇개를 호출한단 말인가..

DB가 기절할 노릇이다.

 

문제점을 파악했으니 정확한 원인을 알아보자.

필자는 위에서 이 마법의 문제를 Lazy Loding으로 인한 부작용, N+1 Problem이라 했다.

DRF 입장에서 PostDetailSerializer는 owner, questions을 추가적으로 직렬화 해야하나 현재 polls 쿼리셋으로는 해당 정보를 공급 해 줄수가 없다. 해서 뒤 늦게 자기가 추가적인 정보를 얻기위해(owner, questions) 쿼리를 실행해 Loading했다. 이를 Lazy Loading이라 한다. 여기서 owner의 UserSerializer 역시 추가적으로 image를 직렬화 한다. 여기서 또 쿼리가 발생한다. 이는 questions 또한 마찬가지다. PollVote 직렬화를 위해 추가적인 쿼리를 생산한다. 

마법은 마법인데.. 수동 마법이다.

 

 

문제 해결 즉시 로딩 (Eager Loading)

Lazy Loading의 반댓말은 Eager Loading이다.

polls queryset에서 부족한 정보를 미리 공급해 추가적인 쿼리를 발생시키지 않겠다는 말이다. 

방법은 여러가지가 있지만 여기서는 PollManager를 생성해 get_queryset 오버라이딩 하는 방식을 취했다.

(django two scoops 책에서 추천하는 방법)

get_queryset에서는 PollDetailSerializer가 필요한 정보를 공급해줘야한다.

class Poll(models.Model):
    class PollManager(models.Manager):
        def get_queryset(self) -> QuerySet:
            queryset: QuerySet['Poll'] = (
                super().get_queryset()
                .prefetch_related(
                    Prefetch('questions', queryset=PollQuestion.objects.prefetch_related(
                        Prefetch('question_votes', queryset=PollVote.objects.select_related('owner__image')))))
                .select_related('owner__image'))
            return queryset

    owner = models.ForeignKey(User, on_delete=models.CASCADE)
    head = models.CharField(max_length=50)
    message = models.CharField(max_length=128)
    end_time = models.DateTimeField(null=True, blank=True)
    is_anonymous_vote = models.BooleanField(verbose_name="익명투표", default=False)
    is_multiple_vote = models.BooleanField(verbose_name="복수투표", default=False)

    objects = PollManager()

 

 

코드 자세히 살펴보기

Poll 모델 입장에서

1. owner는 다 대 일 형식이다. 해서 select_related를 사용해야한다. 

그리고 UserSerializer는 image를 추가적으로 요구한다. 해서 아래와 같이 select_related를 사용하면 된다.

(select_related는 내부적으로 join이다.)

.select_related('owner__image')

 

Poll 모델 입장에서

2. questions는 일 대 다 형식이다. 해서 prefetch_related를 사용해야한다.

그리고 PollQuestionSerializer는 추가적으로 votes를 원한다. 해서 한번 더 prefetch를 해줘야한다.

그리고 PollVote는 한번 더 owner를 요구한다. select_related를 한번 더 추가 해준다.

(prefetch_related는 내부적으로 where in 구절을 사용한다.)

.prefetch_related(
    Prefetch('questions', queryset=PollQuestion.objects.prefetch_related(
        Prefetch('question_votes', queryset=PollVote.objects.select_related('owner__image')))))

 

 

def test_poll_n_plus1(self):
    self.create_poll()

    with CaptureQueriesContext(connection) as expected_num_queries:
        polls: QuerySet[Poll] = Poll.objects.all()
        serializer = PollDetailSerializer(polls, many=True)
        print(serializer.data)

    self.create_poll()
    self.create_poll()
    self.create_poll()
    self.create_poll()
    self.create_poll()

    with CaptureQueriesContext(connection) as checked_num_queries:
        polls: QuerySet[Poll] = Poll.objects.all()
        serializer = PollDetailSerializer(polls, many=True)
        print(serializer.data)

    self.assertEqual(len(expected_num_queries), len(checked_num_queries))

 

 

 

테스트 함수를 살펴보면 투표가 얼마나 늘던지 간에 항상 쿼리 개수가 정해져있다.

나름의 최적화가 완성 되었다!

def test_poll_n_plus1(self):
    self.create_poll()

    with CaptureQueriesContext(connection) as expected_num_queries:
        polls: QuerySet[Poll] = Poll.objects.all()
        serializer = PollDetailSerializer(polls, many=True)
        print(serializer.data)

    self.create_poll()
    self.create_poll()
    self.create_poll()
    self.create_poll()
    self.create_poll()

    with CaptureQueriesContext(connection) as checked_num_queries:
        polls: QuerySet[Poll] = Poll.objects.all()
        serializer = PollDetailSerializer(polls, many=True)
        print(serializer.data)

    self.assertEqual(len(expected_num_queries), len(checked_num_queries))

 

소스코드 : https://github.com/seunwoolee/djangopoll

Why

 

필자는 Django를 사용할때 TestCase 작성을 많이 하는데 prod서버와 같은 환경에서 테스트를 편하게(push 할때마다) 실행하고 싶었다.

 

Test Code

정말 simple한 테스트다.

github action에서 내가 만든 모델을 자동으로 DB migration 해줘야 했기 때문에 간단한 모델도 추가했다.

# models.py
from django.db import models


class Ci(models.Model):
    name = models.CharField(max_length=255)
    age = models.IntegerField()


class CD(models.Model):
    name = models.CharField(max_length=255)
    age = models.IntegerField()
    
    

# test.py
from django.test import TestCase
from dummy.models import Ci, CD


class CiTest(TestCase):
    def test_plus(self):
        self.assertEqual(1+1, 2)

    def test_minus(self):
        self.assertEqual(1-1, 0)

    def test__ci_model(self):
        name = "leemoney93"
        age = 100
        ci = Ci.objects.create(name=name, age=age)
        self.assertEqual(ci.name, name)
        self.assertEqual(ci.age, age)

    def test_cd_model(self):
        name = "leemoney93"
        age = 100
        ci = CD.objects.create(name=name, age=age)
        self.assertEqual(ci.name, name)
        self.assertEqual(ci.age, age)

 

Docker 설정

가장 기본적인 Dockerfile이다. 

requirements 의존성을 install 해준다.

# Dockerfile
FROM python:3.9

ENV PYTHONUNBUFFERED 1
ENV PYTHONDONTWRITEBYTECODE 1

# dependencies를 위한 apt-get update
RUN apt-get update && apt-get -y install libpq-dev -y

RUN apt-get install libssl-dev -y

RUN apt-get install -y netcat

RUN apt-get update \
  # dependencies for building Python packages
  && apt-get install -y build-essential \
  # psycopg2 dependencies
  && apt-get install -y libpq-dev \
  # Translations dependencies
  && apt-get install -y gettext \
  # Additional dependencies
  && apt-get install -y procps \
  # cleaning up unused files
  && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
  && rm -rf /var/lib/apt/lists/*

# Requirements are installed here to ensure they will be cached.
COPY ./requirements.txt /requirements.txt
RUN pip install -r /requirements.txt

WORKDIR /app

 

다음은 docker-compose.yml 파일 

github action에서 test 실행 시 db 컨테이너를 사용한다.

이미지 재활용을 위해 docker hub에 있는 image를 가져와 테스트를 진행한다.

version: '3.8'

services:
  web:
    build:
      context: .
      dockerfile: ./Dockerfile
    image: <본인 docker hub ID>/ci  # 이미지 재활용을 위해 docker hub에서 pull 받아올 때 사용
    volumes:
      - .:/app
    expose:
      - 8000
    env_file:
      - ./.env  # 환경 설정 파일 사용
    depends_on:
      - db

  db:
    image: mysql:5.7
    restart: always
    environment:  # 테스트 DB
      MYSQL_DATABASE: 'ci'  
      MYSQL_USER: 'admin'
      MYSQL_PASSWORD: 'admin123'
      MYSQL_ROOT_PASSWORD: 'admin123'
      MYSQL_ROOT_HOST: '%'
    command:
      - --character-set-server=utf8
      - --collation-server=utf8_general_ci
    ports:
      - '3306:3306'
    expose:
      - '3306'

 

설정파일

docker-compose 파일에서 .env 파일로 부터 환경 설정을 한다고 했다(env_file)

.env 파일에 DB 정보를 넣어주고, django의 settings 파일에 해당 정보를 사용했다. 

# .env
SQL_ENGINE=django.db.backends.mysql
SQL_DATABASE=ci
SQL_USER=root
SQL_PASSWORD=admin123
SQL_HOST=db
SQL_PORT=3306
DATABASE=mysql

# settings.py
DATABASES = {
    'default': {
        "ENGINE": os.environ.get("SQL_ENGINE", ""),
        "NAME": os.environ.get("SQL_DATABASE", ''),
        "USER": os.environ.get("SQL_USER", ""),
        "PASSWORD": os.environ.get("SQL_PASSWORD", ""),
        "HOST": os.environ.get("SQL_HOST", ""),
        "PORT": os.environ.get("SQL_PORT", ""),
        "OPTIONS": {"charset": "utf8mb4"},
    }
}

 

 

Github action 설정

 

필자는 매 push 시 Test를 하고 싶다고 했다. 그러기 위해선 몇가지 스텝이 있다. 하나씩 살펴보자.

 

1. 매번 이미지를 build하면 시간이 너무 오래걸린다. image pull 하자.

(해당 step은 Login ~ Pull까지)

자세한 내용은 https://github.com/marketplace/actions/docker-login 참고)

2. Test 전에 mysql db가 활성화됐는지 확인해야 된다. 해서 django command에 wait_for_db를 명령을 추가해준다

(아래 wait_for_db 참고)

3. Test 실시

name: Checks

on: [push]

jobs:
  test-lint:
    name: Test
    runs-on: ubuntu-18.04
    steps:
      - name: Login to Docker Hub
        uses: docker/login-action@v1
        with:
          username: ${{ secrets.DOCKERHUB_USER }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - name: Checkout
        uses: actions/checkout@v2
      - name: Pull
        run: docker-compose pull
      - name: wait
        run: docker-compose run web python manage.py wait_for_db
      - name: Test
        run: docker-compose run web python manage.py test

 

 

Wait_for_db

 

import time

from django.core.management.base import BaseCommand
from django.db.utils import OperationalError


class Command(BaseCommand):
    def handle(self, *args, **kwargs):
        self.stdout.write('waiting for db ...')
        db_up = False
        while db_up is False:
            try:
                self.check(databases=['default'])
                db_up = True
            except OperationalError:
                self.stdout.write("Database unavailable, waiting 1 second ...")
                time.sleep(1)

        self.stdout.write(self.style.SUCCESS('db available'))

 

 

맺음말: 파이썬은 실수하기 쉬운언어다. 가능한 많은 테스트케이스를 만들자. 그리고 github action을 사용해 테스트 자동화를 구축하자.

코드 github 주소 : https://github.com/seunwoolee/djangoci

문제점

아래의 함수는 emails가 많을수록 문제가 아주 많아진다. 왜냐? 서버 프로세스를 점유(Block)하기 때문이다.

만약 Gunicorn Worker가 3개 있다고 가정할때, 3명의 유저가 동시에 send_emails를 수행하면 모든 프로세스는 Block되기 때문에 더이상 서비스가 불가능해진다. 아주 문제가 심각하다.

이 문제를 async Celery task로 변경해 웹프로세스 Block을 막아보자

def send_emails(request):
    emails: list[str] = request.data.get('emails')
    for email in emails:
        requests.post(URL, data=email)        
    return Response(status=200)

 

WorkFlow

1. send_emails에서 async celery task 호출

2. 메시지 브로커가 방금 호출한 task를 받아서 celery worker로 넘긴다.

3. celery worker가 일을 처리하고 result backend에 결과를 넣어준다.

출처 :&amp;amp;nbsp;https://testdriven.io/

메시지브로커, result backend로 Redis, rabbitmq 등을 많이 사용한다.

 

Celery 사용

from typing import List

import requests
from celery import shared_task
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt


@shared_task()
def task(emails: List[str]) -> None:
    for _ in emails:
        requests.post('https://httpbin.org/delay/1')


@csrf_exempt
def send_emails(request):
    emails: List[str] = request.POST.get('emails')
    task_id = task.delay(['1.naver.com', '2.naver.com', '3.naver.com'])
    return JsonResponse({'task_id': task_id.id})
    
    
def task_status(request):
    task_id = request.GET.get('task_id')

    if task_id:
        task: AsyncResult = AsyncResult(task_id)
        if task.state == 'FAILURE':
            error = str(task.result)
            response = {
                'state': task.state,
                'error': error,
            }
        else:
            response = {
                'state': task.state,
            }
        return JsonResponse(response)

위와 같이 view에서 .delay 함수를 사용해서 호출해 주면 celery에 의해서 async하게 작동한다.

즉 email들이 다 처리되는걸 기다리지 않고(non-block) 일단 Response를 반환한다.

그럼 해당 Task가 성공적으로 처리 됐는지 어떻게 판단하냐? 

Result Backend에서 해당 Task가 지금 처리됐는지 아닌지 확인하면 된다. 그러므로 반환 시 해당 Task의 id를 던져주고 프론트단에서 해당 task_status를 지속적으로 호출해 해당 작업이 완료됐는지 확인한다.

 

결론

시간이 오래걸리거나, 네트워크 io 작업이 있는 작업은 view에서 바로 처리하지말자. 반환 속도도 느려지고, gunicorn worker도 block되기 때문에 좋지 않다.

 

코드 github : https://github.com/seunwoolee/celery_test 

문제점

Django 백엔드 서버에서 대용량 리소스를 메모리에 올려놓고 서비스를 해야 하는 상황이 생겼다.
gunicorn 기본 동작은 workers 사이에 메모리를 공유하지 않고 각각의 worker 프로세스 생성 시 따로따로 리소스를 메모리에 올려놓는 방식이다.
이걸 다르게 말하면 worker 프로세스 수만큼 대용량 리소스를 메모리에 올려놓는다는 말이고 웹서버의 메모리를 엄청나게 잡아먹는다는 말이다.
대용량 리소스를 메모리에 올려놓고 worker 프로세스 사이에서 공유했으면 좋겠다.

환경 및 상황


version: '3.7'

volumes:
  static_vol:

services:
  web:
    build:
      context: ./
      dockerfile: Dockerfile

    command: gunicorn --workers=3 recommend.wsgi:application --bind 0.0.0.0:8000
    volumes:
           - static_vol:/code/static/
           - .:/code/
    expose:
           - 8000
    env_file:
           - ./.env.prod

  nginx:
    build:
      context: ./nginx
      dockerfile: Dockerfile
    volumes:
          - static_vol:/code/static/
          - ./media:/code/media/
    ports:
          - 80:80
    depends_on:
          - web

위와 같은 docker-compose 파일이 있다고 가정하자.
대용량 리소스의 크기는 약 3.2GB, worker 개수가 5개니깐 메모리를 약 16GB를 사용한다.
※ gunicorn --workers=5

하지만 command 제일 마지막에 --preload 라고 붙이면 귀신같이 메모리 사용률이 낮아진다
※ 사용하는 메모리 3.2GB, 그냥 1개!

왜냐? 하나만 메모리에 올리고 그걸 공유하기 때문에!
공유하는 걸 어떻게 알 수 있냐?? 해당 변수의 메모리 주소를 보면 알 수 있다.


print(hex(id(search_model)))

위와 같이 공유하고자 하는 무거운 리소스 변수가 가르치는 메모리 주소를 출력해보자
한번은 --preload를 빼고, 한번은 추가해서 진행해보자.

--preload 빼고 10번 호출!

web_1 | 0x7fe22f072590
web_1 | 0x7fe22f0af790
web_1 | 0x7fe22f073690
web_1 | 0x7fe22f074710
web_1 | 0x7fe22f073610

web_1 | 0x7fe22f074710
web_1 | 0x7fe22f0af790
web_1 | 0x7fe22f073610
web_1 | 0x7fe22f072590
web_1 | 0x7fe22f073690

위와 같이 변수의 주소값은 총 5개! 프로세스 별로 하나씩 메모리에 올려 놓고 사용하는 걸 확인 할 수 있다.

--preload 추가

web_1 | 0x7f1fc2c33550
web_1 | 0x7f1fc2c33550
web_1 | 0x7f1fc2c33550
web_1 | 0x7f1fc2c33550
web_1 | 0x7f1fc2c33550

web_1 | 0x7f1fc2c33550
web_1 | 0x7f1fc2c33550
web_1 | 0x7f1fc2c33550
web_1 | 0x7f1fc2c33550
web_1 | 0x7f1fc2c33550

0x7f1fc2c33550 이 주소 하나로 모든 프로세스들이 접근하고 있다.
--preload만 붙였을 뿐인데 메모리 사용량이 급격히 줄었다.

Django의 queryset을 JS에서 json으로 사용하고자 한다.

1. Django에서 queryset을 json으로 dump해야되고

2. JS에서는 그 dump한 데이터를 escape 처리 해주고 사용하면된다.

 

 

Django Queryset
    def get(self, request):
        car_id = request.GET.get('car_id', None)
        pallet_id = request.GET.get('pallet_id', None)
        ymd = request.GET.get('ymd', None)
        orderList: Manager = OrderList.objects.annotate(test=F('pallet__seq'))\
            .filter(Q(ymd=ymd), Q(pallet__car_id=car_id), Q(pallet_id=pallet_id)).values()
        rows = json.dumps(list(orderList))
        return render(request, 'release/releasetReport.html', {'rows': rows})

orderList 변수에 Queryset을 담는다. 그 내용은 values로 인해서 Model instance가 아니라 dictionary !

그다음 python에서 표준으로 제공하는 json을 불러와 dumps를 해준다. 이 때 orderList의 타입은 Queryset이라서 에러가 나니 list로 변환 해줘야 된다.

 

 

In Javascript
<script>
    let rows = {{ rows|safe }};
    console.log(rows);
</script>

js 파일에서 | safe 라는 템플릿 태그를 사용해주면 알아서 escape 해주고 배열로 사용할 수 있다.

 

아래의 결과값이 도출된다. js에서 편하게 쓰면 된다.

Django ORM related_name, 역참조 삽질기 남긴다.. (피가 거꾸로 솟을뻔 했다)

 

 

아래와 같은 Model이 있다고 하자

차량(Car)이 있고 해당 차량에 적재할 수 있는 팔레트(Pallet)가 있다고 하자.

class Car(models.Model):
    car_number = models.CharField(max_length=50)
    type = models.CharField(max_length=50)

    def __str__(self):
        return f"{self.car_number}({self.type})_팔레트개수:{self.pallet.count()}"


class Pallet(models.Model):
    car = models.ForeignKey(Car, on_delete=models.CASCADE, related_name='pallet')
    seq = models.PositiveIntegerField()

    def __str__(self):
        return f"{self.car}({self.seq})번째"
        
        
class OrderList(models.Model):
    codeName = models.CharField(max_length=255)
    pallet = models.ForeignKey(Pallet, on_delete=models.CASCADE, related_name='order_list', null=True, blank=True)

 

Car에서 자기에게 할당된 팔레트를 찾으려면 어떻게 하면 좋을까?

related_name을 활용한 역참조(reverse relation)를 쓰면된다.

Car.objects.first().pallet.all()

이런식으로 첫번째 차량의 모든 팔레트를 가져 오고 싶으면 여기서 pallet는 내가 related_name에서 pallet라고 명시해서 그렇다! love면 love로 참조하면 된다.

 

그렇다면 역참조 annotate는 어떻게 할까? 예를들어 모든 차량의 팔레트 수를 알고 싶을때 어떻게 할까?

Car.objects.all().annotate(pallet_count=Count(F('pallet')))

이런식으로 annotate를 쓰고 pallet로 참조해주면 된다.

 

 

이걸 쿼리로 풀면 아래와 같다. 진짜 간단한 쿼리다...

SELECT "release_car"."id", "release_car"."car_number", "release_car"."type", COUNT("release_pallet"."id") AS "pallet_count" FROM "release_car" LEFT OUTER JOIN "release_pallet" ON ("release_car"."id" = "release_pallet"."car_id") GROUP BY "release_car"."id"

 

 

마지막으로 역참조 filter + 역참조 annotate까지 붙이려면 어떻게 할까?

예를들면 해당 차량(car_id)의 모든 팔레트를 구하고싶은데 팔레트에 적재된 주문의 일자(주문 내역은 order_list로 역참조)로 filter하고 order_list 역참조의 주문 내역 Count를 알고싶을때는 아래와 같이 하면 된다.

 

상당히 복잡하게 말했는데 filter에서 order_list는 Pallet의 컬럼이 아니다 그런데 역참조로 인해서 컬럼처럼 쓰고있고(filter에서) annotate에서 또한 order_list를 마치 자신의 컬럼인양 사용하고 있다. 편하다 편해

pallets = Pallet.objects.filter(car__id=car_id).filter(order_list__ymd=ymd).annotate(counts=Count(F('order_list')))

 

 

결론 : 역참조는 좋다. 근데 헷갈린다.

'django' 카테고리의 다른 글

Django workers process 메모리 공유  (0) 2021.02.19
Django queryset to json  (0) 2019.12.31
Django Docker 배포(production)  (0) 2019.10.25
헷가리는 Django Static 간략 정리  (0) 2019.10.18
DRF ForeignKey Update  (0) 2019.06.14

+ Recent posts