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]
}

K와 V는 서로 다른 타입 파라미터입니다. K는 comparable(맵 키로 사용 가능한 타입), V는 any(모든 타입) 제약을 가집니다.

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)
}

any는 interface{}의 별칭으로, 모든 타입을 허용합니다. 단, 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
}

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

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

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

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


4. cmp와 slices 패키지: 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/constraints나 golang.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. any와 comparable은 내장 제약이며, 인터페이스나 유니온 타입으로 커스텀 제약을 만들 수 있다.
  3. ~ 틸드 연산자로 사용자 정의 타입까지 포함하는 제약을 정의할 수 있다.
  4. 제네릭 자료구조(Stack, Cache 등)는 타입 안전성을 유지하면서 코드 중복을 제거한다.
  5. 메서드에는 새 타입 파라미터 추가 불가 — 패키지 수준 함수로 대체한다.
  6. 인터페이스와 제네릭은 상호 보완적이다. 행위 추상화에는 인터페이스, 알고리즘/컨테이너에는 제네릭이 적합하다.

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