Go · 언어 기능

Golang Generics
1편 — 개요와 기본 문법

타입을 미리 정하지 않고 함수를 쓴다
— Go 1.18이 바꿔 놓은 것

type parameter constraint generic struct type inference
오늘 배울 문법 Go 1.18+
Generics ├─ 타입 파라미터 [T any] │ ├─ generic 함수 │ └─ generic 구조체 ├─ constraint any · union · interface └─ 타입 추론 대부분 생략 가능
2편에서는 constraint를 깊게 판다
Go 1.18(2022년 3월)에 들어온 Generics를, 문법을 외우는 방식이 아니라 "무슨 문제를 풀려고 들어왔는가"부터 시작해서 본다. 1편은 개요와 기본 문법까지, constraint 자체는 2편에서 다룬다고 미리 못 박아 둔다. 청중에게 Go 1.18 이전 코드를 짜 본 사람이 있는지 손을 들어보게 하면 뒤 이야기가 훨씬 잘 붙는다.
01

Generics란 무엇인가

타입을 언제 결정하는가 — 이 한 가지만 바뀐다. 그리고 Go가 왜 12년 동안 이 기능을 미뤘는지도 함께 본다.

여기는 3~4분. 개념 정의 자체는 짧게 끝내고, "왜 Go만 늦었나"에 시간을 더 쓴다. 그 배경을 알아야 뒤에 나오는 Go Generics의 단순함이 결핍이 아니라 선택으로 읽힌다.
개념정의

Generics는 타입 결정을 사용 시점으로 미룬다

  • 정의할 때가 아니라 쓸 때 타입이 정해진다
  • Java · C++ · Rust에는 이미 있던 기능
  • Go는 1.18(2022년 3월)에 정식 도입
  • 도입 명분은 단 하나 — 코드 중복 제거
// T는 아직 정해지지 않은 타입
func printAny[T any](a T) {
    fmt.Println(a)
}

// 호출하는 순간 결정된다
printAny(10)      // T = int
printAny("hello") // T = string
한 줄 요약 로직은 같은데 타입만 다른 함수를 여러 번 쓰지 않게 하는 장치다.
"제네릭"이라는 말이 어렵게 들리지만 본질은 이 한 줄이다 — 타입 자리를 비워두고 나중에 채운다. 여기서 문법을 자세히 설명하지 말 것. 대괄호가 보인다는 것만 짚고 넘어간다. 문법은 3장에서 제대로 다룬다.
배경왜 이제서야

Go는 단순함을 지키려고 오래 버텼다

미룬 이유

Go의 핵심 철학은 단순함. Rob Pike를 비롯한 설계자들은 언어 복잡도를 낮추려 많은 기능을 의도적으로 뺐다.

Generics도 오랫동안 그 대상이었다

결국 넣은 이유

커뮤니티에서 가장 많이 요청된 기능이었다. Ian Lance Taylor의 Type Parameters Proposal이 채택돼 1.18에 들어갔다.

interface{} 우회는 한계가 뚜렷했다

설계 방향 C++ 템플릿 메타프로그래밍이나 Haskell의 higher-kinded types는 일부러 빼고, 실용적인 수준의 타입 파라미터만 넣었다.
Go Generics를 다른 언어와 비교하며 "빈약하다"고 말하는 사람이 많은데, 그건 결함이 아니라 명시적 선택이다. 이 슬라이드에서 그 점을 못 박아 두면 뒤에서 "왜 이건 안 되나요" 질문이 나올 때 답하기 쉽다. 질문 예상: "Go 1.18 이전 코드는 어떻게 되나?" → 하위 호환은 완전히 유지된다.
02

Generics 없던 시절의 두 가지 통증

타입 안전성을 런타임까지 미루는 interface{}, 그리고 타입 수만큼 늘어나는 복붙 함수. 이 둘이 도입의 실제 명분이다.

이 장이 발표의 설득 구간이다. 문법을 먼저 보여주면 "그래서 뭐가 좋은데?"가 남는데, 아픔을 먼저 보여주면 문법이 해답으로 읽힌다. 5~6분 배정.
한계before → after

interface{}는 타입 검증을 런타임까지 미룬다

// interface{} 기반 — 리턴이 interface{}라 타입 단언이 필요하다
func foo1(a interface{}) interface{} { return a }

