본문으로 건너뛰기

제네릭(Generics, Go 1.18+)

Go 1.18에서 도입된 제네릭은 타입 파라미터를 사용하여 여러 타입에 동작하는 범용 코드를 작성할 수 있게 합니다. 중복 코드를 줄이면서도 타입 안전성을 유지합니다.

타입 파라미터 기본

제네릭 함수는 [T 제약] 형태의 타입 파라미터를 사용합니다.

package main

import "fmt"

// 제네릭 이전: 각 타입마다 별도 함수 필요
func minInt(a, b int) int {
if a < b {
return a
}
return b
}

func minFloat(a, b float64) float64 {
if a < b {
return a
}
return b
}

// 제네릭: 하나의 함수로 여러 타입 처리
// T는 타입 파라미터, comparable은 제약(constraint)
func Min[T int | float64 | string](a, b T) T {
if a < b {
return a
}
return b
}

// 여러 타입 파라미터
func Map[T, U any](slice []T, f func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = f(v)
}
return result
}

func Filter[T any](slice []T, predicate func(T) bool) []T {
var result []T
for _, v := range slice {
if predicate(v) {
result = append(result, v)
}
}
return result
}

func Reduce[T, U any](slice []T, initial U, f func(U, T) U) U {
result := initial
for _, v := range slice {
result = f(result, v)
}
return result
}

func main() {
fmt.Println(Min(3, 7)) // 3
fmt.Println(Min(3.14, 2.72)) // 2.72
fmt.Println(Min("banana", "apple")) // apple

nums := []int{1, 2, 3, 4, 5}

// Map: 정수 → 문자열
strs := Map(nums, func(n int) string {
return fmt.Sprintf("item_%d", n)
})
fmt.Println(strs) // [item_1 item_2 item_3 item_4 item_5]

// Filter: 짝수만
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
fmt.Println(evens) // [2 4]

// Reduce: 합계
sum := Reduce(nums, 0, func(acc, n int) int { return acc + n })
fmt.Println(sum) // 15
}

제약(Constraint)

제약은 타입 파라미터가 만족해야 하는 조건을 정의합니다.

package main

import (
"fmt"
"golang.org/x/exp/constraints" // 외부 패키지 (예시)
)

// 직접 제약 정의
type Number interface {
int | int8 | int16 | int32 | int64 |
uint | uint8 | uint16 | uint32 | uint64 |
float32 | float64
}

type Ordered interface {
int | int8 | int16 | int32 | int64 |
uint | uint8 | uint16 | uint32 | uint64 |
float32 | float64 | string
}

// ~를 사용한 기반 타입 제약 (underlying type)
type Integer interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}

// 커스텀 타입도 int 기반이면 포함됨
type MyInt int

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

func Contains[T comparable](slice []T, target T) bool {
for _, v := range slice {
if v == target {
return true
}
}
return false
}

