.git/hooks를 활용해서 커밋 메시지에 자동으로 브랜치이름 prefix 붙이기

Jihan·2024년 9월 30일
1

모노레포-구축기

목록 보기
1/2

최근 회사에서 새 프로젝트를 시작하면서 사내 프론트엔드 레포지토리를 모노레포로 통합 및 개편하는 작업을 진행했어요. 모노레포는 여러 패키지를 한 레포지토리에서 관리하는 방식으로, 여러 서비스나 모듈을 하나의 저장소에서 통합적으로 관리할 수 있습니다. 이 과정에서 새로운 브랜치 관리 전략, 버전 관리 전략, 배포 전략 등을 재정비하고 개발 환경을 통일하는 작업도 함께 이루어졌는데요, 여기서 여러 흥미로운 내용들을 배우게 되어서 공유차 글을 적어봅니다!

1. 모노레포의 브랜치 관리 문제

앞서 이야기했던 레포지토리 구조를 모노레포로 통합하는 과정에서, 브랜치 관리와 관련된 몇 가지 고민이 생겼습니다. 특히, 하나의 레포지토리에서 여러 패키지를 관리하다 보니 메인에 머지된 커밋들을 커밋 메시지만 가지고 어떤 패키지의 작업 내용을 포함하는 커밋들인지 확인하기가 어려울 것 같았어요.

일단 저희 회사의 커밋 메시지 명명 규칙에 의해 각각의 커밋들은 <type>(<scope>): <subject> 의 형태로 작성됩니다. 예를 들면 feat: add modal component나, chore: README 오타수정 같은 모양이에요.

1-1. 문제상황

그리고 예를 들어, 1번과 2번 패키지 각각에 대한 갱신사항을 가진 브랜치가 비슷한 시기에 작업이 일어나고, 두 브랜치가 모두 메인에 머지된다고 생각해봅시다. 그리고 각 브랜치의 작업내용은 하나는 ui 패키지에 대한, 하나는 api 패키지에 대한 작업인 상황이에요.

ui패키지에 대한 브랜치(dev/ui/modal) 커밋 히스토리

feat: Add modal component for enhanced user interaction

fix: Fix styling issue in modal component

api패키지에 대한 브랜치(dev/api/auth) 커밋 히스토리

feat: Implement authentication logic using JWT

feat: Add refresh token mechanism

이제 작업한 내용을 모두 메인에 머지해봅시다. 만약 위에 적어놓은 두 브랜치의 작업내용이 1->2->3->4의 순서로 커밋되었다면 문제되지 않겠지만, 제각각의 순서로 커밋되었다면, 메인 브랜치에서는 자연스럽게 아래와 같은 커밋 히스토리가 쌓이게 될 겁니다.

메인 브랜치에서 머지된 후의 커밋 히스토리

feat: Add modal component for enhanced user interaction

feat: Implement authentication logic using JWT

fix: Fix styling issue in modal component

feat: Add refresh token mechanism

너무 자연스럽게도 타임스탬프에 따라 커밋들이 섞이면서 어떤 패키지에서 작업한 내용인지 알 수 없게 되었어요. 이런 상황을 생각하면서, 어떤 커밋이 어떤 패키지에 대한 작업 내용을 가지는 지 확인할 수 있도록 만들어야겠다고 생각하게 되었습니다.

아, 그 전에 이 이야기를 먼저 해야할 것 같습니다. 이번에 모노레포로 레포지토리 구조를 변경하면서, 기존 프로젝트들에 있었던 배포 파이프라인을 전부 자동화하는 것도 계획하고 있었는데, 메인에 머지될 때 각 패키지에 대한 파이프라인을 트리거링해주기 위해서 브랜치 이름을 식별자로 사용하려는 계획이 있었어요. 대강 <type>/<package>/<content> 와 같은 형태로 어떤 패키지에서 어떤 작업을 위한 브랜치인 건지, 브랜치 이름을 통해 식별할 수 있도록 하는 것도 계획에 있었습니다.