// generics 기반 — 리턴이 T라 단언이 필요 없다
func foo2[T any](a T) T { return a }

func main() {
    var a, b int = 10, 20
    var c int

    c = foo1(a).(int)  // 타입 단언 필요 — 런타임에 실패 가능
    c = foo2(b)         // 타입이 자동 결정 — 컴파일 타임 안전
}
달라진 것 .(int) 한 조각이 사라졌다. 그런데 사라진 건 문법이 아니라 런타임 panic 가능성이다.
여기서 손가락으로 짚어야 할 곳은 딱 한 줄, `.(int)`이다. foo1은 컴파일러가 "이건 int다"를 모른 채 통과시키고, 틀렸을 때 비용을 런타임에 지불한다. foo2는 컴파일러가 안다. 시간이 부족하면 이 슬라이드만 남기고 다음 두 장은 넘겨도 된다.
보충type assertion

타입 단언은 "꺼내 보고 아니면 터진다"

  • interface{}에서 구체 타입을 꺼내는 연산
  • 형태는 value.(Type)
  • 타입이 안 맞으면 런타임 panic
  • 두 번째 반환값 ok로 안전하게 받을 수 있다
var val interface{} = "hello"

s := val.(string) // OK
i := val.(int)    // panic!

// 안전한 형태
s, ok := val.(string) // true
i, ok := val.(int)    // false, 0
Generics를 쓰면 타입이 컴파일 타임에 정해지므로 단언 자체가 필요 없어진다.
이 개념을 이미 아는 청중이면 30초로 넘긴다. 모르는 사람이 섞여 있으면 `i := val.(int)`가 컴파일은 통과한다는 점을 반드시 강조한다 — 그게 문제의 핵심이다. `, ok` 형태는 panic을 막을 뿐 타입 분기 코드는 그대로 남는다는 것도 덧붙인다.
정리비용 3가지

interface{}로 얻는 유연함에는 값이 붙는다

타입 안전성 부족

잘못된 단언은 컴파일이 아니라 런타임 panic으로 드러난다.

테스트가 안 짚으면 배포 후에 만난다

캐스팅 비용

값을 쓸 때마다 .(Type)을 반복해서 붙여야 한다.

호출부가 지저분해진다

IDE 지원 부족

반환 타입이 interface{}라 자동완성·타입 체크가 막힌다.

에디터가 도와줄 수 없다

공통 원인 세 가지 모두 컴파일러에게 타입을 알려주지 않은 데서 나온다.
세 카드를 하나씩 읽지 말고 마지막 콜아웃만 말한다 — 증상은 셋인데 원인은 하나다. 실무 경험담을 하나 붙이면 좋다(운영에서 type assertion panic으로 장애 났던 경험 등).
한계복붙 지옥

타입만 다른 같은 함수를 세 번 쓴다

func minInt(a, b int) int {
    if a < b { return a }
    return b
}

func minInt16(a, b int16) int16 {
    if a < b { return a }   // 로직이 완전히 동일
    return b
}

func minFloat64(a, b float64) float64 {
    if a < b { return a }   // 또 동일한 로직...
    return b
}
진짜 문제 버그를 고칠 때도 세 곳을 다 고쳐야 한다. 지원 타입이 늘수록 선형으로 커진다.
Go 표준 라이브러리에도 이런 흔적이 많았다고 언급하면 공감이 크다. 중복 자체보다 "고칠 때 빠뜨린다"는 점이 실무의 진짜 통증이다. 이 슬라이드가 다음 장(문법)의 훅이 된다 — "이걸 한 줄로 줄여보자"로 넘어간다.
03

Generics 기본 문법

타입 파라미터 선언부터 generic 함수 · constraint · generic 구조체까지. 앞에서 본 min 중복이 어떻게 한 개로 줄어드는지 따라간다.

발표의 본론. 10분 이상 배정한다. 문법을 나열하지 말고, 매 슬라이드마다 "앞의 무엇을 해결하는가"를 붙여서 넘어간다.
문법해부

타입 파라미터는 대괄호 안에 선언한다

