Go generic(제네릭) – 업무에 사용하는 Go 언어 17

go generic(제네릭)
Go generic(제네릭)

Go 언어는 1.18 버전부터 제네릭(generic)을 공식적으로 지원하기 시작했습니다.
이 기능은 많은 개발자들이 오래전부터 기다려왔던 것으로, 코드의 재사용성과 타입 안정성을 높이는 데 큰 역할을 합니다.
이번 글에서는 go generic에 대해 자세히 살펴보며, 다양한 예제와 함께 필자의 생각도 공유해보겠습니다.


Go generic이란?

기본적으로 제네릭은 하나의 함수나 타입이 다양한 타입의 데이터를 처리할 수 있도록 해주는 기능입니다.
이전까지 Go에서는 비슷한 기능을 인터페이스나 빈 인터페이스(interface{})를 사용해 구현했지만,
타입 안정성(type safety)이 보장되지 않아 런타임 에러의 위험이 있었습니다.

제네릭을 사용하면 컴파일 타임에 타입 검사가 가능하여, 보다 안전하고 깔끔한 코드를 작성할 수 있습니다.
타입을 명시적으로 다루면서도 중복 코드를 줄일 수 있다는 점에서 실무 개발자 입장에서는 매우 매력적인 기능입니다.


Go generic 기본 문법

제네릭을 작성하기 위해서는 타입 파라미터(type parameter)를 정의해야 합니다.
Go에서는 대괄호([])를 이용하여 타입 파라미터를 명시합니다.

간단한 문법 형태는 다음과 같습니다.

Go
func 함수명[타입파라미터](매개변수 타입파라미터) 반환타입 {
    // 구현 내용
}

예를 들어, 두 값을 받아 더하는 제네릭 함수를 작성하려면 이렇게 됩니다.

Go
func Add[T any](a, b T) T {
    // 실제로는 타입 제약을 걸어야 합니다. any는 모든 타입을 의미합니다.
}

타입 파라미터에는 any를 사용하거나, 필요에 따라 제약(constraints)을 걸어줄 수도 있습니다.


간단한 go generic 예제

아래는 두 값을 받아서 더해주는 함수를 제네릭으로 작성한 예제입니다.

Go
package main

import "fmt"

// 타입 파라미터 T를 정의합니다.
func Add[T int | float64](a, b T) T {
    return a + b
}

func main() {
    fmt.Println(Add(10, 20))      // int 타입
    fmt.Println(Add(3.14, 2.71))  // float64 타입
}

이 코드에서는 T라는 타입 파라미터를 정의하고, intfloat64 타입을 받을 수 있도록 제한하고 있습니다.
이처럼 타입 제한을 명시적으로 걸어줄 수 있다는 점이 Go 제네릭의 깔끔한 특징이라고 생각합니다.


구체적인 예제: Stack 자료구조 만들기

제네릭을 사용하면 자료구조를 훨씬 유연하게 구현할 수 있습니다.
다음은 다양한 타입을 저장할 수 있는 스택(Stack)을 제네릭으로 구현한 예시입니다.

Go
package main

import "fmt"

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, bool) {
    if len(s.elements) == 0 {
        var zero T
        return zero, false
    }
    idx := len(s.elements) - 1
    value := s.elements[idx]
    s.elements = s.elements[:idx]
    return value, true
}

func (s *Stack[T]) Peek() (T, bool) {
    if len(s.elements) == 0 {
        var zero T
        return zero, false
    }
    return s.elements[len(s.elements)-1], true
}

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

func main() {
    var intStack Stack[int]
    intStack.Push(1)
    intStack.Push(2)
    intStack.Push(3)

    fmt.Println("Top element:", intStack.Peek()) // 출력: 3 true
    fmt.Println("Size:", intStack.Size())         // 출력: 3

    fmt.Println(intStack.Pop()) // 출력: 3 true
    fmt.Println(intStack.Pop()) // 출력: 2 true
    fmt.Println(intStack.Pop()) // 출력: 1 true
    fmt.Println(intStack.Pop()) // 출력: 0 false

    var stringStack Stack[string]
    stringStack.Push("hello")
    stringStack.Push("world")

    fmt.Println("Top element:", stringStack.Peek()) // 출력: world true
    fmt.Println("Size:", stringStack.Size())         // 출력: 2
}
필자는 제네릭이 등장하기 전에는 이런 자료구조를 만들 때 매번 타입별로 새로 작성하거나,
interface{}any를 사용해야 했던 불편함을 자주 느꼈다.
이제는 훨씬 깔끔하고 안전하게 구현할 수 있어 개발 경험이 한층 쾌적해졌다.
특히 타입 안정성이 보장되니 디버깅하는 시간이 눈에 띄게 줄어들었다.

이렇게 기능보다는 타입이 강조되는 부분에는 제네릭 프로그래밍을 하면 최고라고 생각한다.

타입 제약(Constraints)

constraints 패키지를 이용하여 타입 제약을 통해 타입 파라미터가 어떤 연산을 지원해야 하는지 명시할 수 있습니다.
예를 들어, 비교 가능한 타입만 허용하고 싶다면 다음과 같이 작성할 수 있습니다.

Go
package main

import "golang.org/x/exp/constraints"

func Max[T constraints.Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}

func main() {
    fmt.Println(Max(10, 20))
    fmt.Println(Max("apple", "banana"))
}

여기서 constraints.Ordered는 기본 타입(int, float, string 등)처럼 비교 연산(>, <)이 가능한 타입만을 허용합니다.
이러한 제약 기능 덕분에 코드의 신뢰성을 높일 수 있습니다.

직접 타입 제약을 정의해서, 특정 인터페이스를 구현한 타입만 받을 수 있도록 세밀하게 조정할 수도 있습니다.
예를 들면, 특정 메서드를 가진 타입만 받도록 커스텀 제약을 설정할 수 있습니다.

Go
package main

import "fmt"

type Stringer interface {
    String() string
}

func PrintString[T Stringer](s T) {
    fmt.Println(s.String())
}

type Person struct {
    Name string
}

func (p Person) String() string {
    return "Person: " + p.Name
}

func main() {
    p := Person{"Alice"}
    PrintString(p)
}

이런 방식으로 제네릭과 인터페이스를 조합하면 더욱 강력한 패턴을 만들 수 있습니다.


go 언어 generic 사용 시 주의할 점

Go는 기본적으로 단순성과 명료함을 중시하는 언어입니다.
따라서 제네릭을 남발하기보다는 꼭 필요한 경우에만 사용하는 것이 좋습니다.
특히 읽기 쉽고 유지보수가 쉬운 코드를 작성하려는 Go의 철학을 고려하면,
과도하게 복잡한 제네릭 사용은 오히려 독이 될 수 있습니다.

필자 역시 처음에는 제네릭을 최대한 활용해보려 했지만, 오히려 코드가 이해하기 어려워지는 경험을 한 적이 있다.
그래서 지금은 "제네릭을 써야 코드가 더 깔끔해질 때만" 사용하는 것을 개인적인 원칙으로 삼고 있다.
보통 타입보다 기능이 중요한 경우에는 generic 프로그래밍을 하지 않는 것이 좋을 것이다.
특히 협업을 고려할 때는, 동료 개발자들이 코드를 빠르게 이해할 수 있도록 적절한 수준에서 제네릭을 사용하는 것이 중요하다고 생각한다.