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
}
성능 영향
벤치마크를 돌려봤는데 인터페이스 방식과 비교해 큰 차이는 없었다. 다만 타입 안정성이 컴파일 타임에 보장되니 실수로 인한 버그는 줄었다.
아쉬운 점
- 메서드에는 타입 파라미터를 추가할 수 없어서 일부 구조는 그대로 뒀다
- 표준 라이브러리에 제네릭 컬렉션이 없어서 직접 구현해야 했다
그래도 타입 단언 지옥에서 벗어난 건 큰 개선이었다.