1
func 함수명[T constraint](매개변수 T) T
함수 이름 바로 뒤, 소괄호 앞에 대괄호가 온다
2
T
타입 파라미터 이름. 관례적으로 대문자 한 글자를 쓴다
3
constraint
어떤 타입이 올 수 있는지 제한하는 조건. any가 가장 느슨하다
외울 것은 하나 소괄호는 을 받고, 대괄호는 타입을 받는다.
Java의 <T> 대신 Go가 대괄호를 쓴 이유는 파싱 모호성 때문이다(꺾쇠는 비교 연산자와 충돌). 궁금해하는 사람이 꼭 있으니 미리 준비해 둔다. 콜아웃 한 문장이 이 슬라이드의 전부다.
문법generic 함수

any는 다 받지만, 그래서 아무 연산도 못 한다

  • any모든 타입을 허용하는 constraint
  • 출력·전달처럼 타입을 안 따지는 일에 알맞다
  • 대신 < + 같은 연산은 쓸 수 없다
  • 연산이 필요하면 허용 타입을 좁혀야 한다
// any = 모든 타입 허용
func printAny[T any](a T) {
    fmt.Println(a)
}

func main() {
    printAny(10)      // T = int
    printAny(3.14)    // T = float64
    printAny("hello") // T = string
}
규칙 constraint가 느슨할수록 받을 수 있는 타입은 늘고, 안에서 할 수 있는 일은 준다.
이 트레이드오프가 constraint 설계의 전부다. 여기서 감을 잡아두면 2편이 훨씬 쉽다. `any`는 Go 1.18에서 `interface{}`의 별칭으로 추가된 것이라는 점도 한 줄 언급.
해결union type

세 개였던 min이 하나로 줄어든다

// 허용할 타입을 | 로 나열한다 (union type constraint)
func minType[T int | int16 | int32 | int64 | float32 | float64](a, b T) T {
    if a < b {
        return a
    }
    return b
}

func main() {
    fmt.Println(minType(10, 20))                 // int: 10
    fmt.Println(minType(int16(10), int16(20)))  // int16: 10
    fmt.Println(minType(3.14, 1.14))             // float64: 1.14
}
왜 any가 아닌가 any< 연산을 보장하지 않는다. 비교하려면 비교 가능한 타입만 받아야 한다.
앞의 9번 슬라이드(복붙 세 개)를 다시 띄워서 대비시키면 효과가 크다. union 문법 자체는 2편에서 tilde(~)까지 포함해 제대로 다루니 여기서는 "| 로 나열한다" 정도만. 참고로 Go 1.21부터는 내장 min/max가 생겨 이 예제는 학습용이라는 점을 덧붙이면 정직하다.
문법custom constraint

constraint에 이름을 붙이면 재사용된다

  • 매번 인라인으로 나열하는 건 비효율
  • interface 키워드로 이름 붙인 constraint를 만든다
  • constraint끼리 합성도 된다
  • 메서드 집합이 아니라 타입 집합을 정의하는 용법
type IntegerType interface {
    int | int16 | int32 | int64
}

type Float interface {
    float32 | float64
}

// 합성도 가능하다
type ComparableNumbers interface {
    IntegerType | Float
}
쓸 때는 func minComparableNumbers[T ComparableNumbers](a, b T) T
"interface가 원래 메서드 모음 아니었나?"라는 질문이 반드시 나온다. Go 1.18에서 interface의 의미가 타입 집합(type set)으로 확장됐다고 답한다. 다만 이 확장된 interface는 변수 타입으로는 못 쓰고 constraint 자리에서만 쓸 수 있다 — 이 제약도 함께 언급하면 좋다.
문법generic struct

구조체에도 타입 파라미터를 붙일 수 있다

type Node[T any] struct {
    val  T
    next *Node[T]      // 자기 자신도 T로 참조한다
}

func NewNode[T any](v T) *Node[T] {
    return &Node[T]{val: v}
}

// 사용
node := NewNode(1)          // *Node[int]
node.Push(2).Push(3).Push(4)

strNode := NewNode("hello") // *Node[string]
strNode.Push("world")
여기서 얻는 것 IntNode · StringNode를 따로 정의할 필요가 없다. 자료구조 중복이 사라진다.
링크드 리스트·스택·큐 같은 자료구조가 Generics의 가장 자연스러운 사용처다. `*Node[T]`처럼 재귀적으로 자기 타입을 참조할 수 있다는 점을 짚는다. NewNode 호출부에 [int]를 안 쓴 것도 미리 눈에 띄게 해두면 5장(타입 추론) 연결이 매끄럽다.
주의제약

