지난 1년 동안 Go로 일하며 알게 된, 비교적 덜 알려진 유용한 팁들을 모았습니다.
URL: https://harrisoncramer.me/15-go-sublteties-you-may-not-already-know/
지난 1년 동안 Go로 일하며 알게 된, 제가 특히 좋아하는 자잘한 팁들입니다.
새로운 것을 배우는 가장 좋은 방법 중 하나는, 그에 대해 배운 내용을 꾸준히 기록하는 것입니다. 지난 1년 동안 저는 Go 프로그래밍 언어를 대상으로 이를 실천해 왔습니다. 여기에는 언어에 관한, 비교적 덜 알려진 제가 좋아하는 몇 가지 포인트가 담겨 있습니다.
range 사용하기Go 1.22부터는 정수에 대해 range를 돌릴 수 있습니다:
gofor i := range 10 { fmt.Println(i + 1) // 1, 2, 3 ... 10 }
Go의 LSP를 사용하면 일반 변수뿐 아니라 패키지도 이름을 바꿀 수 있습니다. 새 이름의 패키지는 모든 참조 지점에서 함께 업데이트됩니다. 보너스로 디렉터리 이름까지 바꿔줍니다!
~ 연산자를 사용해 제네릭 타입 시그니처에 제약을 걸 수 있습니다. 예를 들어, 타입이 지정된 상수(typed constant)에 대해 다음처럼 할 수 있습니다:
gopackage main import ( "fmt" ) type someConstantType string const someConstant someConstantType = "foo" // Underlying type is a string func main() { msg := buildMessage(someConstant) fmt.Println(msg) } func buildMessage[T ~string](value T) string { // This accepts any value whose underlying type is a string return fmt.Sprintf("The underlying string value is: '%s'", value) }
이는 Go에서 구체 타입이 타입 지정 상수인 경우에 특히 유용합니다. 다른 언어의 enum과 비슷하다고 볼 수 있습니다.
Go에서는 인덱스 기반 문자열 보간을 할 수 있습니다:
gopackage main import ( "fmt" ) func main() { fmt.Printf("%[1]s %[1]s %[2]s %[2]s %[3]s", "one", "two", "three") // yields "one one two two three" }
같은 값을 여러 번 보간해야 할 때 반복을 줄이고, 보간을 더 읽기 쉽게 만드는 데 도움이 됩니다.
time.After 함수time.After 함수는 x초 후에 메시지가 전송되는 채널을 만듭니다. select 문과 조합하면 다른 루틴에 대해 데드라인을 설정하는 쉬운 방법이 될 수 있습니다.
gopackage main import ( "fmt" "time" ) func main() { ch := make(chan string, 1) go func() { time.Sleep(2 * time.Second) ch <- "result" }() select { case res := <-ch: fmt.Println("Received:", res) case <-time.After(1 * time.Second): fmt.Println("Timeout: did not receive a result in time") } }
embed 패키지“embed” 패키지는 Go가 아닌 파일을 Go 바이너리 안에 직접 포함(embed)할 수 있게 해줍니다. 런타임에 디스크에서 읽을 필요가 없습니다.
HTML, JS, 심지어 이미지까지 포함할 수 있습니다. 에셋을 바이너리에 직접 컴파일해 넣으면 배포가 훨씬 단순해질 수 있습니다.
len() 사용하기와 UTF-8 함정Go의 내장 함수 len()은 문자열의 문자 개수를 반환하지 않습니다. 바이트 수를 반환합니다. 문자열 리터럴이 문자당 1바이트라고 가정할 수 없기 때문입니다(그래서 룬(rune)이 존재합니다).
gopackage main import ( "fmt" ) func main() { s := "Hello 世界" fmt.Println(len(s)) // Prints 11! for i := 0; i < len(s); i++ { fmt.Printf("index %d: value %c\n", i, s[i]) // Iterates over bytes. This will not work as expected.... /* index 0: value H index 1: value e index 2: value l index 3: value l index 4: value o index 5: value index 6: value ä index 7: value ¸ index 8: value <96> index 9: value ç index 10: value <95> index 11: value <8c> */ } for i, r := range s { // The range keyword iterates through runes. fmt.Printf("byte %d: %s\n", i, string(r)) /* byte 0: H byte 1: e byte 2: l byte 3: l byte 4: o byte 5: byte 6: 世 byte 9: 界 */ } }
룬(rune)은 Go에서 코드 포인트(code point)에 해당하며 길이는 1~4바이트입니다. 추가로 복잡한 점은, 문자열 리터럴은 UTF-8로 인코딩되지만 문자열은 그냥 임의의 바이트들의 집합이라는 것입니다. 즉, 기술적으로는 유효하지 않은 데이터를 담는 문자열도 만들 수 있습니다. 이 경우 Go는 유효하지 않은 UTF-8 데이터를 대체 문자(replacement character)로 바꿉니다.
gopackage main import ( "fmt" ) func main() { invalidBytes := []byte{0x48, 0x65, 0x6C, 0x6C, 0x6F, 0xFF} // "Hello" + invalid byte s := string(invalidBytes) for _, r := range s { fmt.Printf("%c ", r) // Prints: H e l l o � } }
다음 코드는 무엇을 출력할까요?
gopackage main import "fmt" type Animal interface { Speak() } type Dog struct{} func (d *Dog) Speak() {} func main() { var d *Dog = nil var a Animal = d fmt.Println(a == nil) }
정답: false 입니다!
이는 값(value)은 nil이지만, 변수의 타입이 *nil이 아닌 인터페이스(non-nil interface)*이기 때문에 발생합니다.
Go는 그 값을 인터페이스 안에 “박싱(boxing)”하는데, 그 인터페이스 자체는 nil이 아닙니다. 이는 함수에서 인터페이스를 반환할 때 정말 발목을 잡을 수 있습니다. 반환 값이 nil이라도, 반환 타입을 인터페이스로 지정해두면 nil 체크가 기대처럼 동작하지 않습니다. 예를 들어:
gopackage main import "fmt" type Car interface { Honk() } type Honda struct{} func (h *Honda) Honk() { fmt.Println("Beep!") } func giveCar() Car { var h *Honda // h is nil return h // nil *Honda wrapped in Car interface } func main() { c := giveCar() if c == nil { fmt.Println("This will never print!") } }
이 예제에서 c는 nil 값이 박싱된 상태이므로 c == nil 체크는 항상 false가 됩니다.
이와 관련해서, nil struct에 대해서도 메서드를 호출할 수 있습니다. 이는 유효한 Go 코드입니다:
gopackage main import "fmt" type Foo struct { Val string } func (f *Foo) Hello() { fmt.Println("hi from nil pointer receiver") } func main() { var f *Foo = nil f.Hello() // This is fine! fmt.Println(f.Val) // This is not! }
물론, 이 포인터 리시버에서 필드에 접근하려고 하면 패닉이 납니다.
range로 순회할 때의 변수 참조루프 안에서 맵을 업데이트할 때, 그 업데이트가 해당 이터레이션 중에 반영되어 방문된다는 보장은 없습니다.
보장되는 것은 루프가 끝날 때쯤에는 맵에 업데이트가 들어가 있다는 것뿐입니다. 물론 이런 코드를 작성하는 일은 거의 없겠지만(그냥 나쁜 코드입니다), 동작을 이해하기에는 흥미롭습니다.
gofunc main() { m := map[int]int{1: 1, 2: 2, 3: 3} for key, value := range m { fmt.Printf("%d = %d\n", key, value) if key == 1 { for i := 10; i < 20; i++ { m[i] = i * 10 // Add many entries } } } }
예를 들어 위 코드에서, 루프 안에서 추가한 값들이 출력될 수도 있고 아닐 수도 있습니다.
이는 Go 내부에서 객체를 관리하는 방식 때문입니다. Go에서 새 키/값을 추가하면 언어 런타임이 키를 해시해서 저장 버킷(bucket)에 넣습니다. 만약 Go의 순회가 그 객체에서 해당 버킷을 이미 “살펴본” 뒤라면, 새로 추가한 엔트리는 그 루프에서 방문되지 않습니다.
이는 삽입 순서가 안정적으로(stable insertion order) 보장되는 예컨대 Python과는 다릅니다. Go가 이렇게 하는 이유는 속도 때문입니다!
Go에서는 예기치 않은 에러를 타입이 있는 에러(typed error)로 반환하면 유용한 경우가 많습니다. 디버깅이나 상위 호출부에서 활용할 추가 컨텍스트를 제공할 수 있기 때문입니다. 타입으로 정의하면 errors.As로 구조화된 데이터를 붙일 수 있고, 커스텀 로직을 구현할 수도 있으면서 error 인터페이스도 만족합니다.
gopackage main import ( "errors" "fmt" ) type MyError struct { Message string Code int } func (e *MyError) Error() string { return fmt.Sprintf("Error %d: %s", e.Code, e.Message) } func someFunction() error { return &MyError{Message: "something went wrong", Code: 404} } func main() { err := someFunction() if err != nil { var myErr *MyError if errors.As(err, &myErr) { fmt.Printf("Handled typed error: %s\n", myErr.Error()) } else { fmt.Printf("Unhandled error: %s\n", err) } } }
컨텍스트를 인지하는 함수에서는 채널뿐 아니라 컨텍스트에 대해서도 항상 select 해야 합니다. 그렇지 않으면 컨텍스트가 취소되었는데도 그 작업이 끝나길 불필요하게 기다릴 수 있습니다.
예를 들어 아래 예제에서는 time.After가 끝나면 채널로 “operation complete” 메시지를 보내거나, 컨텍스트 취소 시 조기 종료합니다.
sendSignal 함수가 컨텍스트 취소를 감지하므로 일찍 빠져나올 수 있습니다.
gopackage main import ( "context" "fmt" "time" ) func sendSignal(ctx context.Context, ch chan<- string) { select { case <-time.After(5 * time.Second): // Fake operation takes five seconds... ch <- "operation complete" case <-ctx.Done(): // But we can short-circuit it with a cancellation. Without this we'd ignore the context cancellation! ch <- "operation cancelled" } } func main() { ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) // Only 1 second timeout defer cancel() ch := make(chan string) go sendSignal(ctx, ch) msg := <-ch close(ch) fmt.Println(msg) }
이는 채널 연산에서 컨텍스트도 함께 select해야 하는 이유를 잘 보여줍니다. 그렇게 하지 않으면, 컨텍스트가 1초 후 취소되었어도 함수는 5초 내내 기다리게 됩니다.
보너스 사실: Go의 컨텍스트는 HTTP 핸들러가 끝나고 응답이 완전히 전송된 뒤에는(성공 응답이라도) 취소됩니다. 그래서 컨텍스트 전파에 주의해야 합니다. 예를 들어 HTTP 요청에서 이벤트 퍼블리셔로 컨텍스트를 그대로 넘기면, HTTP 응답이 빠르게 끝나면서 전파된 컨텍스트가 취소되어 이벤트 발행이 막히는 레이스 컨디션을 만들 수 있습니다.
Go 개발자가 빈 구조체를 이리저리 전달하는 것을 자주 볼 수 있습니다. 왜 불리언이 아니라 이런 걸 쓸까요?
Go에서 빈 구조체는 0바이트를 차지합니다. Go 런타임은 빈 구조체를 포함해 0-사이즈 할당을 모두 처리할 때, 공간을 차지하지 않는 단 하나의 특별한 메모리 주소를 반환합니다.
그래서 실제로 전송할 데이터는 없고 “신호”만 보내고 싶을 때 채널에서 흔히 사용됩니다. 불리언은 그 자체로도 어느 정도 공간을 차지해야 하기 때문입니다.
range 키워드Go 컴파일러는 range 키워드를 더 기본적인 루프로 “내려(lowers)”서 Go가 추가로 컴파일되기 전에 변환합니다. 무엇을 range하느냐에 따라 구현은 달라집니다. 예: 맵, 슬라이스, 또는 iter 패키지의 시퀀스 등.
흥미롭게도 iter 패키지의 경우, range 내부에서 break를 호출하면 반복을 중단하기 위해 보통 yield 함수가 반환하던 false로 변환됩니다.
예를 들어 JSON 응답 필드에 time.Time 구조체를 임베드(embed)하고, 그 부모를 마샬링하려고 한다고 해봅시다.
구조체를 임베드하면, 그 안에 있는 메서드들도 암묵적으로 승격(promote)됩니다. time.Time 타입에는 MarshalJSON() 메서드가 있으므로, 컴파일러는 일반 마샬링 동작 대신 그것을 실행해버립니다.
gopackage main import ( "encoding/json" "fmt" "time" ) type Event struct { Name string `json:"name"` time.Time `json:"timestamp"` } func main() { event := Event{ Name: "Launch", Time: time.Date(2023, time.November, 10, 23, 0, 0, 0, time.UTC), } jsonData, _ := json.Marshal(event) fmt.Println(string(jsonData)) // "2023-11-10T23:00:00Z" weird right? }
이 예제에서 Event 구조체는 time.Time 필드를 임베드합니다. Event 구조체를 JSON으로 마샬링할 때 time.Time의 MarshalJSON() 메서드가 자동으로 호출되어 전체 결과를 포맷하게 되는데, 그 결과가 기대와 다르게 출력됩니다.
이는 다른 메서드에서도 마찬가지로 발생할 수 있어, 이상하고 추적하기 어려운 버그로 이어질 수 있습니다. 구조체 임베딩을 사용할 때 주의하세요!
"-" 태그JSON 마샬링 시 "-" 태그를 사용하면 해당 필드가 생략됩니다. API 응답에서 제외해야 하는 민감한 데이터를 필드에 가지고 있을 때 유용합니다.
gopackage main import ( "encoding/json" "fmt" ) type User struct { Name string `json:"name"` Password string `json:"-"` Email string `json:"email"` } func main() { user := User{ Name: "John Doe", Password: "supersecret", Email: "john.doe@example.com", } data, err := json.Marshal(user) if err != nil { fmt.Println(err) return } fmt.Println(string(data)) // Only {"name":"John Doe","email":"john.doe@example.com"}, not password! }
이는 다소 인위적인 예제입니다(당연히 비밀번호를 이렇게 다루면 안 됩니다). 그래도 꽤 편리한 기능입니다.
Go에서 Time을 문자열로 바꾸면 String()이 자동으로 타임존 정보를 덧붙입니다. 그래서 문자열 비교는 동작하지 않습니다. 대신 .Equal() 메서드를 사용하세요. 이는 다음을 비교합니다: “Equal reports whether t and u represent the same time instant. Two times can be equal even if they are in different locations.”
gopackage main import ( "fmt" "time" ) func main() { t1 := time.Date(2024, 1, 1, 15, 0, 0, 0, time.UTC) t2 := t1.In(time.FixedZone("EST", -5*3600)) // Adds timezone info fmt.Println(t1.String() == t2.String()) // prints false fmt.Println(t1.Equal(t2)) // prints true! }
이는 테스트나 CI 환경에서 자주 등장합니다.
wg.Go 함수Go 1.25에서는 waitgroup.Go 함수가 도입되어 고루틴을 waitgroup에 더 쉽게 추가할 수 있습니다. go 키워드를 쓰는 대신 아래처럼 쓸 수 있습니다:
gowg.Go(func() { // your goroutine code here })
구현은 사실상 아래를 감싼 래퍼(wrapper)일 뿐입니다:
gofunc (wg *WaitGroup) Go(f func()) { wg.Add(1) go func() { defer wg.Done() f() }() }