2021년의 Go를 2026년의 Go로 Part 2: 표준 라이브러리 레시피
이 글은 Claude Opus 4.8 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
들어가며#
Part 1에서는 제네릭, 이터레이터, 빌트인 등 언어와 문법 레벨의 변화를 다뤘습니다. Part 2는 일상 코드에서 가장 손이 자주 가는 표준 라이브러리 의 레시피입니다.
2021~22년의 Go 표준 라이브러리는 의외로 빈약했습니다. 슬라이스에서 원소를 찾으려면 for 루프를 직접 돌렸고, 맵의 키를 모으려면 손으로 슬라이스를 채웠으며, 정렬은 sort.Slice 에 비교 함수를 넘겼습니다. 구조적 로깅을 하려면 logrus, zap 같은 서드파티가 사실상 필수였고, HTTP 라우팅에서 메서드와 경로 파라미터를 다루려면 gorilla/mux 를 깔았습니다.
이 모든 것이 1.21~1.26 사이에 표준으로 흡수되었습니다. 의존성 목록을 한참 줄일 수 있는 변화입니다.
레시피 1: 손으로 짠 슬라이스/맵 헬퍼 대신 slices/maps/cmp (1.21)#
Go 1.21은 제네릭 위에 세워진 slices, maps, cmp 세 패키지를 표준에 추가했습니다. 그 전까지 모든 팀이 각자 만들던 유틸리티가 표준이 되었습니다.
Before (2021) — 직접 구현하거나 sort.Slice 에 의존했습니다.
// 원소 포함 여부
func contains(xs []string, target string) bool {
for _, x := range xs {
if x == target {
return true
}
}
return false
}
// 정렬: 리플렉션 기반 sort.Slice
sort.Slice(users, func(i, j int) bool {
return users[i].Age < users[j].Age
})
// 맵 키 모으기
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
After (2026) — 표준 함수로 대체합니다. slices.SortFunc 은 리플렉션 기반인 sort.Slice 보다 빠르고, 비교 함수도 cmp.Compare 를 활용해 의도가 명확합니다.
import (
"cmp"
"slices"
"maps"
)
ok := slices.Contains(xs, target)
idx := slices.Index(xs, target)
slices.SortFunc(users, func(a, b User) int {
return cmp.Compare(a.Age, b.Age)
})
// 1.23+ 이터레이터로 키 모으기
keys := slices.Collect(maps.Keys(m))
slices.Sort(keys)
cmp.Compare 는 -1, 0, 1 을 반환하므로 다중 기준 정렬이 깔끔해집니다. 또한 1.22에 추가된 cmp.Or 는 “첫 번째 0이 아닌 값"을 반환해, 타이브레이크 정렬에 유용합니다.
slices.SortFunc(users, func(a, b User) int {
return cmp.Or(
cmp.Compare(a.LastName, b.LastName), // 1순위: 성
cmp.Compare(a.FirstName, b.FirstName), // 2순위: 이름
cmp.Compare(a.Age, b.Age), // 3순위: 나이
)
})
자주 쓰는 다른 함수들도 짚어 둡니다. slices.Equal/slices.EqualFunc(슬라이스 비교), slices.Clone(얕은 복사), slices.Insert/slices.Delete(중간 삽입·삭제), slices.Concat(1.22, 여러 슬라이스 연결), slices.BinarySearch(이진 탐색), maps.Clone, maps.Equal 등이 있습니다. 사내 sliceutil, maputil 패키지는 이제 대부분 지울 수 있습니다.
레시피 2: 슬라이스 복사 변환 대신 이터레이터 함수 (1.23)#
Part 1의 이터레이터(레시피 5)가 표준 라이브러리에서 실제로 어떻게 쓰이는지 보여 주는 자리입니다. 1.23은 slices 와 maps 에 이터레이터 기반 함수들을 추가했습니다.
Before (2021) — 중간 슬라이스를 거치는 변환이 흔했습니다.
// 맵의 값들을 정렬된 슬라이스로
vals := make([]int, 0, len(m))
for _, v := range m {
vals = append(vals, v)
}
sort.Ints(vals)
After (2026) — 이터레이터를 직접 소비합니다.
vals := slices.Sorted(maps.Values(m)) // 정렬까지 한 번에
자주 쓰는 조합을 정리하면 다음과 같습니다.
slices.Collect(seq): 이터레이터를 슬라이스로 수집slices.Sorted(seq)/slices.SortedFunc(seq, cmp): 수집하면서 정렬slices.Values(s)/slices.All(s): 슬라이스를 이터레이터로slices.Chunk(s, n): n개씩 끊어 읽는 이터레이터maps.Keys(m)/maps.Values(m)/maps.All(m): 맵을 이터레이터로
이 함수들은 단독으로도 유용하지만, 직접 만든 이터레이터(iter.Seq)와 조합할 때 진가가 드러납니다. 예를 들어 데이터베이스 커서를 iter.Seq[Row] 로 노출하면, 그 위에 slices.Collect 를 씌워 일부만 메모리에 올리는 식의 조합이 가능합니다.
레시피 3: log.Printf 와 서드파티 로거 대신 log/slog (1.21)#
운영 환경에서 로그를 검색·집계하려면 구조적 로깅이 필수입니다. 2021년에는 표준에 없어서 logrus 나 zap 을 썼습니다. Go 1.21이 log/slog 를 표준에 넣으면서, 적어도 “구조적 로깅을 위해 의존성을 추가"할 이유는 사라졌습니다.
Before (2021) — 문자열 포맷 로그는 grep은 되지만 필드 단위 질의가 안 됩니다.
log.Printf("request handled: method=%s path=%s status=%d duration=%dms",
r.Method, r.URL.Path, status, ms)
After (2026) — slog 으로 키-값을 구조화합니다. JSON 핸들러를 쓰면 로그 수집 시스템에서 필드별 질의가 가능합니다.
import "log/slog"
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
slog.Info("request handled",
"method", r.Method,
"path", r.URL.Path,
"status", status,
"duration_ms", ms,
)
slog 의 실전 포인트 몇 가지를 짚습니다.
With로 컨텍스트 고정:reqLogger := logger.With("request_id", id)처럼 요청별 공통 필드를 한 번만 붙입니다.slog.LogAttrs로 할당 최소화: 핫패스에서는slog.LogAttrs(ctx, slog.LevelInfo, msg, slog.Int("status", status))형태가 가변 인자 방식보다 할당이 적습니다.- 레벨 동적 변경:
slog.LevelVar를 핸들러에 연결하면 런타임에 로그 레벨을 바꿀 수 있습니다. context전파:slog.InfoContext(ctx, ...)로 trace ID 등을 핸들러에서 꺼내 쓸 수 있습니다.
그리고 1.26에는 slog.NewMultiHandler 가 추가되어, 같은 로그를 여러 핸들러(예: 콘솔 + 파일)로 동시에 보내는 일이 표준만으로 가능해졌습니다.
다만 성능이 극단적으로 중요한 경우라면 zap 이 여전히 우위에 있을 수 있습니다. slog 의 선택 기준은 “표준만으로 충분한가"입니다. 신규 프로젝트나 의존성을 줄이고 싶은 프로젝트라면 slog 으로 시작하는 것이 합리적입니다.
레시피 4: 수동 에러 집계 대신 errors.Join 과 다중 래핑 (1.20)#
fmt.Errorf("%w", err) 로 단일 에러를 감싸는 것은 1.13부터 가능했지만, 여러 에러를 한꺼번에 다루는 표준 방법은 1.20에야 생겼습니다.
Before (2021) — 여러 작업의 에러를 모으려면 직접 슬라이스에 담고 문자열을 이어 붙였습니다.
var errs []string
for _, task := range tasks {
if err := task.Run(); err != nil {
errs = append(errs, err.Error())
}
}
if len(errs) > 0 {
return fmt.Errorf("some tasks failed: %s", strings.Join(errs, "; "))
// 문제: errors.Is/As로 개별 에러를 다시 검사할 수 없다
}
After (2026) — errors.Join 으로 합치면 errors.Is/errors.As 가 합쳐진 에러 전부를 관통해 검사합니다.
var err error
for _, task := range tasks {
err = errors.Join(err, task.Run()) // nil은 자동으로 무시됨
}
if err != nil {
return err
}
// 호출부: 합쳐진 에러 안의 특정 센티넬도 검사 가능
if errors.Is(err, ErrNotFound) { ... }
fmt.Errorf 도 1.20부터 %w 를 여러 개 받을 수 있습니다.
return fmt.Errorf("load config: %w (fallback also failed: %w)", primaryErr, fallbackErr)
그리고 1.26은 errors.As 의 제네릭 버전인 errors.AsType 을 추가했습니다. 대상 변수를 미리 선언할 필요가 없어집니다.
Before — errors.As 는 포인터 대상 변수가 필요합니다.
var perr *fs.PathError
if errors.As(err, &perr) {
log.Println(perr.Path)
}
After (2026) — errors.AsType 은 타입 파라미터로 받고 (T, bool) 을 반환합니다.
if perr, ok := errors.AsType[*fs.PathError](err); ok {
log.Println(perr.Path)
}
레시피 5: rand.Seed 와 전역 시드 대신 math/rand/v2 (1.22)#
math/rand 의 오래된 함정이 하나 있었습니다. 프로그램 시작 시 rand.Seed(time.Now().UnixNano()) 를 호출하지 않으면 매번 같은 난수열이 나왔습니다. 이걸 까먹어서 “운영에서 항상 같은 값이 나오는” 버그가 흔했습니다.
math/rand/v2(1.22)는 표준 라이브러리 최초의 v2 패키지입니다. 전역 생성기가 무조건 무작위로 시드 되므로, Seed 호출 자체가 사라졌습니다. (실제로 math/rand.Seed 는 1.24에서 no-op이 되었습니다.)
Before (2021)
rand.Seed(time.Now().UnixNano()) // 이걸 까먹으면 매번 같은 결과
n := rand.Intn(100)
After (2026) — 시드 불필요. 함수명도 정돈됐습니다(Intn → IntN).
import "math/rand/v2"
n := rand.IntN(100) // 시드 호출 불필요
d := rand.N(5 * time.Minute) // 제네릭 N: 임의 정수 타입에 동작
shuffle := rand.Perm(10)
rand.N[T] 는 제네릭이라 time.Duration 같은 정수 기반 타입에 바로 동작합니다. 내부 알고리즘도 ChaCha8, PCG 같은 현대적 PRNG로 교체되어 더 빠르고 품질이 좋습니다. (단, 둘 다 암호학적 용도는 아니며, 그 경우는 crypto/rand 를 써야 합니다.)
레시피 6: omitempty 의 함정 대신 omitzero, 그리고 json/v2 (1.24, 1.25)#
encoding/json 의 omitempty 는 오래된 골칫거리였습니다. 가장 악명 높은 사례가 time.Time 입니다. 제로값인 time.Time{} 도 omitempty 기준으로는 “비어 있지 않은” 값이라, 직렬화에서 빠지지 않았습니다. 0인 숫자를 생략하고 싶을 때도 *int 포인터로 바꾸는 우회가 필요했습니다.
Before (2021) — omitempty 가 의도대로 동작하지 않아 포인터로 우회했습니다.
type Event struct {
Name string `json:"name"`
At *time.Time `json:"at,omitempty"` // 포인터로 우회
Qty *int `json:"qty,omitempty"` // 0을 생략하려면 포인터
}
After (2026) — 1.24의 omitzero 는 진짜 제로값을 기준으로 판단하며, 타입에 IsZero() bool 메서드가 있으면 그것을 사용합니다. time.Time 도 의도대로 생략됩니다.
type Event struct {
Name string `json:"name"`
At time.Time `json:"at,omitzero"` // 제로 time.Time이면 생략
Qty int `json:"qty,omitzero"` // 0이면 생략
}
더 큰 변화는 진행 중입니다. Go 1.25는 encoding/json/v2 를 실험적 으로 도입했습니다(GOEXPERIMENT=jsonv2 로 빌드 시 활성화). 저수준 처리를 위한 encoding/json/jsontext 패키지도 함께 들어왔습니다. 디코딩 성능이 크게 개선되었고, 옵션 기반의 유연한 설정을 제공합니다.
// GOEXPERIMENT=jsonv2 환경에서
import "encoding/json/v2"
// 옵션을 명시적으로 받는 새 API 형태
data, err := json.Marshal(v, jsonOpts...)
2026년 6월 현재 json/v2 는 아직 실험 단계이므로 프로덕션 도입은 신중해야 합니다. 다만 방향성은 분명하므로, 새 직렬화 로직을 설계할 때 이 흐름을 염두에 두는 것이 좋습니다. 당장 적용할 수 있는 것은 omitzero 이고, 이것만으로도 포인터 우회 코드를 상당히 걷어낼 수 있습니다.
레시피 7: gorilla/mux 대신 표준 net/http 라우팅 (1.22)#
오랫동안 표준 http.ServeMux 는 메서드 구분도, 경로 파라미터도 지원하지 않아 실무에서 거의 안 썼습니다. gorilla/mux, chi, gin 같은 라우터가 사실상 필수였습니다. Go 1.22가 ServeMux 의 패턴 문법을 대폭 강화하면서 이 그림이 바뀌었습니다.
Before (2021) — 표준 mux로는 메서드/파라미터 처리가 불가능해 서드파티에 의존했습니다.
import "github.com/gorilla/mux"
r := mux.NewRouter()
r.HandleFunc("/items/{id}", getItem).Methods("GET")
r.HandleFunc("/items", createItem).Methods("POST")
// 핸들러 내부
vars := mux.Vars(r)
id := vars["id"]
After (2026) — 표준 ServeMux 가 METHOD /path/{param} 패턴과 r.PathValue 를 지원합니다.
mux := http.NewServeMux()
mux.HandleFunc("GET /items/{id}", getItem)
mux.HandleFunc("POST /items", createItem)
mux.HandleFunc("GET /files/{path...}", serveFiles) // 나머지 경로 전체 매칭
mux.HandleFunc("GET /exact/{$}", exactOnly) // 정확히 이 경로만
// 핸들러 내부
func getItem(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
...
}
패턴 문법을 정리하면 다음과 같습니다.
GET /path: 메서드 한정{id}: 단일 경로 세그먼트 캡처 →r.PathValue("id"){path...}: 남은 경로 전체 캡처{$}: 정확히 해당 경로만 매칭(후행 슬래시 하위 경로 제외)
더 구체적인 패턴이 우선하며, 우선순위가 모호하게 겹치는 패턴은 등록 시점에 panic으로 충돌을 알려 줍니다.
물론 미들웨어 체이닝, 그룹 라우팅 등 풍부한 기능이 필요하면 chi 같은 경량 라우터가 여전히 좋은 선택입니다. 하지만 “메서드 + 경로 파라미터” 정도가 전부인 다수의 서비스라면, 이제 표준만으로 충분합니다. 의존성 하나를 줄이는 것은 보안 패치 추적과 빌드 시간 측면에서 분명한 이득입니다.
마무리#
Part 2에서 다룬 표준 라이브러리 레시피를 정리합니다.
slices/maps/cmp(1.21): 사내 슬라이스·맵 유틸리티를 표준으로 교체.cmp.Or로 다중 기준 정렬.- 이터레이터 함수(1.23):
slices.Collect/Sorted,maps.Keys/Values로 중간 슬라이스 제거. log/slog(1.21): 구조적 로깅을 표준으로. 1.26의NewMultiHandler까지.errors.Join(1.20)·errors.AsType(1.26): 다중 에러 집계와 타입 검사를 표준 방식으로.math/rand/v2(1.22):rand.Seed졸업, 제네릭N.omitzero(1.24)·json/v2(1.25 실험):omitempty의 함정 탈출.net/http라우팅(1.22):gorilla/mux없이 메서드·경로 파라미터 처리.
다음 Part 3에서는 시리즈의 마지막으로 동시성·런타임·툴체인·운영 을 다룹니다. context 신규 API, sync.WaitGroup.Go, testing/synctest, 컨테이너 인식 GOMAXPROCS, PGO와 Green Tea GC, go.mod 의 tool 디렉티브, os.Root 등이 기다리고 있습니다.