ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • golang: 제네릭(Generic) 심화과정
    Back-End/Golang 2024. 11. 10. 13:04
    반응형

    Go 언어의 제네릭(Generic) 심화 이해 및 활용

    Go 언어는 간결함과 효율성으로 널리 사랑받는 언어입니다. 그러나 제네릭(generic)이 없다는 점은 복잡한 코드를 작성할 때 걸림돌이 되었습니다. 이러한 필요성에 따라 Go 1.18 버전부터 제네릭이 도입되었고, 이는 코드 재사용성과 타입 안전성을 크게 향상시켰습니다. 이번 글에서는 제네릭이 무엇인지부터 시작하여, 왜 필요한지, 그리고 현업에서 어떻게 활용되는지 다양한 예시와 함께 깊이 있게 알아보겠습니다.


    1. 제네릭이란 무엇인가?

    1.1 제네릭의 개념

    제네릭은 함수나 타입을 정의할 때 타입을 매개변수처럼 사용할 수 있게 하는 기능입니다. 즉, 코드 작성 시 구체적인 타입을 명시하지 않고도 다양한 타입에 대해 동작하는 코드를 작성할 수 있습니다.

    1.2 왜 제네릭이 필요한가?

    • 코드 중복 제거: 동일한 로직을 수행하지만 타입만 다른 함수나 구조체를 여러 개 작성해야 하는 불편함을 해소합니다.
    • 타입 안전성 강화: interface{}를 사용할 경우 런타임 에러가 발생할 수 있지만, 제네릭은 컴파일 타임에 타입을 체크하여 안정성을 높입니다.
    • 가독성 및 유지보수성 향상: 제네릭을 사용하면 코드가 더욱 간결하고 명확해집니다.

    2. 제네릭의 기본 사용법

    2.1 제네릭 함수

    문제 상황

    다양한 숫자 타입(int, float64 등)의 배열 합계를 계산하는 함수를 작성하고자 합니다.

    전통적인 방식

    func SumInts(numbers []int) int {
        sum := 0
        for _, num := range numbers {
            sum += num
        }
        return sum
    }
    
    func SumFloats(numbers []float64) float64 {
        sum := 0.0
        for _, num := range numbers {
            sum += num
        }
        return sum
    }
    • 문제점: 타입마다 함수를 따로 작성해야 하므로 코드 중복이 발생합니다.

    제네릭을 활용한 방식

    func Sum[T int | float64](numbers []T) T {
        var sum T
        for _, num := range numbers {
            sum += num
        }
        return sum
    }
    
    func main() {
        ints := []int{1, 2, 3, 4}
        floats := []float64{1.1, 2.2, 3.3}
    
        fmt.Println(Sum(ints))    // Output: 10
        fmt.Println(Sum(floats))  // Output: 6.6
    }
    • 설명:
      • Sum[T int | float64]: 타입 매개변수 T는 int 또는 float64 타입만 허용합니다.
      • var sum T: 제네릭 타입 변수 sum을 선언합니다.
      • sum += num: 타입에 따라 적절한 덧셈 연산이 수행됩니다.

    비교 및 개선점

    • 코드 중복 제거: 하나의 함수로 여러 타입을 처리할 수 있습니다.
    • 타입 안전성: 컴파일 타임에 타입 체크가 이루어집니다.
    • 가독성 향상: 함수의 의도가 명확해집니다.

    2.2 제네릭 타입

    문제 상황

    데이터 타입에 상관없이 사용할 수 있는 스택(Stack) 자료구조를 구현하고자 합니다.

    전통적인 방식

    type IntStack struct {
        elements []int
    }
    
    func (s *IntStack) Push(element int) {
        s.elements = append(s.elements, element)
    }
    
    func (s *IntStack) Pop() int {
        if len(s.elements) == 0 {
            panic("stack is empty")
        }
        element := s.elements[len(s.elements)-1]
        s.elements = s.elements[:len(s.elements)-1]
        return element
    }
    • 문제점: 타입마다 별도의 스택 구조체를 만들어야 합니다.

    제네릭을 활용한 방식

    type Stack[T any] struct {
        elements []T
    }
    
    func (s *Stack[T]) Push(element T) {
        s.elements = append(s.elements, element)
    }
    
    func (s *Stack[T]) Pop() T {
        if len(s.elements) == 0 {
            panic("stack is empty")
        }
        element := s.elements[len(s.elements)-1]
        s.elements = s.elements[:len(s.elements)-1]
        return element
    }
    
    func main() {
        intStack := Stack[int]{}
        intStack.Push(10)
        intStack.Push(20)
        fmt.Println(intStack.Pop()) // Output: 20
    
        stringStack := Stack[string]{}
        stringStack.Push("Go")
        stringStack.Push("Lang")
        fmt.Println(stringStack.Pop()) // Output: Lang
    }
    • 설명:
      • type Stack[T any]: 제네릭 타입 Stack을 정의하며, T는 어떤 타입이든 받을 수 있습니다.
      • 메서드들도 [T]를 명시하여 제네릭 타입임을 표시합니다.

    비교 및 개선점

    • 유연성 증가: 하나의 스택 구현으로 모든 타입을 지원합니다.
    • 코드 재사용성: 새로운 타입에 대해 별도의 코드를 작성할 필요가 없습니다.
    • 타입 안전성: 스택에 잘못된 타입의 데이터를 넣는 것을 방지합니다.

    3. 제네릭의 심화 활용

    3.1 타입 제약 조건

    타입 매개변수에 특정 인터페이스를 구현하도록 제약을 걸 수 있습니다.

    문제 상황

    모든 숫자 타입을 지원하는 최소값 계산 함수를 작성하고자 합니다.

    제네릭을 활용한 방식

    // 숫자 타입만 허용하는 인터페이스
    type Number interface {
        ~int | ~int64 | ~float64
    }
    
    func Min[T Number](a, b T) T {
        if a < b {
            return a
        }
        return b
    }
    
    func main() {
        fmt.Println(Min(3, 7))            // Output: 3
        fmt.Println(Min(2.5, 1.8))        // Output: 1.8
        fmt.Println(Min[int64](10, 5))    // Output: 5
    }
    • 설명:
      • ~int: 기본 타입이 int인 모든 타입을 포함합니다.
      • Number 인터페이스를 통해 비교 가능한 숫자 타입만 허용합니다.
      • Min 함수는 타입 제약 조건을 활용하여 비교 연산이 가능한 타입만 받습니다.

    비교 및 개선점

    • 타입 안정성 강화: 비교 연산이 가능한 타입만 허용하여 런타임 에러를 방지합니다.
    • 유연성 증가: 새로운 숫자 타입이 추가되더라도 인터페이스에만 추가하면 됩니다.

    3.2 컨텍스트 기반의 제네릭

    문제 상황

    여러 데이터 모델에 대해 공통적인 데이터베이스 연산을 수행하고자 합니다.

    제네릭을 활용한 방식

    type Model interface {
        TableName() string
    }
    
    func GetByID[T Model](db *sql.DB, id int) (*T, error) {
        var model T
        query := fmt.Sprintf("SELECT * FROM %s WHERE id = ?", model.TableName())
        row := db.QueryRow(query, id)
        err := row.Scan(&model)
        if err != nil {
            return nil, err
        }
        return &model, nil
    }
    
    // 사용자 정의 모델
    type User struct {
        ID   int
        Name string
    }
    
    func (u User) TableName() string {
        return "users"
    }
    
    func main() {
        db, _ := sql.Open("mysql", "dsn")
        user, err := GetByID[User](db, 1)
        if err != nil {
            log.Fatal(err)
        }
        fmt.Println(user.Name)
    }
    • 설명:
      • Model 인터페이스를 구현한 타입만 GetByID 함수에 사용할 수 있습니다.
      • 이로써 다양한 모델에 대해 공통적인 CRUD 연산을 제네릭으로 처리할 수 있습니다.

    비교 및 개선점

    • 코드 재사용성 향상: 모델별로 함수를 작성할 필요가 없습니다.
    • 타입 안정성 및 유지보수성 향상: 모델의 변경 사항이 있어도 함수 로직을 수정할 필요가 없습니다.

    4. 현업에서의 제네릭 활용 예시

    4.1 고성능 캐시 시스템

    대규모 트래픽을 처리하는 서비스에서는 다양한 타입의 데이터를 캐싱해야 합니다.

    제네릭을 활용한 캐시 구현

    type Cache[K comparable, V any] struct {
        data map[K]V
        mu   sync.RWMutex
    }
    
    func (c *Cache[K, V]) Get(key K) (V, bool) {
        c.mu.RLock()
        defer c.mu.RUnlock()
        val, ok := c.data[key]
        return val, ok
    }
    
    func (c *Cache[K, V]) Set(key K, value V) {
        c.mu.Lock()
        defer c.mu.Unlock()
        c.data[key] = value
    }
    
    func NewCache[K comparable, V any]() *Cache[K, V] {
        return &Cache[K, V]{data: make(map[K]V)}
    }
    
    func main() {
        // 문자열을 키로 하고, 정수를 값으로 가지는 캐시
        cache := NewCache[string, int]()
        cache.Set("userID_123", 42)
        if val, ok := cache.Get("userID_123"); ok {
            fmt.Println(val) // Output: 42
        }
    
        // 정수를 키로 하고, 문자열을 값으로 가지는 캐시
        cache2 := NewCache[int, string]()
        cache2.Set(1, "GoLang")
        if val, ok := cache2.Get(1); ok {
            fmt.Println(val) // Output: GoLang
        }
    }
    • 설명:
      • K comparable: 맵의 키로 사용할 수 있는 타입만 허용합니다.
      • V any: 값은 어떤 타입이든 가능합니다.
      • 다양한 시나리오에 대해 하나의 캐시 구조체로 대응할 수 있습니다.

    비교 및 개선점

    • 유연성 증가: 키와 값의 타입을 자유롭게 설정할 수 있습니다.
    • 코드 중복 제거: 캐시 구조체를 여러 개 만들 필요가 없습니다.
    • 타입 안전성: 잘못된 타입의 키나 값을 사용하는 것을 방지합니다.

    4.2 REST API 요청 데이터 검증

    다양한 요청 데이터를 처리하면서 필수 필드를 검증하는 로직이 필요합니다.

    제네릭을 활용한 방식

    type Validator interface {
        Validate() error
    }
    
    func ValidateRequest[T Validator](req T) error {
        return req.Validate()
    }
    
    // 사용자 정의 요청 타입과 구현
    type CreateUserRequest struct {
        Username string
        Email    string
    }
    
    func (r CreateUserRequest) Validate() error {
        if r.Username == "" {
            return fmt.Errorf("username is required")
        }
        if r.Email == "" {
            return fmt.Errorf("email is required")
        }
        return nil
    }
    
    type UpdateUserRequest struct {
        UserID   int
        Username string
    }
    
    func (r UpdateUserRequest) Validate() error {
        if r.UserID <= 0 {
            return fmt.Errorf("valid userID is required")
        }
        return nil
    }
    
    func main() {
        createReq := CreateUserRequest{Username: "John", Email: "john@example.com"}
        updateReq := UpdateUserRequest{UserID: 1, Username: "Johnny"}
    
        fmt.Println(ValidateRequest(createReq)) // Output: <nil>
        fmt.Println(ValidateRequest(updateReq)) // Output: <nil>
    }
    • 설명:
      • Validator 인터페이스를 구현한 타입만 ValidateRequest 함수에 사용할 수 있습니다.
      • 요청 타입별로 Validate 메서드를 구현하여 검증 로직을 분리합니다.

    비교 및 개선점

    • 코드 재사용성: 요청 데이터 검증 로직을 공통화할 수 있습니다.
    • 유지보수성 향상: 새로운 요청 타입이 추가되더라도 기존 검증 로직을 수정할 필요가 없습니다.
    • 타입 안전성: 컴파일 타임에 검증 대상 타입을 제한합니다.

    5. 고급 주제: 코드 최적화 및 병렬 처리

    5.1 병렬 작업에서의 제네릭 활용

    대량의 데이터를 병렬로 처리하고 결과를 병합해야 하는 경우가 있습니다.

    제네릭을 활용한 병렬 맵 함수 구현

    func ParallelMap[T any, R any](data []T, worker func(T) R) []R {
        results := make([]R, len(data))
        var wg sync.WaitGroup
        for i, item := range data {
            wg.Add(1)
            go func(i int, item T) {
                defer wg.Done()
                results[i] = worker(item)
            }(i, item)
        }
        wg.Wait()
        return results
    }
    
    func main() {
        nums := []int{1, 2, 3, 4, 5}
        doubled := ParallelMap(nums, func(n int) int {
            return n * 2
        })
        fmt.Println(doubled) // Output: [2 4 6 8 10]
    
        words := []string{"go", "lang"}
        uppercased := ParallelMap(words, func(s string) string {
            return strings.ToUpper(s)
        })
        fmt.Println(uppercased) // Output: [GO LANG]
    }
    • 설명:
      • ParallelMap[T any, R any]: 입력 타입 T와 출력 타입 R을 제네릭으로 받습니다.
      • worker func(T) R: 각 데이터를 처리하는 함수입니다.
      • 고루틴을 활용하여 병렬 처리를 수행합니다.

    비교 및 개선점

    • 성능 향상: 병렬 처리를 통해 대용량 데이터의 처리 속도를 높입니다.
    • 유연성 증가: 어떤 타입의 데이터든 처리할 수 있습니다.
    • 코드 재사용성: 병렬 처리 로직을 공통화하여 중복 코드를 줄입니다.

    6. 제네릭 사용의 이점과 비교

    6.1 코드 재사용성

    • 제네릭 사용 전: 타입별로 별도의 함수나 구조체를 작성해야 합니다.
    • 제네릭 사용 후: 하나의 제네릭 함수나 타입으로 다양한 타입을 지원할 수 있습니다.

    6.2 타입 안전성

    • 제네릭 사용 전: interface{}를 사용할 경우 런타임에 타입 에러가 발생할 수 있습니다.
    • 제네릭 사용 후: 컴파일 타임에 타입 체크가 이루어져 안정성이 높아집니다.

    6.3 가독성 및 유지보수성

    • 제네릭 사용 전: 코드 중복으로 인해 가독성이 떨어질 수 있습니다.
    • 제네릭 사용 후: 코드가 간결해지고 의도가 명확해집니다.

    6.4 성능 최적화

    • 제네릭 사용 전: 타입 변환이나 반사(reflection)를 사용하면 성능이 저하될 수 있습니다.
    • 제네릭 사용 후: 컴파일 타임에 타입이 결정되므로 성능 오버헤드가 없습니다.

    결론

    Go 언어의 제네릭은 코드 작성의 유연성과 효율성을 크게 향상시킵니다. 이번 글에서는 제네릭의 기본 개념부터 시작하여, 다양한 수준의 예시를 통해 어떻게 현업에서 활용될 수 있는지 알아보았습니다. 제네릭은 강력한 도구이지만, 적절한 곳에 현명하게 사용하는 것이 중요합니다. 직접 코드를 작성해 보며 제네릭의 장점을 체감해 보시길 바랍니다.

    반응형
Designed by Tistory.