Go의 제네릭: 타입 파라미터로 배우기

1. 개요

Go 1.18부터, type parameter를 사용하여 함수와 데이터 타입을 작성할 수 있습니다. 덕분에 int, float64, string 등 각각의 타입마다 같은 로직을 반복해서 작성할 필요가 없어지며, interface{}에서 타입을 강제 변환할 필요도 없습니다.

이 글은 처음의 문제부터 시작하여 generic 함수, constraint, 그리고 ~ 표기법을 다룹니다. 그 다음으로 Stack[T]와 Cache[K, V] 같은 generic 타입, 테스트 작성 방법, 그리고 흔한 에러들을 다룹니다. 다 읽고 나면 어떤 경우에 generics를 사용해야 하고 언제는 일반 interface로 충분한지 알게 될 것입니다.

모든 예제는 Go 1.22 이상을 사용합니다. cmp 패키지는 Go 1.21부터 제공됩니다. go.mod 파일의 최소 요구사항:

module example.com/shop

go 1.22

외부 라이브러리가 필요하지 않습니다. 이 글의 모든 내용은 표준 라이브러리에 포함되어 있습니다.

2. generics 이전의 문제

다음과 같이 상품 수량(int)의 합계와 금액(float64)의 합계를 계산해야 한다고 가정해 봅시다. Go 1.18 이전에는 보통 두 개의 동일한 함수를 작성해야 했습니다:

func SumInts(xs []int) int {
	var total int
	for _, x := range xs {
		total += x
	}
	return total
}

func SumFloats(xs []float64) float64 {
	var total float64
	for _, x := range xs {
		total += x
	}
	return total
}

다른 방법은 []interface{}를 받아 type switch를 사용하는 것입니다. 하지만 이 방법은 타입 안전성을 잃습니다. 에러는 런타임에만 발견되고, 함수를 호출하는 모든 곳에서 타입을 강제 변환해야 합니다.

Generics는 이 문제를 정확히 해결합니다: 로직을 한 번만 작성하면, compiler가 타입을 확인해 줍니다.

3. 첫 번째 generic 함수

3.1. type parameter 문법

Type parameter는 함수 이름 바로 뒤의 대괄호 안에 선언됩니다:

package main

import "fmt"

type Number interface {
	~int | ~int64 | ~float64
}

func Sum[T Number](xs []T) T {
	var total T
	for _, x := range xs {
		total += x
	}
	return total
}

func main() {
	quantities := []int{2, 1, 5}
	prices := []float64{120.5, 99.9, 15}

	fmt.Println(Sum(quantities))
	fmt.Println(Sum(prices))
}

각 부분을 설명하면:

  • [T Number]는 type parameter T를 선언합니다. T는 Number constraint를 만족해야 합니다.
  • Number는 type set를 포함하는 interface입니다: int, int64, float64 그리고 이들의 underlying type (~ 기호의 의미는 4.3절에서 설명합니다).
  • var total T는 T의 zero value를 생성합니다. 즉, 0 또는 0.0입니다.
  • Operator +=는 유효합니다. type set의 모든 타입이 덧셈 연산을 지원하기 때문입니다.

결과:

8
235.4

3.2. Type inference

Sum(quantities)로 호출하는 것을 보셨나요? Sum[int](quantities)가 아니라요? Compiler가 인자에서 T를 자동으로 추론합니다. 이것이 type inference입니다.

compiler가 추론할 정보가 부족할 때만 명시적으로 작성해야 합니다. 예를 들어 type parameter가 반환 타입에만 나타날 때입니다:

func Zero[T any]() T {
	var z T
	return z
}

func main() {
	fmt.Println(Zero[string]() == "") // true
}

3.3. 여러 type parameter: Map과 Filter

이 두 함수는 가장 일반적인 유틸리티 함수들입니다. Order 목록에 적용해 봅시다:

package main

import "fmt"

type Order struct {
	ID       string
	Customer string
	Total    float64
}

func Map[T, U any](xs []T, f func(T) U) []U {
	out := make([]U, 0, len(xs))
	for _, x := range xs {
		out = append(out, f(x))
	}
	return out
}

func Filter[T any](xs []T, keep func(T) bool) []T {
	var out []T
	for _, x := range xs {
		if keep(x) {
			out = append(out, x)
		}
	}
	return out
}

func main() {
	orders := []Order{
		{"A01", "Lan", 250_000},
		{"A02", "Minh", 1_200_000},
		{"A03", "Lan", 890_000},
	}

	big := Filter(orders, func(o Order) bool { return o.Total >= 500_000 })
	ids := Map(big, func(o Order) string { return o.ID })

	fmt.Println(ids)
}
  • Map[T, U any]는 두 개의 type parameter를 가지므로, 입력과 출력의 타입이 다를 수 있습니다.
  • any는 interface{}의 alias입니다. 모든 타입을 허용하지만, 할당과 전달 이외의 연산자는 사용할 수 없습니다.
  • Compiler는 함수 func(o Order) string에서 T = Order와 U = string을 추론합니다.

결과:

[A02 A03]

4. Constraint: T가 할 수 있는 것을 제한하기

