Unity 프로젝트를 위한 Git Hooks 활용

이규호

안녕하세요. 저는 쿠키런 킹덤에서 게임 클라이언트를 개발하고 있는 이규호입니다. 게임 클라이언트란 플레이어 분들의 기기에 설치돼서 게임 서버와 통신하며 게임 화면을 표시하고 플레이어 분들의 입력에 맞게 게임 로직을 실행하는 소프트웨어입니다.

일반적으로 게임 클라이언트 개발자의 주된 업무는 게임 엔진 등을 활용하여 실제 게임에 들어가는 컨텐츠 및 기능을 구현하는 것입니다. 하지만 게임 개발 조직 안에서 발생하는 다양한 비효율을 해소하고 게임 개발 과정이 빠르고 문제 없이 진행될 수 있도록 여러 가지 노력을 기울이는 것도 클라이언트 개발자의 중요한 임무 중 하나일 것입니다. 이 글에서는 그러한 노력의 일환으로 Unity 엔진과 Git을 사용하는 프로젝트에서 Git Hooks를 활용한 경험을 공유하고자 합니다.

이 글의 작성 시점에서 Git의 최신 버전은 2.37.1이며 Unity는 2022.1.8까지 릴리즈되었습니다. 추후 각 소프트웨어 및 개발 환경이 업데이트됨에 따라 이 글에 언급된 내용 중 일부가 더이상 유효하지 않게 되거나 더 나은 방법이 생길 수 있습니다.

서론

진짜_진짜_최종 같은 접미사가 붙는 파일이 담긴 USB 메모리를 손에서 손으로 주고받으며 버전 관리를 한다는 괴담이 실없는 농담이 된 시대입니다. 오늘날 대부분의 소프트웨어 개발 조직은 한 개 이상의 버전 관리 시스템을 사용하고 있을 것입니다. 그 중에서도 가장 대중적인 것은 리누스 토르발스가 Linux 커널 프로젝트를 관리하기 위해 만든 Git입니다. 2005년에 처음으로 공개된 이래로 Git은 꾸준히 발전해 왔으며 Git을 활용하는 전략도 점점 진보해 왔습니다.

GitFlow를 활용한 원활한 버전 관리

대표적인 Git 활용 전략으로는 GitFlow가 있습니다. 일반적인 서비스 백엔드 프로젝트에서 GitFlow를 따르는 개발 과정은 다음과 같을 것입니다.

  • 새 피쳐 구현을 위해 새 브랜치를 생성합니다. 이 피쳐 브랜치에서는 상대적으로 소수의 소프트웨어 엔지니어가 작업합니다.
  • 구현이 끝나면 풀 리퀘스트(혹은 머지 리퀘스트)를 생성하고 CI와 다른 동료들의 코드 리뷰를 통해 작업 내용을 꼼꼼히 점검한 후 이를 메인 브랜치에 머지합니다.

이 경우 브랜치 히스토리는 마치 각자의 레인에서 안정적인 크롤 영법을 구사하는 수영 숙련자들처럼 깔끔한 평행선을 그립니다. 여러 개의 큰 피쳐를 연이어 머지하는 경우가 아니면 각 커밋은 막힘 없이 나아가고 문제가 생기더라도 수영하는 사람들이 다들 수영을 잘 하기 때문에 크게 고생하는 경우는 적습니다.

게임 클라이언트 프로젝트의 개발 양상

그러나 게임 클라이언트 프로젝트의 개발 과정은 이와 다른 경우가 많습니다. 필요에 따라 여러 개의 마이크로 서비스로 분리하는 것을 지향하는 현대적 서비스 백엔드 아키텍쳐와 달리 게임 클라이언트는 기본적으로 모놀리식한 구조이며 상대적으로 더 많은 인원이 하나의 저장소에 커밋을 밀어넣습니다. 인원의 구성도 소프트웨어 엔지니어에 한정되지 않고 게임 디자이너, 아티스트 등 다양한 직군의 동료들이 개발에 참여합니다. 따라서 GitFlow를 따를 경우 다음과 같은 문제가 발생합니다.

  • 게임 내 각 피쳐 및 각 직군 간의 작업물이 상호 의존적인 경우가 많습니다. 따라서 피쳐 브랜치를 잘게 분리하기 어려우며 한 브랜치에 다양한 팀원이 커밋하게 됩니다.
  • 구현된 사항을 즉각 시험해보며 최대한 짧은 주기의 피드백 루프를 여러 번 거쳐 재미를 검증해야 합니다. 메인 브랜치에 가급적 여러 기능이 빠르게 반영되어 중간 구현 결과를 시험해 볼 수 있는 편이 유리합니다.

