Go 제네릭 완벽 가이드 2026: 타입 파라미터, 제약조건, 면접 대비

2026년 기술 면접에서 자주 출제되는 Go 제네릭 질문을 완벽하게 분석합니다. 타입 파라미터, any vs comparable, 틸드 연산자, 커스텀 제약조건, 스레드 세이프 제네릭 캐시 구현까지 다룹니다.

Go 제네릭 완벽 가이드 2026: 타입 파라미터, 제약조건, 면접 대비

Go 1.18은 2022년 3월에 제네릭을 도입했으며, 이후 Go 1.21, 1.22 및 그 이후 버전에서 기능이 성숙해졌습니다. 이 글에서는 2026년 기술 면접에서 자주 출제되는 Go 제네릭 질문을 다루며, 기본적인 타입 파라미터부터 고급 제약조건 패턴까지 설명합니다.

Go 제네릭이란?

Go 제네릭은 컴파일 타임 타입 안전성을 유지하면서 모든 데이터 타입과 함께 작동하는 함수와 타입을 작성할 수 있게 합니다. 런타임 타입 어설션을 사용하는 인터페이스와 달리, 제네릭은 컴파일 시간에 타입을 해결하여 더 나은 성능과 조기 오류 감지를 제공합니다.

Go에서 타입 파라미터 이해하기

타입 파라미터는 Go 제네릭의 기반을 형성합니다. 타입 파라미터는 제네릭 함수나 타입이 사용될 때 지정되는 타입의 플레이스홀더입니다.

generic.gogo
// 타입 파라미터 T를 가진 기본 제네릭 함수
func PrintSlice[T any](items []T) {
    for _, item := range items {
        fmt.Println(item)
    }
}

// 사용 - 타입 추론이 자동으로 T를 결정
func main() {
    PrintSlice([]int{1, 2, 3})        // T는 int
    PrintSlice([]string{"a", "b"})     // T는 string
}

대괄호 문법 [T any]any로 제약된 타입 파라미터 T를 선언합니다. Go 컴파일러는 인자로부터 구체적인 타입을 추론하므로, 대부분의 경우 명시적 타입 지정이 필요하지 않습니다.

면접 빈출 질문: anycomparable의 차이점은?

면접에서는 내장 제약조건인 anycomparable에 대해 자주 질문합니다. 이 차이점을 이해하면 제네릭에 대한 확실한 지식을 보여줄 수 있습니다.

constraints.gogo
// any: 모든 타입을 허용 (interface{}의 별칭)
func Process[T any](value T) T {
    return value
}

// comparable: == 와 !=를 지원하는 타입만
func Contains[T comparable](slice []T, target T) bool {
    for _, v := range slice {
        if v == target {  // 이 비교에는 comparable이 필요
            return true
        }
    }
    return false
}

// 이것은 컴파일됨
Contains([]int{1, 2, 3}, 2)

// 이것은 실패: 슬라이스는 comparable이 아님
// Contains([][]int{{1}, {2}}, []int{1})

comparable 제약조건은 등호 연산자를 지원하는 타입으로 타입 파라미터를 제한합니다. 슬라이스, 맵, 함수는 언어 수준에서 등호가 정의되어 있지 않으므로 제외됩니다.

인터페이스를 사용한 커스텀 타입 제약조건 생성

Go는 인터페이스를 사용하여 커스텀 제약조건을 정의하고, 제네릭 함수가 허용하는 타입을 확장합니다. Go 명세에서는 제약조건 인터페이스 내에서 타입 요소가 작동하는 방식을 정의합니다.

number.gogo
// 타입 유니온을 사용한 커스텀 제약조건
type Number interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64
}

// 숫자 타입으로 제약된 제네릭 함수
func Sum[T Number](values []T) T {
    var total T
    for _, v := range values {
        total += v  // 모든 Number 타입이 +를 지원하므로 작동
    }
    return total
}

func main() {
    fmt.Println(Sum([]int{1, 2, 3}))         // 6
    fmt.Println(Sum([]float64{1.5, 2.5}))    // 4.0
}

|를 사용한 타입 유니온은 제약조건을 만족하는 정확한 타입을 지정합니다. 이 접근 방식은 런타임 타입 어설션을 피하면서 any보다 더 세밀한 제어를 제공합니다.

면접 질문: 제약조건에서 ~ 틸드 연산자 설명

제약조건에서 틸드 연산자 ~는 타입과 해당 기본 타입을 가진 모든 타입과 매칭됩니다. 이 질문은 Go의 타입 시스템에 대한 이해도를 테스트합니다.

underlying.gogo
// ~int는 int와 기본 타입이 int인 모든 타입과 매칭
type Signed interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64
}

// 기본 타입이 int인 커스텀 타입
type UserID int
type Temperature int64

