선발대

[스파르타] Django 심화반 2주차 (완강) 본문

스파르타코딩클럽/강의 정리

[스파르타] Django 심화반 2주차 (완강)

신선한 스타트 2022. 3. 8. 01:01

수업목표

 

  1. Github Action에서 Database와 연결된 자동화 테스트를 실행할 수 있다.
  2. 좋아요 기능의 요구사항을 정의하고 Model 클래스를 구현한다.
  3. auto_now, AbstractBaseClass, unique_together를 배운다.

 

  01. 마이그레이션  

 

Migration

 

  • 데이터베이스 변경할 때마다 마이그레이션이 필요함
  • python manage.py migrate
  • 파이썬 database 탭에서 테이블 생성을 확인함

 

Migration이 필요한 이유

 

  • 데이터베이스의 형상은 마이그레이션을 사용해서 관리함.
  • 버전관리시스템처럼 변경 이력을 보존함.
  • 로컬 데이터베이스에서 일어난 수정사항을 그대로 실서버 데이터베이스에도 적용 가능.
  • 변경이력이 남아야지 필드의 이름이 변경되었고 그 데이터가 그대로 넘어갈 수 있음.
  • rollback 할때도 유리함. 바로 직전의 데이터베이스로 형상 되돌리기.

 

  02. Github Action에서도 데이터베이스 연결...이 안 되잖아?  

 

  • 커밋과 푸쉬하면 에러난다.
  • github action 환경에서는 데이터베이스 없으므로 만들어서 연결해줘야 함.
  • tmate: 액션이 실행되고 있는 host system으로 바로 들어갈 수 있는 방법을 제공함.

 

Github Action에서의 디버깅

 

# tmate
name: Setup tmate session
uses: mxschmitt/action-tmate@v3

# mysql 켜기
sudo systemctl start mysql

# root 유저의 비밀번호 바꾸기
use mysql;
FLUSH PRIVILEGES;
ALTER USER 'root'@'localhost' IDENTIFIED BY '22380476';

# sparta 데이터베이스 생성하기
CREATE DATABASE sparta;

# 시간대가 정확한지 확인하기
SELECT NOW();

# 시간대 변경하기
sudo rm /etc/localtime
sudo ln -s /usr/share/zoneinfo/Asia/Seoul /etc/localtime
  • github action은 자신이 맡은 모든 step, job을 끝내면 자동으로 꺼짐.
  • 이를 방지하기 위해 tmate가 무한루프를 돈다.
  • tmate step이 실행되면 무한루프로 도는 동시에 ssh 연결이나 웹을 통해 머신 안으로
  • 들어갈 수 있는 통로를 열어준다. (근데 버그 있어서 외부보다 ssh 연결이 그나마 용이)
  • 디버깅 기능에서는 강력함.

 

 

  03. Github Action에서 데이터베이스 연결하기  

 

ci.yml

name: Django CI

on:
  push:

