장고에서 모델 수정을 하고 나면 makemigrations로 마이그레이션 파일을 생성하고, migrate로 마이그레이션 적용을 해줘야 합니다.

문제는 모델 수정은 했는데 makemigrations로 마이그레이션 파일을 생성하지 않은 경우가 생길 수 있는데, 그 상태 그대로 Pull Request를 만들고 리뷰에서 발견하지 못해 머지되고 배포되면 문제가 생기게 됩니다. 예를 들어 모델에 필드를 추가했는데 마이그레이션이 적용되지 않아 DB는 outdated 상태라면 해당 모델을 생성하거나 하는 액션은 무조건 에러가 발생할겁니다.

어쨌거나 이런 마이그레이션 파일 누락을 어떻게 미연에 막을 수 있을지 고민을 해봤는데, 기 사용중인 pre-commit 프레임워크를 활용하고, pre-push 훅을 설정하는 방식으로 결정하게 됐습니다.

가볍게 백그라운드 설명을 하고, 실제 적용한 예시를 보여드리겠습니다.

 

background) 깃 훅이란?

git에서 어떤 액션이 일어났을 때 특정 스크립트를 수행하는 hooks라는 기능이 있습니다.

pre-commit 스크립트 예시

.git/hooks 폴더 안에 스크립트가 들어 있고, pre-commit, pre-push, ... 등 여러가지 훅이 존재합니다. (사실 지금은 이거밖에 모름) 예를 들어 "commit 직전에 뭔가를 적용하는 스크립트를 돌리고 싶다"라고 하면 pre-commit 훅에다가 설정을 해야겠죠. 또는 checkout 직후에 뭔가 돌리고 싶다 라고 하면 post-checkout 훅 스크립트를 짜면 됩니다.

전체 목록은 git-scm.com/docs/githooks 에서 확인할 수 있습니다.

직접 pre-commit, pre-push 등의 스크립트를 짜는건 매우 번거롭고 관리도 힘들기 때문에 Husky, pre-commit 등의 프레임워크를 사용하는 경우가 많습니다.

클라이언트 사이드 훅과 서버 사이드 훅이 있는데, 클라이언트 사이드 훅은 보통 커밋, 머지할 때 트리거 되는 훅이고, 서버사이드 훅은 보통 receiving pushed commit 등의 네트워크 오퍼레이션이 있을 때 트리거 됩니다.

  • Client-side hooks (=Local hooks)
    • pre-commit
    • pre-push
    • prepare-commit-msg
    • commit-msg
    • 등등....
  • Server-side hooks
    • pre-receive
    • update
    • post-receive
It’s important to note that client-side hooks are not copied when you clone a repository.

주의할 점이 있는데 클라이언트 사이드 훅은 repo clone할 때 복사되지 않습니다.

 

background) pre-commit이란?

pre-commit; A framework for managing and maintaining multi-language pre-commit hooks.

링크: pre-commit.com

pre-commit은 깃훅 관리 프레임워크입니다. 파이썬으로 구현되어 pip로 설치할 수 있습니다. 파이썬 lint 라이브러리인 flake8, black 등을 

python 뿐 아니라 golang, r, ruby, rust, script, node 등등 이것저것 언어로 된 스크립트를 돌릴 수도 있습니다만 추가 설정이 필요합니다.

 

pre-commit을 이용한 pre-push 깃훅 설정방법

설정 당시 환경
- pre-commit: 2.9.0
- django: 3.0
- isort: 5.7.0
- black: latest (python3.7)
- flake8: 3.7.9

버전 설정은 위와 같은데 따라할 필요는 없고, 전부 latest로 쓰면 될겁니다. 향후 혹시 모를 breaking change를 위해 버전을 써놨습니다.

 

repos:
  - repo: https://github.com/PyCQA/isort
    rev: 5.7.0
    hooks:
      - id: isort
        exclude: ^.*\b(migrations)\b.*$
  - repo: https://github.com/ambv/black
    rev: stable
    hooks:
      - id: black
        language_version: python3.7
        exclude: ^.*\b(migrations)\b.*$
  - repo: https://gitlab.com/pycqa/flake8
    rev: 3.7.9
    hooks:
      - id: flake8

기존의 pre-commi 설정은 위와 같습니다. .pre-commit-config.yaml을 위처럼 구성하면 커밋할 때 isort -> black -> flake8 순으로 적용이 됩니다. 마이그레이션 파일은 linting이 딱히 필요하지 않기 때문에 exclude 설정을 했습니다.

 

