생각하는 Go 언어 프로그래밍 : Go 제네릭의 이해

[Must Have] Tucker의 Go 언어 프로그래밍
골든래빗 출판사
공봉식 지음

학습 목표
Go 1.18 버전에 새롭게 추가된 제네릭 프로그래밍에 대해서 알아봅니다.
학습 내용
제네릭 프로그래밍이 왜 필요한가?
제네릭 함수 사용법
타입 인터페이스
제네릭 타입
제네릭 소개
제네릭 프로그래밍은 타입 파라미터를 통해서 하나의 함수나 타입이 여러 타입들에 대해서 동작할 수 있도록 하여 코드 재사용성을 늘리는 기법입니다.
제네릭의 장점
하나의 함수나 타입이 여러 타입에 대해서 동작할 수 있도록 정의할 수 있습니다.
하나의 코드로 여러 타입에 대해서 재사용할 수 있습니다.
14.1 제네릭 프로그래밍
제네릭 프로그래밍은 Go 1.18 버전부터 새롭게 추가된 기능으로 타입 파라미터를 통해서 하나의 함수나 타입이 여러 타입에 대해서 동작하도록 해주는 프로그래밍 기법입니다. 자바나 C++, C#과 같은 다른 언어에서 이미 제공되던 기능으로 많은 프로그래머들이 Go에서도 이 기능이 지원되길 기다렸습니다.
일단 코드부터 보면서 이 기능이 왜 필요한지 어떤 때 유용한지 살펴보겠습니다.
func add(a, b int) int {
    return a + b
}
이 코드는 int 타입 두 개의 인자를 받아서 그 합을 반환하는 간단한 함수입니다. Go는 강타입 언어이기 때문에 add() 함수는 오직 int 타입에서만 동작하게 됩니다.
var a float32 = 3.14
var b float32 = 1.43

c := add(a, b)            // build failed
그래서 위와 같이 float32 인자로 add() 함수를 호출하면 에러가 발생하게 됩니다. 이 문제를 해결하기 위해서는 float32에서 동작하는 새로운 함수를 다음과 같이 만들어야 합니다.
func addFloat32(a, b float32) float32 {
    return a + b
}
이처럼 int8, int16, float64 등 각 타입별로 함수를 따로 만드는 것은 매우 귀찮은 작업이고, 함수 동작이 바뀐다면 여러 함수를 모두 변경해야 하기 때문에 유지보수도 좋지 않습니다. 제네릭 프로그래밍을 사용하면 이 문제를 다음과 같이 해결할 수 있습니다.
모든 숫자 타입을 사용할 수 있는 add() 함수를 만들어 봅니다.
package main

import (
    "constraints"
    "fmt"
)

func add[T constraints.Integer | constraints.Float](a, b T) T {   // ❶
    return a + b
}