jobs:
  ci:

    env:
      DB_DATABASE: sparta
      DB_USER: root
      DB_PASSWORD: 22380476

    runs-on: ubuntu-latest
    steps:
      - name: Check out the codes
        uses: actions/checkout@v2

      - name: Set timezone to KST
        run: |
          sudo rm /etc/localtime
          sudo ln -s /usr/share/zoneinfo/Asia/Seoul /etc/localtime

      # Start Mysql
      # https://ovirium.com/blog/how-to-make-mysql-work-in-your-github-actions/
      - name: Start Mysql
        run: |
          sudo systemctl start mysql
          mysql -e "use mysql; FLUSH PRIVILEGES; ALTER USER '${{ env.DB_USER }}'@'localhost' IDENTIFIED BY '${{ env.DB_PASSWORD }}';" -uroot -proot
          mysql -e 'CREATE DATABASE ${{ env.DB_DATABASE }};' -u${{ env.DB_USER }} -p${{ env.DB_PASSWORD }}


      - name: Setup python environment
        id: setup-python
        uses: actions/setup-python@v2
        with:
          python-version: 3.9.9

      - name: Install Poetry
        run: |
          curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python
          echo "${HOME}/.poetry/bin" >> $GITHUB_PATH

      - name: Install dependencies
        run: |
          ${HOME}/.poetry/bin/poetry install

      - name: Test python project
        run: |
          poetry run python manage.py test
  • github action 에서 테스트할 때 쓰는 데이터베이스에 중요한 정보 없으므로 secret 사용 안함.
  • 따라서 github action에서 테스트할 때 쓰는 계정정보는 그냥 env에 담아서 전달함.
  • 변경사항:
  • timezone을 kst로 변경
  • github action에서 사용하는 ubuntu 20 이미지에는 이미 mysql이 설치되어있음.
  • 따라서 system control을 사용해서 이미 설치된 mysql을 켜주기만 하면 됨.
  • root user의 비밀번호 변경함. (FLUSH PREVILEGES, ALTER USER)


  04. 환경별로 다른 설정 값 쓰기  

 

  • 환경별로 다른 설정값을 쓰는 방법에는 여러 가지가 있음.
  • 여기에서는 settings, local_settings를 사용할 것임.
  • .gitignore에 sparta/local_settings.py 추가함.
  • 실제로도 sparta/local_settings.py 만들어준다.
## sparta/local_settings.py
# For Production
# SECRET_KEY = "django-insecure-uspu)$wi(do!x3vt#quwvlba)ne=+i=(^r$axqw1r1^6n8rn%w"
# DEBUG = False

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.mysql",
        "NAME": "sparta",
        "USER": "root",
        "PASSWORD": "22380476",
        "HOST": "localhost",
        "PORT": "3306",
    }
}


## sparta/settings.py
try:
	from sparta.local_settings import *
except ImportError:
	pass
  • settings.py 끝에 추가하는 이유. 위의 값들을 덮어쓰기 위해서.
  • 이렇게 분리하면 나중에 프로덕션 서버에서 값 덮어쓰기 가능해짐.


  05. 좋아요 Model 구현해보기  

 

좋아요 기능의 요구사항

 

  • 게시물에 좋아요 누를 수 있음. 누르면 좋아요 개수 증가함.
  • 한 사용자는 하나의 게시글에 하나의 좋아요만 할 수 있음.
  • 이미 좋아요 했는지 여부 표시해줘야 함.
  • 이미 좋아요 했다면 버튼을 다시 눌렀을 때 취소 가능하도록 해야 함.
  • 게시글을 최신순으로 보여주고 좋아요 개수도 같이 보여준다.

 

새로운 app 생성

 

  • 앱 생성: python manage.py startapp tabom
  • settings.py > INSTALLED_APPS에 위의 앱 추가함.
  • "tabom.apps.TabomConfig"


  06. 모델 클래스 만들기  

 

  • tabom의 models.py 삭제
  • 파일을 models.py 라는 디렉터리로 생성함.

 

user 모델 만들기

## user.py
from django.db import models

class User(models.Model)
	name = models.CharField(max_length=50)
    updated_at = models.DateTimeField(auto_now=True)
    created_at = models.DateTimeField(auto_now_add=True)

 

article 모델 만들기

## article.py
from django.db import models

class Article(models.Model):
	title = models.CharField(max_length=255)
    updated_at = models.DateTimeField(auto_now=True)
    created_at = models.DateTimeField(auto_now_add=True)

 

like 모델 만들기

## like.py
from django.db import models
from tabom.models.article import Article
from tabom.models.user import User

class Like(models.Model):
	user = models.ForeignKey(User, on_delete=models.CASCADE)
    article = models.ForeignKey(Article, on_delete=models.CASCADE)
    updated_at = models.DateTimeField(auto_now=True)
    created_at = models.DateTimeField(auto_now_add=True)

 

__init.py__

## __init.py__
from tabom.models.article import Article as Article
from tabom.models.like import like as Like
from tabom.models.user import User as User

 