1-2. 해결 방법을 찾아보자.

요약하자면, dev/ui/modal 브랜치의 작업내용과, dev/api/auth의 작업 내용이 메인에 전부 머지된 이후에 커밋 메시지만 가지고 이 둘을 구분하고 싶었던 것이죠. 저는 크게 두 가지 해결 방법을 생각해보고, 그 중 하나를 실행해봤습니다.

1-2-1. 스쿼시 & 머지(Squash and Merge) 활용하기

첫 번째 방법은 스쿼시 & 머지를 통해서 커밋 히스토리를 최대한 일관성 있게 유지하는 것이었어요.

GitHub에서는 여러 가지 병합 방법이 존재합니다. 일단 커밋 이력을 모두 유지하는 머지 자체에도 Fast-foward mergeRecursive merge가 존재하고, 머지 대상이 되는 브랜치의 커밋 이력을 깔끔히 유지할 수 있도록 도와주는 방식에도 Squash & MergeRebase & Merge가 존재합니다. 머지의 종류나 브랜치 관리에 대한 자세한 내용은 다음에 기회가 있으면 다루도록 하고, 이번 목표인 커밋 히스토리로 작업 내역을 나타내는 데에서는 Squash & Merge 방식(이하 스쿼시머지)이 아주 적절합니다.

스쿼시머지는, 특정 브랜치를 그 브랜치가 파생된 origin 브랜치에 병합할 때, 파생된 시점부터 병합하려는 시점까지의 특정 브랜치 내의 커밋을 한 개로 압축하여 병합하는 방식입니다. 스쿼시머지를 통해서 PR을 관리할 경우, 일반적으로 파생된 브랜치 하나당 커밋 히스토리가 하나씩 남게 되어, 브랜치의 변경 사항은 모두 반영되지만 origin 브랜치의 커밋 히스토리는 단순화됩니다.

앞선 예시 상황에서는 메인 브랜치에 아래와 같은 커밋 히스토리가 남게 될 것입니다. 그리고 그 순서는 각 브랜치가 병합된 순서와 같아질 거예요.

[ui] add modal component and fix styling issue

[api] implement Auth with JWT and add refresh token logic

우리가 찾는 각 브랜치의 작업내용을 커밋 메시지에 포함시키면서 메인에 일관성 있게 머지하는 방식으로 아주 적절하지 않나요? 근데 사실 저희는 브랜치 내에서 작업한 커밋 로그들을 하나로 합치기를 원하지 않았습니다. 완전 깔끔한 메인 브랜치의 커밋 히스토리보다, 어떤 우당탕탕하는 방법으로 브랜치 내에서 개발이 이루어졌는지를 보관하는 게 더 가치있다고 생각했습니다. 그래서 두 번째 방법을 떠올리게 됐어요.

1-2-2. 커밋 메시지에 prefix 붙이기

두 번째 방법은 각 커밋 메시지에 특정 패키지와 관련된 prefix를 붙이는 것이었습니다. 예를 들어, 커밋 메시지를 작성할 때 각 패키지에 따라 [ui], [api]와 같은 식별자를 추가하는 방식이지요. 이렇게 하면 나중에 메인 브랜치에서 커밋 히스토리를 살펴볼 때 어떤 패키지의 작업인지 쉽게 식별할 수 있습니다.

이 방법의 장점은 스쿼시 머지를 사용하지 않고도 커밋 히스토리를 깔끔하게 유지할 수 있다는 점입니다. 즉, 각 브랜치에서 작성된 커밋이 그대로 유지되면서도 메인 브랜치에서 각 패키지의 작업 내용을 명확히 구분할 수 있게 됩니다.

예를 들어 커밋 메시지를 작성해보자면 메인 브랜치의 커밋 히스토리는 다음과 같이 만들어질 것입니다.

