Go 제네릭을 프로덕션에 적용하며 겪은 일들

배경

사내 API 서버에서 반복적으로 작성하던 슬라이스 유틸리티 함수들이 문제였다. FilterUsers, FilterProducts, MapUserIDs 같은 함수들이 타입마다 중복 구현되어 있었고, interface{}를 쓰기엔 타입 안정성이 떨어졌다.

Go 1.18에서 제네릭이 추가된 지 반년이 지났고, 1.19도 안정화되어 이제 적용해봐도 괜찮을 시점이라 판단했다.

기존 코드

func FilterActiveUsers(users []User) []User {
    var result []User
    for _, u := range users {
        if u.Active {
            result = append(result, u)
        }
    }
    return result
}

func FilterPublishedPosts(posts []Post) []Post {
    var result []Post
    for _, p := range posts {
        if p.Published {
            result = append(result, p)
        }
    }
    return result
}

같은 로직이 타입마다 반복되는 전형적인 패턴이었다.

제네릭 적용

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

// 사용
activeUsers := Filter(users, func(u User) bool { return u.Active })
publishedPosts := Filter(posts, func(p Post) bool { return p.Published })

코드량이 확실히 줄었고, 새로운 타입이 추가되어도 함수를 다시 만들 필요가 없어졌다.

주의할 점

  1. 컴파일 시간 증가: 제네릭 함수를 많이 사용하자 빌드 시간이 10% 정도 늘었다. 모노리포 환경에서는 체감이 있었다.

  2. 디버깅 난이도: 스택 트레이스에서 제네릭 함수 시그니처가 복잡하게 표시되어 읽기 어려웠다.

  3. 과도한 추상화 유혹: 제네릭을 쓸 수 있다고 모든 곳에 적용하면 오히려 가독성이 떨어졌다. 3번 이상 반복되는 패턴에만 적용하는 것으로 기준을 정했다.

결론

제네릭은 확실히 유용하지만, Go의 철학인 명시성과 단순함을 해치지 않는 선에서 사용해야 한다. 유틸리티 함수나 자료구조 구현에는 적합했지만, 비즈니스 로직에 무분별하게 쓰면 오히려 독이 될 수 있다는 걸 배웠다.