func main() {
    var a int = 1
    var b int = 2
    fmt.Println(add(a, b))

    var f1 float64 = 3.14
    var f2 float64 = 1.43
    fmt.Println(add(f1, f2))
}
3
4.57
위 예제와 같이 하나의 add() 함수를 int 타입과 float64 타입 모두를 사용해서 호출할 수 있습니다. ❶ 라인이 매우 생소해 보이실 겁니다. 이 부분 바로 타입 파라미터를 사용해서 제네릭 함수를 정의하는 부분입니다. 이제부터 어떻게 타입 파라미터를 정의하고 사용하는지 함께 알아보겠습니다.
14.2 제네릭 함수
제네릭 함수란 앞서 살펴본 add() 함수처럼 타입 파라미터를 통해서 여러 타입에 대해서 동작하는 함수를 말합니다.
제네릭 함수는 위와 같이 정의합니다. func 함수 키워드를 적고 그 뒤 함수명을 적습니다. 그런 다음 대괄호를 열고 타입 파라미터를 적습니다. 타입 파라미터는 파라미터 이름으로, 위 그림에서는 T가 파라미터 이름입니다. 그 뒤 타입 제한을 적습니다. 타입 파라미터는 필요에 따라 여러 개를 적을 수 있습니다. 그런 뒤 대괄호를 닫고 소괄호를 열고 일반 함수처럼 입력과 출력을 씁니다. 이때 타입 파라미터에 사용한 타입 파라미터 이름을 특정 타입 대신 사용할 수 있습니다.
func Print[T any](a, b T) {
    fmt.Println(a, b) 
}
위 print() 함수는 제네릭 함수로 하나의 타입 파라미터를 가지고 있고, 이것은 [T any]라고 정의했습니다. T는 타입 파라미터 이름이고 any는 타입 제한입니다. any는 모든 타입이 다 가능하다는 뜻입니다.
입력 인자를 두 개를 받는데 a, b 모두 T 타입으로 정의되어 있습니다. T 타입은 앞서 정의한 타입 파라미터이고 이것은 모든 타입이 가능하다는 뜻입니다.
Print(1, 2)
Print(3.14, 1.43)
Print("Hello", "World")
그래서 위와 같이 타입에 상관없이 Print() 함수를 호출할 수 있습니다. 하지만 다음과 같이 서로 다른 타입의 인자를 사용하면 에러가 발생합니다.
Print(1, "Hello")
이때 발생하는 에러 메세지는 다음과 같습니다.
default type string of "Hello" does not match inferred type int for T
“Hello” 인자를 int 타입으로 바꿀수 없다는 뜻입니다. 왜 이런 에러가 발생했을까요? 이것을 이해하기 위해서는 먼저 제네릭 함수가 동작하는 방식을 이해하셔야 합니다.
14.2.1 제네릭 함수 동작 방식
제네릭 함수의 타입 파라미터는 그 함수가 호출되는 입력 인자에 따라 달라집니다.
Print(1, 2)가 호출될 때 1, 2가 모두 int 타입이므로 T는 int 타입이 됩니다. 이때 Print() 함수는 다음 함수와 같다고 볼 수 있습니다.
func Print(a, b int) {
    fmt.Println(a, b) 
}
Print(“Hello”, “World”)가 호출될 때는 Print(a, b string)으로 동작하는 거죠.
하지만 Print(1, “Hello”)는 서로 다른 타입의 두 인자 모두 T 타입으로 정의되어 있기 때문에 T 타입을 하나의 타입으로 정의할 수 없어 에러가 발생한 것입니다.
이렇게 여러 개의 다른 타입에서도 동작하게 만들고 싶을 때는 각 타입 갯수에 맞는 함수 파라미터를 정의해 줘야 합니다.
func Print[T1 any, T2 any](a T1, b T2) {
    fmt.Println(a, b) 
}
T1 타입 파라미터와 T2 타입 파라미터 두 개를 정의하고 a와 b가 각각 T1과 T2 타입이 되도록 선언하면 두 개의 인자가 서로 같은 타입일 필요가 없게 됩니다.
그래서 Print(1, “Hello”)를 호출해도 에러가 발생하지 않습니다. 이때 T1은 int 타입이 되고 T2는 string 타입이 됩니다.
하지만 우리는 이미 이럴 때 사용하는 다른 방법을 알고 있습니다. 그것은 바로 빈 인터페이스 interface{}를 이용하는 방법입니다.
모든 타입 값을 출력하는 제네릭 함수와 인터페이스를 이용한 함수를 살펴봅니다.
package main

import "fmt"

func Print[T1 any, T2 any](a T1, b T2) {    // ❶
    fmt.Println(a, b)
}

func Print2(a, b interface{}) {             // ❷
    fmt.Println(a, b)
}

