(Go 언어 날개 달기) 제너릭(Generics) 완전 정복 3편 - 마법의 비밀, 인스턴스화와 타입 추론

안녕하세요!

지난 시간에는 comparable, 인터페이스, 유니온(|), 근사 토큰(~) 등 다양한 제약조건을 통해 제너릭 코드의 안정성과 표현력을 높이는 방법을 배웠습니다.

제약조건 덕분에 타입 파라미터 T가 어떤 능력을 갖춰야 하는지 컴파일러에게 명확히 알려줄 수 있었죠.

그런데 혹시 이런 궁금증, 안 드셨습니까?

"분명 코드는 [T any] 처럼 추상적으로 썼는데, 실행할 때는 어떻게 intstring 같은 실제 타입처럼 딱 맞춰서 돌아가는 걸까?"

"함수 호출할 때 [string] 같이 타입을 안 써줘도 컴파일러는 어떻게 알아서 Tstring인 걸 아는 걸까?"

네, 아주 좋은 질문입니다!

바로 이 질문들에 대한 답이 오늘 우리가 탐험할 주제, 인스턴스화(Instantiation)타입 추론(Type Inference) 입니다.

제너릭이라는 마법이 실제로 어떻게 구현되는지, 그 비밀의 문을 함께 열어보겠습니다!

마법의 첫 단계: 인스턴스화 (Instantiation)

제너릭 코드를 작성할 때 우리는 T 같은 타입 파라미터(Type Parameter) 를 사용합니다.

이건 마치 '이 자리에는 나중에 어떤 타입이든 들어올 수 있어!'라고 표시해둔 빈칸과 같은데요.

실제로 프로그램이 실행되기 전에, Go 컴파일러는 이 빈칸을 구체적인 타입 인수(Type Argument) (예: int, string, float64 등)로 채워 넣는 작업을 수행합니다.

이렇게 타입 파라미터를 실제 타입으로 '교체'하여 제너릭 코드를 특정 타입에 맞는 일반 코드로 변환하는 과정을 바로 인스턴스화(Instantiation) 라고 부릅니다.

마치 우리가 가진 '만능 과자 틀'(제너릭 코드)에 '별 모양 재료'(타입 인수 string)를 넣어 '별 모양 과자'(특정 타입용 코드)를 찍어내는 것과 비슷합니다!

1편에서 봤던 printSlice 함수를 예로 들어볼까요?

func printSlice[T any](inputSlice []T) {
	// ... 내부 로직 ...
}

func main() {
	stringSlice := []string{"a", "b", "c"}

	// 1. 타입을 명시적으로 지정하여 호출 ([string])
	printSlice[string](stringSlice)

	// 2. 타입을 생략하고 호출 (타입 추론 사용)
	printSlice(stringSlice)
}

여기서 중요한 점은, 우리가 printSlice[string](stringSlice) 처럼 타입을 명시적으로 지정하든,

printSlice(stringSlice) 처럼 타입을 생략하든, 내부적으로는 컴파일러가 Tstring으로 교체하는 인스턴스화 과정을 반드시 거친다는 것입니다!

타입 추론(뒤에서 설명합니다)이 일어나더라도, 최종적으로는 printSlice[string]이라는 'string 전용 함수' 코드가 생성되어 사용되는 셈입니다.

인스턴스화는 어떻게 진행될까요?

컴파일러가 인스턴스화를 진행할 때는 크게 두 가지 단계를 거칩니다.

  1. 타입 인수 치환: 코드 내의 모든 타입 파라미터(T)를 제공된 타입 인수(string)로 바꿉니다.
  2. 제약조건 검증: 치환된 타입 인수가 해당 타입 파라미터에 걸려 있던 제약조건을 만족하는지 확인합니다.

만약 2단계에서 제약조건을 만족하지 못하면, 인스턴스화는 실패하고 컴파일 에러가 발생합니다.

2편에서 봤던 Max 함수와 Number 제약조건을 예로 들어볼까요?

package main

import "fmt"

type Number interface {
	~int | ~float64 // 간단히 int, float64 계열만 허용
}

func Max[T Number](a, b T) T {
	if a > b {
		return a
	}
	return b
}

func main() {
	// int 타입으로 인스턴스화 시도 (성공!)
	maxInt := Max[int](10, 5) // T를 int로 치환 -> int는 Number 제약조건 만족 -> OK!
	fmt.Println("Max int:", maxInt)

	// string 타입으로 인스턴스화 시도 (실패!)
	// maxString := Max[string]("hello", "world")
	// 컴파일 에러 발생: string does not implement Number (string is not comparable)
	// 이유: T를 string으로 치환 -> string은 Number 제약조건 불만족 -> 인스턴스화 실패!
}

Max[string]으로 인스턴스화를 시도하면, 컴파일러는 먼저 Tstring으로 바꾸려고 합니다.

하지만 그 다음, string 타입이 Number 제약조건(~int | ~float64)을 만족하는지 확인하는데, 만족하지 못하므로 컴파일 에러를 내보내는 것입니다.

제약조건 덕분에 런타임에 발생할 수 있는 오류를 컴파일 시점에 미리 잡을 수 있는 것이죠!

마법의 두 번째 단계: 타입 추론 (Type Inference)

자, 이제 인스턴스화가 무엇인지 알았습니다.

그런데 1편부터 계속 봤듯이, 우리는 Max[int](10, 5) 처럼 매번 [int]를 써주지 않고 그냥 Max(10, 5) 라고만 써도 코드가 잘 돌아갔습니다. 이게 어떻게 가능했을까요?

바로 컴파일러의 놀라운 능력, 타입 추론(Type Inference) 덕분입니다!

