티스토리 뷰

 

Go 1.22 Release Notes - The Go Programming Language

Go 1.22 Release Notes Introduction to Go 1.22 The latest Go release, version 1.22, arrives six months after Go 1.21. Most of its changes are in the implementation of the toolchain, runtime, and libraries. As always, the release maintains the Go 1 promise o

go.dev

 지난 2024년 2월 6일 Go 1.22.0 버전이 릴리즈 되었습니다. 대부분 툴체인, 런타임 그리고 라이브러리와 관련된 내용이기는 합니다만, 중요해 보이는 것 몇 가지만 자세하게 짚고 넘어가 보도록 합시다.


언어 관련 업데이트

for 루프

 for 루프와 관련해 두 가지 업데이트 내용이 있습니다.

1. 이제 루프 변수는 루프가 반복될 때마다 새로 할당된다

 기존에 for 루프 안에서 goroutine을 사용해 루프 변수 (i.e. i)를 참조해 사용할 경우 모든 goroutine에서 루프 변수의 마지막 업데이트 값만을 참조하게 되는 문제가 있었습니다.

package main

import (
	"fmt"
	"sync"
)

func main() {
	wg := sync.WaitGroup{}

	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func() {
			fmt.Println(i)
			wg.Done()
		}()
	}

	wg.Wait()
}
$ go run .
5
5
5
5
5

 이는 for 루프에서 선언된 변수가 루프가 반복될 때마다 새롭게 선언되는 것이 아니라 업데이트되는 식으로 동작하기 때문에 발생하는 문제입니다. 즉, goroutine이 실행되기 전에 for 루프가 먼저 종료되어서 i 값이 5가 되었는데 goroutine에서는 해당 i를 참조하여 출력하기 때문에 5가 출력이 되는 것입니다.

i := 0

for i < 5 {
	wg.Add(1)
	go func() {
		fmt.Println(i)
		wg.Done()
	}()

	i += 1
}

 실제로, 일부러 for 루프의 동작을 지연시킨다면 i 값은 원래 예상하던 대로 출력이 됩니다.

for i := 0; i < 5; i++ {
	wg.Add(1)
	go func() {
		fmt.Println(i)
		wg.Done()
	}()

	time.Sleep(100 * time.Millisecond)
}
$ go run .
0
1
2
3
4

 그런데 이런 식으로는 groutine을 사용하는 의미가 없습니다. 그래서 일반적으로는 i를 for 루프 안에서 새로 할당하거나 함수 리터럴의 인수로 전달(복사)하여 다음과 같이 사용했습니다.

for i := 0; i < 5; i++ {
	wg.Add(1)

	i := i // Copy the loop variable to a new variable.

	go func() {
		fmt.Println(i)
		wg.Done()
	}()
}
for i := 0; i < 5; i++ {
	wg.Add(1)
    
	go func(n int) {
		fmt.Println(n)
		wg.Done()
	}(i) // Pass i as an argument to the goroutine
}
$ go run .
4
2
0
1
3

 그런데! 이번 업데이트를 통해 더 이상 이런 식으로 수고를 들이지 않고 처음 방식을 그대로 사용하여도 괜찮습니다. 이제는 이전의 루프 변수를 업데이트하지 않고 매번 새로 선언하여 사용한다고 합니다.

 

 이전 방식과 새로운 방식에 대해 0~n-1까지 goroutine을 사용해 더하는 함수를 사용해 각각을 벤치마킹해 봅시다.

// 1.22 이전 방식

func sum(n int) (sum int) {
	wg := sync.WaitGroup{}

	for i := 0; i < n; i++ {
		wg.Add(1)
		i := i

		go func() {
			sum += i
			wg.Done()
		}()
	}

	wg.Wait()

	return
}

var table = []struct {
	n int
}{
	{100},
	{1000},
	{10000},
}