마이그레이션

 

  • 공식문서: https://docs.djangoproject.com/ko/4.0/intro/tutorial02/#activating-models
  • 새로운 app 을 installed_apps 배열에 추가함. 
  • makemigration 이후에 sqlmigrate로 실제 어떤 SQL문이 실행되는지 조회하기.
  • 마이그레이션: python manage.py makemigrations tabom
  • 어떤 sql이 실행되는지 확인하기: python manage.py sqlmigrate tabom 0001
  • python manage,py migrate
  • 테이블이 잘 생성되었는지 database tab에서 확인함.

 

  07. auto_now 사용해보기  

 

auto_now 탐구하기

 

  • updated_at = models.DateTimeField(auto_now=True)
  • created_at = models.DateTimeField(auto_now_add=True)
  • django ORM을 사용해서 model 객체를 save() 했을 때, 필드에 값을 전달하지 않아도
  • 자동으로 해당 필드가 업데이트 됨.
  • django를 통하지 않고 데이터베이스의 row를 직접 수정한다면 실행 안됨.
  • django를 써도 raw sql 실행하면 역시 auto_now, add 실행 안됨.

 

  08. Timezone  

 

실제 데이터베이스에 들어가는 시간 확인하기

 

  • timezone에 대해 짚고 넘어가기
  • console을 열어서 실제 DB에 잘 들어가는지 확인하기
from tabom.models.user import User
user = User(name='test')
user.save()
  • 확인해보면 DB에는 UTC 시간으로 들어간다.

 

TZ 설정하기

 

  • UTC 시간으로 들어가는 이유는 TZ 설정을 하지 않았기 때문.
  • settings.py 에서 UTC = Asia/Seoul, USE_TZ = False로 변경함.
  • 다시 user를 insert 하면 한국 시간으로 들어감.
  • db에 들어가는 시간과 django orm에서 보이는 시간이 일치하게 됨. (DB에 한국시간 넣기)
  • 강의할 때는 이렇게 넣는데, 정석은 그냥 DB에 UTC로 넣고, 서버에서 각자 시간대로 변환함.


  09. auto now의 동작방식 확인  

 

save() 시 동작

 

  • tests/test_auto_now.py 에서 updated_at, created_at에 값을 전달하지 않아도 있는 것 확인 가능.

 

콘솔에서 직접 row를 추가했을 때의 동작

 

  • 장고의 test는 별도의 DB에서 이루어짐.
  • mysql 클라이언트로 user를 생성하려고 하면, auto_now가 도와주지 않기 때문에
  • updated_at, created_at이 null이 될 수 없다는 에러가 뜬다.

 

raw sql을 실행했을 때의 동작

 

def test_auto_now_field_not_set_when_raw_sql_update_executed(self) -> None:
        # Given
        with connection.cursor() as cursor:
            cursor.execute(
                "INSERT INTO tabom_user(id, name, updated_at, created_at) "
                "VALUES (1, 'hihi', '1999-01-01 10:10:10', '1999-01-01 10:10:10')"
            )

            # When
            sleep(1)
            cursor.execute(
                "UPDATE tabom_user SET name='changed' WHERE id=1"
            )

        # Then
        user = User.objects.filter(id=1).get()
        self.assertEqual(user.updated_at, datetime(year=1999, month=1, day=1, hour=10, minute=10, second=10))
  • cursor.execute()를 사용해서 raw sql로 user을 넣음.
  • 만약 auto_now가 작동했다면, sleep 하면서 지나간 시간이 updated_at 컬럼에 들어가야 함.
  • 그렇지만 updated_at 컬럼은 그대로임. 작동 안한 것을 알 수 있음.

 

