안녕하세요. 저는 쿠키런 킹덤에서 게임 클라이언트를 개발하고 있는 이규호입니다. 게임 클라이언트란 플레이어 분들의 기기에 설치돼서 게임 서버와 통신하며 게임 화면을 표시하고 플레이어 분들의 입력에 맞게 게임 로직을 실행하는 소프트웨어입니다.
일반적으로 게임 클라이언트 개발자의 주된 업무는 게임 엔진 등을 활용하여 실제 게임에 들어가는 컨텐츠 및 기능을 구현하는 것입니다. 하지만 게임 개발 조직 안에서 발생하는 다양한 비효율을 해소하고 게임 개발 과정이 빠르고 문제 없이 진행될 수 있도록 여러 가지 노력을 기울이는 것도 클라이언트 개발자의 중요한 임무 중 하나일 것입니다. 이 글에서는 그러한 노력의 일환으로 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