메서드에는 새 타입 파라미터를 붙일 수 없다

가능 — 구조체의 T 사용

func (n *Node[T]) Push(v T) *Node[T]

구조체에 선언된 타입 파라미터는 메서드에서 자유롭게 쓴다

불가 — 메서드에 새 T 선언

func (n *Node[T]) Push[F any](f F)

컴파일 에러. Go는 이 문법을 허용하지 않는다

우회 방법 새 타입 파라미터가 필요하면 메서드가 아니라 일반 함수로 빼서 선언한다.
실무에서 가장 자주 부딪히는 벽이다. Java·C#을 하던 사람은 당연히 될 거라 생각하고 짜다가 막힌다. 이유는 메서드 집합이 타입마다 무한히 늘어나 인터페이스 만족 여부를 결정할 수 없기 때문 — 궁금해하면 이 정도로 답한다. 다음 슬라이드의 Map 함수가 바로 "함수로 뺀" 예다.
활용타입 파라미터 2개

파라미터가 둘이면 변환을 표현할 수 있다

// F: 입력 요소 타입, T: 출력 요소 타입
func Map[F, T any](s []F, f func(F) T) []T {
    rst := make([]T, len(s))
    for i, v := range s {
        rst[i] = f(v)
    }
    return rst
}

doubled := Map([]int{1, 2, 3}, func(i int) int {
    return i * 2
})                                   // [2 4 6]

uppered := Map([]string{"Hello", "world"}, strings.ToUpper)
                                     // [HELLO WORLD]
핵심 FT달라도 되는 것이 요점이다. 그래야 []int → []string 변환도 같은 함수로 쓴다.
함수형 유틸리티가 Generics 없이는 아예 못 만들던 영역이다. F와 T를 같은 타입으로만 쓰면 굳이 두 개일 이유가 없다는 점을 짚는다. golang.org/x/exp/slices, maps 패키지가 이 방식으로 만들어져 있고 상당수가 Go 1.21+ 표준 라이브러리에 들어갔다고 안내하면 실무 연결이 된다.
04

타입 추론

지금까지 호출부에 [int]를 거의 안 썼다. 컴파일러가 인자에서 타입을 알아내기 때문이다 — 언제 되고 언제 안 되는지 본다.

3~4분. Generics 문법이 실제로 "안 무거워 보이는" 이유가 여기 있다. 앞 슬라이드들의 호출부를 다시 떠올리게 하면서 시작한다.
추론명시 vs 추론

인자가 있으면 타입을 안 써도 된다

  • 컴파일러가 인자에서 타입 파라미터를 추론한다
  • 명시 방식과 추론 방식은 결과가 완전히 같다
  • 추론이 가능하면 추론 쪽이 권장된다
  • 읽는 사람에게 타입이 안 보일 때만 명시한다
func identity[T any](v T) T { return v }

// 명시적 타입 지정
r1 := identity[int](42)
r2 := identity[string]("hello")

// 타입 추론 — 인자로부터 자동
r3 := identity(42)      // T = int
r4 := identity("hello") // T = string
체감 호출부만 보면 일반 함수와 구분되지 않는다. Generics가 코드를 무겁게 만들지 않는 이유다.
Java 제네릭의 꺾쇠 노이즈를 겪어본 사람에게는 이 지점이 반갑다. "그럼 언제 명시하나?"에는 두 가지로 답한다 — 추론이 실패할 때(21번 슬라이드), 그리고 명시하는 편이 읽기 좋을 때.
추론파라미터 여러 개

파라미터가 여러 개여도 각각 따로 추론한다

  • 타입 파라미터마다 대응하는 인자에서 결정된다
  • 서로 다른 타입이 섞여도 문제없다
  • 하나만 추론되고 하나는 안 되는 경우는 전부 명시해야 한다
func pair[T, U any](a T, b U) string {
    return fmt.Sprintf("(%v, %v)", a, b)
}

// 모두 추론 가능
pair(1, "hello") // T=int, U=string
pair(3.14, true)  // T=float64, U=bool
주의 타입 파라미터는 부분 지정이 안 된다. 명시하려면 pair[int, string](...)처럼 전부 적는다.
부분 지정 불가는 실제로 걸려 넘어지는 지점이다. 앞쪽 파라미터만 쓰고 뒤는 추론시키는 문법은 Go에 없다. 17번 Map 함수도 F·T 둘 다 인자에서 추론된다는 걸 잠깐 되짚으면 연결이 좋다.
추론실패 조건

