Go의 type은 여러 역할을 가지고 있는데, 예를 들어 switch와 함께하여 타입을 확인할 수 있는 역할도 한다. 그러나 여기서는 type을 통해 새로운 자료형을 만드는, Named Type에 대해서 이야기해 보려고 한다. 그리고 이를 통해 조금 더 코드를 효율적으로 작성하는 법에 대해서도 한번 생각해 보려고 한다.

Named Type

Named Type, 혹은 Type Definition, 명명된 자료형은 쉽게 말해서 타입에 새로운 이름의 타입을 붙인 것들을 말한다. 이를테면

type MyInt int

와 같은 것을 말한다. 여기서는 이것에 대해 조금 더 자세히 정리해 보고, 이를 효과적으로 활용할 수 있는 방법에 대해서 생각해 보려고 한다.

type rune = int32

이런 형식은 go 1.9에서 추가된 type aliasing이라고 하는데, 나중에 다시 적어 보기로 한다.

기능의 노출

Named type은 키워드 type으로 선언이 가능하다. 단순히 type 키워드 뒤에 자료형의 이름과 원래 자료형의 타입을 써주면 된다.

type NewRune int32
type NewRune2 NewRune

타입 NewRune은 Named type이 된다. 또한 NewRune도 새로운 Named type이다. 원시 자료형이든 새롭게 만든 자료형이든 상관없이 새로운 이름으로 새로운 자료형을 만든 것이다.

구조체는 어떤가? 구조체도 우리는 일반적으로

type MyStruct struct {
    Field1 int
}

이와 같은 방법으로 나만의 구조체를 선언하지만 사실은 Go에는 익명 구조체가 있고 따라서 다음과 같은 방식으로도 선언할 수 있다.

a := struct{
    Field1 int
}{
    Field1: 5,
}

결국 구조체의 선언이라는 것도 Named type과 크게 다르지 않다. 위에도 적었지만 결국 struct라는 타입에 새로운 이름을 부여해서 자료형을 새로 생성하는 것이다. 결국 우리는 알게 모르게 자연스럽게 Named type을 사용하고 있었다는 것이다.

그럼 구조체를 사용해서 우리가 어떤 것들을 했는지를 살펴보면, 가장 대표적으로 객체를 컨트롤하는 것이 일반적이다. 객체를 컨트롤하는 것은 객체에 따른 메소드를 사용한다. 그러면 구조체의 Named type에 새로운 메소드를 만들어서 썼던 것처럼 그냥 NewRune에도 이를 사용할 수 있는 것이 아닐까? 당연히 된다.

type RealNumber int64

func (r RealNumber) IsNegative() bool {
    return r < 0
}

func (r RealNumber) IsPositive() bool {
    return r > 0
}

func (r RealNumber) Absolute() RealNumber {
    if r.IsNegative() {
        return -1 * r
    }
    return r
}

Named type이 아닌 자료형, 즉 Unnamed type에는 이런 메소드를 추가할 수 없다. 따라서 string타입의 변수를 조작하기 위해 Python과 같이

"a,b,c,d".split(":")

이렇게 사용할 수 없으며, 따라서 항상 strings패키지를 사용해야 한다.

import "strings"

a := "a,b,c,d"

strings.Split(a, ",")

이런 Named type은 그럼 어디에 쓰이나? 간단하게 go의 패키지 http에서 다음을 찾아볼 수 있다.

type Header map[string][]string

func (h Header) Add(key, value string)
func (h Header) Clone() Header
func (h Header) Del(key string)
func (h Header) Get(key string) string
func (h Header) Set(key, value string)
func (h Header) Values(key string) []string
func (h Header) Write(w io.Writer) error
func (h Header) WriteSubset(w io.Writer, exclude map[string]bool) error

header가 타입이 아닐 경우 유저는 map[string][]string을 직접 조작해야 하는데 매번 키로 맵에 억세스를 하거나 Nil체크를 해야 하므로 간단하게 새로운 타입으로 정의하고 가장 단순한 메소드만을 외부로 노출해 놓았다. 유저는 적절히 주어진 메소드를 사용해서 필요한 조작만 처리하면 사용하는데 크게 문제가 없게 되는 것이다. 거기다 이 장점은 매우 큰데, go에서 타입을 특정한 인터페이스로 볼 수 있느냐를 구분짓는 것은 자료형 혹은 뭔가가 아니라 단순히 인터페이스의 메소드를 충족하느냐이므로, 타입을 인터페이스로 변환할 수 있다는 뜻이다. String() string를 만들었다면 Stringer 인터페이스, Read(p []byte) (n int, err error)를 구현했다면 io.Reader인터페이스를 충족하게 된다.

