Go 언어 제네릭 타입 파라미터와 타입 제약 완전 정복

Go 언어 제네릭: 타입 파라미터, 타입 제약, 실무 적용 패턴 완전 정복

Go 언어는 1.18 버전(2022년 3월)부터 제네릭(Generics)을 공식 지원합니다. 오랫동안 Go 커뮤니티에서 가장 많이 요청되었던 기능으로, 등장 이전에는 interface{}any로 타입 유연성을 흉내 냈지만 컴파일 타임 타입 안전성을 포기해야 했습니다.

이 글에서는 제네릭의 핵심 개념인 타입 파라미터(Type Parameter)타입 제약(Type Constraint)부터 시작하여, 제네릭 자료구조 구현, 인터페이스와의 조합, 그리고 실무에서 언제 제네릭을 쓰고 언제 피해야 하는지 판단 기준까지 심층적으로 다룹니다.

🛠️ 제네릭이 바꾼 실무 경험

제네릭 도입 전에는 int 슬라이스용 함수와 string 슬라이스용 함수를 따로 작성하거나, interface{}로 처리한 뒤 타입 단언으로 값을 꺼내는 방식이 일반적이었습니다. 이 방식은 런타임 패닉 위험과 코드 중복이라는 두 가지 문제를 동시에 안고 있었습니다. 제네릭은 이 두 문제를 모두 해결합니다.


1. 제네릭이란 무엇인가?

제네릭(Generics)타입을 매개변수로 받는 프로그래밍 기법입니다. 함수나 타입을 정의할 때 구체적인 타입 대신 타입 파라미터를 사용하고, 실제 호출 시점에 타입이 결정됩니다.

1-1. 제네릭 없을 때의 문제

제네릭이 없던 시절, 두 값 중 최솟값을 반환하는 함수는 타입마다 따로 작성해야 했습니다.

func MinInt(a, b int) int {
    if a < b {
        return a
    }
    return b
}

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

func MinString(a, b string) string {
    if a < b {
        return a
    }
    return b
}

로직은 완전히 동일한데 타입만 다릅니다. 이런 코드 중복은 유지보수 비용을 높이고 버그가 퍼질 가능성을 키웁니다.

1-2. 제네릭으로 해결

func Min[T int | float64 | string](a, b T) T {
    if a < b {
        return a
    }
    return b
}

func main() {
    fmt.Println(Min(3, 7))         // 출력: 3
    fmt.Println(Min(3.14, 2.71))   // 출력: 2.71
    fmt.Println(Min("apple", "banana")) // 출력: apple
}

[T int | float64 | string]타입 파라미터 선언입니다. 이제 하나의 함수로 세 가지 타입을 모두 처리합니다.


2. 타입 파라미터(Type Parameter) 문법

2-1. 기본 문법 구조

// 함수에 타입 파라미터 적용
func 함수명[타입파라미터 제약조건](매개변수 타입파라미터) 반환타입 {
    // 구현
}

// 타입(구조체)에 타입 파라미터 적용
type 타입명[타입파라미터 제약조건] struct {
    필드 타입파라미터
}

2-2. 여러 타입 파라미터

함수는 여러 개의 타입 파라미터를 가질 수 있습니다.

// 키-값 쌍을 받아 맵으로 변환
func Zip[K comparable, V any](keys []K, values []V) map[K]V {
    result := make(map[K]V)
    for i := 0; i < len(keys) && i < len(values); i++ {
        result[keys[i]] = values[i]
    }
    return result
}

func main() {
    names := []string{"Alice", "Bob", "Carol"}
    ages := []int{25, 30, 28}
    m := Zip(names, ages)
    fmt.Println(m) // map[Alice:25 Bob:30 Carol:28]
}

KV는 서로 다른 타입 파라미터입니다. Kcomparable(맵 키로 사용 가능한 타입), Vany(모든 타입) 제약을 가집니다.

2-3. 타입 인수 추론(Type Inference)

Go 컴파일러는 함수 인수를 통해 타입 파라미터를 자동으로 추론합니다.

// 명시적 타입 인수
result1 := Min[int](3, 7)

// 타입 추론 (권장)
result2 := Min(3, 7)      // T = int로 자동 추론
result3 := Min(3.14, 2.71) // T = float64로 자동 추론

