Go 제네릭 도입 후 공통 유틸리티 리팩토링 경험

배경

사내 마이크로서비스 중 Go로 작성된 API 서버들에서 반복되는 슬라이스 조작 코드가 많았다. Map, Filter, Reduce 같은 함수를 타입별로 각각 구현해야 했던 불편함이 있었는데, 제네릭을 도입하며 정리할 기회가 생겼다.

기존 코드의 문제

// User용
func MapUserIDs(users []User) []int64 {
    ids := make([]int64, len(users))
    for i, u := range users {
        ids[i] = u.ID
    }
    return ids
}

// Product용
func MapProductIDs(products []Product) []int64 {
    ids := make([]int64, len(products))
    for i, p := range products {
        ids[i] = p.ID
    }
    return ids
}

동일한 로직을 타입만 바꿔 반복 작성하는 상황이었다.

제네릭 적용

func Map[T any, R any](slice []T, fn func(T) R) []R {
    result := make([]R, len(slice))
    for i, v := range slice {
        result[i] = fn(v)
    }
    return result
}

func Filter[T any](slice []T, predicate func(T) bool) []T {
    result := make([]T, 0, len(slice))
    for _, v := range slice {
        if predicate(v) {
            result = append(result, v)
        }
    }
    return result
}

// 사용
userIDs := Map(users, func(u User) int64 { return u.ID })
activeUsers := Filter(users, func(u User) bool { return u.Active })

타입 제약 활용

Comparable 타입만 받는 경우 타입 제약을 명시했다.

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

주의사항

제네릭 함수는 편리하지만 성능 오버헤드가 있다. 핫 패스에서는 벤치마크를 돌려보고 판단했다. 대부분의 경우 가독성 이득이 더 컸지만, 고빈도 루프에서는 인라인 구현이 여전히 유효했다.

또한 제네릭 타입 추론이 항상 완벽하지 않아 명시적으로 타입을 지정해야 하는 경우도 있었다.

결론

제네릭 도입으로 유틸리티 코드가 크게 간소화됐다. 타입 안정성을 유지하면서도 중복을 제거할 수 있어 만족스러웠다. 다만 Go의 제네릭은 다른 언어 대비 제약이 있어, 모든 상황에 만능은 아니라는 점을 체감했다.