타입 선언

타입 선언 (Type declarations)

코드를 짜다 보면 "이 이름이 가리키는 타입은 도대체 뭘까?" 고민할 때가 있어요. 같은 모양의 타입이라도 별명일 뿐인지, 완전히 새 타입인지에 따라 동작이 달라지니까요. Go에서 타입 선언은 바로 그 경계를 정확히 그어주는 역할을 해요. 이번 섹션에서는 타입 선언의 두 가지 형태, 앨리어스 선언타입 정의의 차이를 하나씩 짚어볼게요.

출처: Go Specification

본문

타입 선언은 식별자, 즉 타입 이름(type name)을 어떤 타입에 묶는(bind) 일이에요. 그런데 이 타입 선언에는 형태가 두 가지예요. **앨리어스 선언(alias declaration)**과 **타입 정의(type definition)**가 바로 그것이죠. 둘의 결과는 꽤 달라서, 먼저 EBNF로 전체 뼈대를 보고 각각을 자세히 살펴볼게요.

TypeDecl  = "type" ( TypeSpec | "(" { TypeSpec ";" } ")" ) .
TypeSpec  = AliasDecl | TypeDef .

EBNF를 보면 TypeDecltype 키워드 하나로 시작해요. 뒤에 오는 건 타입 하나를 선언하거나, 괄호로 묶어 여러 개를 세미콜론으로 구분해 나열하는 두 가지 방법 중 하나를 택하죠. 그리고 TypeSpec이 앨리어스 선언(AliasDecl)과 타입 정의(TypeDef)로 갈리는 걸 확인할 수 있어요.

앨리어스 선언 (Alias declarations)

앨리어스 선언은 식별자를 주어진 타입에 묶어요 [Go 1.9]. 이 구절 하나가 핵심이에요. 새 타입을 만드는 게 아니라, 이미 존재하는 타입에 그저 이름을 하나 더 붙여주는 일이에요.

AliasDecl = identifier [ TypeParameters ] "=" Type .

문법에서 "="가 보이시죠? 주어진 타입(Type)을 그대로 가리키는 별명을 만든다는 뜻이에요. 식별자의 스코프 안에서 이 이름은 주어진 타입의 **앨리어스(alias)**로 동작해요. 아래 예시로 확인할게요.

type (
    nodeList = []*Node  // nodeList and []*Node are identical types
    Polar    = polar    // Polar and polar denote identical types
)

여기서 눈여겨볼 점은 nodeList[]*Node가 **동일한 타입(identical type)**이라는 거예요. 앨리어스는 별명일 뿐이니까, 가리키는 타입은 똑같죠. 그래서 두 이름은 자유롭게 섞어 써도 타입 오류가 나지 않아요.

만약 앨리어스 선언이 타입 파라미터를 지정하면(Go 1.24) 그 타입 이름은 **제네릭 앨리어스(generic alias)**가 돼요. 제네릭 앨리어스는 사용할 때 반드시 **인스턴스화(instantiate)**해야 해요.

type set[P comparable] = map[P]bool

set[P comparable]map[P]bool의 별명이에요. 그런데 여기에는 제약이 하나 있어요. 앨리어스 선언에서 주어진 타입은, 같은 선언 안에서 선언된 타입 파라미터가 될 수 없어요.

type A[P any] = P   // illegal: P is a type parameter declared in the declaration of A

func f[P any]() {
    type A = P  // ok: T is a type parameter declared by the enclosing function
}

잠깐, 그럼 f 함수 안에서는 왜 가능할까요? 룰이 아주 정확하게 "같은 선언 안에서 선언된 타입 파라미터"를 막는 거라서예요. 바깥 함수 f가 선언한 타입 파라미터 P를 앨리어스로 쓰는 건 괜찮아요.

타입 정의 (Type definitions)

이제 Go를 쓰는 분이라면 훨씬 익숙하실 타입 정의예요. 타입 정의는 주어진 타입과 같은 **기반 타입(underlying type)**과 연산을 가지면서도 완전히 새롭고 구별되는 타입을 만들어요. 그리고 그 새 타입을 타입 이름에 묶죠.

TypeDef = identifier [ TypeParameters ] Type .

이렇게 만들어진 타입을 **정의된 타입(defined type)**이라고 불러요. 그리고 여기가 제일 중요한데, 이 정의된 타입은 다른 어떤 타입과도 다르다고(different) 취급돼요. 심지어 그 타입이 만들어진 원본 타입과도 다르죠.

type (
    Point struct{ x, y float64 }  // Point and struct{ x, y float64 } are different types
    polar Point                   // polar and Point denote different types
)

type TreeNode struct {
    left, right *TreeNode
    value any
}

type Block interface {
    BlockSize() int
    Encrypt(src, dst []byte)
    Decrypt(src, dst []byte)
}