func BenchmarkLoop(b *testing.B) {
	for _, t := range table {
		b.Run(fmt.Sprintf("input_size_%d", t.n), func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				sum(t.n)
			}
		})
	}
}
Running tool: /usr/local/go/bin/go test -benchmem -run=^$ -bench ^BenchmarkLoop$ old

goos: linux
goarch: amd64
pkg: old
cpu: Intel(R) Core(TM) i5-10210U CPU @ 1.60GHz
BenchmarkLoop/input_size_100-8         	   39040	     29034 ns/op	    3224 B/op	     102 allocs/op
BenchmarkLoop/input_size_1000-8        	    4858	    251798 ns/op	   32068 B/op	    1002 allocs/op
BenchmarkLoop/input_size_10000-8       	     488	   2518741 ns/op	  320029 B/op	   10002 allocs/op
PASS
ok  	old	4.182s
// 1.22 업데이트

func sum(n int) (sum int) {
	wg := sync.WaitGroup{}

	for i := 0; i < n; i++ {
		wg.Add(1)

		go func() {
			sum += i
			wg.Done()
		}()
	}

	wg.Wait()

	return
}

var table = []struct {
	n int
}{
	{100},
	{1000},
	{10000},
}

func BenchmarkLoop(b *testing.B) {
	for _, t := range table {
		b.Run(fmt.Sprintf("input_size_%d", t.n), func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				sum(t.n)
			}
		})
	}
}
Running tool: /usr/local/go/bin/go test -benchmem -run=^$ -bench ^BenchmarkLoop$ new

goos: linux
goarch: amd64
pkg: new
cpu: Intel(R) Core(TM) i5-10210U CPU @ 1.60GHz
BenchmarkLoop/input_size_100-8         	   40639	     28613 ns/op	    3224 B/op	     102 allocs/op
BenchmarkLoop/input_size_1000-8        	    4706	    248959 ns/op	   32041 B/op	    1002 allocs/op
BenchmarkLoop/input_size_10000-8       	     494	   2432295 ns/op	  320026 B/op	   10002 allocs/op
PASS
ok  	new	4.116s

 성능 차이는 거의 없는 것 같습니다. 함수 리터럴에서 루프 변수를 사용하기 편해졌다는 점에 의의를 둬야 할 것 같네요.

2. 정수형에 range를 사용할 수 있다

 마참내 Go에서 다음과 같이 for문을 작성할 수 있게 되었습니다.

func main() {
	for i := range 5 {
		fmt.Println(i)
	}
}
$ go run .
0
1
2
3
4

 다만 반복이 시작되는 값은 항상 0이고 끝나는 값은 n-1이기 때문에 사용에 주의가 필요할 것 같습니다.


도구 관련 업데이트

Vet

1. 루프 변수 참조

 1.22 이전 버전에서는 다음과 같이 함수 리터럴 안에서 루프 변수를 참조할 경우, 경고가 출력됩니다.

for i := 0; i < 5; i++ {
	wg.Add(1)

	go func() {
		fmt.Println(i)
		wg.Done()
	}()
}
$ go vet
# old
# [old]
./main.go:15:16: loop variable i captured by func literal

 그러나 1.22 버전부터는 for 루프의 기본 동작이 변경되었으므로 더 이상 경고가 발생하지 않습니다.

2. append

 append 함수를 호출할 때 어떠한 값도 전달되지 않는다면 경고를 출력합니다.

s := []int{1, 2, 3}
s = append(s)
$ go vet
# new
# [new]
./main.go:23:6: append with no values

3. time.Since

 time.Since와 defer 키워드를 다음과 같이 사용하면 경고를 출력합니다. 이는 defer 선언 시점에서 time.Since를 먼저 실행하고 지연되는 것은 로그 출력이기 때문에 올바른 사용방법이 아닙니다.

t := time.Now()
defer log.Println(time.Since(t))
$ go vet
# new
# [new]
./main.go:12:20: call to time.Since is not deferred

 올바른 사용방법은 다음과 같습니다.