[ui] feat: Add modal component for enhanced user interaction
[api] feat: Implement authentication logic using JWT
[ui] fix: Fix styling issue in modal component
[api] feat: Add refresh token mechanism

이렇게 하면 메인 브랜치에서 어떤 패키지와 관련된 작업인지 쉽게 알 수 있습니다. 너무 단순하지만 저희에게 제일 필요한 해결책이지요.

다만, 이 방법은 커밋 메시지를 작성할 때마다 신경을 써야 하므로 팀원들이 일관된 형식으로 메시지를 작성하도록 독려해야 합니다. 그래서 저는 이 방법을 채택하되 팀원들이 불편하지 않게 prefix가 자동으로 완성될 수 있는 방법을 찾아봤습니다.

2. 커밋 메시지에 자동으로 prefix를 붙여보자.

그렇게 어렵지 않게 방법을 찾을 수 있었어요. 다행히도 Git에서 자체적으로, 비교적 원시적인(CLI에서도 충분히 적용 가능한) 방식으로 hook들을 제공하고 있었습니다.

2-1. Git Hooks

Git Hooks는 Git에서 제공하는 스크립트 실행 기능으로, 특정 Git 이벤트가 발생할 때 자동으로 특정 작업을 수행할 수 있도록 해줍니다. 예를 들어 커밋을 만들 때, 푸시하기 전에, 브랜치를 병합할 때 등 다양한 Git 이벤트에 대해 Hook을 설정할 수 있어요.

Git Hooks는 기본적으로 원격으로 공유되지 않는 로컬 레포지토리 설정으로, 레포지토리 루트 디렉토리의 .git/hooks폴더 안에 작성되어요. .git폴더는 git init명령어를 통해서 레포지토리로 초기화된 디렉토리에 자동으로 생성되는데, 이 안에는 레포지토리의 로컬 설정이 다양하게 담겨있습니다. 그리고 .git/hooks 디렉토리 내부에는 다양한 Hooks에 대한 예시들이 담겨있지요. 이것만 봐도 어떤 트리거를 통해서 우리가 스크립트를 동작시킬 수 있는지 충분히 예상이 가능해요.

이름triggerargument
applypatch-msggit am 명령어로 패치를 적용할 때 커밋 메시지를 설정하기 전<commit message file> - 패치가 적용될 커밋 메시지가 담긴 파일 경로
commit-msg커밋 메시지를 입력한 후, 커밋이 완료되기 전<commit message file> - 작성된 커밋 메시지가 담긴 파일 경로
fsmonitor-watchman파일 시스템에서 변경 사항을 모니터링할 때-
post-update원격 저장소에서 업데이트가 완료된 후<ref> <oldrev> <newrev> - 업데이트된 참조와 이전 및 새로운 커밋 해시
pre-applypatchgit am 명령어로 패치를 적용하기 전-
pre-commit커밋이 만들어지기 전-
pre-merge-commit병합을 완료하기 전-
pre-push원격 저장소로 푸시하기 전<remote name> <remote URL> - 푸시될 원격 저장소 이름과 URL
pre-rebase리베이스를 시작하기 전<upstream> <branch> - 리베이스할 상위 브랜치와 리베이스할 브랜치
pre-receive원격 저장소로 푸시되기 전에 원격에서<ref> <oldrev> <newrev> - 푸시된 참조와 이전 및 새로운 커밋 해시
prepare-commit-msg커밋 메시지 편집기 실행 전<commit message file> <source> <sha1> - 커밋 메시지 파일, 소스, SHA1 해시
push-to-checkout푸시된 브랜치가 체크아웃될 때-
update원격 저장소에서 참조가 업데이트될 때<ref> <oldrev> <newrev> - 업데이트된 참조와 이전 및 새로운 커밋 해시

