
Go 제네릭 완전 정복: interface{} 시대의 종말과 새로운 패러다임
Go 언어는 종종 '단순함'과 '명료함'의 미학으로 칭송받아 왔습니다.
하지만 이러한 단순함의 이면에는 한 가지 오랜 골칫거리가 있었으니, 바로 '코드 중복' 문제였습니다.
다른 타입에 대해 동일한 로직을 수행하는 함수를 만들려면, 우리는 각 타입에 맞는 함수를 일일이 따로 만들어야만 했습니다.
이러한 불편함 속에서 마침내 Go 1.18 버전이 등장하며, 언어의 역사에 한 획을 긋는 '제네릭(Generics)' 기능이 도입되었습니다.
오늘은 제네릭이 왜 필요했는지, 그리고 이 강력한 도구를 어떻게 사용하는지 함께 정복해 보겠습니다.
1. 제네릭 이전의 시대: 코드 중복과 interface{}
제네릭의 가치를 제대로 이해하려면, 그전에는 우리가 어떤 고통을 겪었는지 먼저 알아야 합니다.
예를 들어, 정수 슬라이스의 합을 구하는 함수와 부동소수점 슬라이스의 합을 구하는 함수를 만든다고 상상해 봅시다.
// 정수 슬라이스의 합을 구하는 함수
func SumInts(nums []int) int {
var total int
for _, num := range nums {
total += num
}
return total
}
// 부동소수점 슬라이스의 합을 구하는 함수
func SumFloats(nums []float64) float64 {
var total float64
for _, num := range nums {
total += num
}
return total
}
두 함수의 로직은 완전히 동일하지만, 단지 타입이 다르다는 이유만으로 별도의 함수를 만들어야 했습니다.
이것이 바로 코드 중복입니다.
'만능'이지만 위험했던 interface{}
이 문제를 해결하기 위한 오래된 '트릭'이 있었습니다.
바로 어떤 타입이든 담을 수 있는 interface{}(Go 1.18부터는 any라는 별칭으로 사용 가능)를 사용하는 것이죠.
// 어떤 타입이든 받으려는 시도 (하지만 문제가 있습니다)
func SumAnything(nums []interface{}) interface{} {
// ...
// 하지만 어떻게 덧셈을 할 수 있을까요?
// 타입이 int인지 float64인지 알 수 없기 때문에 `+` 연산을 할 수 없습니다.
// 결국 타입 단언(type assertion)을 사용해야만 합니다.
var total float64
for _, num := range nums {
switch v := num.(type) {
case int:
total += float64(v)
case float64:
total += v
default:
// 지원하지 않는 타입에 대한 처리
}
}
return total
}
코드가 훨씬 더 복잡해졌을 뿐만 아니라, 가장 큰 문제가 발생했습니다.
바로 '타입 안정성(Type Safety)'이 깨진 것입니다.
컴파일러는 더 이상 이 함수에 잘못된 타입이 들어오는 것을 막아주지 못하고, 모든 타입 관련 에러는 런타임에 가서야 발견됩니다.
이것이 바로 Go 커뮤니티가 오랫동안 제네릭을 염원했던 이유입니다.
2. 제네릭의 등장: [T any]
제네릭 함수는 '타입 매개변수(Type Parameters)'를 사용하여 정의됩니다.
이를 통해 다양한 타입의 입력을 처리하면서도 타입 안정성을 유지할 수 있습니다.
제네릭 함수 정의하기
가장 기본적인 제네릭 함수의 모습은 다음과 같습니다.
함수 이름 뒤에 대괄호 []를 사용하여 타입 매개변수를 선언합니다.
import "fmt"
// T는 타입 매개변수입니다.
// 'any'는 어떤 타입이든 허용한다는 제약 조건입니다.
func Print[T any](value T) {
fmt.Println(value)
}
// 사용 예시
Print(123) // T는 int가 됩니다.
Print("hello") // T는 string이 됩니다.
Print(true) // T는 bool이 됩니다.
Print 함수는 T라는 타입 매개변수를 가집니다.any 키워드는 interface{}와 동일하며, T가 어떤 타입이든 될 수 있다는 것을 의미하는 '제약 조건(constraint)'입니다.
이제 Print 함수는 어떤 타입의 값이든 받아 출력할 수 있는 범용적인 함수가 되었습니다.
3. 제약 조건(Constraints) 활용하기
any 제약 조건이 유연성을 제공하지만, 모든 상황에 적합한 것은 아닙니다.
우리가 처음에 만들려던 Sum 함수처럼, 특정 연산(예: +)을 지원하는 타입들로만 제한하고 싶을 때가 있습니다.
이때 사용하는 것이 바로 '타입 제약 조건'입니다.
제약 조건은 본질적으로 허용되는 타입의 집합을 정의하는 '인터페이스'입니다.
// Number라는 이름의 인터페이스를 제약 조건으로 정의합니다.
// 이 인터페이스는 int와 float64 타입을 허용합니다.
type Number interface {
int | float64
}
// 이제 Sum 함수는 Number 인터페이스를 만족하는 타입만 받을 수 있습니다.
func Sum[T Number](nums []T) T {
var total T
for _, num := range nums {
total += num // 이제 '+' 연산이 가능합니다!
}
return total
}
// 사용 예시
Sum([]int{1, 2, 3}) // OK
Sum([]float64{1.1, 2.2}) // OK
// Sum([]string{"a", "b"}) // 컴파일 에러! string은 Number 제약 조건을 만족하지 않습니다.
이제 Sum 함수는 오직 int와 float64 타입의 슬라이스만 인자로 받을 수 있습니다.
만약 다른 타입(예: string)을 전달하려고 하면, 컴파일러가 친절하게 에러를 발생시켜 줍니다.
'코드 재사용성'과 '타입 안정성' 두 마리 토끼를 모두 잡은 것입니다.
더 나아가기: 내장 제약 조건과 ~ 토큰
Go는 몇 가지 유용한 제약 조건을 미리 내장하고 있습니다.
예를 들어, ==와 != 연산이 가능한 모든 타입을 의미하는 comparable이 있습니다.
또한, constraints 패키지를 통해 Integer, Float 등 더 다양한 숫자 타입을 포함하는 제약 조건을 사용할 수도 있습니다.
import "golang.org/x/exp/constraints"
// constraints.Integer는 모든 정수 타입(int, int8, int16...)을 포함합니다.
// constraints.Float은 모든 부동소수점 타입(float32, float64)을 포함합니다.
type Number interface {
constraints.Integer | constraints.Float
}
여기서 ~ (틸드) 토큰도 알아두면 유용합니다.~int는 기본 타입(underlying type)이 int인 모든 타입을 의미합니다.
type MyInt int // MyInt의 기본 타입은 int입니다.
// Number 제약 조건에 ~를 추가합니다.
type Number interface {
~int | ~float64
}
var myInts []MyInt = []MyInt{1, 2, 3}
Sum(myInts) // OK! ~int 덕분에 MyInt 타입도 허용됩니다.
4. 제네릭 데이터 구조
제네릭은 함수뿐만 아니라, struct 같은 데이터 구조에도 적용할 수 있습니다.
어떤 타입의 데이터든 담을 수 있는 범용적인 '스택(Stack)'을 만들어 보겠습니다.
// 어떤 타입 T든 담을 수 있는 Stack 구조체
type Stack[T any] struct {
items []T
}
// Push 메서드: T 타입의 아이템을 스택에 추가합니다.
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
// Pop 메서드: 스택에서 T 타입의 아이템을 꺼냅니다.
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T // T 타입의 제로 값을 반환합니다.
return zero, false
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, true
}
// 사용 예시
intStack := &Stack[int]{}
intStack.Push(10)
intStack.Push(20)
val, _ := intStack.Pop() // val은 int 타입의 20이 됩니다.
stringStack := &Stack[string]{}
stringStack.Push("hello")
이제 우리는 int용 스택, string용 스택을 따로 만들 필요 없이, Stack[T]라는 하나의 제네릭 구조체로 모든 타입의 스택을 만들 수 있게 되었습니다.
결론
Go에 도입된 제네릭은 언어의 표현력과 유연성을 극적으로 향상시킨 중요한 진화입니다.
제네릭 함수와 타입을 활용함으로써, 개발자들은 다음과 같은 이점을 얻을 수 있습니다.
- '코드 재사용성': 다양한 데이터 타입에 대해 동작하는 함수를 작성하여 코드 중복을 줄입니다.
- '타입 안정성': 제약 조건을 통해 함수에 적절한 타입만 사용되도록 보장하여, 컴파일 시점에 잠재적인 에러를 잡아냅니다.
- '유지보수성': 명확성이나 성능을 희생하지 않으면서 일반화된 솔루션을 구현할 수 있어, 코드베이스가 더 깔끔하고 관리하기 쉬워집니다.
이제 더 이상 타입별로 중복된 코드를 작성하거나, 타입 안정성을 포기하며 interface{}를 사용하는 고통을 겪을 필요가 없습니다.
제네릭이라는 강력한 도구를 통해 더 추상적이고, 재사용 가능하며, 안전한 코드를 작성해 보시길 바랍니다.
'Go' 카테고리의 다른 글
| Go 언어 난수 생성 완벽 가이드: math/rand부터 crypto/rand까지 (0) | 2025.07.12 |
|---|---|
| Go 언어 JSON 태그 완전 정복: omitempty부터 커스텀 태그까지 (0) | 2025.07.12 |
| Go 언어의 청소부, 가비지 컬렉터 파헤치기: 메모리 관리, 이젠 맡겨주세요! (0) | 2025.06.03 |
| Go 언어에 클래스가 없다고? 걱정 마세요! 구조체와 인터페이스로 다 됩니다! (0) | 2025.06.03 |
| Go 언어 딕셔너리 완전 정복: 맵(map)으로 데이터 관리 마스터하기! (0) | 2025.06.03 |