func Abs[T Signed](value T) T {
    if value < 0 {
        return -value
    }
    return value
}

func main() {
    var id UserID = -42
    var temp Temperature = -10
    
    fmt.Println(Abs(id))    // 42 - ~int가 UserID와 매칭되어 작동
    fmt.Println(Abs(temp))  // 10 - ~int64가 Temperature와 매칭되어 작동
}

~가 없으면 제약조건 int는 정확히 int 타입만 매칭하고, UserID와 같은 커스텀 타입과는 매칭되지 않습니다. 틸드는 파생 타입을 포함하도록 제약조건을 확장하여 제네릭 코드를 더 유연하게 만듭니다.

Go 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

제네릭 타입: 구조체와 메서드

제네릭 타입은 함수를 넘어 구조체와 메서드로 확장됩니다. 이 패턴은 데이터 구조 구현에서 자주 사용됩니다.

stack.gogo
// 제네릭 Stack 타입
type Stack[T any] struct {
    items []T
}

// Push는 스택에 요소를 추가
func (s *Stack[T]) Push(item T) {
    s.items = append(s.items, item)
}

// Pop은 최상위 요소를 제거하고 반환
func (s *Stack[T]) Pop() (T, bool) {
    if len(s.items) == 0 {
        var zero T  // 타입 T의 제로 값
        return zero, false
    }
    index := len(s.items) - 1
    item := s.items[index]
    s.items = s.items[:index]
    return item, true
}

func main() {
    intStack := Stack[int]{}
    intStack.Push(10)
    intStack.Push(20)
    
    val, ok := intStack.Pop()  // val=20, ok=true
}

제네릭 타입의 메서드는 대괄호로 타입 파라미터를 반복해야 하지만, 새로운 타입 파라미터를 도입할 수는 없습니다. 리시버 (s *Stack[T])는 메서드를 Stack의 특정 인스턴스화에 바인딩합니다.

면접 질문: 왜 메서드는 자체 타입 파라미터를 가질 수 없나요?

Go는 메서드에 추가 타입 파라미터를 갖는 것을 금지합니다. 이것은 Java나 C#에서 온 개발자들을 놀라게 하는 설계 결정입니다. Go 제네릭 제안에서는 타입 시스템을 다루기 쉽게 유지하기 위해 이 제한이 존재한다고 설명합니다.

go
// 이것은 유효한 Go 코드가 아님
type Container[T any] struct {
    value T
}

// 오류: 메서드는 타입 파라미터를 가질 수 없음
// func (c *Container[T]) Transform[U any](fn func(T) U) U {
//     return fn(c.value)
// }

// 유효한 대안: 독립 함수 사용
func Transform[T, U any](c *Container[T], fn func(T) U) U {
    return fn(c.value)
}

해결 방법으로 메서드 대신 여러 타입 파라미터를 가진 최상위 함수를 사용합니다. 이 설계는 메서드 디스패치를 단순하게 유지하고 리시버 타입과 메서드 타입 파라미터 간의 복잡한 상호작용을 피합니다.

타입 추론과 제약조건 추론

Go의 타입 추론은 제네릭 함수를 호출할 때 중복성을 줄입니다. 명시적 타입 인자가 필요한 경우를 이해하면 더 깔끔한 코드를 작성할 수 있습니다.

inference.gogo
func Map[T, R any](input []T, transform func(T) R) []R {
    result := make([]R, len(input))
    for i, v := range input {
        result[i] = transform(v)
    }
    return result
}

func main() {
    numbers := []int{1, 2, 3}
    
    // 타입 추론: T=int, R=string이 인자로부터 추론됨
    strings := Map(numbers, func(n int) string {
        return fmt.Sprintf("%d", n)
    })
    
    // 복잡한 경우에는 명시적 타입이 필요할 수 있음
    // Map[int, string](numbers, converter)
}

타입 추론은 함수 인자에서 타입 파라미터로 작동합니다. 컴파일러가 인자만으로 타입을 추론할 수 없을 때, 대괄호 안의 명시적 타입 인자가 모호함을 해결합니다.

제약조건 패키지: 표준 라이브러리의 cmpslices

Go 1.21은 Ordered 제약조건과 비교 함수가 있는 cmp 패키지를 추가했습니다. slices 패키지는 표준 라이브러리에서 관용적인 제네릭 코드를 보여줍니다.

stdlib.gogo
import (
    "cmp"
    "slices"
)

func main() {
    numbers := []int{3, 1, 4, 1, 5, 9}
    
    // slices.Sort는 내부적으로 cmp.Ordered 제약조건 사용
    slices.Sort(numbers)  // [1, 1, 3, 4, 5, 9]
    
    // 정렬된 슬라이스에서 이진 검색
    index, found := slices.BinarySearch(numbers, 4)
    
    // cmp.Compare는 -1, 0, 또는 1을 반환
    result := cmp.Compare(3, 5)  // -1
    
    // cmp.Or는 첫 번째 0이 아닌 값을 반환
    value := cmp.Or(0, 0, 42, 100)  // 42
}

