Skip to content
ioob.dev
Go back

Go 입문 12편 — 제네릭과 실전 패턴

· 5분 읽기

Table of contents

오래 기다린 기능

Go는 오랫동안 제네릭이 없는 언어였다. “제네릭 언제 넣어줄 거냐”는 Go 커뮤니티에서 가장 자주 나오는 질문이었고, Go 팀은 10년 가까이 “아직 올바른 설계를 찾지 못했다”는 입장을 유지했다. 그러다 2022년, Go 1.18에서 드디어 제네릭이 도입되었다. 급하게 넣은 게 아니라 충분히 고민한 끝에 나온 결과물이라, Go 특유의 단순함을 최대한 유지하면서 필요한 표현력을 더한 형태가 되었다.

타입 파라미터 — 기본 문법

제네릭이 없던 시절에는 같은 로직을 타입별로 반복해서 작성하거나, any(당시 interface{})로 받아서 타입 단언을 하는 수밖에 없었다. 두 방법 모두 단점이 뚜렷하다.

// 제네릭 없이: int용, float64용, string용 따로 만들어야 했다
func ContainsInt(slice []int, target int) bool { /* ... */ }
func ContainsFloat(slice []float64, target float64) bool { /* ... */ }
func ContainsString(slice []string, target string) bool { /* ... */ }

제네릭을 쓰면 하나의 함수로 해결된다.

package main

import "fmt"

func Contains[T comparable](slice []T, target T) bool {
    for _, v := range slice {
        if v == target {
            return true
        }
    }
    return false
}

func main() {
    fmt.Println(Contains([]int{1, 2, 3}, 2))           // true
    fmt.Println(Contains([]string{"a", "b"}, "c"))      // false
    fmt.Println(Contains([]float64{1.1, 2.2}, 2.2))    // true
}

[T comparable]에서 T가 타입 파라미터이고, comparable이 제약(constraint)이다. “비교 가능한 어떤 타입이든 T로 쓸 수 있다”는 뜻이다. 호출할 때 타입을 명시하지 않아도 인자에서 추론해준다.

제약(Constraints)

제약은 타입 파라미터가 가져야 할 조건을 정의한다. Go에서 제약은 인터페이스로 표현된다.

package main

import (
    "fmt"
    "golang.org/x/exp/constraints"
)

// 직접 제약 정의
type Number interface {
    int | int8 | int16 | int32 | int64 |
    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.6
}

| 연산자로 허용할 타입들을 나열한다. ~int처럼 ~를 붙이면 int를 기반으로 한 모든 사용자 정의 타입까지 포함한다.

type Celsius float64
type Fahrenheit float64

type Temperature interface {
    ~float64 // Celsius, Fahrenheit 모두 허용
}

func Average[T Temperature](temps []T) T {
    var sum T
    for _, t := range temps {
        sum += t
    }
    return sum / T(len(temps))
}

표준 라이브러리에서 자주 쓰이는 내장 제약들도 있다.

제약의미
any모든 타입
comparable==, != 비교 가능한 타입
cmp.Ordered<, > 등 순서 비교 가능한 타입 (Go 1.21+)

제네릭 타입

함수뿐 아니라 타입에도 제네릭을 적용할 수 있다. 자료구조를 만들 때 특히 유용하다.

package main

import "fmt"

type Stack[T any] struct {
    items []T
}

func (s *Stack[T]) Push(item T) {
    s.items = append(s.items, item)
}

func (s *Stack[T]) Pop() (T, bool) {
    if len(s.items) == 0 {
        var zero T
        return zero, false
    }
    last := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return last, true
}

func (s *Stack[T]) Peek() (T, bool) {
    if len(s.items) == 0 {
        var zero T
        return zero, false
    }
    return s.items[len(s.items)-1], true
}

func (s *Stack[T]) Size() int {
    return len(s.items)
}

func main() {
    // int 스택
    intStack := &Stack[int]{}
    intStack.Push(1)
    intStack.Push(2)
    intStack.Push(3)

    val, _ := intStack.Pop()
    fmt.Println(val) // 3

    // string 스택
    strStack := &Stack[string]{}
    strStack.Push("hello")
    strStack.Push("world")

    top, _ := strStack.Peek()
    fmt.Println(top) // world
}

Stack[int]Stack[string]은 완전히 타입 안전하다. int 스택에 string을 넣으려고 하면 컴파일 에러가 나고, 꺼낸 값도 이미 올바른 타입이므로 타입 단언이 필요 없다. 제네릭 도입 전에는 interface{}로 만든 스택에서 값을 꺼낼 때마다 단언을 해야 했으니, 코드가 훨씬 깔끔해졌다.

테이블 드리븐 테스트

Go의 테스트 관용구 중 가장 널리 쓰이는 패턴이 테이블 드리븐 테스트(table-driven test)다. 테스트 케이스를 슬라이스로 정의하고 루프를 돌면서 하나씩 실행한다.