func main() {
    Print(1, 2)
    Print(3.14, 1.43)
    Print("Hello", "World")
    Print(1, "Hello")

    Print2(1, 2)
    Print2(3.14, 1.43)
    Print2("Hello", "World")
    Print2(1, "Hello")
}
1 2
3.14 1.43
Hello World
1 Hello
1 2
3.14 1.43
Hello World
1 Hello
제네릭 함수를 이용해서 Print() 함수를 정의했습니다.
빈 인터페이스를 이용해서 Print2() 함수를 정의했습니다.
이처럼 타입 파라미터와 인터페이스는 서로 유사한 면이 많습니다. 하지만 이 둘은 엄연히 다른 개념이고 다른 효과를 가지고 있기 때문에 이 둘을 잘 구분하셔야 합니다. 이 부분에 대해서는 이 장의 끝에서 살펴보겠습니다.
14.2.2 타입 제한
타입 파라미터의 타입 제한에 대해서 살펴보겠습니다.
앞서 Print[T any](a, b T) 함수에서 타입 제한은 any를 사용했습니다. any는 모든 타입이 다 가능하다는 뜻입니다. 아래 함수를 살펴보겠습니다.
func add[T any](a, b T) T {
    return a + b
}
add() 함수는 T 타입 파라미터가 정의되어 있고 T 타입 제한은 any입니다. 따라서 a, b 두 개의 인자는 모든 타입이 가능합니다. 하지만 이 함수는 다음과 같은 빌드 에러가 발생합니다.
invalid operation: operator + not defined on a (variable of type T constrained by any)
번역해보면, T 타입 제한 any에는 + 연산자가 정의되어 있지 않다는 뜻입니다. T 타입 제한이 any이기 때문에 모든 타입이 가능합니다. 어떤 타입이 올지 모르기 때문에 그 타입이 + 연산자가 지원되는지 알 수 없어 이 에러가 발생한 겁니다. 우리는 특정 조건을 정의해서 그 타입이 + 연산자를 지원하고 있음을 알려줘야 합니다.
예를 들어서 int8, int16, int32, int64, int 타입에 대해서 동작하는 add() 함수는 다음과 같이 정의될 수 있습니다.
func add[T int8 | int16 | int32 | int64 | int](a, b T) T {
    return a + b
}
타입 제한으로 int8 | int16 | int32 | int64 | int를 사용했습니다. 이것은 T 타입이 모든 int 타입이 가능하다는 것을 나타냅니다. 이제 에러가 발생하지 않습니다. 그 이유는 T의 모든 가능한 타입이 모두 + 연산자를 지원하기 때문입니다.
이렇듯 타입의 특정 연산이나 기능을 사용하기 위해서는 타입 제한을 통해 그 연산자나 기능이 가능함을 보여줘야 합니다.
하지만 매번 이렇게 조건을 길게 적어주는 건 귀찮겠죠. 그래서 타입 제한만 따로 정의할 수 있습니다.
// 타입 제한 선언
type Integer interface {
    int8 | int16 | int32 | int64 | int
}

func add[T Integer](a, b T) T {
    return a + b
}
Integer라는 이름으로 int 타입들을 포함한 타입 제한을 정의하고 add() 제네릭 함수에서 Integer 타입 제한을 사용하고 있습니다.
한 가지 재미있는 점은 타입 제한을 정의할 때 interface 키워드를 사용한다는 점입니다.
Go에서 인터페이스 사용하면 특정 메서드를 포함한 객체만 사용할 수 있도록 제한하기 때문에 타입 제한과 유사하다고 볼 수 있습니다. 그래서 Go에서는 타입 제한을 정의할 때 interface 키워드를 사용합니다. 이것에 대해서는 뒷부분에서 좀 더 자세히 살펴보겠습니다.
Go의 기본 패키지 중 하나인 constraints 패키지는 이미 정의된 몇 가지 타입 제한 등을 제공하고 있습니다. 이 제한 조건 중에는 모든 uint 계열과 int 계열을 모두 포함한 Integer라는 제한 조건과 float32, float64를 포함한 Float라는 제한 조건을 정의하고 있습니다.
이제 우리가 처음에 보았던 다음 제네릭 함수를 이해할 수 있게 되었습니다.
func add[T constraints.Integer | constraints.Float](a, b T) T {
    return a + b
}
add() 제네릭 함수는 하나의 타입 파라미터 T를 가지고 있고, 이것은 constraints 패키지에 미리 정의된 Integer 제한 조건과 Float 제한 조건에 맞는 타입 중 하나를 가질 수 있습니다. T 타입의 인자 a, b를 받아서 그 합을 반환하는 함수가 됩니다.
14.2.3 타입 제한 더 알아보기
타입 제한에 대해서 좀 더 살펴보겠습니다.
아래는 constraints 패키지의 Float 타입 제한 정의입니다.
type Float interface {
    ~float32 | ~float64
}
한 가지 이상한 점은 float32와 float64 앞에 ~이 붙어 있다는 것입니다. ~는 해당 타입을 기본으로 하는 모든 별칭 타입들까지 포함한다는 얘기입니다. 아래 예제를 살펴보겠습니다.
타입 제한에서 별칭 타입이 안 되는 경우를 알아봅니다.
package main

type Integer interface {        // ❶ 타입 제한
    int8 | int16 | int32 | int64 | int
}

func add[T Integer](a, b T) T { // ❷ add() 함수 정의
    return a + b
}

type MyInt int                  // ❸ 별칭 타입 정의