대부분의 경우 타입을 명시할 필요가 없습니다. 코드가 더 간결해집니다.


3. 타입 제약(Type Constraints): 타입 파라미터의 규칙

타입 파라미터에는 제약(Constraint)을 걸어 사용 가능한 타입을 제한합니다. 제약은 인터페이스로 표현됩니다.

3-1. any 제약: 모든 타입 허용

func Print[T any](value T) {
    fmt.Println(value)
}

anyinterface{}의 별칭으로, 모든 타입을 허용합니다. 단, any를 사용하면 타입이 지원하는 연산이 거의 없으므로 출력, 저장 등의 범용 용도에만 적합합니다.

3-2. comparable 제약: 비교 가능한 타입

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

func main() {
    ints := []int{1, 2, 3, 4, 5}
    fmt.Println(Contains(ints, 3))    // true
    fmt.Println(Contains(ints, 6))    // false

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

comparable==!= 연산을 지원하는 타입에 적용됩니다. 슬라이스, 맵, 함수 타입은 포함되지 않습니다.

3-3. 유니온 타입 제약: 특정 타입만 허용

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

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

func main() {
    ints := []int{1, 2, 3, 4, 5}
    fmt.Println(Sum(ints)) // 15

    floats := []float64{1.1, 2.2, 3.3}
    fmt.Println(Sum(floats)) // 6.6000000000000005
}

3-4. ~ 틸드(Tilde) 연산자: 기반 타입 포함

type MyInt int // int를 기반 타입으로 하는 사용자 정의 타입

type Integer interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64
}

func Double[T Integer](v T) T {
    return v * 2
}

func main() {
    var x MyInt = 5
    fmt.Println(Double(x))  // ✅ 출력: 10
    fmt.Println(Double(10)) // ✅ 출력: 20
}

~intint 자체뿐 아니라 int를 기반 타입으로 하는 MyInt 같은 사용자 정의 타입도 포함합니다. 이 차이가 실무에서 매우 중요합니다.

제약의미
int정확히 int 타입만 허용
~intint 및 기반 타입이 int인 모든 타입 허용

🛠️ 실무 팁: ~ 연산자를 적극 활용하라

도메인 모델에서는 type UserID int, type OrderID int처럼 기반 타입이 같은 다양한 사용자 정의 타입을 사용합니다. ~int 제약을 사용하면 이런 타입들도 제네릭 함수에서 처리할 수 있어 코드 재사용성이 크게 높아집니다.


4. cmpslices 패키지: Go 1.21 표준 제네릭

Go 1.21부터 표준 라이브러리에 제네릭 기반 유틸리티 패키지가 추가되었습니다.

import (
    "cmp"
    "slices"
)

func main() {
    // slices.Sort: 제네릭 정렬 (Go 1.21+)
    nums := []int{5, 2, 8, 1, 9, 3}
    slices.Sort(nums)
    fmt.Println(nums) // [1 2 3 5 8 9]

    // slices.Contains: 제네릭 Contains
    fmt.Println(slices.Contains(nums, 8)) // true

    // slices.Max / slices.Min
    fmt.Println(slices.Max(nums)) // 9
    fmt.Println(slices.Min(nums)) // 1

    // cmp.Compare: 범용 비교 함수
    fmt.Println(cmp.Compare(3, 5)) // -1 (3 < 5)
    fmt.Println(cmp.Compare(5, 5)) // 0  (같음)
    fmt.Println(cmp.Compare(7, 5)) // 1  (7 > 5)
}

이전에는 golang.org/x/exp/constraintsgolang.org/x/exp/slices를 써야 했지만, Go 1.21부터는 별도 의존성 없이 표준 라이브러리만으로 충분합니다.


5. 제네릭 자료구조: Stack 완전 구현

제네릭의 진가는 자료구조 구현에서 드러납니다. 어떤 타입의 데이터도 저장할 수 있는 타입 안전한 Stack을 만들어봅니다.

package main

import (
    "errors"
    "fmt"
)

// 제네릭 Stack 정의
type Stack[T any] struct {
    elements []T
}

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

func (s *Stack[T]) Pop() (T, error) {
    if s.IsEmpty() {
        var zero T
        return zero, errors.New("stack is empty")
    }
    idx := len(s.elements) - 1
    value := s.elements[idx]
    s.elements = s.elements[:idx]
    return value, nil
}

