2025-12-25
Go 언어는 1.18 버전(2022년 3월)부터 제네릭(Generics)을 공식 지원합니다. 오랫동안 Go 커뮤니티에서 가장 많이 요청되었던 기능으로, 등장 이전에는 interface{}나 any로 타입 유연성을 흉내 냈지만 컴파일 타임 타입 안전성을 포기해야 했습니다.
이 글에서는 제네릭의 핵심 개념인 타입 파라미터(Type Parameter)와 타입 제약(Type Constraint)부터 시작하여, 제네릭 자료구조 구현, 인터페이스와의 조합, 그리고 실무에서 언제 제네릭을 쓰고 언제 피해야 하는지 판단 기준까지 심층적으로 다룹니다.
🛠️ 제네릭이 바꾼 실무 경험
제네릭 도입 전에는
int슬라이스용 함수와string슬라이스용 함수를 따로 작성하거나,interface{}로 처리한 뒤 타입 단언으로 값을 꺼내는 방식이 일반적이었습니다. 이 방식은 런타임 패닉 위험과 코드 중복이라는 두 가지 문제를 동시에 안고 있었습니다. 제네릭은 이 두 문제를 모두 해결합니다.
제네릭(Generics)은 타입을 매개변수로 받는 프로그래밍 기법입니다. 함수나 타입을 정의할 때 구체적인 타입 대신 타입 파라미터를 사용하고, 실제 호출 시점에 타입이 결정됩니다.
제네릭이 없던 시절, 두 값 중 최솟값을 반환하는 함수는 타입마다 따로 작성해야 했습니다.
func MinInt(a, b int) int {
if a < b {
return a
}
return b
}
func MinFloat64(a, b float64) float64 {
if a < b {
return a
}
return b
}
func MinString(a, b string) string {
if a < b {
return a
}
return b
}
로직은 완전히 동일한데 타입만 다릅니다. 이런 코드 중복은 유지보수 비용을 높이고 버그가 퍼질 가능성을 키웁니다.
func Min[T int | float64 | string](a, b T) T {
if a < b {
return a
}
return b
}
func main() {
fmt.Println(Min(3, 7)) // 출력: 3
fmt.Println(Min(3.14, 2.71)) // 출력: 2.71
fmt.Println(Min("apple", "banana")) // 출력: apple
}
[T int | float64 | string]이 타입 파라미터 선언입니다. 이제 하나의 함수로 세 가지 타입을 모두 처리합니다.
// 함수에 타입 파라미터 적용
func 함수명[타입파라미터 제약조건](매개변수 타입파라미터) 반환타입 {
// 구현
}
// 타입(구조체)에 타입 파라미터 적용
type 타입명[타입파라미터 제약조건] struct {
필드 타입파라미터
}
함수는 여러 개의 타입 파라미터를 가질 수 있습니다.
// 키-값 쌍을 받아 맵으로 변환
func Zip[K comparable, V any](keys []K, values []V) map[K]V {
result := make(map[K]V)
for i := 0; i < len(keys) && i < len(values); i++ {
result[keys[i]] = values[i]
}
return result
}
func main() {
names := []string{"Alice", "Bob", "Carol"}
ages := []int{25, 30, 28}
m := Zip(names, ages)
fmt.Println(m) // map[Alice:25 Bob:30 Carol:28]
}
K와 V는 서로 다른 타입 파라미터입니다. K는 comparable(맵 키로 사용 가능한 타입), V는 any(모든 타입) 제약을 가집니다.
Go 컴파일러는 함수 인수를 통해 타입 파라미터를 자동으로 추론합니다.
// 명시적 타입 인수
result1 := Min[int](3, 7)
// 타입 추론 (권장)
result2 := Min(3, 7) // T = int로 자동 추론
result3 := Min(3.14, 2.71) // T = float64로 자동 추론
대부분의 경우 타입을 명시할 필요가 없습니다. 코드가 더 간결해집니다.
타입 파라미터에는 제약(Constraint)을 걸어 사용 가능한 타입을 제한합니다. 제약은 인터페이스로 표현됩니다.
any 제약: 모든 타입 허용func Print[T any](value T) {
fmt.Println(value)
}
any는 interface{}의 별칭으로, 모든 타입을 허용합니다. 단, any를 사용하면 타입이 지원하는 연산이 거의 없으므로 출력, 저장 등의 범용 용도에만 적합합니다.
comparable 제약: 비교 가능한 타입func Contains[T comparable](slice []T, target T) bool {
for _, v := range slice {
if v == target {
return true
}
}
return false
}
func main() {
ints := []int{1, 2, 3, 4, 5}
fmt.Println(Contains(ints, 3)) // true
fmt.Println(Contains(ints, 6)) // false
strs := []string{"a", "b", "c"}
fmt.Println(Contains(strs, "b")) // true
}
comparable은 ==와 != 연산을 지원하는 타입에 적용됩니다. 슬라이스, 맵, 함수 타입은 포함되지 않습니다.
// 직접 유니온 타입 제약 정의
type Number interface {
int | int8 | int16 | int32 | int64 |
uint | uint8 | uint16 | uint32 | uint64 |
float32 | float64
}
func Sum[T Number](numbers []T) T {
var total T
for _, n := range numbers {
total += n
}
return total
}
func main() {
ints := []int{1, 2, 3, 4, 5}
fmt.Println(Sum(ints)) // 15
floats := []float64{1.1, 2.2, 3.3}
fmt.Println(Sum(floats)) // 6.6000000000000005
}
~ 틸드(Tilde) 연산자: 기반 타입 포함type MyInt int // int를 기반 타입으로 하는 사용자 정의 타입
type Integer interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
func Double[T Integer](v T) T {
return v * 2
}
func main() {
var x MyInt = 5
fmt.Println(Double(x)) // ✅ 출력: 10
fmt.Println(Double(10)) // ✅ 출력: 20
}
~int는 int 자체뿐 아니라 int를 기반 타입으로 하는 MyInt 같은 사용자 정의 타입도 포함합니다. 이 차이가 실무에서 매우 중요합니다.
| 제약 | 의미 |
|---|---|
int | 정확히 int 타입만 허용 |
~int | int 및 기반 타입이 int인 모든 타입 허용 |
🛠️ 실무 팁:
~연산자를 적극 활용하라도메인 모델에서는
type UserID int,type OrderID int처럼 기반 타입이 같은 다양한 사용자 정의 타입을 사용합니다.~int제약을 사용하면 이런 타입들도 제네릭 함수에서 처리할 수 있어 코드 재사용성이 크게 높아집니다.
cmp와 slices 패키지: Go 1.21 표준 제네릭Go 1.21부터 표준 라이브러리에 제네릭 기반 유틸리티 패키지가 추가되었습니다.
import (
"cmp"
"slices"
)
func main() {
// slices.Sort: 제네릭 정렬 (Go 1.21+)
nums := []int{5, 2, 8, 1, 9, 3}
slices.Sort(nums)
fmt.Println(nums) // [1 2 3 5 8 9]
// slices.Contains: 제네릭 Contains
fmt.Println(slices.Contains(nums, 8)) // true
// slices.Max / slices.Min
fmt.Println(slices.Max(nums)) // 9
fmt.Println(slices.Min(nums)) // 1
// cmp.Compare: 범용 비교 함수
fmt.Println(cmp.Compare(3, 5)) // -1 (3 < 5)
fmt.Println(cmp.Compare(5, 5)) // 0 (같음)
fmt.Println(cmp.Compare(7, 5)) // 1 (7 > 5)
}
이전에는 golang.org/x/exp/constraints나 golang.org/x/exp/slices를 써야 했지만, Go 1.21부터는 별도 의존성 없이 표준 라이브러리만으로 충분합니다.
제네릭의 진가는 자료구조 구현에서 드러납니다. 어떤 타입의 데이터도 저장할 수 있는 타입 안전한 Stack을 만들어봅니다.
package main
import (
"errors"
"fmt"
)
// 제네릭 Stack 정의
type Stack[T any] struct {
elements []T
}
func (s *Stack[T]) Push(value T) {
s.elements = append(s.elements, value)
}
func (s *Stack[T]) Pop() (T, error) {
if s.IsEmpty() {
var zero T
return zero, errors.New("stack is empty")
}
idx := len(s.elements) - 1
value := s.elements[idx]
s.elements = s.elements[:idx]
return value, nil
}
func (s *Stack[T]) Peek() (T, error) {
if s.IsEmpty() {
var zero T
return zero, errors.New("stack is empty")
}
return s.elements[len(s.elements)-1], nil
}
func (s *Stack[T]) IsEmpty() bool {
return len(s.elements) == 0
}
func (s *Stack[T]) Size() int {
return len(s.elements)
}
func main() {
// int Stack
var intStack Stack[int]
intStack.Push(10)
intStack.Push(20)
intStack.Push(30)
top, _ := intStack.Peek()
fmt.Printf("Top: %d, Size: %d\n", top, intStack.Size())
// 출력: Top: 30, Size: 3
for !intStack.IsEmpty() {
v, _ := intStack.Pop()
fmt.Print(v, " ")
}
fmt.Println() // 출력: 30 20 10
// string Stack — 동일한 타입, 다른 데이터
var strStack Stack[string]
strStack.Push("Go")
strStack.Push("Generics")
strStack.Push("Rocks")
for !strStack.IsEmpty() {
v, _ := strStack.Pop()
fmt.Print(v, " ")
}
fmt.Println() // 출력: Rocks Generics Go
}
제네릭 없이는 IntStack, StringStack을 따로 만들어야 했습니다. 이제 Stack[int], Stack[string]으로 재사용됩니다.
제네릭 함수에서 빈 값을 반환할 때는 var zero T를 사용하는 것이 관용적입니다.
func (s *Stack[T]) Pop() (T, error) {
if s.IsEmpty() {
var zero T // T의 Zero Value: int → 0, string → "", *Foo → nil
return zero, errors.New("stack is empty")
}
// ...
}
슬라이스를 다루는 Map, Filter, Reduce 같은 함수형 유틸리티를 제네릭으로 구현하면 매우 강력해집니다.
// Map: 슬라이스의 각 요소를 변환
func Map[T, U any](slice []T, fn func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}
// Filter: 조건을 만족하는 요소만 추출
func Filter[T any](slice []T, pred func(T) bool) []T {
var result []T
for _, v := range slice {
if pred(v) {
result = append(result, v)
}
}
return result
}
// Reduce: 슬라이스를 단일 값으로 축약
func Reduce[T, U any](slice []T, initial U, fn func(U, T) U) U {
acc := initial
for _, v := range slice {
acc = fn(acc, v)
}
return acc
}
func main() {
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
// 짝수만 필터링
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
fmt.Println(evens) // [2 4 6 8 10]
// 각 요소를 제곱
squares := Map(evens, func(n int) int { return n * n })
fmt.Println(squares) // [4 16 36 64 100]
// 합계 계산
total := Reduce(squares, 0, func(acc, n int) int { return acc + n })
fmt.Println(total) // 220
// Map으로 타입 변환 (int → string)
strs := Map(nums, func(n int) string {
return fmt.Sprintf("item_%d", n)
})
fmt.Println(strs[:3]) // [item_1 item_2 item_3]
}
Map[T, U any]처럼 입력 타입과 출력 타입이 다를 수 있어 타입 변환 파이프라인을 간결하게 표현할 수 있습니다.
제네릭과 인터페이스를 조합하면 타입 안전한 다형성을 구현할 수 있습니다.
type Stringer interface {
String() string
}
func PrintAll[T Stringer](items []T) {
for _, item := range items {
fmt.Println(item.String())
}
}
type User struct {
Name string
Email string
}
func (u User) String() string {
return fmt.Sprintf("User(%s <%s>)", u.Name, u.Email)
}
type Product struct {
ID int
Title string
}
func (p Product) String() string {
return fmt.Sprintf("Product#%d: %s", p.ID, p.Title)
}
func main() {
users := []User{
{Name: "Alice", Email: "alice@example.com"},
{Name: "Bob", Email: "bob@example.com"},
}
PrintAll(users)
// 출력:
// User(Alice <alice@example.com>)
// User(Bob <bob@example.com>)
products := []Product{
{ID: 1, Title: "Go Programming"},
{ID: 2, Title: "Clean Code"},
}
PrintAll(products)
// 출력:
// Product#1: Go Programming
// Product#2: Clean Code
}
실무에서 자주 쓰이는 Repository 패턴을 제네릭으로 추상화하면 각 엔티티별 중복을 크게 줄일 수 있습니다.
// 기본 CRUD 인터페이스
type Repository[T any, ID comparable] interface {
FindByID(id ID) (T, error)
Save(entity T) error
Delete(id ID) error
FindAll() ([]T, error)
}
// 인메모리 구현 (테스트용)
type InMemoryRepo[T any, ID comparable] struct {
store map[ID]T
getID func(T) ID
}
func NewInMemoryRepo[T any, ID comparable](getID func(T) ID) *InMemoryRepo[T, ID] {
return &InMemoryRepo[T, ID]{
store: make(map[ID]T),
getID: getID,
}
}
func (r *InMemoryRepo[T, ID]) FindByID(id ID) (T, error) {
if v, ok := r.store[id]; ok {
return v, nil
}
var zero T
return zero, fmt.Errorf("entity with id %v not found", id)
}
func (r *InMemoryRepo[T, ID]) Save(entity T) error {
r.store[r.getID(entity)] = entity
return nil
}
func (r *InMemoryRepo[T, ID]) Delete(id ID) error {
delete(r.store, id)
return nil
}
func (r *InMemoryRepo[T, ID]) FindAll() ([]T, error) {
result := make([]T, 0, len(r.store))
for _, v := range r.store {
result = append(result, v)
}
return result, nil
}
type Article struct {
ID int
Title string
}
func main() {
repo := NewInMemoryRepo[Article, int](func(a Article) int { return a.ID })
repo.Save(Article{ID: 1, Title: "Go 제네릭 완전 정복"})
repo.Save(Article{ID: 2, Title: "Go 고루틴 심층 분석"})
article, err := repo.FindByID(1)
if err == nil {
fmt.Println(article.Title) // 출력: Go 제네릭 완전 정복
}
all, _ := repo.FindAll()
fmt.Println(len(all)) // 출력: 2
}
🛠️ 실무 적용 포인트
이 패턴의 핵심은
InMemoryRepo를 테스트 환경에서, 실제 DB 구현체를 프로덕션에서 사용할 수 있다는 점입니다.Repository[Article, int]인터페이스를 의존성으로 주입하면 테스트 시 DB 없이도 전체 비즈니스 로직을 검증할 수 있습니다.
제네릭과 인터페이스는 모두 추상화 도구이지만, 적합한 상황이 다릅니다.
| 기준 | 제네릭 | 인터페이스 |
|---|---|---|
| 타입 안전성 | 컴파일 타임 보장 | 런타임 타입 단언 필요 |
| 성능 | 인스턴스화로 오버헤드 없음 | 동적 디스패치 비용 |
| 컨테이너/알고리즘 | 최적 (슬라이스, 맵, 큐 등) | 부적합 |
| 다형성(행위 추상화) | 제한적 | 최적 |
| 타입 변환 파이프라인 | 자연스러움 | 어색함 |
| 코드 가독성 | 복잡해질 수 있음 | 직관적 |
// ✅ 제네릭: 알고리즘이나 자료구조에서 타입만 다를 때
func Keys[K comparable, V any](m map[K]V) []K {
keys := make([]K, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
// ✅ 인터페이스: 서로 다른 타입의 동작을 통일할 때
type Logger interface {
Info(msg string)
Error(msg string, err error)
}
func ProcessRequest(req Request, logger Logger) {
logger.Info("Processing request")
// ...
}
// ❌ 과도한 제네릭: 실제로 타입 하나만 쓰는 경우
func ProcessUser[T User](u T) {
// ...
}
// ✅ 그냥 구체 타입 사용
func ProcessUser(u User) {
// ...
}
제네릭은 도구입니다. 실제로 여러 타입에서 재사용되지 않는다면 구체 타입을 직접 쓰는 것이 훨씬 명확합니다.
// ❌ 읽기 어려운 중첩 제네릭
type NestedMap[K1 comparable, K2 comparable, V any] map[K1]map[K2]V
// ✅ 명명된 타입으로 가독성 개선
type UserScoreMap map[string]map[string]int
Go의 현재 제네릭 구현에는 한 가지 중요한 제약이 있습니다. 메서드에는 타입 파라미터를 추가로 선언할 수 없습니다.
type Processor[T any] struct{}
// ❌ 컴파일 에러: 메서드에 새 타입 파라미터 추가 불가
func (p Processor[T]) Convert[U any](value T) U {
// ...
}
// ✅ 대신 패키지 수준 함수로 정의
func Convert[T, U any](value T, fn func(T) U) U {
return fn(value)
}
이 제약을 모르면 설계 단계에서 막막해질 수 있으므로 반드시 기억해두세요.
🛠️ 제네릭 사용 결정 체크리스트
- 동일한 로직이 2가지 이상의 타입에 반복되는가? → 제네릭 고려
- 컴파일 타임 타입 안전성이 필요한가? → 제네릭 고려
- 런타임 다형성이 필요한가? → 인터페이스 선택
- 코드가 더 복잡해지는가? → 구체 타입으로 되돌아가라
제네릭으로 타입 안전한 인메모리 캐시를 구현해봅니다.
import (
"sync"
"time"
)
type CacheEntry[V any] struct {
value V
expiresAt time.Time
}
type Cache[K comparable, V any] struct {
mu sync.RWMutex
entries map[K]CacheEntry[V]
ttl time.Duration
}
func NewCache[K comparable, V any](ttl time.Duration) *Cache[K, V] {
return &Cache[K, V]{
entries: make(map[K]CacheEntry[V]),
ttl: ttl,
}
}
func (c *Cache[K, V]) Set(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[key] = CacheEntry[V]{
value: value,
expiresAt: time.Now().Add(c.ttl),
}
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
entry, ok := c.entries[key]
if !ok || time.Now().After(entry.expiresAt) {
var zero V
return zero, false
}
return entry.value, true
}
func main() {
// string 키, User 값 캐시
type User struct{ Name string }
userCache := NewCache[string, User](5 * time.Minute)
userCache.Set("alice", User{Name: "Alice"})
if user, ok := userCache.Get("alice"); ok {
fmt.Println("Found:", user.Name) // 출력: Found: Alice
}
// int 키, []byte 값 캐시 (바이트 배열 캐시)
byteCache := NewCache[int, []byte](time.Minute)
byteCache.Set(1, []byte("cached data"))
}
sync.RWMutex와 제네릭을 조합하여 고루틴 안전한 범용 캐시를 만들었습니다. 하나의 구현으로 어떤 키-값 타입 조합에도 재사용 가능합니다.
Go의 제네릭은 코드 재사용성과 타입 안전성을 동시에 높여주는 강력한 도구입니다. 하지만 Go 언어의 설계 철학인 “단순함과 명확함”을 항상 염두에 두어야 합니다.
오늘 배운 핵심을 요약하면:
[T 제약]로 함수와 타입을 범용화하며, 컴파일러가 타입을 자동 추론한다.any와 comparable은 내장 제약이며, 인터페이스나 유니온 타입으로 커스텀 제약을 만들 수 있다.~ 틸드 연산자로 사용자 정의 타입까지 포함하는 제약을 정의할 수 있다.다음 글에서는 Go 언어의 고루틴(Goroutine)과 채널(Channel)을 심층적으로 다루며, 가벼운 동시성 실행 단위인 고루틴의 내부 동작 원리, 채널을 통한 안전한 데이터 교환, 그리고 실무에서 자주 쓰이는 동시성 패턴들을 살펴보겠습니다.