func main() {
    add(1, 2)
    var a MyInt = 3
    var b MyInt = 5
    add(a, b)                 // ❹ 에러 발생
}
./ex14.3.go:19:5: MyInt does not implement Integer (possibly missing ~ for int in constraint Integer)
int 타입들을 포함한 Integer 타입 제한을 정의했습니다.
이 제한 조건을 사용해서 add() 제네릭 함수를 정의했습니다. 이제 add() 함수는 모든 int 타입들을 인자로 사용 가능합니다.
int를 기반으로 하는 MyInt라는 별칭 타입을 정의했습니다.
출력 결과와 같은 에러가 발생합니다. MyInt 타입이 Integer 타입 제한에 포함되지 않는다는 에러입니다.
모든 별칭 타입까지 포함시키기 위해서는 타입 앞에 ~를 붙여서 표시해주면 됩니다. 아래와 같이 Integer 타입 제한을 수정하면 에러가 발생하지 않습니다.
type Integer interface {
    ~int8 | ~int16 | ~int32 | ~int64 | ~int
}
14.2.4 타입 제한에 메서드 조건 더하기
타입 제한 정의가 interface 키워드를 사용하기 때문에 일반 인터페이스처럼 메서드 조건까지 더할 수 있습니다. 아래 예제를 살펴보겠습니다.
타입 제한에 메서드 조건을 추가한 경우를 살펴봅니다.
package main

import (
    "fmt"
    "hash/fnv"
)

type ComparableHasher interface {           // ❶
    comparable
    Hash() uint32
}

type MyString string                           // ❷

func (s MyString) Hash() uint32 {
    h := fnv.New32a()
    h.Write([]byte(s))
    return h.Sum32()
}

func Equal[T ComparableHasher](a, b T) bool {  // ❸ 
    if a == b {
        return true
    }
    return a.Hash() == b.Hash()
}

