Go를 좋아하지만, 더 나은 언어가 되기 위해 보완되었으면 하는 점들을 정리했다. 표준 라이브러리의 순서가 보장된 맵, 키워드/기본 인자, null(또는 nil) 가능성, 람다 문법, 사용되지 않은 반환값에 대한 경고/오류 등에 대한 생각을 풀어놓았다.
Go를 좋아한다. Go는 합리적으로 괜찮은 언어이고, 결함을 보완해 주는 좋은 특성들도 있다고 생각한다. 그렇다고 해서 언어가 더 나아질 수 없다는 뜻은 결코 아니다.
이 글은 다른 언어들에서 가져와 Go에 있었으면 하는 것들을 모아 둔 목록이다. 여기 있는 것들 중에는 곧 구현될 수 있는 것도 있고, 언어의 큰 개정이 필요해 보이는 것도 있다. 목록에 순서를 매기지 않은 이유는, 나중에 다른 것을 떠올렸을 때 업데이트하기 쉽기 때문이기도 하고, 각 항목의 우선순위를 굳이 매기고 싶지 않기 때문이기도 하다.
그럼 시작하자.
파이썬에서 딕셔너리를 처음 배웠을 때, 금세 내가 가장 좋아하는 자료구조 중 하나가 되었다. 딕셔너리는 매우 다재다능하고, 대부분의 현대 프로그래밍 언어들은 표준 라이브러리에 이와 유사한 것을 제공한다. Go도 다르지 않다. Go에는 해시 테이블 구현인 map이 있다. 하지만 Go의 map에는 특이한 점이 있다. 예를 들면:
package main
func main() {
m := map[string]bool{"foo": true, "bar": false, "baz": true, "qux": false, "quux": true}
for k := range m {
println(k)
}
}
$ go run ./test.go
bar
baz
qux
quux
foo
$ go run ./test.go
foo
bar
baz
qux
quux
$ go run ./test.go
qux
quux
foo
bar
baz
해시 테이블 구현이 원소의 순서를 유지하리라 기대하는 것은 아니지만, Go는 실제로 각 맵 인스턴스의 순서를 무작위화한다:
핵심은 이렇다. Go의 맵에서 사용하는 해시 함수는 “같은 키 타입”에 대해서는 모든 맵에 걸쳐 일관되지만, 그 해시 함수가 쓰는
seed는 맵 인스턴스마다 다르다. 즉, 새 맵을 만들 때마다 Go는 그 맵만을 위한 랜덤 시드를 생성한다.
이 결정의 이유(즉, 개발자가 특정 반복 순서에 의존하지 못하게 하려는 것)는 이해하지만, 여전히 특이하다고 느끼고, 이것은 Go에만 있는 독특한 점이라고 생각한다. 이 결정 때문에 특정 순서가 중요하지 않더라도 재현 가능성을 원한다면 무엇을 하든 그 전에 맵을 정렬해야 한다. 나는 재현 가능성을 꽤 중요하게 여긴다.
해결책은? Go의 표준 라이브러리에 순서가 보장된 맵 구현을 제공할 수 있다. 순서가 보장된 맵은 순회 순서가 삽입 순서와 동일함을 보장한다(이것은 매우 강력한 성질이라서, 위에서 말한 개인적인 불만을 넘어서 다른 문맥에서도 맵을 유용하게 만들어 준다).
파이썬은 3.6부터 모든 딕셔너리에 대해 이 성질을 보장하고, 그 전에는 OrderedDict를 제공했다(그리고 OrderedDict에는 일반 dict에는 없는 몇몇 메서드가 있어서 특정 상황에서 유용할 수 있다).
제네릭 이전에는, 언어 차원에서 새로운 자료형(예: slice)을 도입하지 않고서는 이런 자료구조에 타입 안전한 API를 제공할 수 없었지만, 이제 Go에는 제네릭이 있으니 더는 문제가 아니다. 또 다른 문제는 이런 새 자료구조에서는 수동으로 순회해야 한다는 점이었는데, Go 1.23의 새로운 range-over-func 덕분에, 라이브러리로서의 순서 보장 맵도 map과 거의 동일한 방식으로 순회할 수 있게 되었다:
import "orderedmap"
func main() {
m := orderedmap.New[string, bool]()
m.Set("foo", true)
m.Set("bar", false)
m.Set("baz", true)
for k := range m.Iterator() {
println(k) // 순서는 항상: foo, bar, baz
}
}
물론 표준 라이브러리에 순서 보장 맵이 없어도 서드파티 구현으로 채울 수 있다. 예를 들어, 나는 내 프로젝트 중 하나에서 이 구현을 쓰고 있다. 하지만 표준 라이브러리에 있으면 마찰이 줄어든다. 표준 라이브러리에 구현이 있다면 특별한 요구가 없는 한 대체로 그것을 선호할 것이다. 반면 표준 라이브러리에 내가 필요한 것이 없으면 스스로 적절한 라이브러리를 찾아야 하고, 대개 대안이 여럿이어서 시간이 들기 마련이다.
파이썬에서 바로 가져오고 싶은 것 중 하나는, 함수 선언과 호출에서 다음과 같은 일을 할 수 있다는 점이다:
def hello(name="World"):
print(f"Hello, {name}")
hello("Foo") # "일반적인" 함수 호출
hello(name="Bar") # 키워드 인자로 호출
hello() # 기본 인자로 호출
$ python hello.py
Hello, Foo
Hello, Bar
Hello, World
기본 인자가 없다는 점은 Go 표준 라이브러리의 API 결정에도 영향을 준다. 예를 들어 strings.Replace:
func Replace(s, old, new string, n int) stringReplace는 문자열 s에서 겹치지 않는 처음 n개의 old를 new로 치환한 사본을 반환한다. old가 비어 있으면, 문자열의 시작과 각 UTF-8 시퀀스 뒤에서 일치하여, k-룬 문자열에 대해 최대 k+1번 치환된다. n < 0이면 치환 횟수에 제한이 없다.
Go에 기본 인자가 있었다면, Replace는 예컨대 func Replace(s, old, new string, n int = -1) 같은 시그니처가 될 수 있었을 것이다. 그러면 strings.ReplaceAll이 필요 없어진다(이 함수가 하는 일은 본질적으로 strings.Replace(s, old, new, -1)을 호출하는 것이기 때문이다).
이전에 Go에 관한 글에서 이 주제를 조금 언급했지만, 여기서는 좀 더 확장해 보겠다.
첫째, 언어가 널 가능성에 대한 일반 해법(즉, 제대로 된 유니언/합타입)을 반드시 지원해야 한다고는 생각하지 않는다. 내가 아는 한 코틀린은 둘 다 지원하지 않지만, 2년 동안 코틀린을 쓰면서 느낀 점은, nullable 타입만 있어도 타입 안정성 확보에 큰 도움이 된다는 것이다.
둘째, Go는 많은 경우 nil 대신 제로 값을 사용하기로 한 결정 덕분에, 자바 등에 비해 nil과 관련된 문제가 적다고 느낀다. 예를 들어 문자열은 nil이 될 수 없지만, 문자열 포인터는 그럴 수 있다. 따라서 다음은 안전하다:
func(s string) {
// s로 무언가를 한다
}
반면에:
func(s *string) {
// s는 nil일 수 있으므로 먼저 확인하는 편이 낫다
}
그럼에도 불구하고, nullable을 제공하는 다른 언어들(심지어 mypy를 사용하는 파이썬)보다 nil 포인터 역참조로 인한 panic을 더 자주 보게 된다.
안타깝게도, 이 글에서 다룬 변화 중에서는 이게 언어의 전면 개정이 가장 필요해 보인다. nil 가능성은 예전에 제안된 바 있지만, 하위 호환성을 깨지 않고는 실현 가능성이 낮다.
자바처럼 표준 라이브러리에 nullable 타입(JSR305)을 추가하는 방식으로 할 수도 있겠지만, 많은 이들이 JSR305를 사실상 죽은 규격으로 보는 현실은, 언어의 큰 변화를 동반하지 않고서는 이런 작업이 얼마나 어려운지를 보여 준다. 내가 아는 바로는 다트만이 이것을 비교적 성공적으로 해냈지만, 분명 고통이 따랐다. 그리고 다트를 쓰는 대부분의 사람이 플러터 때문이라는 사실(플러터가 결국 null-safety를 요구하는 최신 버전을 요구했다)도 좋은 신호는 아니다.
2024-08-18에 추가
Go는 일급 함수와 클로저 덕분에 의외로 함수형 스타일의 코드를 꽤 잘 지원한다. 안타깝게도 문법이 돕지는 않는다. Go에서 익명 함수를 쓰는 유일한 방법은 func 키워드를 쓰는 것이다. 타입이 복잡할수록 문법은 장황해지기 쉽다. range-over-func 실험의 예를 보자:
package slices
func Backward[E any](s []E) func(func(int, E) bool) {
return func(yield func(int, E) bool) {
for i := len(s)-1; i >= 0; i-- {
if !yield(i, s[i]) {
return
}
}
}
}
만약 Go에 람다 문법이 있고, 특히 타입을 생략할 수 있다면, 코드는 훨씬 간결해질 수 있다:
package slices
func Backward[E any](s []E) func(func(int, E) bool) {
return (yield) => {
for i := len(s)-1; i >= 0; i-- {
if !yield(i, s[i]) {
return
}
}
}
}
아니면 특별한 문법 없이, 익명 함수에서 타입 생략만 허용해도 도움이 된다:
package slices
func Backward[E any](s []E) func(func(int, E) bool) {
return func(yield) {
for i := len(s)-1; i >= 0; i-- {
if !yield(i, s[i]) {
return
}
}
}
}
이 기능은 아직도 언젠가 언어에 도입될 수 있지 않을까 하는 희망이 있다. 관련 이슈가 아직 닫히지 않았고, 가능성에 대한 논의가 계속되고 있기 때문이다.
2024-09-12에 추가
최근 LLM에 관한 글에서, 컨텍스트를 받는 소켓을 만들기 위해 ChatGPT에 부탁했던 함수에 대해 이야기했다:
func readWithContext(ctx context.Context, conn net.Conn, buf []byte) (int, error) {
done := make(chan struct{})
var n int
var err error
// 읽기 작업을 수행할 고루틴 시작
go func() {
n, err = conn.Read(buf)
close(done)
}()
select {
case <-ctx.Done(): // 컨텍스트가 취소되었거나 타임아웃됨
// Read()를 깨우기 위해 짧은 데드라인을 설정
conn.SetReadDeadline(time.Now())
<-done // 읽기가 끝날 때까지 대기
return 0, ctx.Err()
case <-done: // 읽기 성공적으로 완료
return n, err
}
}
그런데 “The Error Model”이라는 블로그 글을 읽다가, 이 함수가 conn.SetReadDeadline() 호출의 오류 체크를 빠뜨렸다는 걸 깨달았다:
func readWithContext(ctx context.Context, conn net.Conn, buf []byte) (n int, err error) {
done := make(chan struct{})
// 읽기 작업을 수행할 고루틴 시작
go func() {
n, err = conn.Read(buf)
close(done)
}()
select {
case <-done:
return n, err
case <-ctx.Done():
// Read()를 깨우기 위해 짧은 데드라인을 설정
err = conn.SetReadDeadline(time.Now())
if err != nil {
return 0, err
}
// 읽기 데드라인 복원
defer func() {
if e := conn.SetReadDeadline(time.Time{}); e != nil {
err = errors.Join(err, e)
}
}()
// 고루틴 누수를 막기 위해 완료될 때까지 보장
<-done
return 0, errors.Join(err, ctx.Err())
}
}
이걸 LLM 탓으로 돌릴 수도 있겠지만, 실제 사람의 PR에도 충분히 나올 법한 실수다.
오류를 무시하는 것은 나쁘다. 특히 한 번 오류를 무시하면 그것은 영원히 사라진다. 존재하지 않는 것을 알 방법이 없기 때문에 디버깅이 불가능한 이상한 문제가 생길 수 있다. 이런 이유로 나는 예외가 더 낫다고 생각한다. 이런 종류의 오류는 언어가 예외를 지원한다면 무시할 수 없고, 예외는 결국 포착되거나 크래시가 날 때까지 스택을 타고 전파되며(스택 트레이스는 문제를 디버그하는 데 도움이 된다).
그렇다고 오류를 값으로 다루는 Go의 접근이 잘못됐다고 생각하지는 않는다. 고정된 코드에서 보이듯, Go에는 오류를 캡슐화하고 제대로 전파할 수 있는 도구가 있다. 다만 누군가 이런 실수를 해도 컴파일러가 경고나 오류를 내지 않는 점은 좋지 않다.
만약 Go가 사용되지 않은 반환값에 경고나 오류를 내주었다면 상황이 달라졌을 것이다:
func foo(conn *net.Conn) {
// ...
_ = conn.SetReadDeadline(time.Now())
}
이 경우에는 명확하다. 나는 오류를 의도적으로 무시하고 있고, 아마도 분명한 이유가 있을 것이다. PR에서, 커미터가 적절한 맥락 없이 오류를 무시했다면 왜 그런지 물어볼 수 있고, 안전한 이유를 주석으로 남기라고 요구할 수도 있다. 실수로 무시되는 일만은 없어야 한다.
오류에만 적용할지, 모든 미사용 반환값에 적용할지는 확신이 없다. 오류가 아닌 값을 무시해도 괜찮은 경우가 많다. 그렇지만 다음과 같은 형태를 강제한다고 해서 크게 문제될 것 같지도 않다:
func foo() {
// ...
_, _ = FuncThatReturnIntAndError()
}
적어도 이 코드를 나중에 읽는 사람은 이 함수가 부수효과만을 위해 호출되었다는 점을 분명히 알 수 있다.
덧붙여, errcheck 같은 린터가 존재하긴 한다. 하지만 언어 자체가 이를 강제하지 않으면, 아마도 많은 라이브러리들이 실수로 오류를 무시하고 있을 것이다. 라이브러리가 실수로 오류를 무시했다면, 실제로 오류가 발생했을 때 우리가 할 수 있는 일은 많지 않다.