Pointstruct{ x, y float64 }와 모양이 똑같아도 전혀 다른 타입이에요. polarPoint와 다른 타입이고요. 이런 차이를 두는 이유는 간단해요 — 타입 정의의 힘은 메서드를 붙일 수 있는 새 타입을 만드는 데 있으니까요.

정의된 타입에는 메서드(method)를 연결할 수 있어요. 다만 주어진 타입에 이미 묶여 있던 메서드는 상속되지 않아요. 그런데 흥미로운 점이 하나 있어요. 인터페이스 타입이나 복합 타입의 요소의 메서드 집합(method set)은 그대로 유지된다는 거예요. 예시로 차이를 볼게요.

// A Mutex is a data type with two methods, Lock and Unlock.
type Mutex struct         { /* Mutex fields */ }
func (m *Mutex) Lock()    { /* Lock implementation */ }
func (m *Mutex) Unlock()  { /* Unlock implementation */ }

// NewMutex has the same composition as Mutex but its method set is empty.
type NewMutex Mutex

// The method set of PtrMutex's underlying type *Mutex remains unchanged,
// but the method set of PtrMutex is empty.
type PtrMutex *Mutex

// The method set of *PrintableMutex contains the methods
// Lock and Unlock bound to its embedded field Mutex.
type PrintableMutex struct {
    Mutex
}

// MyBlock is an interface type that has the same method set as Block.
type MyBlock Block

주석이 낯설게 느껴질 수 있어요. MutexLockUnlock 두 메서드를 가진 타입인데, NewMutex Mutex처럼 정의하면 새 타입인 NewMutex의 메서드 집합은 비어 있어요. 메서드를 상속받는 게 아니라 새 타입에 새로 정의해줘야 하죠. 반면 PrintableMutex처럼 필드로 Mutex를 **임베드(embed)**하면 그 메서드 집합은 그대로 유지돼요. 그리고 MyBlock Block처럼 인터페이스 타입을 정의하면 Block과 같은 메서드 집합을 가진 인터페이스 타입이 만들어져요.

타입 정의는 서로 다른 불리언(boolean), 숫자(numeric), 문자열(string) 타입을 만들고 거기에 메서드를 붙이는 데도 쓰여요. 대표적인 예가 시간대를 표현하는 코드예요.

type TimeZone int

const (
    EST TimeZone = -(5 + iota)
    CST
    MST
    PST
)

func (tz TimeZone) String() string {
    return fmt.Sprintf("GMT%+dh", tz)
}

TimeZone int는 밑바탕은 int지만 EST, CST 같은 이름을 쓸 수 있게 해주는 별도의 타입이에요. 거기에 String() 메서드를 붙였으니, 출력하면 GMT-5h 같은 문자열이 나오죠. 이름 없이 숫자만 나열하면 의미도 헷갈리고 실수도 나기 쉬운데, 이렇게 타입으로 감싸면 타입 안전성까지 챙길 수 있어요.

타입 정의가 타입 파라미터를 지정하면, 그 타입 이름은 **제네릭 타입(generic type)**이 돼요. 제네릭 타입도 사용할 때 반드시 인스턴스화해야 해요.

type List[T any] struct {
    next  *List[T]
    value T
}

제네릭 타입에서는 한 가지 제약이 더 엄격해져요. 타입 정의에서 주어진 타입은, 어떤 경우에도 타입 파라미터가 될 수 없어요.

type T[P any] P    // illegal: P is a type parameter

func f[P any]() {
    type L P   // illegal: P is a type parameter declared by the enclosing function
}

아까 앨리어스 선언에서는 바깥 함수가 가진 타입 파라미터를 쓸 수 있었는데, 타입 정의에서는 그런 경우까지 포함해 모두 비허용이에요. 같은 선언의 파라미터든 바깥 함수의 파라미터든 상관없이, 타입 정의가 바로 타입 파라미터를 지칭하는 건 안 돼요.

마지막으로, 제네릭 타입에도 메서드를 붙일 수 있어요. 이때 규칙이 하나 있어요. 메서드 리시버는 제네릭 타입 정의에 있는 것과 같은 개수의 타입 파라미터를 선언해야 해요.

// The method Len returns the number of elements in the linked list l.
func (l *List[T]) Len() int  { … }

List[T]는 타입 파라미터를 하나 쓰니까, 메서드의 리시버도 T 한 개를 똑같이 선언했어요. 이 개수가 서로 맞지 않으면 리시버가 정의된 제네릭 타입과 일치하지 않아서 문제가 생겨요.

더 알아보기

  • 기반 타입이 정확히 무엇이고, 왜 메서드가 상속되지 않는지가 궁금하다면 Underlying typesMethod sets 섹션을 함께 보면 좋아요.
  • 타입 정의로 만든 타입이 왜 원본과 "다른 타입"으로 취급되는지, 그 판단 기준은 Type identity에서 자세히 다뤄요.
  • 제네릭 타입과 타입 파라미터 선언 문법, 그리고 인스턴스화 규칙은 Type parameter declarationsInstantiations를 참고해요.