이에 보다 적합한 Git 활용 전략은 모두가 메인 브랜치에 커밋을 밀어넣는 트렁크 기반 개발일 것입니다. 그래서 개발 과정은 레인이 나뉜 실내 수영장보다는 워터파크의 파도 풀장에 가까운 양상을 띱니다. 수많은 사람이 한 풀장에 들어가며 레인 같은 것은 나뉘지 않으니 수영을 하던 사람들끼리 부딪히는 일도 잦습니다.

사람들이 모두 수영에 능숙한 것도 아닙니다. Git은 기본적으로 커맨드라인 인터페이스로 작동하는 도구이며 파일 시스템이나 트리 구조, 참조 등의 멘탈 모델에 근거하다 보니 아무래도 소프트웨어 엔지니어 이외 직군의 동료들은 Git에 덜 익숙한 편입니다. 그래서 혼잡한 파도 풀장에서 문제가 발생할 경우 수영에 보다 능숙한 소프트웨어 엔지니어의 도움이 필요할 때가 많습니다.

더군다나 파도 풀장에는 수영복을 입은 사람들 이외에도 튜브, 스노클, 구명조끼, 풀장 옆 매점에서 파는 츄러스 등 정말 다양한 것들이 들어옵니다. 코드 이외에도 아트워크, 사운드 리소스 등 다양한 에셋이 프로젝트 저장소에 올라오며 이러한 파일이 코드보다 더 큰 비중을 차지합니다. 그리고 다양한 종류의 물건은 다양한 문제를 일으킵니다.

파도 풀장의 파수꾼

이런 피서철의 혼잡한 파도풀에는 사고를 예방할 안전 요원이 필요합니다. 일반적으로 CI(Continuous Integration, 지속적 통합)가 그러한 역할을 맡지만 게임 클라이언트 프로젝트의 경우에는 CI만으로는 충분하지 않습니다. 이전에 올린 커밋의 CI가 채 돌기도 전에 새 커밋이 끊임없이 올라오기 때문에 중간에 문제가 있는 커밋이 생길 경우 CI는 명절 고속도로 IC에 늘어선 차량들의 브레이크등처럼 기나긴 빨간 불의 행렬을 띄웁니다. 이 경우 어느 커밋에서부터 어떤 문제가 생겼는지 추적하기가 번거롭습니다.

CI가 띄우는 경고를 읽고 대처하는 것은 대개 소프트웨어 엔지니어의 역할입니다. 프로젝트 저장소에 문제가 생겨서 교통 체증이 발생하면 다른 동료들의 작업이 모두 지장을 받기 때문에 하던 작업을 내려놓고 높은 우선순위로 대응해야 하며, 때문에 문제가 빈번하게 발생할수록 주 업무에 할애할 집중력과 시간이 고갈됩니다. 따라서 소프트웨어 엔지니어 스스로의 생산성 향상을 위해서라도 CI보다 즉각적으로 예방 효과를 발휘할 수 있는 수단이 필요합니다. 그 수단 중 하나로 Git Hooks를 소개하고자 합니다. 물론 Git Hooks는 보편적인 기능이다 보니 소개라고 하기에는 다소 새삼스럽습니다. 이 글이 뻔한 내용이 되지 않도록 쿠키런 킹덤에서 사용하는 Unity 개발 환경에서 Git Hooks를 활용해 온 주관적이고 덜 보편적인 경험과 의견을 공유하겠습니다.

Git Hooks란

Git Hooks(이하 “Hook”으로 짧게 적겠습니다)는 Git에서 특정 이벤트가 발생할 때 호출되는 스크립트이며 기본적으로 저장소 루트 디렉토리의 .git/hooks 경로 안에 위치합니다.

프로젝트에서 발생할 문제를 예방하기 위해 가장 많이 사용하는 이벤트는 pre-commit 입니다. 이는 커밋 시 가장 먼저 발생하는 이벤트로 커밋 메시지를 작성하기 전 단계에 발생합니다. (물론 git commit -m 옵션이나 GUI 클라이언트로 커밋 메시지를 함께 전달하는 경우 이 이벤트가 발생한 후 따로 커밋 메시지 작성을 요구하지 않습니다.) 0 이외의 Exit Code를 리턴하면 커밋이 취소되기 때문에 커밋을 검사 후 문제가 생길 경우 Exit Code를 통해 커밋을 취소하는 방식으로 문제 예방에 활용할 수 있습니다.