t := time.Now()
defer func() {
	log.Println(time.Since(t))
}()

4. log/slog

 구조화된 로그 패키지인 log/slog로 로그를 출력할 때 키와 매칭되는 값이 존재하지 않는 경우, 경고가 출력됩니다.

slog.Info("Hello, World!", "key")
$ go vet
# new
# [new]
./main.go:23:2: call to slog.Info missing a final value

Runtime

 런타임이 타입 기반 가비지 컬렉션 메타데이터를 각 힙 객체에 더 가깝게 유지함으로 Go 프로그램의 CPU 성능을 1~3% 향상 시킵니다. (조금 어려운 내용)

Compiler

PGO(Profile-guided Optimization) 빌드를 통한 탈가상화와 성능 개선.


라이브러리 관련 업데이트

새로운 math/rand/v2 패키지

 기존 패키지를 업그레이드하여 최초로 'v2'가 붙은 내장 패키지. 변경점은 다음 링크를 참고.

 

math/rand/v2: revised API for math/rand · Issue #61716 · golang/go

Based on earlier discussions in #60751, #26263, and #21835, as well as discussions with @robpike, I propose adding a new version of math/rand, imported as math/rand/v2, to the standard library. The...

github.com

새로운 go/version 패키지

 Go 버전의 유효성을 검사하거나 비교하는 패키지.

package main

import (
	"fmt"
	"go/version"
)

func main() {
	ok := version.IsValid("go1.22")
	fmt.Println(ok)
}
$ go run .
true

net/http.ServeMux의 강화된 라우터 패턴 매칭

 이제는 별도의 라우터 패키지를 사용하지 않고도 내장 패키지인 net.http만을 사용하여 상당히 간편하게 라우터를 구성할 수 있게 되었습니다.

메서드 지정

 우선 http.ServeMux를 사용하여 핸들러와 매칭되는 메서드를 다음과 같은 패턴으로 지정할 수 있습니다.

mux := http.NewServeMux()

mux.HandleFunc("GET /items", GetItems)
mux.HandleFunc("POST /items", CreateItem)

 이하는 각각 서버를 실행하고 'POST /items'와 'Get /items'에 해당하는 API 호출을 실행한 결과입니다.

핸들러를 GET 메서드로 등록하는 경우에는 HEAD 메서드도 자동으로 등록됩니다.

와일드 카드 사용

 패턴에 와일드 카드 '{}'를 사용하여 경로 변수를 추가할 수 있습니다.

mux.HandleFunc("GET /items/{id}", GetItem)

 이렇게 추가된 경로 변수는 net/http.Request 타입에 새롭게 추가된 PathValue 메서드를 사용하여 불러올 수 있습니다.

func GetItem(w http.ResponseWriter, r *http.Request) {
	defer r.Body.Close()

	id := r.PathValue("id")
    
    ...
 }

 경로에 id를 지정하여 API를 호출한 결과는 다음과 같습니다.

 경로에 id를 지정하지 않고 요청을 보내면 404 Not Found 오류를 반환합니다.

 다음과 같이 '{}' 안에 경로 변수 이름과 '...'를 사용하여 '/hello/' 뒤에 이어지는 모든 경로와 매칭되게 만들 수도 있습니다.

mux.HandleFunc("GET /hello/{name...}", Greeting)

/로 끝나는 경로와 정확히 매칭하기

 다음과 같이 경로가 '/'로 끝나는 패턴은 '/' 뒤에 어떤 값이 들어오더라도 모두 매칭이 됩니다.

mux.HandleFunc("GET /bye/", Bye)

 만약 정확히 '/'로 끝나는 경로가 매칭되게 만들고 싶다면, '{$}' 와일드카드를 패턴에 추가하면 됩니다.

mux.HandleFunc("GET /bye/{$}", ByeExactly)