func main() {
    var str1 MyString = "Hello"
    var str2 MyString = "World"
    fmt.Println(Equal(str1, str2))
}
false
ComparableHasher라는 이름의 타입 제한을 정의했습니다. comparable은 ==, != 를 지원하는 타입들을 정의한 Go 내부 타입 제한입니다. 그리고 이 제한에는 Hash() uint32 메서드를 포함하도록 제한했습니다. 그래서 ComparableHasher는 ==와 !=를 지원하고 Hash() uint32 메서드를 포함한 타입만 가능하게 됩니다.
MyString이란 string 별칭 타입을 정의하고 Hash() uint32 메서드를 포함하도록 했습니다. 이로써 MyString은 ComparableHasher 제한에 만족한 타입이 됩니다.
ComparableHasher 제한을 사용하는 Equal()이라는 제네릭 함수를 정의했습니다. 이 함수는 먼저 == 연산자로 둘이 같은지 확인하고 만약 다를 경우 Hash() 메서드 호출 결과로 다시 한번 확인해서 같은지 확인하는 함수입니다.
이처럼 타입 제한에 일반 인터페이스와 같이 특정 메서드를 포함하도록 제한을 추가할 수 있습니다.
주의
타입 제한에 메서드 조건을 포함시킬 수 있고, 같은 interface를 사용하지만, 둘은 같지 않고 서로 다른 개념입니다. 타입 제한은 제네릭 프로그래밍의 타입 파라미터에서만 사용될 수 있고 일반 인터페이스처럼 사용할 수 없습니다.
func Equal(a, b ComparableHasher) bool { // Error
ComparableHasher는 타입 제한을 포함하고 있기 때문에 일반 인터페이스처럼 사용할 수 없어서 에러가 발생합니다. 타입 제한을 포함한 인터페이스는 반드시 타입 파라미터로 정의되어야 합니다. 그래서 다음 코드는 에러가 발생하지 않습니다.
func Equal[T ComparableHasher](a, b T) bool { // OK
14.2.5 제네릭 함수 예
이제까지 배운 것을 활용하여 간단히 만들어 볼 수 있는 제네릭 함수들을 알아보겠습니다.
슬라이스의 각 요소를 돌면서 값을 변경하는 함수를 적용하여 새로운 슬라이스를 반환합니다.
package main

import (
    "fmt"
    "strings"
    "strconv"
)

func Map[F, T any](s []F, f func(F) T) []T {      // ❶
    rst := make([]T, len(s))
    for i, v := range s {
        rst[i] = f(v)
    }
    return rst
}

func main() {
    doubled := Map([]int{1, 2, 3}, func(v int) int {  // ❷ 
        return v * 2
    })
    uppered := Map([]string{"hello", "world", "abc"}, func(v string) string { // ❸
        return strings.ToUpper(v)
    })
    tostring := Map([]int{1, 2, 3}, func(v int) string { // ❹
        return "str" + strconv.Itoa(v)
    })

    fmt.Println(doubled)
    fmt.Println(uppered)
    fmt.Println(tostring)
}
[2 4 6]
[HELLO WORLD ABC]
[str1 str2 str3]
Map() 제네릭 함수를 정의했습니다. F, T 두 개의 타입 파라미터가 있고, 이 모두는 모든 타입이 가능합니다. 함수 인자를 두 개를 받는데 첫 번째 인자는 F 타입 슬라이스이고 두 번째 인자는 함수 타입으로 F 타입 값을 받아서 T 타입 값을 반환합니다. Map() 함수는 s 슬라이스의 각 요소를 두 번째 인자인 f 함수를 사용해서 변환한 새로운 슬라이스를 반환하는 함수입니다.
Map() 함수를 이용해서 int 슬라이스의 각 값을 두 배씩 증가시킨 새로운 슬라이스를 만들었습니다.
Map() 함수를 이용해서 string 슬라이스의 각 요소를 대문자로 변경시킨 새로운 슬라이스를 만들었습니다.
Map() 함수를 이용해서 int 슬라이스의 각 값을 문자열로 변경시킨 새로운 슬라이스를 만들었습니다.
이처럼 하나의 제네릭 함수를 이용해서 여러가지 다양한 기능으로 활용할 수 있습니다. 이런 기법은 제네릭 프로그래밍을 지원하던 다른 언어에서는 흔하게 사용되는 기법이었으나 Go에서는 1.18 이전까지는 제네릭을 지원하지 않았기 때문에 사용할 수 없었습니다.
14.3 제네릭 타입
타입 파라미터는 함수뿐 아니라 타입 선언 시에도 사용될 수 있습니다.
type Node[T any] struct {
    val T
    next *Node[T]
}
Node 구조체는 타입 파라미터를 사용해서 val 필드 타입이 어떤 타입이든 가능하도록 정의하고 있습니다. 아래 예제를 통해서 이 구조체를 사용하는 법을 살펴보겠습니다.
타입 파라미터를 이용해서 Node 구조체를 정의하고 메서드를 추가합니다.
package main

import "fmt"

type Node[T any] struct {
    val  T
    next *Node[T]
}

func NewNode[T any](v T) *Node[T] {              // ❶
    return &Node[T]{val: v}
}

func (n *Node[T]) Push(v T) *Node[T] {        // ❷
    node := NewNode(v)
    n.next = node
    return node
}

func main() {
    node1 := NewNode(1)                    // ❸
    node1.Push(2).Push(3).Push(4)

    for node1 != nil {
        fmt.Print(node1.val, " - ")
        node1 = node1.next
    }
    fmt.Println()

    node2 := NewNode("Hi")                // ❹
    node2.Push("Hello").Push("How are you")

    for node2 != nil {
        fmt.Print(node2.val, " - ")
        node2 = node2.next
    }
    fmt.Println()
}
1 - 2 - 3 - 4 -
Hi - Hello - How are you -
제네릭 함수를 사용해서 T 타입의 val 필드값을 갖는 Node 객체를 생성합니다.
Node[T] 타입의 메서드를 정의합니다. 이렇게 제네릭 타입은 타입명 뒤에 [T]와 같이 붙여서 메서드를 추가할 수 있습니다.
type Node[T1 any, T2 any] struct {
    val1 T1
    val2 T2
    next *Node[T]
}

func (n *Node[T1, T2]) Push(val1 T1, val2 T2) *Node[T1, T2] { ... }
만약 위와 같이 타입 파라미터가 두 개일 경우 Node[T1, T2]와 같이 필요한 타입 파라미터를 모두 적어서 메서드를 정의합니다.
NewNode(1)로 새로운 Node 객체를 생성합니다. 1은 int 타입이므로 이때 생성된 node1 변수 타입은 *Node[int]가 됩니다. 따라서 node1 변수의 val 필드 타입은 int가 됩니다.
NewNode(“Hi”)로 새로운 Node 객체를 생성합니다. “Hi”가 string타입이므로 이때 생성된 node2 변수 타입은 *Node[string]이 됩니다. 따라서 node2 변수의 val 필드 타입은 string이 됩니다.
14.3.1 깊은 이야기 인터페이스와 제네릭은 무엇이 다른가?
앞서 살펴본 Node 구조체는 빈 인터페이스를 사용해서 아래와 같이 만들 수 있습니다.
type Node struct {
    val  interface{}
    next *Node
}
Go에 제네릭 프로그래밍이 추가되기 전까진 이런 식으로 많이 사용했습니다.
하지만 이처럼 빈 인터페이스를 사용한 경우 val 필드의 타입이 빈 인터페이스 타입 즉 interface{} 타입인 반면 타입 파라미터를 사용한 경우에는 실제 구체화된 타입으로 val 필드 타입이 정의되는 점이 다릅니다.
아래 예제를 살펴보겠습니다.
빈 인터페이스와 타입 파라미터를 사용한 방법이 무엇이 다른지 살펴봅니다.
package main

import "fmt"

type NodeType1 struct {                   // ❶
    val  interface{}
    next *NodeType1
}

type NodeType2[T any] struct {           // ❷ 
    val  T
    next *NodeType2[T]
}

func main() {
    node1 := &NodeType1{val: 1}         // ❸
    node2 := &NodeType2[int]{val: 2}

    var v1 int = node1.val              // ❹ 에러 발생
    fmt.Println(v1)
    var v2 int = node2.val              // ➎
    fmt.Println(v2)
}
./ex14.2.go:21:15: cannot use node1.val (variable of type interface{}) as type int in variable declaration:
need type assertion
NodeType1 구조체는 빈 인터페이스를 이용해 정의하였습니다.
NodeType2 구조체는 타입 파라미터를 사용해서 제네릭 타입으로 정의하였습니다.
node1과 node2 변수 모두 val 필드 값으로 int 타입 값을 사용했습니다.
node1.val 값을 int 변수 v1에 대입할 때 출력 결과와 같은 에러가 발생합니다. 그 이유는 node1.val 값, 즉 NodeType1 구조체의 val 필드 타입은 interface{} 타입이라, 이를 바로 int 타입 변수 값으로 대입할 수 없기 때문입니다.
반면 라인은 아무 문제 없이 v2 변수값에 node2.val 값을 대입할 수 있습니다. NodeType2[int] 구조체의 val 필드 타입은 타입 파라미터에 의해서 int 타입으로 정해지기 때문입니다.
라인의 에러를 없애기 위해서는 다음과 같이 interface{} 타입 값을 int 타입으로 타입 변환해야 합니다.
var v1 int = node1.val.(int)
이처럼 빈 인터페이스를 이용하는 경우 모든 타입 값을 가질 수 있으나 그 값을 사용할 때 실제 타입 값으로 타입 변환을 해야 하고, 넣을 때 값의 타입과 뺄 때 값의 타입을 정확히 알고 있어야 한다는 문제가 있습니다.
반면 제네릭 타입을 사용하는 경우 타입 파라미터에 의해서 필드 타입이 결정되므로 값을 사용할 때 타입 변환이 필요없습니다.
14.3.2 깊은 이야기 성능 차이
타입 파라미터를 사용할 때와 인터페이스를 사용할 때에는 성능 차이가 발생합니다.
var v1 int = 3
var v2 interface{} = v1             // boxing
var v3 int = v2.(int)               // unboxing
위와 같이 기본 타입 값을 빈 인터페이스 변수에 대입할 때 Go에서는 빈 인터페이스를 만들어서 기본 타입 값을 가리키도록 합니다. 박스에 넣는 것과 같다고 해서 이를 박싱Boxing이라고 합니다. 다시 값을 꺼낼 때는 박스에서 꺼내는 것과 같다고 해서 언박싱Unboxing이라고 합니다. 박싱할때 빈 인터페이스 객체를 사용하게 됩니다.
박싱과 언박싱을 살펴봅니다.
package main

import "fmt"

func main() {
    var v1 int = 3
    var v2 interface{} = &v1  // boxing
    var v3 int = *(v2.(*int)) // unboxing

    fmt.Printf("v1: %x %T\n", &v1, &v1)
    fmt.Printf("v2: %x %T\n", &v2, &v2)
    fmt.Printf("v3: %x %T\n", &v3, &v3)
}
v1: c00018a000 *int
v2: c000188050 *interface {}
v3: c00018a008 *int
위 예제와 같이 박싱을 한 v2와 v1이 서로 다른 주소값을 가지고 있음을 알 수 있습니다. 즉, 서로 다른 객체임을 알 수 있습니다.
박싱한다는 것은 이 그림처럼 빈 인터페이스 박스 안에 실제 값인 int 타입 값인 3을 넣는다고 볼 수 있습니다. 즉 값을 감싸는 박스 객체를 만들어야 합니다. 이 값을 감싸는 빈 인터페이스 박스는 크기가 매우 작기 때문에 성능상 큰 문제가 되지 않지만, 많아지면 박싱, 언박싱을 위해서 임시로 사용되는 박스 수가 늘어나기 때문에 문제가 될 수 있습니다.
하지만 제네릭 프로그래밍을 사용하면 타입 파라미터에 의해서 타입이 고정되기 때문에 박싱, 언박싱이 필요없습니다. 그에 따라 값을 감싸는 임시 박스도 필요없어서 성능상 이득이 발생합니다. 그럼 제네릭을 사용하는 게 무조건 이득일까요? 꼭 그렇지는 않습니다.
func add[T constraints.Integer | constraints.Float](a, b T) T {
    return a + b
}

add(1, 3)
add(3.14, 1.43)
14.2절에서 살펴보았던 제네릭 함수 add()를 살펴보겠습니다. add(1, 3)과 add(3.14, 1.43)이 마치 하나의 함수를 서로 다른 타입으로 두 번 호출한 것처럼 보이지만, 사실은 그렇지 않습니다.
add(1, 3)은 사실 add[int](1, 3) 함수를 호출한 것이고 add(3.14, 1.43)은 사실 add[float64](3.14, 1.43) 함수를 호출한 것입니다.
제네릭 함수나 타입의 경우 하나의 함수나 타입처럼 보이지만 실제로는 컴파일 타임에 사용한 타입 파라미터별로 새로운 함수나 타입을 생성해서 사용하게 됩니다.
따라서 제네릭 프로그래밍을 많이 사용할 경우 컴파일 타임에 생성해야 할 함수와 타입 갯수가 늘어나고 컴파일 시간도 더 걸리게 됩니다. 또 생성된 코드양이 증가되어 실행 파일 크기가 늘어납니다. 실행 파일 크기는 일반적인 프로그램에서는 문제가 되지 않지만 용량의 제한이 있는 임베디드 프로그램에서는 문제가 될 수 있습니다.
14.4 언제 제네릭 프로그래밍을 사용해야 하는가?
제네릭 프로그래밍은 여러 타입에 대해서 같은 동작을 하는 코드를 하나의 제네릭 함수나 타입으로 표현할 수 있기 때문에 코드 재사용성에 도움이 됩니다. 하지만 제네릭 프로그래밍을 너무 많이 사용하면 코드 가독성이 떨어집니다.
동작하는 코드 먼저, 제네릭은 나중에
Go 언어에 특징이 있다면 동작하는 코드를 빠르고 쉽게 만들 수 있도록 해준다는 점이 있습니다. 제네릭 프로그래밍을 할지 안 할지 고민하기보다는 먼저 동작하는 코드에 집중하고 나중에 여러 타입에 공통적으로 쓰이는 부분에 대해서 부분적으로 제네릭 프로그래밍을 적용하는 게 좋습니다.
프로그래밍 분야에는 이런 격언이 있습니다.
성급한 최적화가 프로그래밍에서 모든 죄악의 뿌리이다- Tony Hoare
실제 필요하지 않는 부분에 신경쓰기보다는 동작하는 코드를 먼저 작성하고 그다음에 최적화나 개선을 해도 늦지 않다는 뜻입니다. 제네릭 프로그래밍 역시 마찬가지입니다. 일반적인 타입이나 구조체를 사용해서 동작하는 코드에 먼저 집중하고 나서 제네릭을 사용해서 도움이 되는 부분이 있다면 그때 적용해도 늦지 않습니다.
그럼 제네릭은 어디에 쓰기 좋은지 살펴보겠습니다.
14.4.1 제네릭을 사용하기 좋은 곳
자료구조Data structure
제네릭을 사용하기 좋은 대표적인 곳은 일반적인 데이터 타입에 대해서 공통적으로 사용되는 자료구조에 사용할 수 있습니다. 트리, 그래프, 리스트, 맵과 같은 자료구조는 일반적인 타입에 대해서 같은 동작을 보장해야 하기 때문에 제네릭을 사용하기 좋습니다. 일반적이라는 뜻을 가진 제네릭Generic이 가장 잘 어울리는 케이스입니다.
다양한 타입에 대해서 비슷한 동작을 하는 경우
어떤 리스트를 순회하면서 값을 변경하는 경우, 그래프를 순회하면서 길찾기를 하는 것과 같이 정해지지 않은 다양한 타입에 대해서 비슷한 동작을 하는 코드가 필요한 경우 제네릭을 사용해서 중복 코드를 없애고 코드 재사용성을 늘릴 수 있습니다. 여러 타입에 대해서 중복된 기능이 필요해서 복사/붙여넣기를 하게 된다면 제네릭을 사용할 기회라고 생각할 수 있습니다.
14.4.2 제네릭을 사용하기 좋지 않은 곳
객체의 타입이 아닌 객체의 기능이 강조되는 곳에서는 제네릭이 아닌 인터페이스를 사용하시는 게 좋습니다.
// io package
type Reader interface {
    Read(p []byte) (n int, err error)
}

// bufio package
func NewReader(rd io.Reader) *Reader
위는 io 패키지의 Reader 인터페이스와 이를 사용하는 bufio 패키지의 NewReader() 함수입니다. bufio.NewReader() 함수는 인자로 들어오는 io.Reader의 타입이 궁금한 것이 아니라 그 객체가 Read()라는 메서드가 있는지 여부만 중요합니다.
위와 같이 타입이 아니라 객체의 기능이 중요한 곳에서는 제네릭이 아닌 인터페이스를 사용하는 게 좋습니다.
사실 제네릭과 인터페이스는 그 쓰임이 비슷하고 개념이 다를 뿐이라서 어떤 때 제네릭을 쓰고 어떤 때 인터페이스를 쓰라고 확정하기 힘듭니다. 하지만, 한 가지 말씀드릴수 있는 건 앞서 살펴본 제네릭을 사용하기 좋은 곳이 아닌 모든 곳에서 제네릭이 아닌 인터페이스나 다른 방법을 사용하시는 게 좋다는 점입니다.
핵심 요약
1. 제네릭 프로그래밍은 타입 파라미터를 통해서 하나의 함수나 타입이 여러 타입에 대해서 동작할 수 있도록 해줍니다.
2. 타입 제한을 통해서 타입 파라미터로 사용되는 타입을 제한합니다.
3. 인터페이스와 제네릭 타입은 각각의 사용법이 있습니다.
4. 동작하는 코드 먼저, 제네릭은 나중에 고민하세요.

WRITER

공봉식

13년 차 게임 서버 프로그래머로 다양한 장르의 온라인 게임을 개발했습니다. 넥슨과 네오위즈를 거쳐서 현재는 EA 캐나다에서 근무 중입니다. 「Tucker Programming」 유튜브 채널을 운영합니다.

4 Comments

  • func add(T Integer) (a, b T) T {
    return a + b
    }

    인터페이스를 이용해서 타입제한 선언 설명해주시는 예제에서
    타입 정의 하실 때 “(” 소괄호를 사용하셨습니다. “[” 대괄호로 수정 부탁드립니다.

    • 안녕하세요! 해당 부분은 오류라 방금 반영했습니다 🙂
      의견 주셔서 정말 감사합니다.

  • 항상 좋은 콘텐츠 제공해 주셔서 감사합니다.
    “constraints” package 예제 설명 시 아래와 같이 “golang.org/x/exp/constraints” 패키지가 import 되어야 한다는 설명이 추가 되었으면 좋겠습니다.

    import (
    “fmt”
    “golang.org/x/exp/constraints”
    )

    • 안녕하세요! 말씀해주신 부분은 저자님께 전달해 드렸으며 확인 후 수정하겠습니다!
      감사합니다. 🙂

Leave a Reply

©2020 GoldenRabbit. All rights reserved.
서울시 마포구 신촌로2길 19 마포출판문화진흥센터 Platform-P 302호 (우)04051
master@goldenrabbit.co.kr
개인정보처리방침