func (s *Stack[T]) Peek() (T, error) {
    if s.IsEmpty() {
        var zero T
        return zero, errors.New("stack is empty")
    }
    return s.elements[len(s.elements)-1], nil
}

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

func (s *Stack[T]) Size() int {
    return len(s.elements)
}

func main() {
    // int Stack
    var intStack Stack[int]
    intStack.Push(10)
    intStack.Push(20)
    intStack.Push(30)

    top, _ := intStack.Peek()
    fmt.Printf("Top: %d, Size: %d\n", top, intStack.Size())
    // 출력: Top: 30, Size: 3

    for !intStack.IsEmpty() {
        v, _ := intStack.Pop()
        fmt.Print(v, " ")
    }
    fmt.Println() // 출력: 30 20 10

    // string Stack — 동일한 타입, 다른 데이터
    var strStack Stack[string]
    strStack.Push("Go")
    strStack.Push("Generics")
    strStack.Push("Rocks")

    for !strStack.IsEmpty() {
        v, _ := strStack.Pop()
        fmt.Print(v, " ")
    }
    fmt.Println() // 출력: Rocks Generics Go
}

제네릭 없이는 IntStack, StringStack을 따로 만들어야 했습니다. 이제 Stack[int], Stack[string]으로 재사용됩니다.

5-1. Zero Value 처리 패턴

제네릭 함수에서 빈 값을 반환할 때는 var zero T를 사용하는 것이 관용적입니다.

func (s *Stack[T]) Pop() (T, error) {
    if s.IsEmpty() {
        var zero T  // T의 Zero Value: int → 0, string → "", *Foo → nil
        return zero, errors.New("stack is empty")
    }
    // ...
}

6. 제네릭 맵 연산 유틸리티

슬라이스를 다루는 Map, Filter, Reduce 같은 함수형 유틸리티를 제네릭으로 구현하면 매우 강력해집니다.

// Map: 슬라이스의 각 요소를 변환
func Map[T, U any](slice []T, fn func(T) U) []U {
    result := make([]U, len(slice))
    for i, v := range slice {
        result[i] = fn(v)
    }
    return result
}

// Filter: 조건을 만족하는 요소만 추출
func Filter[T any](slice []T, pred func(T) bool) []T {
    var result []T
    for _, v := range slice {
        if pred(v) {
            result = append(result, v)
        }
    }
    return result
}

// Reduce: 슬라이스를 단일 값으로 축약
func Reduce[T, U any](slice []T, initial U, fn func(U, T) U) U {
    acc := initial
    for _, v := range slice {
        acc = fn(acc, v)
    }
    return acc
}

func main() {
    nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

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

    // 각 요소를 제곱
    squares := Map(evens, func(n int) int { return n * n })
    fmt.Println(squares) // [4 16 36 64 100]

    // 합계 계산
    total := Reduce(squares, 0, func(acc, n int) int { return acc + n })
    fmt.Println(total) // 220

    // Map으로 타입 변환 (int → string)
    strs := Map(nums, func(n int) string {
        return fmt.Sprintf("item_%d", n)
    })
    fmt.Println(strs[:3]) // [item_1 item_2 item_3]
}

Map[T, U any]처럼 입력 타입과 출력 타입이 다를 수 있어 타입 변환 파이프라인을 간결하게 표현할 수 있습니다.


7. 인터페이스 + 제네릭 조합 패턴

제네릭과 인터페이스를 조합하면 타입 안전한 다형성을 구현할 수 있습니다.

7-1. 메서드를 요구하는 타입 제약

type Stringer interface {
    String() string
}

func PrintAll[T Stringer](items []T) {
    for _, item := range items {
        fmt.Println(item.String())
    }
}

type User struct {
    Name  string
    Email string
}

func (u User) String() string {
    return fmt.Sprintf("User(%s <%s>)", u.Name, u.Email)
}

type Product struct {
    ID    int
    Title string
}

func (p Product) String() string {
    return fmt.Sprintf("Product#%d: %s", p.ID, p.Title)
}