꽤 다양한 Hook들이 있는 것 같습니다. 이들 모두를 정말 적절하고 고도화해서 활용하면, 복잡한 형상 관리 과정 속에서도 일관성 있는 형상 관리 품질을 유지할 수 있을 것 같다고 느껴지네요. 또한 각 Hook들은 호출될 때 그 트리거에 맞는 argument를 입력받습니다. 예를 들어 commit-msg Hook은 commit message file의 경로를 argument로 입력받아, 그 file에 접근하여 commit message를 편집하는 등, 이를 활용한 스크립트로 동작될 수 있지요.

2-1-1. prepare-commit-msg

하나하나 살펴보는 건 각자의 역할로 미뤄놓고, 우리는 이 중에서 prepare-commit-msg라는 Hook을 활용해보려고 합니다.

이름triggerargument
prepare-commit-msg커밋 메시지 편집기 실행 전<commit message file> <source> <sha1> - 커밋 메시지 파일, 소스, SHA1 해시

preparecommit-msg Hook은 세 가지 arguement를 가집니다.

  • commit message file: 커밋 메시지가 저장된 파일의 경로를 나타냅니다. 이 경로에 접근하여 마치 interceptor와 유사하게 커밋되는 메시지의 내용을 변경할 수 있습니다. 메시지 파일의 첫 번째 줄은 커밋 제목이며, 이후에는 본문이 포함됩니다. 이 파일을 직접 수정함으로써 커밋 메시지를 자동으로 변경할 수 있습니다.
  • source: 커밋 메시지가 어떻게 생성되었는지를 나타내는 값입니다. 커밋 메시지가 어떤 경로로 생성되었는지에 따라 동작을 다르게 설정할 수 있습니다. 예를 들어, 스쿼시 커밋일 때만 커밋 메시지를 수정하거나, 병합 시에만 특정 메시지를 추가할 수 있습니다.
  • sha1: 부모 커밋의 SHA-1 해시 값입니다.

2-2. prepare-commit-msg Hook을 적용해보자.

이 중에서 commit message file과, git 명령어를 사용하여 아래와 같은 Hook script를 작성해보았습니다.

# .git/hooks/prepare-commit-msg
#!/bin/bash

branch_name=$(git rev-parse --abbrev-ref HEAD)
# git 명령어를 통해 branch 이름을 받아옴

commit_msg_file=$1
# 첫 번째 argument인 commit msg file을 받아옴

if [[ $branch_name =~ ^[^/]+/([^/]+)/[^/]+$ ]]; then 
# 브랜치 이름이 <>/<>/<> 형태일 경우
  package_name="${BASH_REMATCH[1]}"
  # 위에서 정규식을 통해 얻어온 문자열의 첫 번째 요소를 가져옴

  if [ -f "$commit_msg_file" ]; then
    sed -i.bak "1s/^/[$package_name] /" "$commit_msg_file" 
    # commit msg file의 맨 앞에 [package_name]을 추가해줌
  else
    echo "Error: Commit message file not found."
    exit 1
  fi
fi

이제 이 Hook이 적용된 레포지토리에서 한 번 커밋을 해보겠습니다.

vim .git/hooks/prepare-commit-msg

.git 폴더는 숨김파일이라 터미널로 접근하는 게 편할 것 같았습니다. vim으로 접근해서 작성한 스크립트를 그대로 추가해줍니다.

chmod +x prepare-commit-msg

Git Hooks의 트리거에 실행 권한을 열어주기 위하여 chmod로 작성한 스크립트에 실행 권한을 부여해줍니다.

이제 Hook 설정은 완료되었으니, 브랜치 규칙과 커밋 규칙에 맞게 커밋을 작성해서, prefix가 잘 추가되는지 확인해봅시다.

git init
echo "README" > README.md
git add .
git commit -m "init"
# 로컬 레포지토리 초기화

git checkout -b "dev/package/add-script"
# 브랜치 생성
echo "TEST" > TEST
git add .
git commit -m "feat: commit-maker-script"
# 변경사항 생성 후 커밋