컴파일 타임 에러 체크

또다른 장점으로는 새로운 자료형을 추가함으로 인해 런타임 에러가 발생할 것이 이제는 컴파일 에러로 바뀐다는 것이다.

type People struct {
    Age     int
    Name    string
    License string
}

func New(age int, name, license string) *People {
    return &People{
        Age: age,
        Name: name,
        License: license,
    }
}

이와 같은 구조체와 생성자가 있다고 가정해 보자. 이 구조체는 name과 license 두 파라미터가 string타입을 가지는데, 생성자에 name과 license를 바꿔서 집어넣게 될 경우 메소드에 따라 어떤 부작용이 야기될지 알 수가 없다. 제일 좋은 것은 Named Parameter인데, 언어에서 지원하지 않으므로 사용하는 쪽에서는 반드시 name과 license가 바뀌어 들어가지 않도록 조심히 생성자를 호출해야 한다.

만약 다음과 같이 각각을 타입으로 지정했다면 이야기가 달라진다.

type Name string
type License string

type People struct {
    Age     int
    Name    Name
    License License
}

func New(age int, name Name, license License) *People {
    return &People{
        Age: age,
        Name: name,
        License: license,
    }
}

이렇게 될 경우 변수 name과 license가 바뀌어 들어올 일이 애초에 발생하지 않는다. 왜냐하면 이 코드가 컴파일 될 떄 다른 타입의 파라미터를 사용하여 생성자를 호출했기 때문이다. 바꿔 넣었다면 IDE에서 경고가 뜨고 컴파일도 진행이 되지 않을 것이다.

명확한 의도 전달

코드를 조금 더 발전시켜 보자. 위에서 사람을 정의하는 People 구조체를 정의했는데 여기에는 그 사람이 가질 수 있는 자격증을 License를 통해 표현하려고 한다. 우선 License를 먼저 정의해 보자.

const (
    LicenseDriving  = License("driving")
    LicenseComputer = License("computer")
)

그러면 License 타입에 다음과 같은 메소드를 추가해볼 수 있다.

type License string

func (l License) IsLicenseDriving() {
    return l == LicenseDriving
}

func (l License) IsLicenseComputer() {
    return l == LicenseComputer
}

License의 메소드 HasLicenseDriving/HasLicenseComputer를 통해 License가 어떤 것인지를 간단하게 알아볼 수 있다. 실제로 코드가 작성되는 것을 확인해 보면,

l := RandomLicense()

if l == LicenseDriving {
    RenewDrivingLicense()
} else if l == LicenseComputer {
    RenewComputerLicense()
}

이런 과정을 거칠 코드가

l := RandomLicense()

if l.IsLicenseDriving() {
    RenewDrivingLicense()
} else if l.IsLicenseComputer() {
    RenewComputerLicense()
}

이런 코드로 변경될 것이다. 음? 별 차이가 나지 않는다고? 여기는 상수를 잘 정의해놔서 그렇고 만약 변수가 복잡해지고 식이 길게 변한다면 이런 식의 접근 방식은 훨씬 훌륭해질 수 있다.

타입 캐스팅

단점도 이야기해 보자. Named type을 선언하면서 제일 괴로운 문제는 타입을 캐스팅해야 하는 상황이 발생한다는 것이다. 왜냐하면 기존에는 타입이 같았기 때문에 없었던 문제들이 이제는 발생하게 된다. 예를 들어

type ID string
type Password string

func Same(id ID, password Password) bool {
    return id == password
}

이와 같은 경우 idpassword는 타입이 아예 다르므로 같은지 아닌지를 비교할 수 없다. 따라서 이런 비교를 원한다면 이를 같은 타입을 변환해야 하는 것이 필요하다. 변환은 단순히 기존 값을 원시 타입으로 새롭게 생성하면 된다.(참고: Conversions)

    str_id := string(id)
    str_pw := string(password)

아니면 다음과 같은 방식으로 풀 수도 있겠다.

func (i ID) Same(p Password) bool {
    return string(i) == string(p)
}

아니면…

func (i ID) String() string {
    return string(i)
}

func (p Password) String() string {
    return string(p)
}

func Same(id fmt.Stringer, pw fmt.Stringer) {
    return id.String() == pw.String()
}

이 타입 캐스팅은 생각보다 까다로운 것이, 같은 타입임을 아는데도 불구하고 사용하는 입장에서는 변환 과정이 필요하다는 것이 굉장히 불편하게 다가올 수 있다. 사용할 때 변환이 필요한 부분을 어떻게 해결할 수 있는지 미리 생각해 보면 좋겠다.

