이 글은 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은 slicesmaps 에 이터레이터 기반 함수들을 추가했습니다.

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년에는 표준에 없어서 logruszap 을 썼습니다. 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 을 추가했습니다. 대상 변수를 미리 선언할 필요가 없어집니다.

Beforeerrors.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) — 시드 불필요. 함수명도 정돈됐습니다(IntnIntN).

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/jsonomitempty 는 오래된 골칫거리였습니다. 가장 악명 높은 사례가 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) — 표준 ServeMuxMETHOD /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 등이 기다리고 있습니다.


References#