표준 라이브러리 문서는 이러한 패키지가 순서가 있는 타입에 대한 타입 안전 연산을 위해 제네릭을 어떻게 활용하는지 보여줍니다. 이러한 패키지에 익숙하다는 것은 이론적 이해를 넘어 실용적인 제네릭 지식을 보여줍니다.

면접 문제: 제네릭 캐시 구현

일반적인 면접 과제에서는 제네릭하고 스레드 세이프한 캐시 구현을 요청합니다. 이것은 제네릭, sync 패키지를 사용한 동시성, 그리고 API 설계를 테스트합니다.

cache.gogo
import (
    "sync"
    "time"
)

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

type cacheItem[V any] struct {
    value      V
    expiration time.Time
}

func NewCache[K comparable, V any]() *Cache[K, V] {
    return &Cache[K, V]{
        items: make(map[K]cacheItem[V]),
    }
}

func (c *Cache[K, V]) Set(key K, value V, ttl time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = cacheItem[V]{
        value:      value,
        expiration: time.Now().Add(ttl),
    }
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    
    item, exists := c.items[key]
    if !exists || time.Now().After(item.expiration) {
        var zero V
        return zero, false
    }
    return item.value, true
}

이 구현은 키에 comparable(맵 요구사항), 값에 any를 사용합니다. 별도의 cacheItem 구조체는 제네릭 타입이 서로 중첩될 수 있음을 보여줍니다. 스레드 안전성은 sync.RWMutex에서 얻으며, 이는 Go 동시성 면접에서 다루는 패턴입니다.

제로 값과 타입 제약조건

제네릭 코드에서 제로 값을 처리하려면 Go가 파라미터화된 타입의 변수를 어떻게 초기화하는지 이해해야 합니다.

zero.gogo
// 슬라이스가 비어있을 때 제로 값 반환
func First[T any](slice []T) T {
    if len(slice) == 0 {
        var zero T  // 제로 값: int는 0, string은 "", 포인터는 nil
        return zero
    }
    return slice[0]
}

// 대안: 모호함을 피하기 위해 포인터 반환
func FirstOrNil[T any](slice []T) *T {
    if len(slice) == 0 {
        return nil
    }
    return &slice[0]
}

var zero T 패턴은 모든 타입 T의 제로 값을 생성합니다. 제로가 유효한 값인 타입(정수의 0 등)의 경우, 포인터를 반환하는 변형이 "찾지 못함"과 "제로를 찾음"을 구분합니다.

고급: 여러 제약조건 결합

복잡한 제네릭 함수는 타입이 여러 제약조건을 만족해야 할 수 있습니다. Go는 인터페이스 임베딩을 통해 이를 처리합니다.

combined.gogo
// 순서와 문자열 변환 모두를 요구하는 제약조건
type Stringable interface {
    String() string
}

type OrderedStringable interface {
    cmp.Ordered
    Stringable
}

// 대안: 인라인으로 여러 제약조건을 가진 타입 파라미터 사용
func PrintSorted[T interface{ cmp.Ordered; fmt.Stringer }](items []T) {
    slices.Sort(items)
    for _, item := range items {
        fmt.Println(item.String())
    }
}

인터페이스 임베딩은 제약조건을 결합하여, 타입이 모든 임베딩된 인터페이스를 구현하도록 요구합니다. 인라인 문법 interface{ A; B }는 명명된 제약조건 타입을 선언하지 않고도 동일한 기능을 제공합니다.

결론

  • [T any]를 사용한 타입 파라미터는 런타임 리플렉션 없이 재사용 가능하고 타입 안전한 코드를 작성할 수 있게 합니다
  • comparable==!=를 지원하는 타입으로 제한하며, 맵 키에 필요합니다
  • ~ 틸드는 특정 기본 타입을 가진 타입과 매칭하여 제약조건의 유연성을 확장합니다
  • 커스텀 제약조건은 타입 유니온(int | string)을 사용하여 허용되는 정확한 타입을 지정합니다
  • 메서드는 자체 타입 파라미터를 가질 수 없습니다. 대안으로 최상위 함수를 사용합니다
  • 표준 라이브러리 패키지 cmpslices는 관용적인 제네릭 패턴을 보여줍니다
  • var zero T를 통한 제로 값은 제네릭 함수에서 빈 데이터나 누락된 데이터를 처리합니다
  • 스레드 세이프 제네릭 데이터 구조는 제네릭과 sync 프리미티브를 결합합니다

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

공유

관련 기사