Go 제네릭 도입 후 코드 리팩토링 경험

배경

회사 프로젝트에서 Go 1.18로 업그레이드하면서 제네릭을 도입할 수 있게 됐다. 기존에 interface{}로 작성된 유틸리티 함수들이 많았는데, 타입 단언을 반복하는 코드가 불편했다.

기존 코드의 문제

슬라이스에서 특정 조건을 만족하는 요소를 찾는 함수가 이렇게 작성되어 있었다.

func Find(slice []interface{}, predicate func(interface{}) bool) interface{} {
    for _, item := range slice {
        if predicate(item) {
            return item
        }
    }
    return nil
}

// 사용할 때마다 타입 단언 필요
users := []User{{ID: 1}, {ID: 2}}
result := Find(toInterfaceSlice(users), func(v interface{}) bool {
    return v.(User).ID == 1
}).(User)

타입 변환과 단언이 반복되고, 런타임 패닉 위험도 있었다.

제네릭 적용

타입 파라미터를 사용해 리팩토링했다.

func Find[T any](slice []T, predicate func(T) bool) *T {
    for _, item := range slice {
        if predicate(item) {
            return &item
        }
    }
    return nil
}

// 사용
users := []User{{ID: 1}, {ID: 2}}
result := Find(users, func(u User) bool {
    return u.ID == 1
})
if result != nil {
    fmt.Println(result.ID)
}

타입 추론이 작동해서 명시적으로 타입을 지정할 필요도 없었다.

Map, Filter, Reduce도 구현

비슷하게 자주 쓰는 함수들을 제네릭으로 만들었다.

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

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

성능 영향

벤치마크를 돌려봤는데 인터페이스 방식과 비교해 큰 차이는 없었다. 다만 타입 안정성이 컴파일 타임에 보장되니 실수로 인한 버그는 줄었다.

아쉬운 점

  • 메서드에는 타입 파라미터를 추가할 수 없어서 일부 구조는 그대로 뒀다
  • 표준 라이브러리에 제네릭 컬렉션이 없어서 직접 구현해야 했다

그래도 타입 단언 지옥에서 벗어난 건 큰 개선이었다.