Timezone을 설정하지 않았을 때의 동작

 

  • 위에서 timezone 설정을 했는데, 설정 안하면 에러 난다.
  • 왜냐하면 한 쪽은 tzinfo가 있는 datetime이고,
  • 다른 하나는 가지고 있지 않은 datetime이라서 서로 같은 게 아님.

 

  • 관련 링크: https://spoqa.github.io/2019/02/15/python-timezone.html
  • datetime: 파이썬에서 기본으로 제공하는 표준 라이브러리. 날짜, 시각 조작하기 위한 클래스 제공.
  • naive datetime: 날짜, 시각만을 가짐
  • aware datetime: + 시간대정보(tzinfo)
  • 시간 관련 작업을 한다면 시간대를 꼭 명시해라. (not naive, but aware datetime)
  • 시각을 애플리케이션 로직이나 데이터베이스에서 저장할 때는 UTC로 사용하고,
  • 유저에게 표시할 때만 유저의 시간대로 변환하여 보여주도록 해라.
  • 장기적으로 보존해야하는 datetime은 항상 UTC를 기준으로 저장해라. 지역 시간대는 변경 가능성.
  • 강의에서만 naive한 객체 사용할 예정임.

 

test 주석처리

 

  • 써드파티 모듈은 단위테스트로 테스트하지 않는 것이 맞으므로 주석처리함.

 

정리

 

  • 관리상의 목적으로 updated_at, created_at은 웬만하면 모든 테이블에 생성하기
  • auto_now, auto_now_add가 해당 컬럼을 set 하는 것을 도와주는데,
  • 이들은 django model 객체를 save() 하는 시점에 작동함.
  • raw sql 실행, db console로 직접 접근할 때는 작동하지 않음.
  • datetime 에는 naive, aware 2종류가 있음. 차이점은 시간대정보의 유무임.
  • db에는 UTC로 저장하고, 서버가 db에서 가져올 때 자기 시간대에 맞춰서 변환하는 것이 정석임.

 

  10. AbstractBaseClass  

 

공통 필드를 AbstractBaseClass로부터 상속받기

 

## tabom.models.base_model.py
from django.db import models

class BaseModel(models.Model):
	updated_at = models.DateTimeField(auto_now=True)
    created_at = modles.DateTimeField(auto_now_add=True)
    
    class Meta:
    	abstract = True
  • 다른 모든 model 클래스에서 상속을 받고, updated_at, created_at은 지워줌.
  • 그리고 ./test.sh 실행함

 

Migration 실행해서 확인하기

 

  • 내부 구조는 AbstractBaseClass 상속받도록 변경되었지만, 막상 table 컬럼은 변화가 없었음.
  • 따라서 migration 해도 변화 없음.


  11. 좋아요 service 구현  

 

서비스 레이어 만들기

 

  • 원래 django에선 필수가 아니지만, spring처럼 service 레이어를 만들어봄.
  • servies/like_service.py, tests/test_like_service.py
## test/test_like_service.py
from django.test import TestCase
from tabom.models.article import Article
from tabom.models.user import User
from tabom.services.like_service import do_like

Class TestLikeService(TestCase):
	def test_a_user_can_like_an_article(self) -> None:
    	# Given
        user = User.objects.create(name="test")
        article = Article.objects.create(title="test_title")
        
        # When
        like = do_like(user.id, article.id)
        
        # Then
        self.assertIsNotNone(like.id)
        self.assertEqual(user.id, like.user_id)
        self.assertEqual(article.id, like.article_id)
  • like 함수가 없으므로 에러가 발생한다. 수정해보자.

 

like() 함수 작성

# 초반
from tabom.models.like import Like

def do_like(user_id: int, article_id: int) -> Like:
	pass
    
# 수정
from tabom.models.like import Like

def do_like(user_id: int, article_id: int) -> Like:
	return Like.objects.create(user_id=user_id, article_id=article_id)
  • 테스트 실행하면 함수가 비어있으므로 당연히 실패함.
  • 이것이 TDD(Test Driven Development)임. 테스트 주도 개발로, 소스코드 작성 전에 테스트부터 작성.
  • 테스트가 딱 실행이 될 정도로만 코드 짜고 실패시킨다. 이 테스트가 성공하도록 함수 내부 채워주기.
  • 테스트부터 작성하는 이유는 매번 성공하는 테스트 코드만 작성하지 않기 위해서.
  • 수정한 다음에 테스트 실행. ./test.sh

 