원하는 대로 잘 동작하네요, 최신 커밋의 커밋 메시지 앞에 브랜치 이름에서 따온 패키지 이름인 [package]가 prefix로 추가된 것을 확인할 수 있습니다. 기분이 좋은데요? 이제 팀원들과 일관성 있는 커밋을 공유할 수 있을 것 같네요. 어서 팀원들한테 재밌는 삽질을 했다고 자랑하러 가봅시다.

3. commit prefix 설정을 구성원들과 공유하기

하지만 여기서 또 다른 문제가 생깁니다. .git 디렉토리는 기본적으로 원격 저장소에 반영되지 않습니다. 즉, 아무리 .gitignore가 없는 레포지토리라고 해도, .git/hooks에 있는 훅은 다른 팀원들과 공유되지 않죠. 따라서, 저희의 커밋 prefix 스크립트를 다른 팀원들과 공유하려면 새로운 방식이 필요합니다.

이에 대해 두 가지 방법을 제시할 수 있습니다. 첫 번째 방법은 자체 스크립트를 통해 팀원들과 공유하는 방법이고, 두 번째 방법은 Husky와 같은 Git Hooks 관리 도구를 사용하는 것입니다. 두 가지 방법 모두 장단점이 있으니 팀에 맞는 방식으로 적용해보면 좋습니다. 저는 husky와 같이 공부가 필요해보이지만 공부하기 귀찮은(...) 아이들을 즉석으로 도입하는 데에는 조금 두려움이 있어서, 저희 팀에는 첫 번째 방법을 제시하게 되었어요.

3-2. 자체 스크립트를 공유하기

가장 단순한 방법은 저희가 사용한 Git Hook을 스크립트로 만들어서 팀원들과 공유하는 것입니다. 앞서 설명한 것처럼, .git/hooks/prepare-commit-msg 파일을 자동으로 생성하고, 그 파일에 커밋 메시지를 수정하는 코드를 삽입하는 방식으로 팀원들의 환경에 설정을 적용할 수 있습니다.

이를 위해, 프로젝트의 루트 디렉토리에 스크립트를 작성하여 팀원들이 쉽게 사용할 수 있도록 합니다.

# ./setup-commit-maker.sh
#!/bin/bash

mkdir -p .git/hooks

# prepare-commit-msg 훅 파일 경로 설정
hook_file=".git/hooks/prepare-commit-msg"

# prepare-commit-msg 훅 파일 생성 및 내용 작성
cat << 'EOF' > "$hook_file"
#!/bin/bash

branch_name=$(git rev-parse --abbrev-ref HEAD)

commit_msg_file=$1

if [[ $branch_name =~ ^[^/]+/([^/]+)/[^/]+$ ]]; then
  package_name="${BASH_REMATCH[1]}"

  if [ -f "$commit_msg_file" ]; then
    sed -i.bak "1s/^/[$package_name] /" "$commit_msg_file"
  else
    echo "Error: Commit message file not found."
    exit 1
  fi
fi
EOF

# 생성된 prepare-commit-msg 훅 파일에 실행 권한 부여
chmod +x "$hook_file"

echo "commit message maker setup completed."

그리고 npm 명령어를 통해 쉽게 스크립트를 실행할 수 있도록, package.json에 아래 명령어를 추가해줍니다.

{
  "name": "test",
  "version": "1.0.0",
  "description": "test",
  "main": "index.js",
  "scripts": { // here
    "setup": "./setup-commit-maker.sh"
  },
  "author": "",
  "license": "ISC"
}

이제 프로젝트를 초기에 클론하였을 때, npm run setup 명령어를 실행해달라는 문구를 README 등에 추가하는 식으로 팀원들에게 스크립트 사용법을 공유할 수 있습니다.