Hook은 대개 셸 스크립트로 작성되지만 다음의 사례처럼 생산성과 유지보수, 혹은 기능적, 성능적 필요에 의해 다른 프로그래밍 언어로 HookHook 작성하는 경우도 많습니다.

  • Git LFS(Git Large File Storage): Git은 기본적으로 소스 코드와 같은 평문(Plain Text) 파일의 버전 관리에 용이한 시스템입니다. Git LFS는 이미지나 오디오처럼 용량이 큰 바이너리 등의 파일을 별도의 원격 저장소에 저장해 효율적으로 관리하기 위한 도구로, 일반 Git 저장소에는 해당 파일의 레퍼런스를 저장하고 post-commit, post-checkout 등의 이벤트에서 Hook을 실행해 해당 레퍼런스와 원격 저장소의 파일을 동기화하는 방식으로 동작합니다. Go로 작성되었습니다.
  • Gitmoji CLI: Gitmoji는 커밋 메시지 앞에 작업의 성격을 나타내는 이모지를 달아 커밋 의도를 더 쉽게 파악할 수 있게 하려는 운동입니다. Gitmoji CLI는 prepare-commit-msg 이벤트에서 작동해 커밋 앞에 편리하게 이모지를 달 수 있게 해 주는 CLI 도구이며 Node.js로 작성되었습니다.

Git Hooks는 터미널에서 Git 명령을 실행하는 경우는 물론 Fork, Sourcetree 등의 GUI Git 클라이언트에서도 문제 없이 작동합니다. 다만 Hook에서 출력하는 stdout 등의 스트림을 터미널에서는 바로 확인할 수 있으나 GUI 클라이언트에서는 0 이외의 Exit Code를 리턴하는 경우에만 로그가 출력된 오류 팝업이 열리기 때문에 그렇지 않은 경우에는 일부러 조회하지 않는 한 확인이 불가능합니다.

자주 발생하는 문제와 Hook 활용 예시

게임 클라이언트 프로젝트 저장소에서 자주 발생하는 문제와 이를 예방하기 위해 Hook을 활용하는 예시를 몇 가지 소개하겠습니다.

파일이나 디렉토리 이름에 잘못된 문자나 문자열이 포함된 경우

각 운영체제마다 파일 시스템에서 사용할 수 없는 문자나 문자열이 있습니다. 물론 IT 업종에 종사하는 프로들인 만큼 파일이나 디렉토리 명칭에는 가급적 문제 소지가 없는 알파벳 대소문자 및 숫자와 _(언더스코어)만으로 파일 이름을 짓는 분들이 대부분이나 간혹 실수로 인해 파일을 새로 만들거나 이름을 변경하면서 특정 OS에서 사용할 수 없는 문자나 문자열이 포함된 상태로 커밋을 올리는 경우가 있습니다.

대표적인 사례는 Windows 외의 운영체제에서 엔터 키를 누르려다가 가까이 있는 \(백슬래시)를 같이 누르는 경우입니다. MacOS, Linux에서는 파일이나 디렉토리 이름에 \를 사용할 수 있으나 Windows에서는 C:\Documents\CookieRun 등과 같이 경로 구분자로 \를 사용하기 때문에 이를 사용할 수 없습니다. 따라서 이러한 파일명이 포함된 커밋을 Windows 외의 컴퓨터에서 올린 후 Windows 컴퓨터에서 이를 체크아웃할 경우 오류가 발생합니다.

물론 특정 조건을 만족하는 문자열만 허용하는 방식이나 특정 조건을 만족하지 않는 문자열을 전부 허용하지 않는 방식으로 문제가 되는 문자 및 문자열을 모두 걸러내는 것이 최선이겠지만 간단하게 \만 걸러내는 pre-commit Hook을 예시로 작성하겠습니다.

먼저 .git/hooks/pre-commit 파일을 생성 후 텍스트 에디터로 다음과 같은 스크립트를 작성합니다.

#!/bin/sh

# 커밋으로 생성되는 파일을 나열하기 위해 현재 Index와 비교할 HEAD를 지정합니다
if git rev-parse --verify HEAD >/dev/null
then
	against=HEAD
else
	# 새로 생성해서 커밋이 없는 저장소의 경우 HEAD가 없기 때문에 빈 Tree와 비교합니다
	against=`git hash-object -t tree /dev/null`
fi

# Index에서 ADD된 파일의 이름만 순회합니다. RENAME도 ADD에 포함됩니다
git diff --cached --name-only --diff-filter=A -z $against | while IFS= read -r -d '' filename; do
	if [[ $filename =~ "\\" ]]; then
		# 사용자가 수정할 수 있도록 문제가 무엇인지 제대로 안내해야 합니다
		>&2 echo "새로 생성된 파일 \"${filename}\" 의 이름 혹은 경로에 잘못된 문자 '\'이 포함됐습니다."
		# 0이 아닌 Exit Code를 리턴해 커밋을 중단합니다
		exit 2
	fi
done

첫 줄의 Shebang이 누락되면 Hook이 실행되지 않음에 주의해야 합니다.

생성한 파일의 실행 권한을 부여하기 위해 다음 명령을 실행합니다. 이후 이름이나 경로에 \를 포함한 커밋을 시도할 경우 오류 메시지와 함께 도중에 취소됩니다.

chmod +x .git/hooks/pre-commit

.meta 파일의 짝이 맞는지 검사하기

