Git Hooks 는 Git 워크플로우의 특정 이벤트가 발생할 때 자동으로 실행되는 사용자 정의 스크립트로, 개발 프로세스 자동화와 코드 품질 향상에 역할을 한다. 클라이언트 측 (pre-commit, commit-msg, pre-push 등) 과 서버 측 (pre-receive, update, post-receive 등) 훅으로 나뉘며, 코드 린팅, 테스트 자동화, 커밋 메시지 표준화, 보안 검사, 배포 자동화 등 다양한 용도로 활용된다. 최근에는 AI 기반 코드 검증, GitOps 통합, 클라우드 네이티브 환경 지원 등으로 발전하고 있으며, Husky 와 pre-commit 같은 관리 도구를 통해 팀 전체에서 일관된 훅 설정을 공유할 수 있다. Git Hooks 는 개발 생산성 향상, 코드 품질 보장, 오류 감소, 워크플로우 표준화의 장점이 있지만, 초기 설정 복잡성, 팀 공유의 어려움, 유지보수 부담 등의 단점도 존재한다.
Git Hooks 는 Git 워크플로우의 특정 이벤트 (커밋, 푸시 등) 가 발생할 때 자동으로 실행되는 사용자 정의 스크립트이다. 이 스크립트들은 소스 코드 관리, 품질 보증, 배포 자동화 등 다양한 목적으로 활용된다. Git Hooks 는 클라이언트 측과 서버 측으로 나뉘며, 로컬 개발 환경에서의 코드 검증부터 원격 저장소에서의 배포 자동화까지 광범위한 워크플로우를 지원한다. 이를 통해 개발자들은 코딩 표준 준수, 테스트 자동화, 지속적 통합/배포 등을 효율적으로 구현할 수 있다.
Git Hooks 는 Git 작업 이벤트 트리거에 반응하는 자동화 스크립트이다. .git/hooks 디렉토리에 위치하며, 실행 가능한 권한이 부여된 스크립트 파일 형태로 존재한다. 이벤트는 커밋 생성, 브랜치 전환, 원격 저장소로의 푸시 등 Git 워크플로우의 다양한 시점에서 발생할 수 있으며 사전/사후 작업을 실행한다.
repos:- repo:https://github.com/pre-commit/pre-commit-hooksrev:v4.6.0hooks:- id:detect-private-keystages:[pre-push] # push 시에만 실행- repo:https://github.com/pre-commit/pre-commit-hooksrev:v4.5.0hooks:- id:trailing-whitespace # 줄 끝 공백 제거- id:end-of-file-fixer # 파일 끝 개행 문자 추가- id:check-yaml # YAML 문법 검사- repo:https://github.com/psf/blackrev:24.1.1hooks:- id:blacklanguage_version:python3.11args:["--line-length=88"]# 최대 줄 길이 설정- repo:https://github.com/pycqa/isortrev:5.13.2hooks:- id:isort # import 정렬- repo:https://github.com/pycqa/flake8rev:7.0.0hooks:- id:flake8 # 코드 스타일 검사
상황: 팀에서 코드 품질을 유지하기 위해 커밋 전 린트와 테스트를 자동 실행하고, 실패 시 커밋을 막고 싶다.
sequenceDiagram
participant 개발자
participant Git
participant 훅 스크립트
개발자->>Git: git commit 실행
Git->>훅 스크립트: pre-commit 훅 실행
훅 스크립트->>Git: 린트/테스트 결과 반환
alt 성공
Git->>개발자: 커밋 성공
else 실패
Git->>개발자: 커밋 차단, 오류 메시지 출력
end
# .pre-commit-config.yamlrepos:# 1. Black: 코드 포맷 자동화 (PEP8 기준으로 포맷팅)- repo:https://github.com/psf/blackrev:24.3.0# 사용할 Black의 버전. 최신 버전 참고hooks:- id:blacklanguage_version:python3.10 # 프로젝트에 맞는 파이썬 버전 선언# 2. Flake8: 코드 스타일&에러 정적분석- repo:https://github.com/pycqa/flake8rev:7.0.0# 사용할 Flake8의 버전hooks:- id:flake8additional_dependencies:[flake8-bugbear]# flake8 보완 플러그인 추가# 3. isort: import문 정렬- repo:https://github.com/pre-commit/mirrors-isortrev:v5.13.2hooks:- id:isort# 4. debug-statements: print, pdb 등 디버깅 코드 커밋 방지- repo:https://github.com/pre-commit/pre-commit-hooksrev:v4.6.0hooks:- id:debug-statements# 5. detect-secrets: 시크릿(비밀번호, 토큰 등) 커밋 방지- repo:https://github.com/Yelp/detect-secretsrev:v1.4.0hooks:- id:detect-secrets# 추가 설정은 필요에 따라 detect-secrets 설정 파일 이용# 6. check-yaml: yaml파일 문법 오류 검증- repo:https://github.com/pre-commit/pre-commit-hooksrev:v4.6.0hooks:- id:check-yaml# 7. end-of-file-fixer: 파일 끝에 빈 줄 삽입 보장- repo:https://github.com/pre-commit/pre-commit-hooksrev:v4.6.0hooks:- id:end-of-file-fixer
repos:- repo:https://github.com/charliermarsh/ruff-pre-commitrev:v0.4.4 # 사용하려는 Ruff 버전의 최신 태그로 맞추세요hooks:- id:ruffargs:["."]# 현재 디렉토리 전체에 적용, 필요시 경로/옵션 조정additional_dependencies:[]# 플러그인 쓸 땐 여기에 패키지 추가- id:ruff-formatargs:["."]# 코드 포매팅도 동시에 적용하려면 추가
#!/usr/bin/env python3# .git/hooks/pre-commit (chmod +x 필요)importsubprocessimportsysdefmain():"""커밋 전에 코드 품질 검사를 수행합니다."""print("코드 품질 검사를 시작합니다...")# 변경된 Python 파일 찾기result=subprocess.run(['git','diff','--cached','--name-only','--diff-filter=ACM','*.py'],capture_output=True,text=True)ifnotresult.stdout:print("변경된 Python 파일이 없습니다.")return0files=result.stdout.strip().split('\n')print(f"{len(files)}개의 Python 파일이 변경되었습니다.")# Black으로 코드 포맷팅 검사print("코드 포맷팅 검사 중...")format_check=subprocess.run(['black','--check']+files,capture_output=True)ifformat_check.returncode!=0:print("코드 포맷팅 문제가 발견되었습니다. 자동으로 수정합니다...")subprocess.run(['black']+files)print("코드가 재포맷되었습니다. 변경사항을 다시 스테이징해주세요.")return1# Flake8으로 린팅print("린팅 검사 중...")lint_result=subprocess.run(['flake8']+files,capture_output=True,text=True)iflint_result.returncode!=0:print("린팅 문제가 발견되었습니다:")print(lint_result.stdout)return1# 테스트 실행print("단위 테스트 실행 중...")test_result=subprocess.run(['pytest'],capture_output=True,text=True)iftest_result.returncode!=0:print("테스트 실패:")print(test_result.stdout)return1print("모든 검사가 통과되었습니다!")return0if__name__=="__main__":sys.exit(main())
#!/usr/bin/env python3# scripts/validate_commit_message.pyimportreimportsysdefvalidate_commit_message(commit_msg_file):"""
커밋 메시지가 Conventional Commits 형식을 따르는지 검증합니다.
형식: <type>(<scope>): <subject>
예: feat(user): 사용자 인증 기능 추가
"""withopen(commit_msg_file,'r')asf:commit_msg=f.read().strip()# Conventional Commits 패턴pattern=r'^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\([a-z0-9-]+\))?: .+'ifnotre.match(pattern,commit_msg):print("오류: 커밋 메시지가 Conventional Commits 형식에 맞지 않습니다.")print("예시: feat(user): 사용자 인증 기능 추가")print("유효한 유형: feat, fix, docs, style, refactor, perf, test, chore, ci, build, revert")return1return0if__name__=="__main__":iflen(sys.argv)<2:print("커밋 메시지 파일 경로를 지정해주세요.")sys.exit(1)sys.exit(validate_commit_message(sys.argv[1]))
#!/usr/bin/env python3# scripts/version_bump.pyimportreimportsubprocessfrompathlibimportPathdefget_last_commit_type():"""마지막 커밋의 유형을 가져옵니다."""result=subprocess.run(['git','log','-1','--pretty=%B'],capture_output=True,text=True)commit_msg=result.stdout.strip()# Conventional Commits 패턴에서 유형 추출match=re.match(r'^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)',commit_msg)ifmatch:returnmatch.group(1)returnNonedefupdate_version():"""버전 파일을 업데이트합니다."""version_file=Path('VERSION')ifnotversion_file.exists():version_file.write_text('0.1.0')print("버전 파일이 생성되었습니다: 0.1.0")return# 현재 버전 읽기current_version=version_file.read_text().strip()major,minor,patch=map(int,current_version.split('.'))# 커밋 유형에 따라 버전 업데이트commit_type=get_last_commit_type()ifcommit_type=='feat':minor+=1patch=0elifcommit_typein('fix','perf'):patch+=1else:# 다른 유형은 버전 변경 없음return# 새 버전 쓰기new_version=f"{major}.{minor}.{patch}"version_file.write_text(new_version)print(f"버전이 업데이트되었습니다: {current_version} -> {new_version}")# 버전 변경사항 커밋subprocess.run(['git','add','VERSION'])subprocess.run(['git','commit','--amend','--no-edit'])print("버전 변경사항이 커밋에 추가되었습니다.")if__name__=="__main__":update_version()
#!/usr/bin/env python3# scripts/check_dependencies.pyimportjsonimportsubprocessimportsysfrompathlibimportPathdefcheck_dependencies():"""프로젝트의 의존성 패키지에서 보안 취약점을 검사합니다."""print("의존성 보안 취약점 검사 중...")# requirements.txt 존재 확인req_file=Path('requirements.txt')ifnotreq_file.exists():print("requirements.txt 파일이 존재하지 않습니다.")return0# safety 패키지를 사용하여 의존성 취약점 검사try:result=subprocess.run(['safety','check','-r','requirements.txt','--json'],capture_output=True,text=True)# 결과 파싱ifresult.returncode!=0:try:vulnerabilities=json.loads(result.stdout)print(f"{len(vulnerabilities)} 개의 취약점이 발견되었습니다:")forvulninvulnerabilities:package=vuln.get('package','Unknown')vuln_id=vuln.get('id','Unknown')spec=vuln.get('spec','Unknown')cve=vuln.get('cve','N/A')print(f" - {package} ({spec}): {vuln_id} (CVE: {cve})")print(f" 대응 방안: {vuln.get('advisory','N/A')}")print("\n심각한 보안 취약점이 발견되었습니다. 의존성을 업데이트하세요.")return1exceptjson.JSONDecodeError:print("취약점 결과를 파싱할 수 없습니다.")print(result.stdout)return1print("알려진 보안 취약점이 없습니다.")return0exceptFileNotFoundError:print("safety 패키지가 설치되어 있지 않습니다. 설치하려면:")print("pip install safety")return1if__name__=="__main__":sys.exit(check_dependencies())
#!/usr/bin/env python3# scripts/validate_migrations.pyimportosimportreimportsysfrompathlibimportPathdefvalidate_migrations(migration_files):"""마이그레이션 파일의 형식과 내용을 검증합니다."""ifnotmigration_files:print("검증할 마이그레이션 파일이 없습니다.")return0errors=[]forfile_pathinmigration_files:path=Path(file_path)ifnotpath.exists():errors.append(f"파일을 찾을 수 없습니다: {file_path}")continue# 파일 이름 검증filename=path.nameifnotre.match(r'^\d{4}_[a-z0-9_]+\.py$',filename):errors.append(f"마이그레이션 파일 이름이 규칙을 따르지 않습니다: {filename}")# 파일 내용 검증withopen(path,'r')asf:content=f.read()# 설명 문서 확인ifnotre.search(r'""".*?"""',content,re.DOTALL):errors.append(f"마이그레이션 설명 문서가 없습니다: {filename}")# upgrade 및 downgrade 함수 확인ifnotre.search(r'def upgrade\(\):',content):errors.append(f"upgrade() 함수가 없습니다: {filename}")ifnotre.search(r'def downgrade\(\):',content):errors.append(f"downgrade() 함수가 없습니다: {filename}")iferrors:forerrorinerrors:print(f"오류: {error}")return1print("모든 마이그레이션 파일이 유효합니다.")return0if__name__=="__main__":sys.exit(validate_migrations(sys.argv[1:]))
#!/usr/bin/env python3# scripts/generate_db_docs.pyimportimportlibimportinspectimportosimportsysfrompathlibimportPathdefgenerate_db_docs():"""SQLAlchemy 모델을 기반으로 데이터베이스 스키마 문서를 생성합니다."""print("데이터베이스 스키마 문서 생성 중...")models_dir=Path('models')ifnotmodels_dir.exists()ornotmodels_dir.is_dir():print("models 디렉토리를 찾을 수 없습니다.")return1# 모델 모듈 동적 임포트sys.path.insert(0,str(Path.cwd()))model_classes=[]forpy_fileinmodels_dir.glob('*.py'):ifpy_file.name=='__init__.py':continuemodule_name=f"models.{py_file.stem}"try:module=importlib.import_module(module_name)# SQLAlchemy 모델 클래스 찾기forname,objininspect.getmembers(module):ifinspect.isclass(obj)andhasattr(obj,'__tablename__'):model_classes.append(obj)exceptImportErrorase:print(f"모듈 임포트 오류: {module_name} - {e}")ifnotmodel_classes:print("SQLAlchemy 모델 클래스를 찾을 수 없습니다.")return1# 마크다운 문서 생성docs_dir=Path('docs')docs_dir.mkdir(exist_ok=True)withopen(docs_dir/'database_schema.md','w')asf:f.write("# 데이터베이스 스키마 문서\n\n")formodelinsorted(model_classes,key=lambdam:m.__tablename__):f.write(f"## {model.__name__}\n\n")f.write(f"**테이블 이름:** `{model.__tablename__}`\n\n")ifmodel.__doc__:f.write(f"{model.__doc__.strip()}\n\n")f.write("### 컬럼\n\n")f.write("| 이름 | 타입 | 제약 조건 | 설명 |\n")f.write("|------|------|-----------|------|\n")forcolumn_name,columninmodel.__table__.columns.items():constraints=[]ifcolumn.primary_key:constraints.append("기본 키")ifnotcolumn.nullable:constraints.append("NOT NULL")ifcolumn.unique:constraints.append("고유")ifcolumn.foreign_keys:forfkincolumn.foreign_keys:constraints.append(f"외래 키 ({fk.target_fullname})")constraints_str=", ".join(constraints)column_type=str(column.type)column_doc=getattr(column,'doc','')f.write(f"| {column_name} | {column_type} | {constraints_str} | {column_doc} |\n")f.write("\n\n")print(f"데이터베이스 스키마 문서가 생성되었습니다: {docs_dir}/database_schema.md")# 문서 변경사항 스테이징os.system(f"git add {docs_dir}/database_schema.md")print("문서 변경사항이 스테이징되었습니다.")return0if__name__=="__main__":sys.exit(generate_db_docs())
#!/usr/bin/env python3# scripts/pre_push_build_check.pyimportosimportsubprocessimportsysimporttimedefpre_push_build_check():"""푸시 전에 빌드 검증을 수행합니다."""print("푸시 전 빌드 검증을 시작합니다...")# 브랜치 확인result=subprocess.run(['git','rev-parse','--abbrev-ref','HEAD'],capture_output=True,text=True)current_branch=result.stdout.strip()# main/master 브랜치인 경우 추가 검증ifcurrent_branchin('main','master'):print(f"{current_branch} 브랜치로 푸시합니다. 추가 검증을 수행합니다...")# 모든 테스트 실행print("전체 테스트 실행 중...")test_result=subprocess.run(['pytest','--cov=app'],capture_output=True,text=True)iftest_result.returncode!=0:print("테스트 실패:")print(test_result.stdout)return1# 커버리지 검사coverage_output=test_result.stdoutcoverage_match=re.search(r'TOTAL\s+\d+\s+\d+\s+(\d+)%',coverage_output)ifcoverage_match:coverage=int(coverage_match.group(1))ifcoverage<80:print(f"코드 커버리지가 기준보다 낮습니다: {coverage}% (기준: 80%)")return1print(f"코드 커버리지: {coverage}%")# 빌드 검증print("빌드 검증 중...")ifos.path.exists('setup.py'):build_result=subprocess.run(['python','setup.py','sdist'],capture_output=True,text=True)ifbuild_result.returncode!=0:print("빌드 실패:")print(build_result.stderr)return1# 일반적인 검증else:print(f"{current_branch} 브랜치로 푸시합니다. 기본 검증을 수행합니다...")# 필수 테스트 실행print("기본 테스트 실행 중...")test_result=subprocess.run(['pytest'],capture_output=True,text=True)iftest_result.returncode!=0:print("테스트 실패:")print(test_result.stdout)return1print("모든 검증이 통과되었습니다. 푸시를 진행합니다...")return0if__name__=="__main__":sys.exit(pre_push_build_check())