추론은 인자에서만 나온다 — 인자가 없으면 실패한다

func toSlice[T any](args ...T) []T {
    return args
}

// 추론 성공 — 인자에서 타입 결정
ints := toSlice(1, 2, 3)          // T = int

// 추론 실패 — 인자가 없어 결정 불가, 명시 필요
emptyInts := toSlice[int]()
emptyStrings := toSlice[string]()

result := toSlice()   // 컴파일 에러: cannot infer T
실패하는 세 경우 인자가 없을 때 · 리턴 타입만으로는 결정할 수 없을 때 · 인자 타입이 모호할 때
"리턴 타입으로는 추론하지 않는다"가 가장 자주 놀라는 지점이다. Go 컴파일러는 대입 대상 타입을 보고 T를 정하지 않는다. `cannot infer T` 에러 메시지를 실제로 본 적 있는지 물어보면 반응이 온다. 해법은 언제나 같다 — 대괄호로 직접 적어준다.
정리before vs after

달라진 것을 한 표로

항목Generics 도입 전Generics 도입 후
여러 타입 지원interface{} + 타입 단언타입 파라미터 [T any]
타입 안전성런타임 panic 위험컴파일 타임 검증
코드 중복타입별 함수 반복 작성하나의 Generic 함수
자료구조타입별 struct 정의Generic struct [T any]
IDE 지원제한적완전한 타입 추론
고르는 기준 표를 읽지 말고 한 줄만 — 컴파일러가 타입을 아느냐 모르느냐, 나머지는 그 결과다.
다섯 행을 다 읽지 말고 강조된 "타입 안전성" 행만 짚는다. 나머지 네 행은 그 하나에서 파생된 결과라고 말하면 표가 한 문장으로 압축된다.
확인클릭하면 답이 열립니다

스스로 답해보기

타입 단언과 그에 딸린 런타임 panic 가능성. 반환 타입이 T이므로 컴파일 타임에 타입이 확정된다. → 슬라이드 06 · 08
any< 연산을 보장하지 않는다. 비교하려면 union type 등으로 비교 가능한 타입만 받도록 좁혀야 한다. → 슬라이드 12 · 13
없다. 구조체에 선언된 타입 파라미터만 쓸 수 있고, 새 파라미터가 필요하면 일반 함수로 빼야 한다. → 슬라이드 16
추론은 인자에서만 이뤄지는데 인자가 하나도 없다. toSlice[int]()처럼 명시해야 한다. → 슬라이드 21
부분 지정은 안 된다. 명시하려면 pair[int, string](...)처럼 전부 적는다. → 슬라이드 20
한 문제씩 5~10초 기다린 뒤 연다. Q2와 Q4가 실무에서 실제로 막히는 지점이니 여기에 시간을 더 쓴다. 시간이 부족하면 Q1·Q3·Q5는 건너뛰고 자료로 넘긴다.
마무리다음 편

다음은 Type Constraint를 깊게 판다

오늘 가져갈 것

대괄호로 타입 자리를 비워두고, constraint로 그 자리를 얼마나 열어둘지 정한다.

호출부는 추론 덕분에 거의 그대로다

2편에서 다룰 것

any · comparable · union(|) · tilde(~) · 커스텀 constraint 설계.

오늘 남겨둔 질문들이 여기서 풀린다

참고 go.dev/doc/tutorial/generics · go.dev/blog/intro-generics · go.dev/ref/spec#Type_parameter_declarations
1편은 "어떻게 쓰는가"였고 2편은 "얼마나 열어둘 것인가"다. constraint 설계가 Generics의 진짜 난이도라고 예고하며 닫는다. 질문 시간을 3분 정도 남겨둔다.
발표자 노트
← 글 보기 Go Generics · 1편
01 / 24

단축키

→ · Space · PgDn
다음 슬라이드
← · PgUp
이전 슬라이드
Home · End
처음 · 마지막
O
전체 슬라이드 개요
N
발표자 노트
T
밝은 테마 / 어두운 테마
F
전체 화면
Esc
닫기