Unity 엔진은 각 에셋, 스크립트, 디렉토리 등을 프로젝트에서 사용하기 위한 추가적인 메타데이터를 정의하기 위해 Assets 디렉토리 내부의 각 파일 및 디렉토리마다 해당 대상의 이름 뒤에 .meta 가 붙는 별도의 메타데이터 파일을 생성합니다.

그러나 종종 이 .meta 파일과 원 대상이 쌍을 이루지 않는 경우가 발생합니다. 원인이 되는 대표적인 시나리오는 다음과 같습니다.

  • 동료가 다른 툴로 작업한 파일을 저장소에 새로 가져오면서 운영체제의 파일 탐색기에서만 파일을 옮겨 두고 Unity를 실행하지 않은 채 모든 변경사항을 스테이징한 후 커밋했습니다. 이로 인해 저장소에 파일은 추가되었으나 쌍을 이루는 .meta 파일이 생성되지 않았습니다. 혹은 Unity를 거치지 않고 원본 파일만 삭제하거나 이름을 변경하는 등의 조작을 통해서도 유사한 문제가 발생합니다.
  • 동료가 새 디렉토리를 생성했으나 안에 다른 파일을 추가하지 않았습니다. Unity는 해당 디렉토리와 쌍을 이루는 .meta 파일을 자동으로 생성했고 모든 변경사항을 스테이징한 후 커밋했습니다. Git은 디렉토리가 아니라 파일을 추적하기 때문에 빈 디렉토리는 Git으로 추적되지 않았고 이후 다른 동료가 자신의 기기에서 해당 커밋을 체크아웃했으나 추적되지 않은 빈 디렉토리는 동료의 로컬 저장소에 생성되지 않았습니다. 따라서 .meta 파일만 생성되었습니다.

이러한 불일치는 다음과 같은 번거로운 문제를 야기합니다.

  • .meta 파일만 있고 쌍을 이뤄야 할 원 대상이 존재하지 않는 경우 Unity는 자동으로 .meta 파일을 삭제합니다. 이 경우 다른 동료는 자신이 작업하지 않은 변경사항이 생기는 것을 보고 의아해하며 매번 이 변경사항을 폐기(Discard)하거나 스태시에 밀어넣은 후 풀을 받습니다. 이 번거로운 일은 누군가가 해당 .meta 파일을 삭제하는 커밋을 올릴 때까지 되풀이됩니다. 안타깝게도 그렇게 일단락되더라도 빈 디렉토리로 인해 생성된 .meta 파일인 경우 해당 .meta 빈 디렉토리를 로컬에 가지고 있는 동료가 .meta 파일을 계속해서 커밋하는 경우 누군가가 정확한 원인을 파악하고 해결하기 전까지는 영원한 핑퐁이 계속됩니다.
  • 위의 만성적인 사례와 달리 원 대상은 존재하나 쌍을 이뤄야 할 .meta 파일이 존재하지 않는 경우 좀 더 즉각적인 문제가 발생합니다. 이 경우 Unity는 자동으로 .meta 파일을 생성하며, 이 .meta 파일을 여러 동료가 커밋하는 경우 .meta 파일에 기입된 guid가 각 동료의 로컬 저장소에서 일치하지 않기 때문에 처음으로 푸시한 동료를 제외한 다른 동료는 충돌(Conflict)을 겪게 됩니다. 충돌은 특히 소프트웨어 엔지니어 이외의 직군에게는 당혹스러운 경우가 많으며 “임의로 생성된 guid니 어느 쪽을 채택해서 머지하든 문제가 되지 않을 것”이라고 가볍게 넘기다가 가끔 더 번거로운 문제가 생기기도 합니다. 채택되지 않은 guid를 사용한 쪽에서 해당 에셋을 참조하는 다른 에셋이나 프리팹을 생성한 경우 guid에 맞는 대상을 찾지 못해 참조가 소실되는 문제가 발생합니다.

