Go로 스크립팅하기: 저장소를 생성하고 자기 자신을 GitHub에 푸시할 수 있는 400줄짜리 Git 클라이언트
원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기
몇 년 전 저는 pygit이라는 작은 Python 프로그램을 만들었습니다. 저장소를 만들고, 커밋을 추가한 뒤 자기 자신을 GitHub에 푸시할 수 있을 만큼만 Git 클라이언트 기능을 구현한 프로그램입니다.
Go로는 어떻게 보일지 비교해 보고 싶었습니다. Go로 작은 스크립트를 작성하는 것이 합리적인지 알아보기 위해서였죠. 성능이 크게 중요하지 않고 에러 처리는 스택 트레이스만으로 충분한, 빠르게 대충 만드는 코드 말입니다.
그 결과가 gogit입니다. 저장소를 초기화하고 커밋한 뒤 GitHub에 푸시할 수 있는 400줄짜리 Go 프로그램이죠. 평범한 Go로 작성됐습니다. 단 에러 처리만 빼고요. 관용적인 Go의 에러 처리는 너무 장황해서 스크립팅에는 잘 맞지 않거든요(이에 대해서는 아래에서 더 다룹니다).
기술 요약
여기서 Git이 어떻게 동작하는지 자세히 설명하진 않겠습니다(제 pygit 글에 조금 더 나와 있습니다). Git 데이터 모델이 꽤 근사하다는 정도만 말하면 충분할 것 같습니다. .git/objects에 있는 단순한 파일 기반 오브젝트 저장소를 사용하는데, 각 오브젝트는 40자 해시를 갖고 commit이나 tree(디렉터리 목록) 또는 blob(커밋된 파일)이 될 수 있습니다. 그게 전부입니다. commit, tree, blob을 작성하는 gogit 코드는 약 50줄 정도입니다.
저는 pygit보다도 더 적은 기능만 구현했습니다. init, commit, push뿐이죠. gogit은 인덱스(스테이징 영역)조차 지원하지 않습니다. 그래서 gogit add 대신 커밋할 때마다 커밋하려는 경로 목록과 함께 gogit commit을 실행하면 됩니다. pygit 코드에서 볼 수 있듯이 인덱스를 다루는 건 꽤 번거롭습니다. 게다가 필요하지도 않고, gogit은 최소주의를 연습해 보고 싶었습니다.
gogit은 cat-file, hash-object, diff 명령어도 빼 버렸습니다. GitHub에 커밋하고 푸시하는 데는 필요하지 않기 때문입니다. 다만 디버깅할 때는 Git의 cat-file을 사용하긴 했습니다.
저장소를 만들고 커밋한 뒤 GitHub에 푸시하는 데 사용한 명령어는 다음과 같습니다(go run으로 “스크립트”를 컴파일하고 실행한 점에 주목하세요):
# Initialise the repo
$ go run . init
# Make the first commit (other commits are similar)
$ export GIT_AUTHOR_NAME='Ben Hoyt'
$ export [email protected]
$ go run . commit -m 'Initial commit' gogit.go go.mod LICENSE.txt
commited 0580a17 to master
# Push updates to GitHub
$ export GIT_USERNAME=benhoyt
$ export GIT_PASSWORD=...
$ go run . push https://github.com/benhoyt/gogit
updating remote master from 0000000 to 0580a17 (5 objects)에러 처리
Go의 장황한 에러 처리는 많이 비판받아 왔습니다. 단순하고 명시적이긴 하지만, 실패할 수 있는 함수를 호출할 때마다 에러를 처리하는 데 세 줄의 코드가 추가로 필요합니다:
mode, err := strconv.ParseInt(modeStr, 8, 64)
if err != nil {
return err
}프로덕션 코드를 작성할 때는 그리 큰 문제가 아닙니다. 어차피 에러 처리를 더 세밀하게 제어하고 싶기 때문입니다. 예를 들어 잘 래핑된 에러나 사람이 읽기 쉬운 메시지 같은 것들이죠:
mode, err := strconv.ParseInt(modeStr, 8, 64)
if err != nil {
return fmt.Errorf("mode must be an octal number, not %q", modeStr)
}하지만 간단한 스크립트에서는 필요한 에러 처리라는 게 메시지를 보여주고, 스택 트레이스를 출력한 뒤 프로그램을 종료하는 것뿐입니다. Python에서 예외를 잡지 않았을 때 일어나는 일이 바로 그것이고, Go에서도 몇 개의 헬퍼 함수로 쉽게 흉내 낼 수 있습니다:
func check0(err error) {
if err != nil {
panic(err)
}
}
func check[T any](value T, err error) T {
if err != nil {
panic(err)
}
return value
}
func assert(cond bool, format string, args ...any) {
if !cond {
panic(fmt.Sprintf(format, args...))
}
}이제 Go에 제네릭이 생겼으니 결과를 반환하는 check 함수를 쉽게 정의할 수 있습니다. 하지만 반환값 개수에 따라 변형이 여전히 필요합니다. 보통은 0개 아니면 1개이고, 1개인 경우가 가장 흔하므로 그 변형은 그냥 check라고 이름 붙이고, 결과가 없는 쪽은 check0이라고 했습니다. 또 에러 대신 불리언과 포맷된 메시지를 받는 assert도 정의했습니다.
이 헬퍼들을 이용하면 다음 코드를:
func writeTree(paths []string) ([]byte, error) {
sort.Strings(paths) // tree object needs paths sorted
var buf bytes.Buffer
for _, path := range paths {
st, err := os.Stat(path)
if err != nil {
return nil, err
}
if st.IsDir() {
panic("sub-trees not supported")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
hash, err := hashObject("blob", data)
if err != nil {
return nil, err
}
fmt.Fprintf(&buf, "%o %s\x00%s", st.Mode().Perm()|0o100000, path, hash)
}
return hashObject("tree", buf.Bytes())
}다음과 같이 바꿀 수 있습니다. 함수 본문이 21줄에서 10줄로 줄어들어 Python의 간결함에 견줄 만합니다:
func writeTree(paths []string) []byte {
sort.Strings(paths) // tree object needs paths sorted
var buf bytes.Buffer
for _, path := range paths {
st := check(os.Stat(path))
assert(!st.IsDir(), "sub-trees not supported")
data := check(os.ReadFile(path))
hash := hashObject("blob", data)
fmt.Fprintf(&buf, "%o %s\x00%s", st.Mode().Perm()|0o100000, path, hash)
}
return hashObject("tree", buf.Bytes())
}완벽하진 않습니다. check라는 단어 때문에 호출하는 함수가 약간 가려지긴 하지만, 빠르게 대충 만드는 스크립트를 작성할 때는 훨씬 편해집니다.
단순한 return err보다 “더 나은” 에러를 얻을 수도 있습니다. 스택 트레이스에 정확히 어떤 함수와 코드 줄이 실행 중이었는지 나타나기 때문입니다:
$ go run . push https://github.com/benhoyt/gogit
panic: Get "https://github.com/benhoyt/gogit/info/refs?service=git-receive-pack":
context deadline exceeded (Client.Timeout exceeded while awaiting headers)
goroutine 1 [running]:
main.check[...](...)
/home/ben/h/gogit/gogit.go:94
main.getRemoteHash(0x416ad0?, {0x7ffe1f0152d9?, 0x4b87d4?}, {0xc00001c00d, 0x7}, {0xc00001a00d, 0x28})
/home/ben/h/gogit/gogit.go:245 +0x6da
main.push({0x7ffe1f0152d9, 0x20}, {0xc00001c00d, 0x7}, {0xc00001a00d, 0x28})
/home/ben/h/gogit/gogit.go:217 +0xd9
main.main()
/home/ben/h/gogit/gogit.go:73 +0x21e
exit status 2return err에서 check로 바꾼 뒤 코드 줄 수가 607줄에서 415줄로 32% 줄었습니다.
이 접근 방식을 더 발전시키고 싶다면, Joe Tsai와 Josh Bleecher Snyder가 만든 try라는 라이브러리도 있습니다. recover를 이용해 이를 “제대로” 구현한 것이죠. 흥미로운 시도입니다! 저는 Go 팀이 에러 처리를 덜 장황하게 만들 방법을 찾길 아직도 기대하고 있습니다.
성능
이 섹션은 짧게 끝내겠습니다. 이 프로그램에서는 속도가 중요하지 않고, Go 버전이 Python 버전만큼 빠르거나 더 빠를 가능성이 높기 때문입니다. Go가 훨씬 빠를 수도 있지만, 다루는 파일이 매우 작고 Python에서도 해싱이나 디스크 쓰기 같은 핵심 코드는 어차피 C로 작성되어 있습니다.
메모리 사용량도 성능의 한 측면입니다. 역시 작은 파일을 다루기 때문에 모든 것을 메모리에 읽어 들여도 문제가 되지 않습니다. Python에서도 스트리밍을 할 수 있지만, Go의 훌륭한 io.Reader와 io.Writer 인터페이스 덕분에 Go만큼 일관되게 쉽지는 않습니다.
그렇다고 해도 Go에서도 모든 것을 []byte나 string으로 읽어 들여 처리하는 편이 조금 더 쉽기 때문에, gogit에서는 그렇게 했습니다. 어차피 몇 KB 수준의 메모리 이야기고, 제 머신에는 몇 GB가 있으니까요.
Python 버전과의 비교
현재 pygit은 약 600줄, gogit은 약 400줄입니다. 하지만 Go 버전을 작성하면서 여러 기능을 뺐기 때문에 이 비교는 다소 오해를 불러일으킬 수 있습니다. Git 인덱스에 대한 지원이 없고, cat-file, hash-object, diff도 없습니다.
Python 버전에서 해당 함수들을 제거하고 간단히 테스트해 보니 360줄이 되었습니다. Go의 400줄 대 Python의 360줄이라면 나쁘지 않다고 생각합니다. 불과 10% 더 길 뿐이니까요. 게다가 Go 버전에는 import 20줄과 check/assert 함수 20줄이 포함되어 있습니다. 그러니 실제로는 크기가 거의 같다고 할 수 있습니다!
몇 가지 구체적인 함수를 살펴보겠습니다. 먼저 find_object입니다. Git 오브젝트 저장소에서 주어진 접두사를 가진 오브젝트를 찾는 함수죠. 다음은 Python 버전입니다:
def find_object(sha1_prefix):
obj_dir = os.path.join('.git', 'objects', sha1_prefix[:2])
rest = sha1_prefix[2:]
objects = [name for name in os.listdir(obj_dir) if name.startswith(rest)]
if not objects:
raise ValueError('object {!r} not found'.format(sha1_prefix))
if len(objects) >= 2:
raise ValueError('multiple objects ({}) with prefix {!r}'.format(
len(objects), sha1_prefix))
return os.path.join(obj_dir, objects[0])그리고 다음은 Go 버전입니다:
func findObject(hashPrefix string) string {
objDir := filepath.Join(".git/objects", hashPrefix[:2])
rest := hashPrefix[2:]
entries, _ := os.ReadDir(objDir)
var matches []string
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), rest) {
matches = append(matches, entry.Name())
}
}
assert(len(matches) > 0, "object %q not found", hashPrefix)
assert(len(matches) == 1, "multiple objects with prefix %q", hashPrefix)
return filepath.Join(objDir, matches[0])
}많은 부분이 비슷합니다. 예를 들어 os.path.join과 filepath.Join, os.listdir와 os.ReadDir 등이 그렇죠. 하지만 Python에서 한 줄짜리 리스트 컴프리헨션이 Go에서는 다섯 줄짜리 for 루프가 된다는 점에 주목하세요. Go로 스크립팅할 때 리스트 컴프리헨션이 정말 그립습니다…
다른 하나인 commit 함수도 살펴보겠습니다. 먼저 Python 버전입니다:
def commit(message, author):
tree = write_tree()
parent = get_local_master_hash()
timestamp = int(time.mktime(time.localtime()))
utc_offset = -time.timezone
author_time = '{} {}{:02}{:02}'.format(
timestamp,
'+' if utc_offset > 0 else '-',
abs(utc_offset) // 3600,
(abs(utc_offset) // 60) % 60)
lines = ['tree ' + tree]
if parent:
lines.append('parent ' + parent)
lines.append('author {} {}'.format(author, author_time))
lines.append('committer {} {}'.format(author, author_time))
lines.append('')
lines.append(message)
lines.append('')
data = '\n'.join(lines).encode()
sha1 = hash_object(data, 'commit')
master_path = os.path.join('.git', 'refs', 'heads', 'master')
write_file(master_path, (sha1 + '\n').encode())
return sha1다음은 Go 버전입니다:
func commit(message, author string, paths []string) string {
tree := writeTree(paths)
var buf bytes.Buffer
fmt.Fprintln(&buf, "tree", hex.EncodeToString(tree))
parent := getLocalHash()
if parent != "" {
fmt.Fprintln(&buf, "parent", parent)
}
now := time.Now()
offset := now.Format("-0700")
fmt.Fprintln(&buf, "author", author, now.Unix(), offset)
fmt.Fprintln(&buf, "committer", author, now.Unix(), offset)
fmt.Fprintln(&buf)
fmt.Fprintln(&buf, message)
data := buf.Bytes()
hash := hashObject("commit", data)
check0(os.WriteFile(".git/refs/heads/master", []byte(hex.EncodeToString(hash)+"\n"), 0o664))
return hex.EncodeToString(hash)
}흥미롭게도 이번에는 Python 버전이 더 깁니다. Go의 19줄에 비해 23줄이죠. 이는 대부분 타임스탬프 처리의 차이 때문입니다. Go의 표준 라이브러리가 완벽하진 않지만, time 패키지 하나가 Python의 time과 datetime 패키지를 합친 것보다 낫습니다.
전반적으로 Go의 표준 라이브러리는 Python보다 훨씬 일관되고 잘 설계된 느낌입니다. Python의 표준 라이브러리는 수십 년에 걸쳐 수많은 사람들이 설계한 느낌이 드는데(실제로 그렇습니다).
결론
panic 기반 에러 처리와 함께 사용하면, Go는 빠르게 대충 만드는 커맨드라인 스크립트를 작성하기에 좋습니다.
솔직히 말하면, 일회용 스크립트라면 여전히 Python을 먼저 집어 들 것 같습니다. 더 간결한 문법과 리스트(및 기타) 컴프리헨션, 그리고 기본으로 제공되는 예외 처리 덕분입니다.
하지만 일회용을 넘어서는 스크립트라면 바로 Go로 옮겨 갈 것 같습니다. 표준 라이브러리가 더 잘 설계되어 있고, io.Reader와 io.Writer 인터페이스가 훌륭하며, 가벼운 정적 타이핑이 방해되지 않으면서도 버그를 잡는 데 도움이 되기 때문입니다.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기