package calc

import "testing"

func Add(a, b int) int {
    return a + b
}

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"양수끼리", 2, 3, 5},
        {"음수끼리", -1, -2, -3},
        {"영과 양수", 0, 5, 5},
        {"큰 수", 1000000, 2000000, 3000000},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := Add(tt.a, tt.b)
            if got != tt.expected {
                t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.expected)
            }
        })
    }
}
go test -v ./calc/
# === RUN   TestAdd
# === RUN   TestAdd/양수끼리
# === RUN   TestAdd/음수끼리
# === RUN   TestAdd/영과_양수
# === RUN   TestAdd/큰_수
# --- PASS: TestAdd (0.00s)

이 패턴의 장점은 케이스 추가가 쉽다는 거다. 새 엣지 케이스가 발견되면 슬라이스에 한 줄만 추가하면 된다. t.Run으로 서브테스트를 만들면 어떤 케이스가 실패했는지 바로 알 수 있고, -run 플래그로 특정 케이스만 실행할 수도 있다.

Go 표준 라이브러리의 테스트 코드에서도 이 패턴을 광범위하게 쓴다. Go 코드를 작성한다면 이 패턴은 반드시 익혀둬야 할 기본기다.

빌드 태그

빌드 태그(build tag)는 컴파일할 때 특정 파일을 포함하거나 제외하는 메커니즘이다. OS별, 아키텍처별, 또는 커스텀 조건별로 다른 코드를 빌드할 수 있다.

//go:build linux

package platform

func GetOS() string {
    return "Linux"
}
//go:build darwin

package platform

func GetOS() string {
    return "macOS"
}
//go:build windows

package platform

func GetOS() string {
    return "Windows"
}

같은 패키지, 같은 함수 시그니처를 가진 파일 세 개가 있지만, 빌드 태그에 따라 하나만 컴파일된다. Linux에서 빌드하면 첫 번째 파일만, macOS에서는 두 번째만 포함되는 식이다.

커스텀 태그도 가능하다.

//go:build integration

package myapp

import "testing"

func TestDatabaseConnection(t *testing.T) {
    // 통합 테스트 — 실제 DB 필요
    // go test -tags integration ./...
}

-tags 플래그로 커스텀 태그를 활성화한다. 시간이 오래 걸리는 통합 테스트를 기본 테스트에서 분리할 때 유용하다. CI에서는 go test -tags integration으로 전체를 돌리고, 로컬에서는 빠른 유닛 테스트만 실행하는 식으로 나눌 수 있다.

go generate

go generate는 코드 생성 도구를 실행하는 명령이다. 소스 파일에 특별한 주석을 달아두면, go generate ./...를 실행할 때 해당 명령을 자동으로 수행한다.

package main

//go:generate stringer -type=Color

type Color int

const (
    Red Color = iota
    Green
    Blue
)
go install golang.org/x/tools/cmd/stringer@latest
go generate ./...

이 명령을 실행하면 stringer 도구가 Color의 각 상수에 대한 String() 메서드를 자동으로 생성한다. color_string.go 같은 파일이 만들어지고, fmt.Println(Red)을 했을 때 “0” 대신 “Red”가 출력되게 해준다.

go generate의 핵심은 빌드 과정의 일부가 아니라는 점이다. 개발자가 명시적으로 실행해야 하고, 생성된 파일은 버전 관리에 포함시킨다. 빌드 시점이 아닌 개발 시점의 도구인 거다.

자주 쓰이는 코드 생성 도구들을 몇 가지 소개하면 다음과 같다.

시리즈를 마치며

7편부터 12편까지, Go의 중급에서 실전까지의 핵심 주제들을 돌아봤다. 인터페이스의 암묵적 구현이 코드의 결합도를 낮추고, 포인터가 값 전달의 한계를 넘어서며, 고루틴과 채널이 동시성을 자연스럽게 만들어주는 걸 살펴봤다. 패키지 시스템의 단순한 규칙들이 어떻게 큰 프로젝트를 지탱하는지, 제네릭이 어떤 반복을 줄여주는지도 다뤘다.

Go의 매력은 배우기 쉬운 것에서 끝나지 않는다. 단순한 도구들의 조합으로 복잡한 시스템을 만들 수 있다는 게 진짜 강점이다. 이 시리즈에서 다룬 내용만으로도 웹 서버, CLI 도구, 동시성 처리 프로그램을 만들 수 있을 만큼의 기초 체력은 갖춰졌다. 나머지는 직접 코드를 짜면서 채워가는 수밖에 없다. Go Playground에서 실험하고, 작은 프로젝트를 하나 시작해보는 걸 추천한다.


Share this post on:

Comments

Loading comments...


Previous Post
Kotlin 6편 — Null 안전성 심화
Next Post
Go 입문 11편 — 패키지와 모듈