Service 레이어에 관하여

 

  • 강의에서 정의하는 service는 다음의 특징을 가짐
  • view 함수가 service를 호출함.
  • (web과 분리되어야 하므로) request를 인자로 받지 않음.
  • request로부터 들어온 변수를 전달하고 싶다면 dto를 전달하거나, int인 id 자체를 전달함.
  • http exception을 터뜨리지 않음.
  • service가 터뜨리는 exception은 view 함수에서 받아서 다시 http exception으로 만듦
  • 위의 규칙을 지키면 service 함수는 웹과 상관없는 순수한 함수가 됨.
  • 여기에서의 순수한 함수는 역동성, 참조적 투명성 등이 아니라, 그저 웹 레이어와 상관 없다는 것.

 

  • web client를 사용하지 않고도 service를 쉽게 테스트 할 수 있다는 장점이 있음.
  • 추상화 계층이 하나임. web 계층과 비즈니스 로직계층이 분리됨으로써 가독성 향상됨. (SRP 원칙)
  • 만약 서비스 레이어 없이 모든 로직을 view에서 처리하게 되면, 비즈니스 로직이 복잡해지면서,
  • view 함수가 점점 두꺼워지고, 가독성도 떨어짐. 
  • 그렇지만 service 레이어와 함꼐라면 새로운 서비스 함수를 만들어서 호출만 해도 쉽게 해결 가능.

 

  12. 유저는 하나의 게시글에 하나의 좋아요만 할 수 있다  

 

테스트 작성: 하나의 게시글에는 하나의 좋아요만 가능하게 하기

 

# 요구사항 반영해서 테스트로 작성하기
def test_a_user_can_like_an_article_only_once(self) -> None:
	# Given
    user = User.objects.create(name="test")
    article = Article.objects.create(title="test_title")
    
    # Expect
    like1 = do_like(user.id, article.id)
    with self.assertRaises(Exception):
    	like2 = do_like(user.id, article.id)
  • 우선 가장 넓은 범위의 에러인 Exception을 캐치하는 테스트 코드를 생성함.
  • context manager: with 블록과 같이 쓰는 것
  • Exception: 모든 exception의 상위 클래스
  • with 블록 안에서 에러가 발생해야 테스트가 성공함.

 

unique_constraint

 

  • 하나의 좋아요만 보장하기 > unique_constraint 방법을 써보자
  • id는 unique index가 걸려있음.
  • user_id 1, article_id 1인 좋아요 추가할 때 각각 있는지 확인하고 추가하게 됨. (이래서 속도 느려진대)
  • 모든 컬럼이 동일할 때 문제인거지, 하나라도 다르다면 문제는 없음.
  • 과거 django에서는 unique_together이라는 옵션으로 2개 이상 컬럼에 대한 unique index 생성 가능.
  • 공식문서에 따르면 이제는 unique_together 보다 unique constraints 쓰라고 나와있음.
  • constraints 옵션과 함께 사용하면 됨.
  • 공식문서: https://docs.djangoproject.com/en/4.0/ref/models/constraints/#uniqueconstraint
  • 공식문서: https://docs.djangoproject.com/en/4.0/ref/models/options/#constraints
class Like(BaseModel):
	user = models.ForeignKey(User, on_delete=models.CASCADE)
    article = models.ForeignKey(Article, on_delete=models.CASCADE)
    
    class Meta:
    	constraints = [
        	models.UniqueConstraint(fields=["user", "article"], name="unique_user_article"),
        ]
  • python manage.py makemigrations tabom
  • python manage.py sqlmigrate tabom 0002
  • python manage.py migrate
  • 이제 테이블에서 확인하면 실제로 unique index 들어간 것을 확인할 수 있음.

 