패턴 충돌

 다음과 같이 2개의 경로가 등록되어 있을 때, 만약 '/bye/'로 요청을 보낸다면 어떤 핸들러가 요청을 처리할까요? 이 경우는 더 구체적인 패턴과 우선적으로 매칭됩니다. '/bye/{$}' 패턴은 반드시 '/bye/'와 매칭되어야 하므로 더 구체적인 패턴입니다. 따라서 '/bye/'로 보낸 요청은 ByeExactly 핸들러가 처리하게 됩니다.

mux.HandleFunc("GET /bye/{$}", ByeExactly) // 더 구체적인 패턴 (반드시 '/bye/'와 매칭되어야 한다)
mux.HandleFunc("GET /bye/", Bye) // 덜 구체적인 패턴 ('/' 뒤에 어떤 것과도 매칭이 된다)

 

👇 전체 코드

더보기
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"sync"
)

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /items", GetItems)
	mux.HandleFunc("POST /items", CreateItem)
	mux.HandleFunc("GET /items/{id}", GetItem)
	mux.HandleFunc("GET /hello/{name...}", Greeting)
	mux.HandleFunc("GET /bye/{$}", ByeExactly)
	mux.HandleFunc("GET /bye/", Bye)

	srv := &http.Server{
		Addr:    ":8080",
		Handler: mux,
	}

	srv.ListenAndServe()
}

type Item struct {
	ID          int    `json:"id"`
	Description string `json:"description"`
}

var (
	items = []Item{}
	mu    = &sync.RWMutex{}
)

func GetItems(w http.ResponseWriter, r *http.Request) {
	defer r.Body.Close()

	mu.RLock()

	body := struct {
		Items []Item `json:"items"`
	}{}

	body.Items = items

	mu.RUnlock()

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	_ = json.NewEncoder(w).Encode(body)
}

func GetItem(w http.ResponseWriter, r *http.Request) {
	defer r.Body.Close()

	id := r.PathValue("id")

	idInt, err := strconv.Atoi(id)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	mu.RLock()

	if idInt < 1 || idInt > len(items) {
		http.Error(w, "not found", http.StatusNotFound)
		mu.RUnlock()
		return
	}

	item := items[idInt-1]

	mu.RUnlock()

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	_ = json.NewEncoder(w).Encode(item)
}

func CreateItem(w http.ResponseWriter, r *http.Request) {
	defer r.Body.Close()

	b, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	mu.Lock()

	items = append(items, Item{
		ID:          len(items) + 1,
		Description: string(b),
	})

	mu.Unlock()

	w.WriteHeader(http.StatusCreated)
}

func Greeting(w http.ResponseWriter, r *http.Request) {
	name := r.PathValue("name")

	resp := fmt.Sprintf("Hello, %s!", name)

	_, _ = w.Write([]byte(resp))
}

func ByeExactly(w http.ResponseWriter, r *http.Request) {
	_, _ = w.Write([]byte("Bye! Exactly!"))
}

func Bye(w http.ResponseWriter, r *http.Request) {
	_, _ = w.Write([]byte("Bye!"))
}

마무리

 중요하다고 생각되는 부분만 짚어봤는데 ServeMux 패턴 매칭이 개선된 부분이 개인적으로 마음에 듭니다. 이제는 정말 프레임워크도 라우터 패키지도 필요가 없어진 것 같군요. 이런 식으로 Go는 내장 패키지가 정말 잘 되어있고 꾸준히 개선되고 있다는 점에서 개발자들의 편의성에 크게 기여하는 것 같습니다. 다음 업데이트에서는 어떤 부분들이 개선될까요? 새로운 업데이트와 함께 다시 돌아오겠습니다.

'Go > 문서 읽기' 카테고리의 다른 글

[Go] Vault를 사용해 시크릿 저장하고 불러오기 기초  (0) 2023.10.25
[Go] 컨텍스트(Context)  (1) 2023.10.05
[Effective Go] Names  (0) 2023.03.29
최근에 올라온 글
최근에 달린 댓글
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Total
Today
Yesterday
글 보관함