golang: 제네릭(Generic)이란?
제네릭(Generic)은 Go 언어에서 매우 강력한 도구로, 다양한 데이터 타입을 처리하고 코드를 간결하고 유연하게 만드는 데 유용합니다. 이를 통해 코드의 확장성, 안정성, 재사용성을 높일 수 있으며, 다양한 상황에서 효율적으로 사용할 수 있습니다. 제네릭을 잘 활용하면 더 나은 설계를 하고 변화하는 요구사항에 유연하게 대응할 수 있는 코드를 작성할 수 있습니다.
1. 타입 확장성
제네릭을 사용하면 다양한 데이터 타입을 하나의 함수나 구조체로 처리할 수 있습니다. 이는 특정 타입에 제한되지 않고, 여러 타입에 대해 동일한 로직을 사용할 수 있게 합니다.
예시: 숫자 계산 함수
package main
import "fmt"
// 제네릭 타입 정의
type Number interface {
~int | ~float64 | ~int32
}
// 제네릭을 사용한 합계 계산 함수
func Sum[T Number](a, b T) T {
return a + b
}
func main() {
fmt.Println(Sum(10, 20)) // int 타입
fmt.Println(Sum(10.5, 20.5)) // float64 타입
fmt.Println(Sum(int32(10), int32(20))) // int32 타입
}
설명:
- 제네릭 타입 T는 Number 인터페이스에 의해 제약됩니다.
- Number 인터페이스는 int, float64, int32와 같은 숫자 타입을 포함하며, 이들을 모두 처리할 수 있습니다.
2. 타입 안정성
제네릭은 컴파일 시점에 타입 검사를 수행하므로, 잘못된 타입 사용으로 인한 오류를 방지할 수 있습니다. 이는 특히 대규모 코드베이스에서 중요한 역할을 합니다.
예시: 타입 검사를 통한 안전한 데이터 처리
package main
import "fmt"
// 제네릭 스택 구현
type Stack[T any] struct {
items []T
}
// Push 메서드: 스택에 아이템 추가
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
// Pop 메서드: 스택에서 아이템 제거
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T
return zero, false
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, true
}
func main() {
// int 타입 스택
intStack := Stack[int]{}
intStack.Push(1)
intStack.Push(2)
// Pop 연산
item, ok := intStack.Pop()
if ok {
fmt.Println("Popped item:", item) // Popped item: 2
}
// string 타입 스택
stringStack := Stack[string]{}
stringStack.Push("hello")
stringStack.Push("world")
// Pop 연산
strItem, ok := stringStack.Pop()
if ok {
fmt.Println("Popped item:", strItem) // Popped item: world
}
}
설명:
- 제네릭 스택은 컴파일 시점에 타입 검사를 수행하여 안전한 데이터 처리를 보장합니다.
- 잘못된 타입의 데이터가 들어오는 것을 방지합니다.
3. 코드 재사용성
제네릭을 사용하면 동일한 로직을 여러 타입에 대해 재사용할 수 있어, 코드 중복을 줄이고 유지보수를 용이하게 합니다.
예시: 데이터 필터링 함수
package main
import "fmt"
// 제네릭 필터링 함수
func Filter[T any](data []T, predicate func(T) bool) []T {
var result []T
for _, v := range data {
if predicate(v) {
result = append(result, v)
}
}
return result
}
func main() {
// 정수 배열에서 짝수만 필터링
numbers := []int{1, 2, 3, 4, 5, 6}
evenNumbers := Filter(numbers, func(n int) bool {
return n%2 == 0
})
fmt.Println("Even numbers:", evenNumbers) // Even numbers: [2 4 6]
// 문자열 배열에서 특정 문자 포함하는 문자열 필터링
strings := []string{"apple", "banana", "cherry"}
filteredStrings := Filter(strings, func(s string) bool {
return len(s) > 5
})
fmt.Println("Filtered strings:", filteredStrings) // Filtered strings: [banana cherry]
}
설명:
- 제네릭 필터링 함수는 다양한 타입의 데이터를 처리할 수 있으며, 코드 재사용성을 극대화합니다.
- 동일한 로직을 여러 타입에 대해 사용할 수 있어 코드 중복을 줄입니다.
4. 유연한 API 설계
제네릭은 API 설계에서 다양한 타입을 유연하게 지원할 수 있도록 도와줍니다. 이를 통해 API 사용자에게 더 많은 선택권을 제공할 수 있습니다.
예시: 데이터베이스 조회 API
package main
import (
"fmt"
)
// 제네릭 데이터베이스 레코드
type Record[T any] struct {
ID int
Data T
}
// 제네릭 데이터베이스 조회 함수
func GetRecords[T any](records []Record[T]) []T {
var results []T
for _, record := range records {
results = append(results, record.Data)
}
return results
}
func main() {
// int 타입의 데이터베이스 레코드
intRecords := []Record[int]{
{ID: 1, Data: 10},
{ID: 2, Data: 20},
}
intResults := GetRecords(intRecords)
fmt.Println("Int results:", intResults) // Int results: [10 20]
// string 타입의 데이터베이스 레코드
stringRecords := []Record[string]{
{ID: 1, Data: "apple"},
{ID: 2, Data: "banana"},
}
stringResults := GetRecords(stringRecords)
fmt.Println("String results:", stringResults) // String results: [apple banana]
}
설명:
- 제네릭을 사용하여 다양한 타입의 데이터베이스 레코드를 조회할 수 있는 API를 설계할 수 있습니다.
- 사용자에게 더 많은 선택권을 제공하며, API의 유연성을 높입니다.
5. 간결한 코드
제네릭을 사용하면 여러 타입에 대해 동일한 로직을 반복해서 작성할 필요가 없어 코드가 간결해집니다.
예시: 데이터 스왑 함수
package main
import "fmt"
// 제네릭 스왑 함수
func Swap[T any](a, b T) (T, T) {
return b, a
}
func main() {
// int 타입 스왑
x, y := 1, 2
x, y = Swap(x, y)
fmt.Println("Swapped:", x, y) // Swapped: 2 1
// string 타입 스왑
s1, s2 := "hello", "world"
s1, s2 = Swap(s1, s2)
fmt.Println("Swapped:", s1, s2) // Swapped: world hello
}
설명:
- 제네릭 스왑 함수는 여러 타입에 대해 동일한 로직을 사용하여 코드 중복을 줄이고 간결한 표현을 제공합니다.
6. 타입 안정성 및 제약 조건 (Constraints)
Go의 제네릭은 제약 조건을 지원하여 특정 인터페이스나 타입에 맞는 데이터만 처리할 수 있도록 제한할 수 있습니다.
예시: 제약 조건을 사용한 연산
package main
import (
"fmt"
)
// 숫자 타입에 대한 제약 조건 정의
type Number interface {
~int | ~float64
}
// 두 숫자를 더하는 제네릭 함수
func Add[T Number](a, b T) T {
return a + b
}
func main() {
fmt.Println(Add(10, 20)) // int 타입
fmt.Println(Add(3.5, 2.7)) // float64 타입
// fmt.Println(Add("a", "b")) // 컴파일 오류 발생: string은 Number 인터페이스에 맞지 않음
}
설명:
- 제약 조건을 사용하여 특정 타입에 맞는 연산만 수행하도록 제한할 수 있으며, 타입 안정성을 유지합니다.
7. 유연한 데이터 구조
제네릭을 사용하여 다양한 타입의 데이터를 저장하고 처리할 수 있는 데이터 구조를 쉽게 정의할 수 있습니다.
예시: 제네릭 리스트 구현
package main
import "fmt"
// 제네릭 리스트 구조체
type List[T any] struct {
items []T
}
// 아이템 추가
func (l *List[T]) Add(item T) {
l.items = append(l.items, item)
}
// 아이템 출력
func (l List[T]) Print() {
for _, item := range l.items {
fmt.Println(item)
}
}
func main() {
// int 타입 리스트
intList := List[int]{}
intList.Add(1)
intList.Add(2)
intList.Print() // 1, 2
// string 타입 리스트
stringList := List[string]{}
stringList.Add("hello")
stringList.Add("world")
stringList.Print() // hello, world
}
설명:
- 제네릭 리스트는 다양한 타입의 데이터를 저장할 수 있으며, 타입에 따라 다른 리스트를 생성하여 사용할 수 있습니다.
8. 성능 최적화
제네릭은 특정 타입에 맞는 코드를 컴파일 시점에 생성하므로, 런타임에 발생할 수 있는 불필요한 타입 변환을 줄이고 성능을 최적화할 수 있습니다.
예시: 제네릭 기반의 성능 최적화
package main
import "fmt"
// 제네릭 평균 계산 함수
func Average[T float64 | int](data []T) float64 {
var sum T
for _, v := range data {
sum += v
}
return float64(sum) / float64(len(data))
}
func main() {
ints := []int{1, 2, 3, 4, 5}
floats := []float64{1.1, 2.2, 3.3, 4.4, 5.5}
fmt.Printf("Average of ints: %.2f\n", Average(ints)) // 3.00
fmt.Printf("Average of floats: %.2f\n", Average(floats)) // 3.30
}
설명:
- 제네릭을 사용하여 특정 타입에 맞는 코드를 생성하므로 런타임에서 불필요한 타입 변환을 피하고 성능을 최적화할 수 있습니다.