Go 1.17 제네릭 없이 타입 안전한 슬라이스 유틸 만들기

문제 상황

레거시 API 서버를 Go로 마이그레이션하면서 슬라이스 처리 로직이 반복되는 문제가 있었다. Map, Filter, Reduce 같은 함수형 유틸이 필요했지만 Go 1.17에는 제네릭이 없어서 매번 타입별로 구현해야 했다.

시도한 방법들

1. 코드 생성

처음엔 go generate로 타입별 함수를 자동 생성하는 방식을 고려했다. 하지만 타입이 추가될 때마다 generate를 돌려야 하고, 생성된 코드가 저장소에 커밋되는 게 부담스러웠다.

2. interface{}와 타입 단언

결국 런타임 타입 체크를 감수하고 interface{} 기반으로 구현했다.

func Map(slice interface{}, fn interface{}) interface{} {
    sliceVal := reflect.ValueOf(slice)
    fnVal := reflect.ValueOf(fn)
    
    if sliceVal.Kind() != reflect.Slice {
        panic("first argument must be slice")
    }
    
    resultSlice := reflect.MakeSlice(
        reflect.SliceOf(fnVal.Type().Out(0)),
        sliceVal.Len(),
        sliceVal.Len(),
    )
    
    for i := 0; i < sliceVal.Len(); i++ {
        result := fnVal.Call([]reflect.Value{sliceVal.Index(i)})
        resultSlice.Index(i).Set(result[0])
    }
    
    return resultSlice.Interface()
}

사용은 이렇게 한다.

users := []User{{ID: 1}, {ID: 2}}
ids := Map(users, func(u User) int {
    return u.ID
}).([]int)

성능과 트레이드오프

reflection을 사용하기 때문에 성능은 당연히 떨어진다. 벤치마크 결과 직접 구현 대비 약 10배 느렸다. 하지만 우리 케이스는 API 응답 가공이 주 용도였고, 슬라이스 크기도 수백 개 수준이라 병목이 되지 않았다.

핫패스에서는 여전히 직접 for loop를 쓰고, 비즈니스 로직 레이어에서만 이 유틸을 사용하기로 했다.

2022년을 기다리며

Go 1.18에서 제네릭이 정식 지원되면 이 코드는 전부 교체할 예정이다. 그때까지는 이 정도 추상화로 코드 중복을 줄이는 게 합리적인 선택이라고 판단했다.

Go 1.17 제네릭 없이 타입 안전한 슬라이스 유틸 만들기