2021년의 Go를 2026년의 Go로 Part 1: 언어와 문법 레시피
이 글은 Claude Opus 4.8 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
들어가며: 5년 동안 Go에 무슨 일이 있었나#
2021~22년에 Go로 서비스를 짜던 시절을 떠올려 봅니다. 그때 우리가 쓰던 버전은 대략 Go 1.16~1.18 언저리였습니다. 제네릭이 없거나(1.18 이전), 갓 도입되어 아직 손에 익지 않았고, interface{} 와 sort.Slice, for _, v := range xs { v := v; ... } 같은 관용구가 일상이었습니다.
그로부터 8개의 메이저 릴리스(1.19부터 1.26까지)가 지났습니다. 2026년 6월 현재 stable 버전은 2026년 2월에 나온 Go 1.26 입니다. 이 5년 사이에 언어 자체, 표준 라이브러리, 런타임, 툴체인이 모두 크게 달라졌습니다. 문제는 코드베이스가 그만큼 따라가지 못한다는 점입니다. 많은 프로덕션 코드가 여전히 “그때 그렇게 짜던 방식"으로 멈춰 있습니다.
이 시리즈는 그런 코드를 위한 리팩토링 레시피집 입니다. “2021년에는 이렇게 짰지만, 2026년에는 이렇게 짠다"를 Before/After 형태로 정리합니다. 대상 독자는 Go를 3년 이상 다뤄 온 중급~중상급 개발자입니다. 기본 문법 설명은 생략하고, 관용구의 교체에 집중합니다.
버전 타임라인 한눈에 보기#
| 버전 | 출시 | 이 시리즈에서 다루는 주요 변화 |
|---|---|---|
| 1.18 | 2022.03 | 제네릭, 퍼징, 워크스페이스 |
| 1.19 | 2022.08 | GOMEMLIMIT, atomic 타입 |
| 1.20 | 2023.02 | errors.Join, 다중 %w 래핑, PGO 프리뷰 |
| 1.21 | 2023.08 | min/max/clear, slices/maps/cmp, log/slog, sync.OnceValue, context 확장, PGO 정식 |
| 1.22 | 2024.02 | 루프 변수 per-iteration, range-over-int, math/rand/v2, net/http 라우팅 강화 |
| 1.23 | 2024.08 | range-over-func(이터레이터), iter, unique, slices/maps 이터레이터 함수 |
| 1.24 | 2025.02 | 제네릭 타입 별칭, Swiss Tables 맵, go.mod tool 디렉티브, os.Root, weak, omitzero, testing.B.Loop |
| 1.25 | 2025.08 | 컨테이너 인식 GOMAXPROCS, testing/synctest 정식, sync.WaitGroup.Go, encoding/json/v2(실험), Green Tea GC(실험) |
| 1.26 | 2026.02 | Green Tea GC 기본 활성화, new() 표현식, 자기참조 제네릭, go fix modernizers, errors.AsType |
Part 1에서는 언어와 문법 레벨 의 변화를 다룹니다. 표준 라이브러리는 Part 2, 동시성·런타임·툴체인은 Part 3에서 이어집니다.
레시피 1: interface{} 와 코드 생성 대신 제네릭 (1.18)#
가장 큰 변화이자 가장 많은 레거시를 만들어 낸 지점입니다. 2021년에는 타입에 무관한 컬렉션 유틸리티를 만들려면 두 가지 선택지밖에 없었습니다. 런타임 타입 단언을 동반한 interface{}, 아니면 go generate 기반 코드 생성입니다.
Before (2021) — interface{} 기반 유틸리티는 타입 안전성을 포기하고 런타임 단언 비용을 치릅니다.
// 호출부마다 .(int) 단언이 필요하고, 잘못된 타입은 런타임 panic.
func Map(in []interface{}, f func(interface{}) interface{}) []interface{} {
out := make([]interface{}, len(in))
for i, v := range in {
out[i] = f(v)
}
return out
}
After (2026) — 타입 파라미터로 컴파일 타임 안전성과 인라이닝 친화적인 코드를 동시에 얻습니다.
func Map[T, U any](in []T, f func(T) U) []U {
out := make([]U, len(in))
for i, v := range in {
out[i] = f(v)
}
return out
}
doubled := Map([]int{1, 2, 3}, func(n int) int { return n * 2 })
labels := Map(users, func(u User) string { return u.Name })
핵심은 “모든 곳에 제네릭을 쓰라"가 아닙니다. 제네릭이 빛나는 자리는 컨테이너 자료구조(셋, 순서 있는 맵, 링버퍼 등)와 컬렉션 변환 유틸리티 입니다. 반대로, 동작이 타입마다 달라지는 추상화에는 여전히 인터페이스가 정답입니다. “데이터 구조에는 제네릭, 행위 추상화에는 인터페이스"라는 기준을 두면 대부분의 설계 고민이 정리됩니다.
참고로 2021~22년에 흔하던
func(a, b interface{}) bool비교 콜백이나 빈 인터페이스 슬라이스([]interface{}) 변환 코드는 거의 전부 제네릭으로 대체 가능합니다. Part 2에서 다룰slices/maps패키지가 이 작업의 상당 부분을 이미 표준으로 흡수했습니다.
레시피 2: 직접 만든 min/max 헬퍼 대신 빌트인 (1.21)#
Go에 min, max, clear 가 언어 빌트인 으로 들어온 것은 1.21입니다. 그 전에는 프로젝트마다 작은 헬퍼가 굴러다녔습니다.
Before (2021) — 타입마다 헬퍼를 만들거나, 제네릭 도입 후에도 직접 정의했습니다.
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// 맵 비우기: 키를 순회하며 delete
for k := range cache {
delete(cache, k)
}
After (2026) — 빌트인이라 임포트도, 정의도 필요 없습니다. min/max 는 가변 인자를 받고, clear 는 맵을 비우거나 슬라이스를 제로값으로 채웁니다.
hi := max(a, b, c) // 인자 여러 개 가능
lo := min(x, y)
clamped := min(max(v, 0), 100)
clear(cache) // 맵의 모든 엔트리 삭제
clear(buf) // 슬라이스 원소를 전부 제로값으로
사소해 보이지만, 사내 pkg/util 에 쌓여 있던 MaxInt, MinInt64, MaxFloat 류 함수들을 통째로 지울 수 있는 변화입니다. 코드 리뷰에서 “이거 표준에 있는데요"를 줄여 줍니다.
레시피 3: 루프 변수 캡처 트릭의 졸업 (1.22)#
Go 입문자를 가장 많이 울린 버그가 1.22에서 언어 차원으로 해결됐습니다. 1.21 이전에는 루프 변수가 반복 전체에서 공유 되었기 때문에, 클로저나 고루틴이 루프 변수를 캡처하면 전부 마지막 값을 참조했습니다.
Before (2021) — v := v 섀도잉이 거의 의무였습니다.
for _, item := range items {
item := item // 이 줄이 없으면 모든 고루틴이 마지막 item을 봄
go func() {
process(item)
}()
}
After (2026) — Go 1.22부터 루프 변수는 매 반복마다 새로 생성 됩니다. 섀도잉 줄이 필요 없습니다. 이는 for range 뿐 아니라 3-절 for i := 0; ... 형태에도 동일하게 적용됩니다.
for _, item := range items {
go func() {
process(item) // item은 이번 반복 전용 변수. 안전.
}()
}
주의할 점이 하나 있습니다. 이 동작은 go.mod 의 go 지시문이 1.22 이상 일 때만 켜집니다. 즉, 코드가 같아도 모듈이 선언한 Go 버전에 따라 의미가 달라집니다. 그래서 레거시 모듈을 올릴 때는 go.mod 의 go 1.16 을 그냥 올리기 전에, 이 시맨틱 변화를 의도적으로 검토해야 합니다. 실수로 v := v 에 의존하던 코드가 있다면 동작은 그대로지만(섀도잉은 여전히 합법), vet 이 불필요한 섀도잉을 알려줄 수 있습니다.
또한 1.22에는 go vet 에 루프 변수가 함수 종료 후에도 참조되는 위험 패턴을 잡는 검사가 강화되어 있어, 업그레이드 후 go vet ./... 를 한 번 돌려 보는 것을 권합니다.
레시피 4: 인덱스 카운터 루프 대신 range-over-int (1.22)#
“N번 반복"이라는 가장 흔한 패턴이 1.22에서 한 줄 짧아졌습니다.
Before (2021)
for i := 0; i < 10; i++ {
fmt.Println(i)
}
// 값이 필요 없는 반복
for i := 0; i < n; i++ {
work()
}
After (2026) — 정수에 직접 range 를 걸 수 있습니다.
for i := range 10 {
fmt.Println(i)
}
for range n { // 인덱스조차 필요 없으면 변수 생략
work()
}
for range n 형태는 “정확히 n번 실행"이라는 의도를 더 명확히 드러냅니다. 테스트 코드의 “100번 반복해서 부하 주기” 같은 곳에서 특히 가독성이 좋아집니다.
레시피 5: 콜백·슬라이스 반환 대신 이터레이터 (1.23)#
이번 시리즈에서 가장 “새로운 사고방식"을 요구하는 변화입니다. Go 1.23은 range-over-func, 즉 함수를 for range 로 순회하는 기능을 도입했습니다. 함께 들어온 iter 패키지가 iter.Seq[T] 와 iter.Seq2[K, V] 타입을 정의합니다.
2021년에는 커스텀 컬렉션을 순회시키는 방법이 빈약했습니다. 슬라이스를 통째로 복사해 반환하거나(메모리 낭비), Each(func(...) bool) 같은 콜백 메서드를 노출하거나(for 와 이질적인 제어 흐름), 인덱스 기반 커서를 직접 관리해야 했습니다.
Before (2021) — 콜백 기반 순회. break/continue 가 자연스럽지 않고, 중첩 시 가독성이 떨어집니다.
type Set[T comparable] struct{ m map[T]struct{} }
// 호출자는 표준 for 문이 아니라 콜백 규약을 따라야 한다.
func (s *Set[T]) Each(f func(T) bool) {
for v := range s.m {
if !f(v) {
return
}
}
}
s.Each(func(v string) bool {
fmt.Println(v)
return true // 계속하려면 true를 반환해야 한다는 규약
})
After (2026) — iter.Seq[T] 를 반환하는 All() 메서드를 노출하면, 호출자는 그냥 for range 를 씁니다. break, continue, return 이 전부 자연스럽게 동작합니다.
import "iter"
func (s *Set[T]) All() iter.Seq[T] {
return func(yield func(T) bool) {
for v := range s.m {
if !yield(v) {
return
}
}
}
}
for v := range s.All() {
if v == target {
break // 표준 제어 흐름이 그대로 동작
}
fmt.Println(v)
}
Go 표준 라이브러리의 컨벤션은 “컨테이너 타입은 All 메서드로 이터레이터를 제공한다"입니다. 이를 따르면 사용자가 “직접 range를 걸어야 하나, 아니면 호출해서 값을 받아야 하나"를 고민할 필요가 없습니다.
처음 작성할 때 func(yield func(T) bool) 시그니처가 낯설게 느껴질 수 있습니다. 커뮤니티에서도 “처음엔 머리가 꼬이지만, 한 번 써 보면 상태 머신을 직접 관리하는 것보다 훨씬 쉽다"는 평이 지배적입니다. 무한 시퀀스, 페이지네이션, 스트리밍 파싱처럼 “전부 메모리에 올리고 싶지 않은” 경우에 특히 강력합니다.
Part 2에서 다룰
slices.Collect,slices.Sorted,maps.Keys등이 모두 이 이터레이터 위에 세워져 있어서, 1.23 이후의 표준 라이브러리는 이 개념을 모르면 절반만 쓰는 셈이 됩니다.
레시피 6: 제네릭 타입 별칭과 최신 타입 시스템 (1.24, 1.26)#
제네릭이 1.18에 들어왔지만, 타입 별칭(type alias)에는 타입 파라미터를 붙일 수 없다 는 제약이 오래 남아 있었습니다. 이 제약이 1.24에서 풀렸습니다.
Before (2022) — 별칭에 타입 파라미터를 못 붙이니, 별칭으로 짧게 쓰고 싶어도 새 정의 타입(defined type)을 만들어야 했고, 그러면 메서드 집합과 변환 규칙이 달라지는 부수 효과가 생겼습니다.
// 이건 컴파일 에러였다 (1.24 이전):
// type StringMap[V any] = map[string]V
// 그래서 어쩔 수 없이 새 타입을 정의하곤 했다.
type StringMap[V any] map[string]V // 별칭이 아니라 별개 타입
After (2026) — 타입 별칭에 타입 파라미터를 붙일 수 있습니다. 긴 제네릭 타입에 짧고 의미 있는 이름을 주면서도, 원본 타입과 완전히 호환됩니다.
type StringMap[V any] = map[string]V // 진짜 별칭
type Result[T any] = struct { // 복잡한 제네릭 구조에 별칭
Value T
Err error
}
여기에 1.26은 타입 시스템을 두 군데 더 다듬었습니다.
첫째, 자기참조 제네릭 타입 제약이 풀렸습니다. 타입 파라미터 목록에서 자기 자신을 참조하는, 이른바 CRTP 스타일의 인터페이스를 표현할 수 있습니다.
// Go 1.26부터 합법. "자기 자신과만 더해지는 타입" 같은 제약을 표현.
type Adder[A Adder[A]] interface {
Add(A) A
}
둘째, new() 가 표현식을 받습니다. 초기값을 가진 포인터를 만들 때 흔히 쓰던 헬퍼 함수가 사라집니다.
Before (2021) — “값을 가리키는 포인터"를 만들려면 제네릭 헬퍼나 임시 변수가 필요했습니다.
func ptr[T any](v T) *T { return &v }
cfg := Config{
Timeout: ptr(30),
Name: ptr("default"),
}
After (2026) — new() 에 표현식을 직접 넘깁니다.
cfg := Config{
Timeout: new(30),
Name: new("default"),
}
ptr/ref/addrOf 같은 헬퍼는 거의 모든 Go 코드베이스에 하나씩 있었는데, 1.26부터는 이걸 지울 수 있습니다.
마무리#
Part 1에서 다룬 변화를 한 줄씩 정리하면 다음과 같습니다.
- 제네릭(1.18):
interface{}와 코드 생성으로 만들던 컨테이너·컬렉션 유틸리티를 타입 안전하게 다시 씁니다. min/max/clear(1.21): 사내 헬퍼를 빌트인으로 교체합니다.- 루프 변수 per-iteration(1.22):
v := v섀도잉 트릭을 졸업합니다(단,go.mod버전 주의). - range-over-int(1.22):
for i := range n으로 카운터 루프를 간결하게. - 이터레이터(1.23): 콜백·슬라이스 반환을
iter.Seq로 대체해 표준for range제어 흐름을 회복합니다. - 제네릭 타입 별칭(1.24)·
new()표현식·자기참조 제네릭(1.26): 타입 시스템의 마지막 거친 모서리들을 다듬습니다.
다음 Part 2에서는 일상 코드에서 가장 자주 손이 가는 표준 라이브러리 — slices/maps/cmp, log/slog, errors, encoding/json, net/http 라우팅 — 의 레시피를 다룹니다.