func Keys[K comparable, V any](m map[K]V) []K {
keys := make([]K, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}

func Values[K comparable, V any](m map[K]V) []V {
vals := make([]V, 0, len(m))
for _, v := range m {
vals = append(vals, v)
}
return vals
}

func main() {
ints := []int{1, 2, 3, 4, 5}
floats := []float64{1.1, 2.2, 3.3}

fmt.Println(Sum(ints)) // 15
fmt.Println(Sum(floats)) // 6.6

// MyInt도 ~int 제약을 만족하므로 작동
myInts := []MyInt{10, 20, 30}
fmt.Println(Sum(myInts)) // 60

strs := []string{"a", "b", "c", "d"}
fmt.Println(Contains(strs, "c")) // true
fmt.Println(Contains(strs, "z")) // false
fmt.Println(Contains(ints, 3)) // true

m := map[string]int{"a": 1, "b": 2, "c": 3}
fmt.Println(Keys(m)) // [a b c] (순서 비결정)
fmt.Println(Values(m)) // [1 2 3] (순서 비결정)
}

제네릭 타입

제네릭은 함수뿐 아니라 구조체, 인터페이스에도 적용됩니다.

package main

import (
"errors"
"fmt"
)

// 제네릭 스택
type Stack[T any] struct {
items []T
}

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

func (s *Stack[T]) Pop() (T, error) {
if len(s.items) == 0 {
var zero T
return zero, errors.New("스택이 비었습니다")
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, nil
}

func (s *Stack[T]) Peek() (T, error) {
if len(s.items) == 0 {
var zero T
return zero, errors.New("스택이 비었습니다")
}
return s.items[len(s.items)-1], nil
}

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

// 제네릭 Optional (Go의 Maybe 패턴)
type Optional[T any] struct {
value *T
}

func Some[T any](v T) Optional[T] {
return Optional[T]{value: &v}
}

func None[T any]() Optional[T] {
return Optional[T]{}
}

func (o Optional[T]) IsPresent() bool { return o.value != nil }

func (o Optional[T]) Get() (T, bool) {
if o.value == nil {
var zero T
return zero, false
}
return *o.value, true
}

func (o Optional[T]) OrElse(defaultVal T) T {
if o.value == nil {
return defaultVal
}
return *o.value
}

// 제네릭 Result 타입 (에러 처리)
type Result[T any] struct {
value T
err error
}

func Ok[T any](v T) Result[T] { return Result[T]{value: v} }
func Err[T any](e error) Result[T] { return Result[T]{err: e} }

func (r Result[T]) IsOk() bool { return r.err == nil }
func (r Result[T]) Unwrap() T { return r.value }
func (r Result[T]) Error() error { return r.err }

func main() {
// 제네릭 스택
intStack := &Stack[int]{}
intStack.Push(1)
intStack.Push(2)
intStack.Push(3)

for !intStack.IsEmpty() {
v, _ := intStack.Pop()
fmt.Print(v, " ")
}
fmt.Println()

strStack := &Stack[string]{}
strStack.Push("Go")
strStack.Push("Python")
strStack.Push("Rust")
top, _ := strStack.Peek()
fmt.Println("Top:", top)

// Optional
name := Some("홍길동")
if n, ok := name.Get(); ok {
fmt.Println("이름:", n)
}

empty := None[string]()
fmt.Println("기본값:", empty.OrElse("익명"))

// Result
divide := func(a, b float64) Result[float64] {
if b == 0 {
return Err[float64](errors.New("0으로 나눌 수 없습니다"))
}
return Ok(a / b)
}

r1 := divide(10, 3)
r2 := divide(10, 0)

if r1.IsOk() {
fmt.Printf("10/3 = %.4f\n", r1.Unwrap())
}
if !r2.IsOk() {
fmt.Println("에러:", r2.Error())
}
}

제네릭 인터페이스와 타입 집합

package main

import (
"fmt"
"math"
)

// 인터페이스를 제약으로 사용
type Stringer interface {
String() string
}

type Numeric interface {
~int | ~int32 | ~int64 | ~float32 | ~float64
}

// 인터페이스 메서드와 타입 집합 조합
type NumberStringer interface {
Numeric
String() string
}

// 제네릭 함수에서 인터페이스 메서드 호출
func PrintAll[T Stringer](items []T) {
for _, item := range items {
fmt.Println(item.String())
}
}

// 제네릭 수학 함수들
func Abs[T Numeric](v T) T {
if v < 0 {
return -v
}
return v
}

func Clamp[T Numeric](v, min, max T) T {
if v < min {
return min
}
if v > max {
return max
}
return v
}

func Distance[T Numeric](x1, y1, x2, y2 T) float64 {
dx := float64(x2 - x1)
dy := float64(y2 - y1)
return math.Sqrt(dx*dx + dy*dy)
}

// 제네릭 페어
type Pair[A, B any] struct {
First A
Second B
}

func NewPair[A, B any](a A, b B) Pair[A, B] {
return Pair[A, B]{First: a, Second: b}
}

func (p Pair[A, B]) String() string {
return fmt.Sprintf("(%v, %v)", p.First, p.Second)
}

func (p Pair[A, B]) Swap() Pair[B, A] {
return Pair[B, A]{First: p.Second, Second: p.First}
}

type Point[T Numeric] struct {
X, Y T
}

func (p Point[T]) String() string {
return fmt.Sprintf("Point(%v, %v)", p.X, p.Y)
}

func main() {
fmt.Println(Abs(-42)) // 42
fmt.Println(Abs(-3.14)) // 3.14
fmt.Println(Clamp(15, 0, 10)) // 10
fmt.Println(Clamp(-5, 0, 10)) // 0
fmt.Println(Distance(0, 0, 3, 4)) // 5

p1 := NewPair("hello", 42)
p2 := NewPair(3.14, true)
fmt.Println(p1) // (hello, 42)
fmt.Println(p1.Swap()) // (42, hello)
fmt.Println(p2) // (3.14, true)

intPoint := Point[int]{X: 3, Y: 4}
floatPoint := Point[float64]{X: 1.5, Y: 2.5}
fmt.Println(intPoint) // Point(3, 4)
fmt.Println(floatPoint) // Point(1.5, 2.5)

points := []Point[int]{{1, 2}, {3, 4}, {5, 6}}
PrintAll(points)
}

실전 예제: 제네릭 캐시

package main

import (
"fmt"
"sync"
"time"
)

type CacheEntry[V any] struct {
value V
expiresAt time.Time
}

func (e CacheEntry[V]) IsExpired() bool {
return time.Now().After(e.expiresAt)
}

type Cache[K comparable, V any] struct {
mu sync.RWMutex
entries map[K]CacheEntry[V]
ttl time.Duration
}

func NewCache[K comparable, V any](ttl time.Duration) *Cache[K, V] {
c := &Cache[K, V]{
entries: make(map[K]CacheEntry[V]),
ttl: ttl,
}
// 만료 항목 주기적 정리
go c.cleanup()
return c
}

func (c *Cache[K, V]) Set(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[key] = CacheEntry[V]{
value: value,
expiresAt: time.Now().Add(c.ttl),
}
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
entry, ok := c.entries[key]
if !ok || entry.IsExpired() {
var zero V
return zero, false
}
return entry.value, true
}

func (c *Cache[K, V]) Delete(key K) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, key)
}