func main() {
    users := []User{
        {Name: "Alice", Email: "alice@example.com"},
        {Name: "Bob", Email: "bob@example.com"},
    }
    PrintAll(users)
    // 출력:
    // User(Alice <alice@example.com>)
    // User(Bob <bob@example.com>)

    products := []Product{
        {ID: 1, Title: "Go Programming"},
        {ID: 2, Title: "Clean Code"},
    }
    PrintAll(products)
    // 출력:
    // Product#1: Go Programming
    // Product#2: Clean Code
}

7-2. 제네릭 Repository 패턴

실무에서 자주 쓰이는 Repository 패턴을 제네릭으로 추상화하면 각 엔티티별 중복을 크게 줄일 수 있습니다.

// 기본 CRUD 인터페이스
type Repository[T any, ID comparable] interface {
    FindByID(id ID) (T, error)
    Save(entity T) error
    Delete(id ID) error
    FindAll() ([]T, error)
}

// 인메모리 구현 (테스트용)
type InMemoryRepo[T any, ID comparable] struct {
    store  map[ID]T
    getID  func(T) ID
}

func NewInMemoryRepo[T any, ID comparable](getID func(T) ID) *InMemoryRepo[T, ID] {
    return &InMemoryRepo[T, ID]{
        store: make(map[ID]T),
        getID: getID,
    }
}

func (r *InMemoryRepo[T, ID]) FindByID(id ID) (T, error) {
    if v, ok := r.store[id]; ok {
        return v, nil
    }
    var zero T
    return zero, fmt.Errorf("entity with id %v not found", id)
}

func (r *InMemoryRepo[T, ID]) Save(entity T) error {
    r.store[r.getID(entity)] = entity
    return nil
}

func (r *InMemoryRepo[T, ID]) Delete(id ID) error {
    delete(r.store, id)
    return nil
}

func (r *InMemoryRepo[T, ID]) FindAll() ([]T, error) {
    result := make([]T, 0, len(r.store))
    for _, v := range r.store {
        result = append(result, v)
    }
    return result, nil
}
type Article struct {
    ID    int
    Title string
}

func main() {
    repo := NewInMemoryRepo[Article, int](func(a Article) int { return a.ID })

    repo.Save(Article{ID: 1, Title: "Go 제네릭 완전 정복"})
    repo.Save(Article{ID: 2, Title: "Go 고루틴 심층 분석"})

    article, err := repo.FindByID(1)
    if err == nil {
        fmt.Println(article.Title) // 출력: Go 제네릭 완전 정복
    }

    all, _ := repo.FindAll()
    fmt.Println(len(all)) // 출력: 2
}

🛠️ 실무 적용 포인트

이 패턴의 핵심은 InMemoryRepo를 테스트 환경에서, 실제 DB 구현체를 프로덕션에서 사용할 수 있다는 점입니다. Repository[Article, int] 인터페이스를 의존성으로 주입하면 테스트 시 DB 없이도 전체 비즈니스 로직을 검증할 수 있습니다.


8. 제네릭 vs 인터페이스: 언제 무엇을 선택하나?

제네릭과 인터페이스는 모두 추상화 도구이지만, 적합한 상황이 다릅니다.

기준제네릭인터페이스
타입 안전성컴파일 타임 보장런타임 타입 단언 필요
성능인스턴스화로 오버헤드 없음동적 디스패치 비용
컨테이너/알고리즘최적 (슬라이스, 맵, 큐 등)부적합
다형성(행위 추상화)제한적최적
타입 변환 파이프라인자연스러움어색함
코드 가독성복잡해질 수 있음직관적

8-1. 제네릭을 선택해야 할 때

// ✅ 제네릭: 알고리즘이나 자료구조에서 타입만 다를 때
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
}

8-2. 인터페이스를 선택해야 할 때

// ✅ 인터페이스: 서로 다른 타입의 동작을 통일할 때
type Logger interface {
    Info(msg string)
    Error(msg string, err error)
}

func ProcessRequest(req Request, logger Logger) {
    logger.Info("Processing request")
    // ...
}

9. 제네릭 사용 시 주의할 점

9-1. 과도한 추상화를 피하라

// ❌ 과도한 제네릭: 실제로 타입 하나만 쓰는 경우
func ProcessUser[T User](u T) {
    // ...
}

