Generic(제네릭)이란?
Go 1.18부터 도입된 제네릭(Generic) 기능은 하나의 함수나 타입이 다양한 타입을 처리할 수 있도록 해주는 기능입니다. 제네릭(Generics)이 도입된 지 꽤 시간이 흘렀지만, 실무 환경에서는 기존의 `interface{}`와 타입 단언(Type Assertion) 패턴에 익숙해져 제네릭을 적극적으로 활용하지 못하는 경우가 많습니다. 이번 문서에서는 Golang 제네릭의 핵심 개념부터, 범용 자료구조, 실무 유틸리티 함수, 그리고 아키텍처 레벨에서의 고급 활용법까지 단계별로 정리해 보겠습니다.
제네릭의 핵심은 "타입(Type)을 파라미터로 받는다"는 것입니다. 함수나 구조체를 작성할 때 구체적인 타입(`int`, `string` 등)을 미리 정하지 않고, 실제로 호출하거나 사용할 때 타입을 결정하도록 만드는 기능입니다. 기존에는 여러 타입을 처리하기 위해 타입별로 함수를 중복 생성하거나, `interface{}`를 사용하여 런타임 에러의 위험을 감수해야 했습니다. 제네릭을 사용하면 코드의 중복을 줄이면서도 컴파일 단계에서 완벽한 타입 안정성을 보장받을 수 있습니다.
Generic이 필요한 이유
Go에서 Generic이 없던 시절에서는 다음과 같은 일이 자주 발생하였습니다.
func PrintInts(list []int) {
for _, v := range list {
fmt.Println(v)
}
}
func PrintStrings(list []string) {
for _, v := range list {
fmt.Println(v)
}
}
Generic이 없던 시절에는 interface{}를 이용하여 형변환을 이용하거나 아니면 위와 같이 PrintInts, PrintStrings 각각 타입에 맞는 함수를 정의하여 사용해야만 했습니다.
Generic 사용해보기
Generic을 이용하면 타입에 대한 안정성을 유지하며 위와 같이 각 타입별로 함수를 정의하지 않고 다음과 같이 간단하게 코드를 구현하여 사용할 수 있습니다.
func PrintSlice[T any](s []T) {
for _, v := range s {
fmt.Println(v)
}
}
Generic constraint
모든 타입이 아닌, 특정 조건을 가진 타입만 허용하고 싶을 때 다음과 같이 Number(숫자)에 관련된 타입만 정의하여 사용할 수 있습니다.
type Number interface {
int | int8 | int16 | int32 | int64 | float32 | float64
}
func SumNumber[T Number](a, b T) T {
return a + b
}
func main() {
result := SumNumber[int](int(1), int(2))
fmt.Println(result) // 3
result2 := SumNumber[int8](int8(2), int8(3))
fmt.Println(result2) // 5
result3 := SumNumber[float32](float32(1.1), float32(2.2))
fmt.Println(result3) // 3.3
}
Generic constraint 상세
덧셈이나 대소 비교 같은 연산을 위해 우리가 직접 허용할 타입들을 묶어 인터페이스로 정의할 수 있습니다. 이때 ~ 기호를 사용하면 해당 타입을 기반으로 만들어진 커스텀 타입(예: type CustomInt int)까지 모두 허용할 수 있습니다.
type Number interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 | ~float32 | ~float64
}
func SumNumber[T Number](a, b T) T {
return a + b
}
type CustomInt int
func main() {
result := SumNumber(CustomInt(1), CustomInt(2))
fmt.Println(result) // 3
}
Generic struct
제네릭이 가장 강력한 힘을 발휘하는 곳은 스택(Stack)이나 큐(Queue) 같은 자료구조를 만들 때입니다. 타입 단언 없이 데이터를 안전하게 넣고 뺄 수 있습니다.
// 1. 제네릭 구조체 정의
// T 자리에 어떤 타입이든 들어올 수 있도록 'any' 제약조건을 사용합니다.
type Stack[T any] struct {
items []T
}
// 2. 제네릭 메서드: 데이터를 맨 끝에 추가하는 Push
// 리시버(Receiver)에 반드시 타입 파라미터 [T]를 적어주어야 구조체와 연결됩니다.
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
// 3. 제네릭 메서드: 가장 마지막에 추가된 데이터를 빼내는 Pop
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T // T 타입의 기본값(Zero value) 자동 할당 (숫자는 0, 문자열은 "")
return zero, false
}
lastIndex := len(s.items) - 1
item := s.items[lastIndex]
s.items = s.items[:lastIndex] // 마지막 요소를 제외하고 슬라이스 갱신
return item, true
}
func main() {
myStack := Stack[string]{}
myStack.Push("111")
myStack.Push("222")
myStack.Push("333")
for v, ok := myStack.Pop(); ok; v, ok = myStack.Pop() {
fmt.Println("Pop values: ", v)
}
}
Generic과 함수 구현 응용
다른 프로그래밍 언어에서는 흔하게 제공되는 Map(데이터 일괄 변환)이나 Filter(조건부 추출) 같은 기능들이 Go에는 내장되어 있지 않아 매번 for 루프를 작성해야 했습니다. 하지만 제네릭을 활용하면 어떤 타입의 슬라이스가 들어오든 유연하게 처리할 수 있는 범용 Map, Filter 함수를 직접 만들어 사용할 수 있습니다.
// 1. Map 함수: T 타입 슬라이스를 받아서, 변환 함수 f를 거쳐 U 타입 슬라이스로 반환합니다.
// 두 개의 타입 파라미터 [T any, U any]가 필요합니다.
func Map[T any, U any](input []T, f func(T) U) []U {
result := make([]U, len(input))
for i, v := range input {
result[i] = f(v) // 각 요소에 함수 f를 적용
}
return result
}
// 2. Filter 함수: T 타입 슬라이스에서 조건 함수 f가 true를 반환하는 요소만 걸러냅니다.
// 타입이 변하지 않으므로 하나의 타입 파라미터 [T any]만 사용합니다.
func Filter[T any](input []T, f func(T) bool) []T {
var result []T
for _, v := range input {
if f(v) {
result = append(result, v)
}
}
return result
}
func main() {
// [실습 1] Map 사용해보기: 문자열 슬라이스를 대문자로 일괄 변환
words := []string{"deploy", "build", "test"}
upperWords := Map(words, func(s string) string {
return strings.ToUpper(s)
})
fmt.Println("대문자 변환:", upperWords)
// 출력: 대문자 변환: [DEPLOY BUILD TEST]
// [실습 2] Map 사용해보기: 문자열 슬라이스의 길이를 구해 정수 슬라이스로 변환 (T=string, U=int)
lengths := Map(words, func(s string) int {
return len(s)
})
fmt.Println("단어 길이:", lengths)
// 출력: 단어 길이: [6 5 4]
// [실습 3] Filter 사용해보기: 실행 시간이 10초 이상인 작업만 추출
executionTimes := []int{5, 12, 8, 20, 3}
slowTasks := Filter(executionTimes, func(t int) bool {
return t >= 10
})
fmt.Println("10초 이상 걸린 작업:", slowTasks)
// 출력: 10초 이상 걸린 작업: [12 20]
}
Generic을 이용한 Interface 정의
실무 아키텍처를 설계할 때 핵심이 되는 부분입니다. 제네릭과 인터페이스를 결합하여 interface 추상화할 때 코드 중복을 획기적으로 줄이고 단위 테스트(Mocking)를 매우 쉽게 만들 수 있습니다.
type Comparable[T any] interface {
Less(b T) bool
}
type Person struct {
Name string
Age int
}
func (p Person) Less(other Person) bool {
return p.Age < other.Age
}
func Min[T Comparable[T]](a, b T) T {
if a.Less(b) {
return a
}
return b
}
func main() {
p1 := Person{"Alice", 25}
p2 := Person{"Bob", 30}
fmt.Println(Min(p1, p2)) // Alice
}
'Go언어' 카테고리의 다른 글
| 안전하게 개발하기 레츠GO! #6 TestMain, testing.M (0) | 2026.04.01 |
|---|---|
| go reflect 이해하기 (0) | 2026.03.22 |
| go version upgrade (MacOS) (0) | 2026.03.21 |
| 안전하게 개발하기 레츠GO! #5 실전 테스트 (0) | 2025.11.23 |
| 안전하게 개발하기 레츠GO! #4 graceful shutdown (0) | 2025.11.21 |