package.json의 script에 "dev": "turbo dev && ./setup-commit-maker.sh"와 같은 식으로 몰래 실행 스크립트에 포함시키면, 실행마다 동작하긴 하겠지만 아무런 추가작업 없이 구성원들이 모두 설정을 공유하도록 (몰래) 강제할 수도 있겠네요. 물론 제가 그러진 않았습니다.

3-3. husky 사용하기

두 번째 방법은 Git Hooks를 더욱 쉽게 관리하고, 팀원들과 공유할 수 있는 도구인 Husky를 사용하는 것입니다. Husky는 Git Hooks를 프로젝트에 버전 관리할 수 있게 해주고, 팀원들이 별도의 설정 없이 동일한 훅을 사용할 수 있도록 도와줍니다.

husky를 설치하고, 초기화해주겠습니다.

npm install -D husky
npx husky init
git config core.hooksPath

husky 명령어를 사용하면, 자동으로 Git Hooks의 타겟 디렉토리가 husky의 설정에 따라 설정되는데요, 그래서 위의 결과로 Git Hooks의 타겟 디렉토리가 .husky/_로 변경된 것을 확인할 수 있습니다.

husky에서는 다양한 Hook들을 기본 제공합니다. 이 안에는 아마 husky의 기능 중에서 가장 유명한 commit 전에 prettier를 자동으로 실행시켜주는 pre-commit Hook도 포함되어 있어요. 위에는 우리가 사용하려는 prepare-commit-msg에 대한 husky의 기본 설정입니다. 이제 이 스크립트를 우리가 원하는 내용으로 수정해줍시다.

rm .husky/_/prepare-commit-msg
# husky에서 기본제공하는 스크립트를 지웁니다.
mv .git/hooks/prepare-commit-msg .husky/_/prepare-commit-msg
# 우리가 준비한 스크립트를 이동해줍니다.

이렇게 하면 팀원들이 프로젝트를 클론할 때 자동으로 이 훅이 설정되고, 모든 팀원이 동일한 규칙을 사용할 수 있습니다. husky는 npm의 의존성 목록에도 포함되면서, husky의 설정 파일이 원격 저장소에 공유되기 때문에, npm install과 같은 의존성 설치 스크립트만으로도 모든 팀원들이 자동 설정이 가능하니 훨씬 더 편리합니다. 아무래도 프로젝트를 클론하면 가장 먼저 하는 게 npm install이다보니까, 앞선 방식에 비해서 스크립트를 추가하는 과정이 누락될 확률이 적겠지요.

마치며

⛓ Git Hooks는 버전 관리에서 일관성 유지를 강제시켜주는 강력한 도구로 사용될 수 있을 것 같습니다. 저와 같은 경우 모노레포 환경에서 여러 패키지를 관리할 때 브랜치 명명 규칙과 맞물려서 꽤 유용하게 활용할 수 있을 것도 같아요. 이번 포스트를 통해 더 자세하게 이와 관련해서 공부할 수 있었고, 이후에 추가적인 스크립트 적용에 많은 도움이 될 것 같습니다.

이번에 제가 준비한 헤딩 기록은 이정도입니다. 워낙에 유명하면서 어렵지 않은 내용이라, 많은 분들께서 이미 알고 적용하고 계실 것 같지만, 저 같은 초보 엔지니어들에게 도움이 되지 않을까 해서 최대한 자세하게 작성해봤어요.

turborepo를 통한 모노레포 자체에 대한 구축부터, 기존 프로젝트 마이그레이션이나 pnpm 패키지 설정을 하던 도중 있었던 트러블 슈팅, 개별 모놀리스 레포지토리의 CI/CD 파이프라인을 최대한 건드리지 않으면서 모노레포로 이전하기 위한 파이프라인 트리거링 설정, 모노레포의 브랜치 관리 전략과 커밋 관리 전략 등. 모노레포를 구축하면서 아주 다양한 경험을 할 수 있었는데요, 이걸 시리즈로 천천히 풀어볼까 합니다.

profile
DIVIDE AND CONQUER

0개의 댓글

관련 채용 정보