이러한 .meta 파일 불일치를 검사하기 위한 오픈소스 Hook이 있으며 대부분의 상황에서 잘 작동합니다. 쿠키런 킹덤 프로젝트에서는 해당 Hook이 검출하지 못하거나(빈 폴더는 아니지만 빈 폴더와 빈 폴더의 .meta만을 포함하는 “재귀적으로 사실상 빈 폴더" 등) 오검출하는(이름이 ~ 로 끝나는 디렉토리 및 하위 항목은 Unity에서 무시하며 쌍을 이루는 .meta 파일도 생성하지 않아 검사해서는 안되는 등) 상황을 정확하게 처리하고 검사 속도 최적화와 좀 더 쉬운 문제 해결을 위해 빠르고 안전하며 현대적인 프로그래밍 언어인 Rust로 Hook을 만들어 사용하고 있습니다. 이에 관해서는 다른 기회에 소개하겠습니다.

이외에도 컴파일러나 린터를 실행해 코드의 문제를 검사하거나 에셋 간의 잘못된 의존관계(프로덕션에 나갈 에셋이 테스트용 에셋에 의존하는 등)를 검사, 혹은 이러한 pre-commit 단계에서의 검사 외에도 post-checkout이나 post-merge 단계에서 정리 작업 또는 디펜던시 관련 처리를 하는 등 다양한 문제를 예방하거나 편의성 개선을 위해 Hook을 활용할 수 있습니다.

설치 및 버전 관리

기본적으로 Hook 파일은 해당 로컬 저장소에서만 유효하며 공유 및 버전관리가 불가능합니다. 이는 Hook 파일의 디폴트 경로인 .git/hooks 디렉토리의 상위 디렉토리인 .git 디렉토리를 Git이 추적하지 않기 때문입니다. 이로 인해 Hook을 작성하더라도 팀 내에서 모두가 설치해서 사용하도록 공유하며 변경이 있을 때마다 이를 업데이트하는 것이 다소 번거로운 경우가 많습니다. 이러한 설치 및 버전 관리에 관한 고민 및 나름대로의 해법을 소개하겠습니다.

팀 동료들과 Hook을 공유하기 위해서는 먼저 Hook 디렉토리를 Git이 추적하는 경로로 옮길 필요가 있습니다. 이를 위해 프로젝트 저장소 안에 다음과 같이 .githooks 디렉토리를 새로 생성합니다.

repository-root
│
├── .git/
│   ├── config
│   │   ...
│
├── .githooks/
│   ├── pre-commit
│   ├── post-checkout
│				...
│
├── .gitconfig

.githooks 디렉토리 아래에는 각 Hook 파일을 작성합니다. 주의할 점은, Git LFS와 같이 팀 내에서 공통적으로 설치해서 사용하는 필수적인 Hook이 있다면 해당 Hook을 실행하는 코드도 같이 가져와야 그 Hook을 다시 설치하는 번거로운 일이 일어나지 않습니다. 예를 들어 Git LFS를 저장소에 설치 시 post-checkout Hook에는 다음과 같은 두 라인이 추가됩니다.

# .git/hooks/post-checkout
...
command -v git-lfs >/dev/null 2>&1 || { echo >&2 "...생략..."; exit 2; }
git lfs post-checkout "$@"

새로 작성한 Hook의 경로는 Git에 의해 추적되기 때문에 공유와 버전관리가 가능합니다. 다음 명령을 실행해 각 파일을 실행 권한과 함께 저장소에 추가한 후 커밋합니다.

git add --chmod=+x .githooks/*

그러나 이 상태로는 새로 작성한 Hook이 실행되지 않습니다. 여전히 Git은 .git/hooks 디렉토리에서 Hook 파일을 찾으려 하기 때문입니다. 다음과 같이 저장소 루트 디렉토리에 .gitconfig 파일을 생성해 Hook 경로 설정을 동료 간에 공유할 수 있도록 합니다.

# .gitconfig
[core]
	hooksPath = .githooks

.gitconfig 파일에는 이외에도 다양한 옵션을 추가해 동료 간에 일관적인 설정을 유지할 수 있습니다. 예를 들어 MacOS에서는 core.ignoreCase 가 기본적으로 false로 설정돼 있기 때문에 파일명의 대소문자를 바꾸더라도 변경 내역을 추적하지 못하는 경우가 많습니다. 이를 true로 설정하면 이러한 문제를 피할 수 있습니다.

하지만 여전히 새로 작성한 Hook은 실행되지 않습니다. Git의 로컬 설정은 기본적으로 .git/config 파일에 저장되며 임의로 작성한 .gitconfig 파일은 Git에게 특별할 것이 없는 그냥 평범한 파일일 뿐입니다. 이 파일에서 설정을 읽게 하기 위해서는 다음과 같은 명령을 통해 로컬 설정에 .gitconfig 파일을 등록해야 합니다.

git config --local include.path ../.gitconfig

이제 Hook은 정상적으로 실행됩니다. 그러나 문제는 아직도 끝나지 않았습니다. 각 동료의 로컬 저장소에서 위 명령을 실행해야 하기 때문입니다. 위 명령을 실행하도록 안내하는 것도 방법이지만 이는 생각보다 쉽지 않습니다. 동료의 숫자가 많지 않으면 개별 안내 및 점검이 가능하겠지만 동료의 숫자가 늘어날수록, 재택근무를 하는 인원이 많을수록 안내 및 점검에 필요한 비용이 증가합니다. 또한 동료가 컴퓨터를 교체하거나 저장소를 새로 클론받으면 매번 같은 명령을 실행해야 하며 문서화를 통해 안내하더라도 이는 누락되기 쉽습니다.

문제를 해결하려면 가끔 정석적이지 않은 방법이 필요한 때도 있습니다.
문제를 해결하려면 가끔 정석적이지 않은 방법이 필요한 때도 있습니다.

이 문제를 해결하기 위해 약간의 편법을 사용합니다. UnityEditor 네임스페이스에는 InitializeOnLoadMethod Attribute가 있는데, 이 Attribute를 가진 메서드는 Unity 에디터가 해당 메서드를 어셈블리에서 불러올 때마다 호출됩니다. 따라서 그러한 메서드가 .gitconfig 파일을 Git 설정에 등록하는 명령을 실행하도록 하면 Unity로 프로젝트를 1회 이상 실행한 동료의 저장소에서 Hook 및 기타 설정이 모두 적용되도록 할 수 있습니다. Unity 프로젝트의 Assets/Editor 디렉토리 하위 경로에 다음과 같은 파일을 작성합니다.

using System.Diagnostics;

public class InstallGitHooks {
    [UnityEditor.InitializeOnLoadMethod]
    private static void Install() {
        Process.Start("git", "config --local include.path ../.gitconfig");
    }
}

위 스크립트의 실행에 걸리는 소요 시간은 Unity 에디터가 로드되는 시간에 비해 지극히 짧으며 여러 번 실행되더라도 문제가 없는, 일종의 멱등적(Idempotent) 명령이기 때문에 어떠한 불편도 체감되지 않습니다. 이제 모든 문제가 해결되었습니다.

Hook에 관해 주의할 점

Hook을 사용하며 느낀 주관적인 주의점을 몇 가지 적어 보았습니다.

잘못된 Hook으로 작업이 중단되지 않도록 유의

워터파크의 안전 요원이 잘못된 안전 수칙을 적용해 별 문제가 없는 상황에서도 조치를 가한다면 오히려 물놀이 경험이 훼손됩니다. 특히 pre-commit Hook이 제대로 작동하지 않아 오류가 발생할 경우 모든 팀원의 커밋이 멈추게 되며 이는 Hook을 도입한 취지와 정면으로 위배되는 최악의 상황입니다. 충분한 테스트 코드 및 수동 검증을 통해 Hook이 팀 내에서 사용하는 모든 환경에서 제대로 작동하는 것을 확인한 후 메인 브랜치에 적용해야 합니다.

드물게 휴리스틱을 사용해야 하는 경우, 오류 검출에 있어서는 거짓 음성보다는 거짓 양성을 줄이는 것이 바람직합니다. Hook이 거짓 음성을 잡지 못하더라도 여전히 CI와 소프트웨어 엔지니어의 관찰력이라는 종전의 수단으로 문제를 해결할 수 있습니다.

또한 Hook에서 버그 발생 시 대응할 수 있는 소프트웨어 엔지니어가 자리를 비운 상황이나 예외적으로 Hook을 우회해야 하는 상황에서 빠른 대처가 가능하도록 Hook을 건너뛸 수 있는 수단을 안내해야 합니다. Git의 --no-verify 옵션이나 GUI Git 클라이언트의 Hook을 건너뛰는 메뉴 등의 사용법, 혹은 관련 문서 링크를 에러 메시지와 함께 안내하면 해결법을 애타게 찾는 상황을 피할 수 있습니다.

사용자의 환경에 주는 영향 최소화

위에서 Unity의 InitializeOnLoadMethod Attribute를 통해 아무도 몰래 Hook을 로컬 저장소에 설치하는 편법을 소개했습니다. 이러한 방법을 따른다면 해당 저장소를 사용하는 사람들은 Git 작업 시마다 Hook이 작동하는 것을 알기 어려울 것입니다. 모르는 상태에서 의도하지 않은 변경이 일어나지 않도록 Hook의 기능 및 취지에 대해 미리 공유하고 Hook이 작동할 때 로컬 저장소 밖의 파일 시스템, Git 전역 설정 등을 건드리지 않도록 영향 범위를 로컬 저장소 안쪽으로 한정해야 합니다.

실수를 제재하는 것이 아니라 보완하는 방향으로

안전 요원은 범죄자를 잡는 형사가 아니라 안타까운 사고로 즐거운 경험이 훼손되지 않도록 돕는 사람입니다. 문제가 있는 커밋을 무작정 가로막기보다는 상세하고 이해하기 쉬운 오류 시각화를 통해 빠르게 문제를 해결하고 작업을 이어갈 수 있도록 하는 것이 바람직합니다.

성능도 중요

특히 프로젝트 내 파일 시스템을 순회해야 하는 작업은 프로젝트 전체 파일 및 디렉토리 개수가 늘어날수록 더 많은 시간이 소요됩니다. Git에서 발생하는 이벤트마다 긴 시간이 걸리면 이는 팀의 생산성 저하로 이어집니다. Hook 작동에 걸리는 시간이 유의미하게 늘어나기 시작하면 최적화를 고민해야 합니다.

복잡한 Hook은 다른 프로그래밍 언어로 작성

셸 스크립트는 Unix 계통의 다양한 CLI 도구를 활용해 간단하고 효율적인 코드를 작성하기에는 용이합니다. 그러나 코드 라인 수가 늘어날수록 코드 흐름을 파악하기 어려우며 다양한 기능을 구현하기에도 적합하지 않으므로 복잡한 Hook을 작성할 경우 상황에 맞는 프로그래밍 언어를 사용하는 것이 좋습니다.

Python, C#, Java 등 다양한 언어를 사용할 수 있겠지만 제 주관적인 선호는 빠르고 안전하며 현대적인 시스템 프로그래밍 언어인 Rust이며 이유는 다음과 같습니다.

  • 실행에 별도의 런타임을 필요로 하는 언어는 해당 런타임을 같이 설치해야 하기 때문에 개발 환경을 갖추는 과정이 복잡해집니다. 물론 런타임을 같이 번들링하는 방법도 있으나 용량이 커지고 번잡합니다. Shell처럼 거의 모든 환경에서 실행 가능한 경우가 아니라면(Windows의 경우에도 보편적인 Git 클라이언트가 mingw 위의 Git Bash를 기반으로 하므로 Hook 내에서 대부분의 기본적인 Shell 명령을 수행할 수 있습니다.) 바이너리 실행 파일을 컴파일할 수 있는 언어가 편리합니다.
  • C나 C++ 등 전통적인 시스템 프로그래밍 언어가 갖추지 못한 현대적인 패키지 매니저와 손쉬운 빌드 및 크로스 컴파일을 제공합니다.
  • 강력한 타입 시스템 및 값에 기반한 에러 핸들링 등을 통해 런타임에 발생할 수 있는 버그를 상당 부분 컴파일 타임에 예방하기에 좋은 언어입니다.
  • 기본적으로 성능이 좋고 동시성 프로그래밍에 용이해 최적화에 좋습니다.
  • 코어 모듈 및 상당수의 라이브러리가 포터블하기 때문에 OS별로 큰 추가 작업 없이 쉽게 크로스 컴파일이 가능합니다. Go도 이를 제외한 위의 장점을 다수 공유하지만 Windows 환경에서 다소 까다로운 점들이 있습니다.

Rust를 활용한 바이너리 Hook 구현 예시

마지막으로 원격 저장소로부터 풀을 받았을 때 로컬에 설치된 Unity 버전이 풀을 받은 프로젝트 버전보다 낮으면 Unity Hub에서 다운로드 팝업을 띄우는 간단한 Hook을 Rust로 작성해 보겠습니다.

프로젝트에서 사용하는 Unity 버전을 업데이트할 때마다 동료들에게 현재 컴퓨터에 새 Unity 버전 설치 안내를 해야 할 필요가 있습니다. 문제는 다른 바쁜 업무나 휴가 등으로 버전 업데이트 소식을 보지 못한 동료는 버전이 업데이트됐다는 것을 놓치기 쉬우며 나중에 업데이트 안내문을 찾으려면 메일함이나 메신저를 뒤져야 해 번거롭습니다.

이 Hook의 목적은 Unity 버전업이 있을 때마다 자동으로 다운로드 팝업을 띄워 이러한 불편을 해소하고자 하는 것이며 복잡한 Hook은 아니지만 프로젝트에 바이너리 Hook을 적용하는 예제로 적합하다는 생각에 간단히 작성해 보았습니다.

//! src/main.rs

use anyhow::Context;
use regex::Regex;
use std::fs;
use std::path::{Path, PathBuf};

#[derive(PartialOrd, PartialEq)]
struct UnityVersion {
    major: u32,
    minor: u32,
    patch: u32,
    final_ver: Option<u32>,
    full: String,
    revision: String,
}

fn extract_version<P: AsRef<Path>>(path: P) -> anyhow::Result<UnityVersion> {
    let text = fs::read_to_string(path.as_ref())?;
    let re = Regex::new(
        r"m_EditorVersion(WithRevision)*:[ ]*((\d+)\.(\d+)\.(\d+)(f(\d+))?)[ ]*\(([0-9a-f]+)\)",
    )
    .unwrap();
    let caps = re.captures(&text).context("Version not exists")?;
    Ok(UnityVersion {
        major: caps[3].parse().unwrap(),
        minor: caps[4].parse().unwrap(),
        patch: caps[5].parse().unwrap(),
        final_ver: caps.get(7).map(|m| m.as_str().parse().unwrap()),
        full: caps[2].to_string(),
        revision: caps[8].to_string(),
    })
}

fn main() -> anyhow::Result<()> {
    let unity_root = PathBuf::from(std::env::var("UNITY_ROOT")?);
    let remote_ver =
        extract_version(unity_root.join(Path::new("ProjectSettings/ProjectVersion.txt")))?;
    let last_local_ver =
        extract_version(unity_root.join(Path::new("Library/PackageManager/ProjectCache")))?;
    if remote_ver > last_local_ver {
        open::that(format!(
            "unityhub://{}/{}",
            remote_ver.full, remote_ver.revision
        ))?;
    }
    Ok(())
}

위 Hook은 환경 변수 UNITY_ROOT를 Unity 프로젝트의 루트 디렉토리로 읽어 해당 디렉토리에서 프로젝트의 Unity 버전이 기록된 파일(ProjectSettings/ProjectVersion.txt)과 로컬에서 마지막으로 해당 프로젝트를 실행한 버전이 기록된 캐시 파일(Library/PackageManager/ProjectCache)을 파싱해 두 버전명을 기록하고 로컬 버전이 더 낮으면 Unity 허브의 커스텀 URL 스킴을 실행해 Unity 허브에서 해당 버전의 설치 페이지를 엽니다.

.unwrap()을 여러 번 호출하고 있으나 정규식으로 캡쳐된 결과이므로 panic이 일어날 여지는 없습니다. OS에 종속적인 기능을 사용하지 않았으며 파일 경로가 Unix 스타일이긴 하지만 Rust의 Path는 포터블하기 때문에 위 코드는 별다른 추가 작업 없이 크로스 컴파일이 가능합니다.

예시 코드는 Rust 1.62.0에서 컴파일했으며 의존하는 Crate 목록은 다음과 같습니다.

# cargo.toml
...
[dependencies]
regex = "1"
open = "3.0"
anyhow = "1"

이 Hook은 단순해서 굳이 필요하진 않지만 복잡하거나 성능이 중요한 Hook의 경우 lto = true로 링크 타임 최적화를 통해 추가적인 성능 향상을 노릴 수 있습니다.

컴파일 결과로 나온 바이너리 파일을 프로젝트 저장소에 등록 후 다음과 같이 post-merge Hook에서 OS에 맞는 바이너리를 실행하도록 설정합니다. 의도한 대로 원격 저장소에서 Unity 버전을 올릴 경우 풀을 받으면 Unity 허브에서 새 Unity 버전을 다운로드하는 페이지가 열리는 것을 확인할 수 있습니다. (post-pull에 해당하는 Hook은 존재하지 않으나 git pull 명령 실행 시 post-merge Hook이 발동됩니다.)

#!/bin/bash

run_binary_hook()
{
    case "$OSTYPE" in
        linux-gnu*)
            <Linux 바이너리 Hook 경로> ;;
	    darwin*)
            <MacOS 바이너리 Hook 경로> ;;
        # 일반적으로 Windows에서는 Git Bash를 통해 접근하므로 msys 외의 경우는 없을 것입니다.
        win*|msys*|cygwin*)
            <Windows 바이너리 Hook 경로> ;;
        *)
        echo "${OSTYPE}: 지원하지 않는 실행환경입니다." ;;
	esac
}

export UNITY_ROOT=<Unity 프로젝트 루트 디렉토리 경로>
# pre-commit처럼 특정 조건을 검사하는 Hook이 아니기 때문에 에러가 발생하더라도 무시합니다.
run_binary_hook || true

맺음말

이상으로 Unity와 Git을 사용하는 쿠키런 킹덤 프로젝트에서 Git Hooks를 활용해 온 몇 가지 사례와 고민을 소개했습니다. 다소 장황하게 적다 보니 길어서 스크롤을 내리셨을 분들을 위해 본문을 세 줄로 요약하겠습니다.

  • 게임 클라이언트 프로젝트에서 자주 발생하는 골칫거리 중에서는 Git Hooks를 활용해 완화할 수 있는 것들이 있습니다.
  • 여러 인원이 참여하는 프로젝트에서 Git Hooks를 동기화하기 위해 Unity Editor의 InitializeOnLoadMethod Attribute를 활용하는 편법이 있습니다.
  • Rust는 훌륭한 언어입니다.

읽어주셔서 감사합니다. 다음에도 쿠키런 킹덤에서 여러가지 문제를 해결한 경험을 소개하는 글로 찾아뵙겠습니다.

데브시스터즈에서는 다양한 문제들을 적절한 기술로 탁월하게 같이 해결할 분들을 모집하고 있습니다.

데브시스터즈는 최고의 인재를 찾고 있습니다.

데브시스터즈에서는 능력있는 클라이언트 소프트웨어 엔지니어플랫폼 클라이언트 소프트웨어 엔지니어를 찾고 있습니다.
자세한 내용은 채용 사이트를 확인해주세요!
쿠키런 킹덤Unity게임 클라이언트CI/CDDeveloper Experience

© 2022 Devsisters Corp. All Rights Reserved.