Named Function type

이건 명명된 함수라고 하면 될 것 같다. Named type과 마찬가지로 함수의 형태를 새롭게 지정한다. 이런 식으로 정의한다.

type StringProcessor func(string) string

새로운 타입 StringProcessor는 함수의 시그니처를 사용하여 새롭게 정의되었다. 뭔지는 모르겠지만, 어떤 함수가 스트링을 받아 새로운 스트링을 리턴한다면 이는 StringProcessor가 된다. Go에서는 일반적으로 이런 Named Function을 만들 때, 관습적으로인지 접미사로 Func를 붙이는 것 같다. Go의 웹 프레임워크 echo에서도 이런 명명된 함수를 사용하고 있다.

type HandlerFunc func(Context) error

echo에서의 HandlerFunc는 라우팅이 도달할 함수를 정의하는데, 이를테면 특정한 요청이 왔을 때 처리해야 하는 내용을 작성한다.

e.GET("/users/:id", func(c echo.Context) error {
    return c.String(http.StatusOK, "/users/:id")
})

echo의 GET함수는 다음과 같다.

func (e *Echo) GET(path string, h HandlerFunc, m ...MiddlewareFunc) *Route

HandlerFunc의 시그니처를 익명 함수를 사용해서 넘기든 직접 정의한 함수를 넘기든 큰 상관은 없다.


func GetHandler(c echo.Context) error {
    return c.String(http.StatusOK, "/users/:id")
}

e.GET("/users/:id", GetHandler)

그러면 명명된 함수의 타입은 크게 구분하지 않는 것인가? 하면 그건 또 아니다.

type IntProcessFunc func(int) int
type IntReturnFunc func(int) int

func Multiply3(i int) int {
    return i * 3
}

func main() {
    var Multiply2 IntProcessFunc

    Multiply2 = func(i int) int {
        return i * 2
    }

    var IntReturn IntReturnFunc
    IntReturn = func(i int) int {
        return i
    }

    result := HandlingIntProcess(2, Multiply2)
    result2 := HandlingIntProcess(2, Multiply3)
    result3 := HandlingIntProcess(2, IntReturn)
    //cannot use IntReturn (type IntReturnFunc) as type IntProcessFunc in argument to HandlingIntProcess

    fmt.Println(result, result2, result3)
}

func HandlingIntProcess(i int, f IntProcessFunc) int {
    return f(i)
}

/*
// 함수의 파라미터에 시그니처를 받을 수도 있다. 이러면 result3도 문제없이 처리된다.
func HandlingIntProcess(i int, f func(int) int) int {
    return f(i)
}
*/

Unnamed function에서 Named function으로는 타입 캐스팅이 일어나지만 명명된 함수 A에서 명명된 함수 B로는 타입 캐스팅이 일어나지 않는다. 함수 Multiply3의 Multiply3func(int) int의 호출 수단이지 타입이 아니기 때문에 result2에서는 문제가 없다.

그러나 go를 써 본 대부분의 경우 문서를 보고 함수의 타입을 맞추는 것이 아니라 주로 시그니처를 맞춘 함수를 그대로 넘겨주는 경우가 많아서 컴파일 에러를 내는 것에 대한 장점은 여기서는 딱히 없는 것처럼 보인다. 예를 들어 echo.HandlerFunc의 경우에도 이 시그니처와 같은 함수를 만들고 바로 사용하기 때문이다. 특히 명명된 함수끼리만 컴파일 에러가 발생하므로 일반적인 사용시 크게 겪을 일이 없어 보인다. 특히나 위 코드의 주석 처리된 HandlingIntProcess를 봤을 때 파라미터로 함수의 시그니처도 전달이 가능한 것을 보았으므로 결국 사용하는 입장에서는 명명된 함수 타입이 크게 중요하지 않다는 것을 알 수 있다.

그러나 문서와 함께 라이브러리를 제공하는 상태에서는 이런 식의 명명된 함수가 필요하다. 해당 함수에 대한 설명을 시그니처로 하기보다는 타입을 지정해 놓고 그 타입으로 설명하는 것이 훨씬 명확하기 때문이다. echo의 경우에도 HandlerFunc를 정의한 내용이 있기 때문에 설명이 조금 더 명확하다.

예를 들어 grpc의 미들웨어 grpc_retry의 경우, Backoff를 위해서 BackoffFunc를 파라미터로 전달해야 하는데, grpc_retry의 pkg.go.dev에서 BackoffExponential등의 함수가 이를 리턴해 준다는 것을 파악할 수 있다.