func (c *Cache[K, V]) cleanup() {
ticker := time.NewTicker(c.ttl / 2)
defer ticker.Stop()
for range ticker.C {
c.mu.Lock()
for k, entry := range c.entries {
if entry.IsExpired() {
delete(c.entries, k)
}
}
c.mu.Unlock()
}
}

func main() {
// 문자열 키, 정수 값 캐시
intCache := NewCache[string, int](5 * time.Second)
intCache.Set("count", 42)
intCache.Set("total", 100)

if v, ok := intCache.Get("count"); ok {
fmt.Println("count:", v)
}

// 정수 키, 구조체 값 캐시
type UserData struct {
Name string
Email string
}

userCache := NewCache[int, UserData](10 * time.Second)
userCache.Set(1, UserData{Name: "홍길동", Email: "hong@example.com"})

if user, ok := userCache.Get(1); ok {
fmt.Printf("사용자: %s (%s)\n", user.Name, user.Email)
}

if _, ok := userCache.Get(999); !ok {
fmt.Println("사용자 없음")
}
}

제네릭 남용 주의

package main

import "fmt"

// 나쁜 예: 제네릭이 필요 없는 경우
func BadPrint[T any](v T) {
fmt.Println(v) // any면 충분, 제네릭 불필요
}

// 좋은 예: 타입 안전성이 필요한 경우
func GoodMin[T int | float64](a, b T) T {
if a < b {
return a
}
return b
}

// 인터페이스가 더 적합한 경우
type Printable interface {
String() string
}

func PrintObject(p Printable) { // 인터페이스가 더 명확
fmt.Println(p.String())
}

// 제네릭이 확실히 유리한 경우: 동일 로직, 다른 타입
func MapSlice[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}

func main() {
// 제네릭 컴파일 시 타입 추론
nums := []int{1, 2, 3, 4, 5}
doubled := MapSlice(nums, func(n int) int { return n * 2 })
fmt.Println(doubled) // [2 4 6 8 10]

strs := MapSlice(nums, func(n int) string {
return fmt.Sprintf("%d번", n)
})
fmt.Println(strs) // [1번 2번 3번 4번 5번]

fmt.Println(GoodMin(3, 7)) // 3
fmt.Println(GoodMin(3.14, 2.72)) // 2.72
}

핵심 정리

  • 타입 파라미터 [T 제약]: 여러 타입에 동작하는 범용 코드
  • comparable: ==·!= 비교 가능한 타입 (맵 키 등에 사용)
  • any: 제약 없음 (interface{} 별칭)
  • ~T: T와 동일한 기반 타입을 가진 모든 타입 포함
  • 제네릭 타입은 구조체, 인터페이스에도 적용 가능
  • 제네릭보다 인터페이스가 적합한 경우 인터페이스 사용
  • 타입 추론으로 대부분 타입 파라미터를 명시하지 않아도 됨