Constraint는 함수 내부에서 타입 T의 값에 무엇을 할 수 있는지 결정합니다.

4.1. comparable

comparable은 미리 정의된 constraint입니다. ==와 !=를 사용하게 해주며, T가 map의 키가 되기 위한 조건이기도 합니다:

func Unique[T comparable](xs []T) []T {
	seen := make(map[T]struct{}, len(xs))
	var out []T
	for _, x := range xs {
		if _, ok := seen[x]; !ok {
			seen[x] = struct{}{}
			out = append(out, x)
		}
	}
	return out
}

// Unique([]string{"Lan", "Minh", "Lan"}) => [Lan Minh]

위의 Order 구조체도 comparable입니다. 그 필드들이 모두 비교 가능하기 때문입니다. Slice, map 또는 func을 포함하는 구조체는 comparable을 만족하지 않으며, compiler가 오류를 보고합니다.

4.2. cmp.Ordered

크거나 작은 값을 비교할 때는 표준 라이브러리의 cmp.Ordered를 사용하세요:

import "cmp"

func MaxBy[T any, K cmp.Ordered](xs []T, key func(T) K) (T, bool) {
	var best T
	if len(xs) == 0 {
		return best, false
	}
	best = xs[0]
	for _, x := range xs[1:] {
		if key(x) > key(best) {
			best = x
		}
	}
	return best, true
}

// top, _ := MaxBy(orders, func(o Order) float64 { return o.Total })
// top.ID => "A02"
  • K cmp.Ordered는 키 값에 >를 사용할 수 있게 해줍니다.
  • 함수는 (T, bool)을 반환하여 panic을 일으키지 않고 빈 slice를 처리합니다.

참고: 간단한 숫자 slice의 경우 직접 작성할 필요가 없습니다. Go 1.21부터는 built-in 함수 max/min이 있고, slices 패키지에는 slices.Max, slices.Contains, slices.Sort가 있습니다. generic 유틸리티를 작성하기 전에 slices와 maps를 확인하세요.

4.3. ~ (tilde) 기호

실제 프로젝트에서는 종종 금액을 위한 자신만의 타입을 정의합니다:

type VND int64

func main() {
	payments := []VND{150_000, 350_000}
	fmt.Println(Sum(payments)) // 500000
}

이 줄이 컴파일되는 이유는 Number의 ~int64 때문입니다. ~int64 기호는 “underlying type이 int64인 모든 타입”을 의미합니다. ~ 없이 int64를 작성하면 VND가 constraint를 만족하지 않고, compiler는 다음과 같이 보고합니다:

VND does not satisfy Number (possibly missing ~ for int64 in Number)

간단한 규칙: 기본 타입에 대한 constraint를 작성할 때는 거의 항상 ~를 사용해야 합니다.

4.4. Method를 포함하는 Constraint

Constraint는 일반 interface처럼 method를 요구할 수도 있습니다:

type Identifiable interface {
	comparable
	Key() string
}

Identifiable을 만족하려면 비교 가능하면서 동시에 Key() string method를 가져야 합니다.

5. Generic 타입

5.1. Stack[T]

Type parameter는 함수뿐만 아니라 구조체에도 사용됩니다:

type Stack[T any] struct {
	items []T
}

func (s *Stack[T]) Push(v T) {
	s.items = append(s.items, v)
}

func (s *Stack[T]) Pop() (T, bool) {
	var zero T
	if len(s.items) == 0 {
		return zero, false
	}
	last := len(s.items) - 1
	v := s.items[last]
	s.items = s.items[:last]
	return v, true
}

func (s *Stack[T]) Len() int { return len(s.items) }

사용 방법:

var history Stack[Order]
history.Push(Order{ID: "A01"})
history.Push(Order{ID: "A02"})

o, _ := history.Pop()
fmt.Println(o.ID, history.Len()) // A02 1
  • Receiver를 *Stack[T]로 작성해야 하며, [T]를 꼭 포함해야 합니다.
  • 변수를 선언할 때 타입을 명시해야 합니다: Stack[Order]. 여기에는 inference가 없습니다.
  • Stack[Order]의 zero value는 바로 사용 가능합니다. append가 nil slice에서도 잘 작동하기 때문입니다.

5.2. 여러 goroutine에 안전한 Cache[K, V]

더 현실적인 예제: Customer와 Order를 모두 사용할 수 있는 in-memory cache입니다.

package cache

import "sync"

type Cache[K comparable, V any] struct {
	mu   sync.RWMutex
	data map[K]V
}

func New[K comparable, V any]() *Cache[K, V] {
	return &Cache[K, V]{data: make(map[K]V)}
}

func (c *Cache[K, V]) Put(key K, value V) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.data[key] = value
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.data[key]
	return v, ok
}
type Customer struct {
	ID   int
	Name string
}

customers := cache.New[int, Customer]()
customers.Put(1, Customer{ID: 1, Name: "Lan"})

orders := cache.New[string, Order]()
orders.Put("A01", Order{ID: "A01", Total: 250_000})

c, ok := customers.Get(1)
fmt.Println(c.Name, ok) // Lan true