테스트 실행, Narrowing Exception에 관해

 

  • 테스트는 끝났지만 아직 끝난 것이 아님. 
  • assertRaises에서 Exception 클래스 사용했는데, 정말 이 Exception이
  • unique key에 의해 발생한 것인지 확인 필요함.
  • 기본적으로 Exception을 except(또는 catch) 하는 건 좋은 습관이 아님.
  • 항상 우리가 의도한 구체적인 Exception만 잡아내는 것이 좋음. 너무 범위가 넒음.
# 예시 (1)
# input: integer, 만약 integer로 받을 수 없는 값이면 ValueError를 캐치함.
try:
	my_int = int('a')
exception ValueError:
	print('error catch!')


# 예시 (2)
# ValueError가 아니라 더 넓은 Exception을 캐치할 경우.
# 아래처럼 의도하지 않은 에러까지 잡게 됨.
# 따라서 Exception이 아니라 Exception의 sub class를 except 해야 함.
try:
	my_int = int(1)
    pa
except Exception:
	print('error catch!') # error catch! 출력

 

테스트 수정

 

  • 에러 종류 확인하기 위해 테스트 코드 수정함.
## 테스트 코드
def test_a_user_can_like_an_article_only_once(self) -> None:
	# Given
    user = User.objects.create(name="test")
    article = Article.objects.create(title="test_title")
    
    # Except
    like1 = do_like(user.id, article.id)
    
    try: 
    	like2 = do_like(user.id, article.id)
    except Exception as e:
    	print(e)
        
        
## e 타입
type(e)
  • 위에서처럼 수정하고 디버깅함.
  • e: IntegrityError. integrity: 에러, 무결성 에러
  • unique Key가 달려있는데, 중복된 key가 있을 때,
  • foreign key가 걸려있는데, null이 들어갔거나 할 때 발생함.
  • 에러 내용을 보면 duplicate entry라고 적혀있고, unique key 이름도 우리가 만든 그대로 있음.
  • 의도한대로 에러가 터지는구나!

 

def test_a_user_can_like_an_article_only(self) -> None:
	# Given
    user = User.objects.create(name="test")
    aritcle = Article.objects.create(title="test_title")
    
    # Exception
    do_like(user.id, article.id)
    with self.assertRaises(IntegrityError):
    	do_like(user.id, article.id)
  • Exception이 아니라, IntegrityError를 사용해서 에러 감지하기.

 

  13. 숨은 요구사항: 없는 user_id나 article_id가 input  

 

input: 없는 user_id, article_id. 에러

 

  • 실제로 현업에서는 기상천외한 예외들이 들어온다.
  • 숨은 요구사항 test:
def test_it_should_raise_exception_when_like_an_user_does_not_exist(self) -> None:
	# Given
    invalid_user_id = 9998
    article = Article.objects.create(title="title")
    
    # Except
    with self.assertRaises(IntegrityError):
    	do_like(invalid_user_id, article.id)

def test_it_should_raise_exception_when_like_an_article_does_not_exist(self) -. None:
	# Given
    user = User.objects.create(name="test")
    article = 9998
    
    # Except
    with self.assertRaises(IntegrityError):
    	do_like(user.id, invalid_article_id)

 

정리

 

  • 실패하는 테스트 먼저 짜고, 그 다음에 테스트가 성공하도록 구현하는 TDD 맛보기 했음.
  • unique constraint 생성해봤다.
  • 위에서는 IntegrityError를 raise 하는데, 이것은 나중에 view 할 때 다른 에러로 변경함.

 

  14. 숙제  

 

  • django 쓰지 않고 mysql 서버에서 datetime의 default 값을 사용하는 방법.
  • 장고 없이도 auto_now_add와 비슷한 설정을 mysql 서버에서 설정할 수 있다.


  15. 2주차 숙제 해설  

 

  • DATETIME DEFAULT CURRENT_TIMESTAMP
Comments