타입 추론은 우리가 명시적으로 타입 인수를 제공하지 않았을 때, 컴파일러가 주변 코드(주로 함수 호출 시 전달된 인수)를 보고 타입 파라미터에 어떤 타입이 들어가야 할지 스스로 알아내는 기능입니다.

마치 탐정이 단서들을 모아 범인을 추리해내듯 말이죠!

타입 추론의 가장 중요한 단서: 함수 인수!

컴파일러가 타입을 추론하는 가장 흔하고 강력한 단서는 바로 함수에 전달되는 인수의 타입입니다.

func printSlice[T any](inputSlice []T) { /* ... */ }

func main() {
	intSlice := []int{1, 2, 3}
	floatSlice := []float64{1.1, 2.2}

	// 컴파일러는 intSlice의 타입이 []int 인 것을 보고,
	// inputSlice []T 와 비교하여 T가 int 임을 추론합니다!
	printSlice(intSlice)

	// 마찬가지로 floatSlice의 타입이 []float64 인 것을 보고,
	// T가 float64 임을 추론합니다!
	printSlice(floatSlice)
}

printSlice(intSlice)를 호출하면, 컴파일러는 intSlice 변수의 타입이 []int라는 것을 알고 있습니다.

그리고 printSlice 함수의 정의를 보며 inputSlice 매개변수의 타입이 []T라는 것을 확인합니다.

이 둘을 비교하여, '아하! []int[]T가 같으려면 Tint여야 하는구나!' 라고 똑똑하게 추론해내는 것입니다.

정말 편리하지 않습니까?

타입 추론의 또 다른 단서: 제약조건 (살짝 엿보기)

함수 인수 외에도, 때로는 타입 파라미터 간의 관계를 정의하는 제약조건 자체가 타입 추론의 단서가 되기도 합니다.

이건 조금 더 복잡한 경우에 해당하는데요. 예를 들어, 한 타입 파라미터가 다른 타입 파라미터에 의존하는 형태의 제약조건이 있다면, 하나의 타입 인수가 정해졌을 때 나머지 타입 인수도 추론될 수 있습니다. (이 부분은 조금 심화 내용이므로, '이런 것도 있구나' 정도로만 알아두셔도 좋습니다!)

타입 추론은 만능일까요? 언제 명시적으로 타입을 알려줘야 할까요?

타입 추론은 매우 편리하지만, 항상 마법처럼 작동하는 것은 아닙니다.

컴파일러가 타입을 추론할 단서가 전혀 없거나, 단서가 모호한 경우에는 타입 추론이 실패하고 우리는 직접 타입 인수를 알려줘야 합니다.

가장 대표적인 경우가 1편에서 봤던 NewStack 함수입니다.

// T 타입의 빈 스택 포인터를 반환하는 함수
func NewStack[T any]() *Stack[T] {
	s := make(Stack[T], 0)
	return &s
}

func main() {
	// 컴파일러는 이 호출만 보고서는 T가 무엇이어야 할지 알 수 없습니다!
	// 인수가 전혀 없기 때문이죠.
	// stringStack := NewStack() // 컴파일 에러! cannot infer T

	// 따라서 반드시 명시적으로 타입을 알려줘야 합니다.
	stringStack := NewStack[string]() // OK!
	intStack := NewStack[int]()       // OK!
}

NewStack() 함수는 어떤 인수도 받지 않습니다.

따라서 컴파일러는 T를 무엇으로 결정해야 할지 추론할 근거가 전혀 없는 상태입니다.

이럴 때는 우리가 NewStack[string]() 처럼 T가 될 타입을 명시적으로 지정해주어야만 합니다.

정리하자면, 컴파일러가 타입을 추론할 수 없을 때는 우리가 직접 [Type] 형태로 타입 인수를 제공해야 한다! 이것만 기억하시면 됩니다.

정리 및 다음 여정 예고

오늘은 Go 제너릭의 마법이 실제로 어떻게 일어나는지, 그 두 가지 핵심 원리인 인스턴스화타입 추론에 대해 알아보았습니다.

  • 인스턴스화: 컴파일러가 타입 파라미터(T)를 실제 타입 인수(string)로 교체하여 제너릭 코드를 특정 타입용 코드로 변환하는 과정입니다. 모든 제너릭 코드는 실행 전에 인스턴스화됩니다.
  • 제약조건 검증: 인스턴스화 과정에서 타입 인수가 제약조건을 만족하는지 반드시 확인하며, 만족하지 못하면 컴파일 에러가 발생합니다.
  • 타입 추론: 컴파일러가 함수 인수 등의 단서를 이용해 명시되지 않은 타입 인수를 스스로 알아내는 편리한 기능입니다.
  • 명시적 타입 인수: 타입 추론이 불가능한 경우(예: 인수가 없는 제너릭 함수 호출), [Type] 형태로 직접 타입 인수를 제공해야 합니다.

이제 여러분은 제너릭 코드가 어떤 과정을 거쳐 실행되는지, 그리고 왜 때로는 타입을 명시해야 하는지에 대한 깊은 이해를 갖게 되셨습니다!

다음 시간에는 Go 제너릭 여정의 마지막 장이 될 수도 있고, 혹은 더 깊은 탐험의 시작이 될 수도 있는데요.

제너릭을 사용하면서 마주칠 수 있는 조금 더 까다로운 경우들(예: 포인터 리시버와의 조합), 그리고 제너릭의 내부 동작과 관련된 심화 개념(Core Type 등), 마지막으로 제너릭 사용 시 주의할 점들에 대해 이야기 나눠보는 시간을 갖겠습니다.

다음 시간에 뵙겠습니다.

그럼.