// ✅ 그냥 구체 타입 사용
func ProcessUser(u User) {
    // ...
}

제네릭은 도구입니다. 실제로 여러 타입에서 재사용되지 않는다면 구체 타입을 직접 쓰는 것이 훨씬 명확합니다.

9-2. 타입 파라미터 중첩을 피하라

// ❌ 읽기 어려운 중첩 제네릭
type NestedMap[K1 comparable, K2 comparable, V any] map[K1]map[K2]V

// ✅ 명명된 타입으로 가독성 개선
type UserScoreMap map[string]map[string]int

9-3. 제네릭과 메서드 조합의 제약

Go의 현재 제네릭 구현에는 한 가지 중요한 제약이 있습니다. 메서드에는 타입 파라미터를 추가로 선언할 수 없습니다.

type Processor[T any] struct{}

// ❌ 컴파일 에러: 메서드에 새 타입 파라미터 추가 불가
func (p Processor[T]) Convert[U any](value T) U {
    // ...
}

// ✅ 대신 패키지 수준 함수로 정의
func Convert[T, U any](value T, fn func(T) U) U {
    return fn(value)
}

이 제약을 모르면 설계 단계에서 막막해질 수 있으므로 반드시 기억해두세요.

🛠️ 제네릭 사용 결정 체크리스트

  1. 동일한 로직이 2가지 이상의 타입에 반복되는가? → 제네릭 고려
  2. 컴파일 타임 타입 안전성이 필요한가? → 제네릭 고려
  3. 런타임 다형성이 필요한가? → 인터페이스 선택
  4. 코드가 더 복잡해지는가? → 구체 타입으로 되돌아가라

10. 실무 예제: 캐시 구현

제네릭으로 타입 안전한 인메모리 캐시를 구현해봅니다.

import (
    "sync"
    "time"
)

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

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] {
    return &Cache[K, V]{
        entries: make(map[K]CacheEntry[V]),
        ttl:     ttl,
    }
}

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 || time.Now().After(entry.expiresAt) {
        var zero V
        return zero, false
    }
    return entry.value, true
}

func main() {
    // string 키, User 값 캐시
    type User struct{ Name string }
    userCache := NewCache[string, User](5 * time.Minute)

    userCache.Set("alice", User{Name: "Alice"})

    if user, ok := userCache.Get("alice"); ok {
        fmt.Println("Found:", user.Name) // 출력: Found: Alice
    }

    // int 키, []byte 값 캐시 (바이트 배열 캐시)
    byteCache := NewCache[int, []byte](time.Minute)
    byteCache.Set(1, []byte("cached data"))
}

sync.RWMutex와 제네릭을 조합하여 고루틴 안전한 범용 캐시를 만들었습니다. 하나의 구현으로 어떤 키-값 타입 조합에도 재사용 가능합니다.


마치며: 제네릭은 강력하지만 신중하게

Go의 제네릭은 코드 재사용성과 타입 안전성을 동시에 높여주는 강력한 도구입니다. 하지만 Go 언어의 설계 철학인 “단순함과 명확함”을 항상 염두에 두어야 합니다.

오늘 배운 핵심을 요약하면:

  1. 타입 파라미터 [T 제약]로 함수와 타입을 범용화하며, 컴파일러가 타입을 자동 추론한다.
  2. anycomparable은 내장 제약이며, 인터페이스나 유니온 타입으로 커스텀 제약을 만들 수 있다.
  3. ~ 틸드 연산자로 사용자 정의 타입까지 포함하는 제약을 정의할 수 있다.
  4. 제네릭 자료구조(Stack, Cache 등)는 타입 안전성을 유지하면서 코드 중복을 제거한다.
  5. 메서드에는 새 타입 파라미터 추가 불가 — 패키지 수준 함수로 대체한다.
  6. 인터페이스와 제네릭은 상호 보완적이다. 행위 추상화에는 인터페이스, 알고리즘/컨테이너에는 제네릭이 적합하다.

다음 글에서는 Go 언어의 고루틴(Goroutine)채널(Channel)을 심층적으로 다루며, 가벼운 동시성 실행 단위인 고루틴의 내부 동작 원리, 채널을 통한 안전한 데이터 교환, 그리고 실무에서 자주 쓰이는 동시성 패턴들을 살펴보겠습니다.