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',)
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가 필요한 정보를 공급해줘야한다.
필자는 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 파일에 해당 정보를 사용했다.
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을 사용해 테스트 자동화를 구축하자.