한 가지 구현이 두 가지 데이터 타입을 처리합니다. customers.Get은 정확히 Customer를 반환하며, type assertion이 필요 없습니다. 실수로 Order를 customers에 Put하려고 하면 코드가 컴파일되지 않습니다.

6. Generic 코드를 위한 테스트 작성

Go는 표준 testing 패키지를 사용합니다. Table-driven test는 generic 함수에서도 정상적으로 작동합니다. 각 테스트 표는 특정 타입에 대응됩니다:

package main

import "testing"

func TestSum(t *testing.T) {
	ints := []struct {
		name string
		in   []int
		want int
	}{
		{"empty", nil, 0},
		{"positive", []int{2, 1, 5}, 8},
		{"mixed", []int{10, -3}, 7},
	}
	for _, tc := range ints {
		t.Run(tc.name, func(t *testing.T) {
			if got := Sum(tc.in); got != tc.want {
				t.Errorf("Sum(%v) = %d, want %d", tc.in, got, tc.want)
			}
		})
	}

	if got := Sum([]VND{150_000, 350_000}); got != 500_000 {
		t.Errorf("Sum VND = %d, want 500000", got)
	}
}

func TestStackPopEmpty(t *testing.T) {
	var s Stack[string]
	v, ok := s.Pop()
	if ok || v != "" {
		t.Fatalf("Pop() on empty = (%q, %v), want (\"\", false)", v, ok)
	}
}

테스트 실행:

go test ./... -v
=== RUN   TestSum
=== RUN   TestSum/empty
=== RUN   TestSum/positive
=== RUN   TestSum/mixed
--- PASS: TestSum (0.00s)
=== RUN   TestStackPopEmpty
--- PASS: TestStackPopEmpty (0.00s)
PASS

VND 같은 파생 타입을 최소한 한 번은 테스트하세요. 이것이 ~가 빠진 constraint를 발견하는 가장 빠른 방법입니다.

7. 흔한 오류와 generics를 사용하지 말아야 할 때

Method는 자신만의 type parameter를 가질 수 없습니다. 다음 코드는 컴파일되지 않습니다:

// lỗi: methods cannot have type parameters
func (s *Stack[T]) Map[U any](f func(T) U) *Stack[U] { ... }

top-level 함수로 작성하세요: func MapStack[T, U any](s *Stack[T], f func(T) U) *Stack[U].

T에서 직접 type switch를 사용하지 마세요. 타입에 따라 분기하려면 먼저 any로 변환해야 합니다: switch v := any(x).(type) { ... }. 이 작업을 자주 한다면 generics가 올바른 도구가 아닐 수 있습니다.

Type set이 있는 constraint는 변수 타입으로 사용할 수 없습니다. var n Number는 오류입니다. | 또는 ~을 포함하는 interface는 constraint 위치에서만 사용할 수 있습니다.

interface에서 comparable은 panic될 수 있습니다. K가 interface 타입이고 그 내부의 동적 값이 slice라면, 비교 또는 map 키로 사용하면 런타임에 panic이 발생합니다.

참고: Generics는 자동으로 interface보다 빠르지 않습니다. Go compiler는 “GC shape stenciling”과 사전(dictionary)을 사용합니다. 서로 다른 포인터 타입은 같은 코드를 공유할 수 있으며, constraint를 통한 method 호출은 여전히 간접 비용이 있습니다. 성능이 중요하다면 추측하지 말고 benchmark로 측정하세요.

Generics를 사용하지 말아야 할 때:

  • Logic이 행동(method)에 따라 달라지고 타입에 따라 달라지지 않을 때입니다. 예를 들어 PaymentService가 Send method를 가진 Notifier를 받을 때입니다. 보통 interface가 더 간결하고 읽기 쉽습니다.
  • 함수를 사용하는 타입이 하나뿐일 때입니다. “나중에 필요할 지도”라는 이유로 generic화하지 마세요.
  • slices, maps, cmp에 이미 필요한 함수가 있을 때입니다.

기억하기 쉬운 규칙: 같은 코드를 여러 타입에 대해 작성하고 있는데 타입 이름만 다르다면, 그때가 generics를 사용할 때입니다.

8. 결론

이 글은 Go의 generics의 주요 부분들을 살펴보았습니다:

  • type parameter [T C]를 선언하고 compiler가 타입을 자동으로 추론하게 하기.
  • any, comparable, cmp.Ordered 같은 constraint를 사용하고, VND 같은 파생 타입을 지원하기 위해 ~로 직접 정의된 constraint 사용하기.
  • 여러 goroutine에 안전한 generic 타입 Stack[T]와 Cache[K, V] 구축하기.
  • generic 함수를 위한 table-driven test 작성하기.
  • 제한사항 인식하기: method는 자신만의 type parameter를 가질 수 없음, constraint는 변수 타입이 될 수 없음, generics는 interface를 대체하지 않음.

프로젝트에서 정말 반복되는 코드에서 시작하세요. 최소한의 수준에서 generic을 작성하고, 표준 라이브러리를 우선으로 사용하며, 항상 최소 두 가지 다른 타입에 대한 테스트를 포함하세요.

golanggenericstype parameterconstraint