이제 새로운 pre-push 훅을 추가해 보겠습니다. 커밋 직전엔 isort->black->flake8를 그대로 적용하고, push 직전에만 미생성된 migration이 있는지 확인해보겠습니다.

--dry-run¶
Shows what migrations would be made without actually writing any migrations files to disk. Using this option along with --verbosity 3 will also show the complete migrations files that would be written.

--check¶
Makes makemigrations exit with a non-zero status when model changes without migrations are detected.

docs.djangoproject.com/en/3.1/ref/django-admin/#makemigrations 링크를 보면 여러 옵션이 있는데, --check와 --dry-run argument를 추가하면 될 거라는 감이 옵니다.

  • --dry-run: 실제 마이그레이션 파일을 만들지는 않고 mock run을 진행함
  • --check: 생성되지 않은 마이그레이션이 있는 경우 non-zero exit

pre-commit으로 새로운 훅을 생성하는 과정은 pre-commit.com/#creating-new-hooks 링크를 참조하시면 되겠습니다

 

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index aaaaaaaa..bbbbbbbb 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
 repos:
+  # pre-commit hooks
   - repo: https://github.com/PyCQA/isort
     rev: 5.7.0
     hooks:
       - id: isort
         exclude: ^.*\b(migrations)\b.*$
+        stages: [commit]
   - repo: https://github.com/ambv/black
     rev: stable
     hooks:
       - id: black
         language_version: python3.7
         exclude: ^.*\b(migrations)\b.*$
+        stages: [commit]
   - repo: https://gitlab.com/pycqa/flake8
     rev: 3.7.9
     hooks:
       - id: flake8
+        stages: [commit]
+  # pre-push hooks
+  - repo: local
+    hooks:
+      - id: pre-push-django-makemigrations
+        stages: [push]
+        name: Check django migrations
+        entry: python manage.py makemigrations --check --dry-run
+        language: system
+        types: [python]
+        pass_filenames: false
+        always_run: true

진행 과정과 옵션별 설명은 다음과 같습니다.

  • 기존 pre-commit 훅에 stages: [commit] 추가
    • default_stages가 [commit, push] (=pre-commit, pre-push)로 되어 있기 때문에 pre-commit 스테이지에만 사용하기 위해 이렇게 써줘야 합니다
  • pre-push 훅 추가
    • stages: [push]
      • pre-push에만 쓸 것이니 stages를 push로 설정해줍니다
      • 다른 깃훅은 스테이지와 깃훅 이름이 동일한데 (ex. merge-commit, prepare-commit-msg, 등등..) pre-commit, pre-push는 commit, push입니다. 그 이유는 레거시...
    • name: 깃훅 체크 단계에서 표시될 이름입니다
    • entry, language
      • language는 훅 스크립트를 실행할 언어를 말합니다. 터미널에서 makemigrations를 실행할것이니 system으로 설정합니다
      • entry: 실제 동작할 executable을 나타냅니다. 그냥 명령어 입력하면 됩니다
    • types: 트리거 시킬 파일의 타입을 말합니다.
    • pass_filenames: pre-commit 훅이 변경 있는 매 파일마다 실행되는게 아니라, 전체 딱 한번만 실행될거니까 이걸 false로 설정해줘야 합니다 (참고)
    • always_run: 해당 훅이 트리거되는 파일에 관계 없이 무조건 실행됩니다

지금 보니 always_run이 있으니까 types가 필요가 없겠네요 뭐 이건 나중에 수정해도 되니까.. 아무튼 현재 설정은 이렇습니다/

$ pre-commit install --hook-type pre-commit --hook-type pre-push

마지막으로 위 명령어로 .git/hooks에 설치하면 완료입니다.

 

완성

모델에 수정을 하고 마이그레이션 파일을 만들지 않고 커밋 후 푸시를 하면 위처럼 pre-push 훅이 동작하고 Failed가 나와 푸시가 실패하게 됩니다. 이제 마이그레이션 파일이 만들어지지 않아 문제가 생길 일은 (거의) 없습니다!

거의 없다고 한 이유는, pre-commit으로 깃훅 install을 하지 않으면 클라이언트 사이드 훅이 동작하지 않기 때문입니다.. 이걸 강제로 적용할 방법은 아직 없는 것 같네요. (참고)

만약 깃훅을 무시하고 푸시를 해야 한다면, git push --no-verify처럼 옵션을 붙이면 됩니다.