Go 언어 완벽 마스터: 함수형 프로그래밍이 최고의 선택인 이유

Go 언어 완벽 마스터: 함수형 프로그래밍이 최고의 선택인 이유

Go에서의 함수형 프로그래밍: 기존의 인식을 깨부수다

'함수형 프로그래밍'이라고 하면 보통 Go (고) 언어가 가장 먼저 떠오르지는 않을 겁니다.

아마도 순수 함수와 모나드(Monad) (걱정 마세요, 나중에 자세히 설명할 겁니다!)로 유명한 Haskell (하스켈)이나, 고차 함수와 콜백으로 자신의 기능을 뽐내기 좋아하는 JavaScript (자바스크립트)를 떠올릴지도 모르겠습니다.

하지만 사실 Go (고) 언어로도 함수형 프로그래밍을 할 수 있고, 그 과정이 결코 지루하지만은 않답니다.

고차 함수 (Higher-Order Functions)

가장 먼저, 고차 함수(Higher-Order Functions)에 대해 이야기해 볼까요?

고차 함수는 다른 함수들과 잘 작동하는데요, 함수를 매개변수로 받거나 값으로 반환할 수 있습니다.

Go (고)의 세계에서는 고차 함수를 구현하는 것이 가능할 뿐만 아니라 꽤나 기발한 방식으로 할 수 있답니다.

package main

import (
    "fmt"
)

// filter 함수는 정수 슬라이스와 판단 함수 f를 매개변수로 받아,
// 슬라이스 내에서 판단 조건을 만족하는 요소들만 반환합니다.
func filter(numbers []int, f func(int) bool) []int {
    var result []int
    for _, value := range numbers {
        if f(value) {
            result = append(result, value)
        }
    }
    return result
}

// isEven 함수는 정수가 짝수인지 판단합니다.
func isEven(n int) bool {
    return n%2 == 0
}

func main() {
    numbers := []int{1, 2, 3, 4}
    even := filter(numbers, isEven)
    fmt.Println(even) // [2, 4]가 출력됩니다.
}



보시다시피, 이 예제에서 filter (필터) 함수는 정수 슬라이스와 판단 함수 f를 받아서, 슬라이스에서 판단 조건을 만족하는 요소들을 반환합니다.

뭔가 더 빠른 자바스크립트(JavaScript) 같지 않나요?

커링 (Currying)

다음은 커링(Currying)인데요.

여러 인자를 받는 함수를 각각 단일 인자를 받는 일련의 함수로 분해하는 과정입니다.

커링(Currying)은 생각보다 그렇게 복잡하지 않답니다.

package main

import "fmt"

// add 함수는 정수 a를 받아 새로운 함수를 반환합니다.
// 이 새로운 함수는 다른 정수 b를 받아 a + b의 결과를 반환합니다.
func add(a int) func(int) int {
    return func(b int) int {
        return a + b
    }
}

func main() {
    addFive := add(5)
    fmt.Println(addFive(3)) // 8이 출력됩니다.
}



이 예제에서 add 함수는 정수 a를 받고 새로운 함수를 반환합니다.

이 새로운 함수는 또 다른 정수 b를 받아 a + b의 결과를 반환하는데요.

간단하고, 직관적이며, 군더더기 없이 제 역할을 해낸답니다.

불변성 (Immutability)

함수형 프로그래밍의 특징 중 하나는 바로 불변성(Immutability)입니다.

일단 무언가가 만들어지면, 그것은 변하지 않습니다.

대신 다른 것이 필요하다면, 새로운 것을 만듭니다.

처음에는 이게 좀 낭비처럼 들릴 수도 있지만, 사실 코드를 깔끔하게 유지하고 부작용을 줄이는 데 도움이 된답니다.

package main

import "fmt"

func main() {
    obj := map[string]int{"a": 1, "b": 2}
    // 새로운 newObj를 만듭니다.
    newObj := make(map[string]int)
    for k, v := range obj {
        newObj[k] = v
    }
    // newObj를 수정합니다.
    newObj["b"] = 3
    fmt.Println(newObj) // map[a:1 b:3]이 출력됩니다.
}



이 예시에서는 원본 obj를 직접 수정하는 대신, 새로운 newObj를 만들어서 수정했습니다.

순수 함수 (Pure Functions)

순수 함수(Pure Functions)는 마치 깔끔한 친구 같아요.

자신의 범위 밖의 것은 건드리거나 수정하지 않거든요.

전달받은 것만 사용하고, 반환하는 것이 유일한 효과입니다.

package main

import "fmt"

// square 함수는 오직 전달받은 매개변수 x에만 의존합니다.
func square(x int) int {
    return x * x
}

func main() {
    fmt.Println(square(5)) // 25가 출력됩니다.
}



이 예제에서 square 함수는 전달된 매개변수 x에만 의존하고 외부 변수에는 전혀 영향을 주지 않습니다.

펑터 (Functors)

가장 간단하게 말해서, 펑터(Functors)는 함수를 매핑할 수 있는 모든 것이라고 할 수 있는데요.

흔히 볼 수 있는 배열을 생각해보세요.

각 항목에 함수를 적용해서 새로운 배열을 얻는 것처럼 말입니다.

Go (고)에는 내장된 일반적인 map (맵) 함수는 없지만, 우리가 직접 만들 수 있습니다.

package main

import "fmt"

// 정수 슬라이스에 대한 펑터(Functor)입니다.
// mapInts 함수는 정수 슬라이스와 함수를 받아
// 각 요소에 함수를 적용한 결과로 새로운 슬라이스를 반환합니다.
func mapInts(values []int, f func(int) int) []int {
    result := make([]int, len(values))
    for i, v := range values {
        result[i] = f(v)
    }
    return result
}

func main() {
    numbers := []int{1, 2, 3, 4}
    squared := mapInts(numbers, func(x int) int { return x * x })
    fmt.Println(squared) // [1, 4, 9, 16]이 출력됩니다.
}



여기서 우리는 정수 슬라이스와 함수를 받아, 원본 슬라이스 요소를 함수로 처리한 결과로 새로운 슬라이스를 반환하는 mapInts 함수를 정의했습니다.

엔도펑터 (Endofunctors)

자, 이제 엔도펑터(Endofunctors)에 대해 이야기해 볼까요?

엔도펑터(Endofunctors)라는 건 좀 있어 보이는 표현인데요, 사실 같은 타입으로 매핑하는 펑터(Functor)를 말합니다.

간단히 말해, Go (고) 슬라이스에서 시작해서 같은 타입의 Go (고) 슬라이스로 끝나는 겁니다.

그렇게 어려운 개념은 아니고, 그냥 타입 일관성에 관한 이야기랍니다.

앞서 살펴본 mapInts를 예로 들면, 이건 사실 위장한 엔도펑터(Endofunctor)의 한 종류라고 할 수 있습니다.

[]int를 받아서 타입 변환 없이 []int를 반환하니까요.

모노이드 (Monoids)

모든 사람이 친구를 데려와야 하는 파티를 상상해 보세요.

모노이드(Monoids)는 그런 것과 비슷한데, 타입에 대한 이야기입니다.

모노이드(Monoids)에는 두 가지가 필요한데요, 두 타입을 결합하는 연산과 특별한 값, 즉 항등원(identity element)입니다.

이 항등원은 마치 가장 인기 있는 친구 같아서 모든 사람과 잘 어울리지만, 그들에 대해 아무것도 바꾸지 않습니다.

Go (고)에서는 슬라이스나 숫자에서 이런 모습을 볼 수 있는데요.

다루기 쉬운 숫자를 예로 들어보겠습니다.

package main

import "fmt"

// 정수 덧셈은 0을 항등원으로 갖는 모노이드(Monoid)입니다.
func add(a, b int) int {
    return a + b
}

func main() {
    fmt.Println(add(5, 5))  // 10이 출력됩니다.
    fmt.Println(add(5, 0))  // 5가 출력됩니다.
    fmt.Println(add(0, 0))  // 0이 출력됩니다.
}



여기서 0이 바로 우리의 주인공, 즉 숫자를 변하지 않게 유지하는 항등원입니다.

모나드 (Monads)

"모나드(Monad)는 엔도펑터(Endofunctor)들의 카테고리 안에서의 모노이드(Monoid)다"라는 말을 누군가 한다면, 그건 그냥 컴퓨터 과학 용어를 뽐내는 거라고 생각하면 됩니다.

자세히 설명하자면, 모나드(Monad)는 타입과 함수를 아주 특별한 방식으로 다루는 프로그래밍 구조체인데요, 마치 어떤 사람들이 커피 내리는 방식에 까다로운 것처럼 말입니다.

가장 간단하게 말하면, 모노이드(Monoid)는 쓸모없는 요소 또는 항등원을 포함하는 특별한 규칙을 사용하여 여러 가지를 함께 결합하는 것입니다.

이제 여기에 엔도펑터(Endofunctor)를 추가하는데요, 이것은 평범한 오래된 함수와 같지만 자신의 작은 우주(카테고리) 내에서만 변환을 고수합니다.

이 모든 것을 종합하면, 모나드(Monad)는 함수들을 순서대로 연결하는 방법으로 볼 수 있지만, 데이터의 원래 구조를 존중하면서 매우 자기 완결적인 방식으로 이루어집니다.

마치 '우리 자동차 여행 갈 건데, 경치 좋은 뒷길로만 가야 하고, 결국 출발했던 곳으로 돌아올 거야'라고 말하는 것과 비슷합니다.

모나드(Monad)는 만능 재주꾼입니다.

에러나 리스트와 같은 컨텍스트를 가진 값을 다룰 수 있을 뿐만 아니라, 컨텍스트를 전달하여 연산들을 연결할 수도 있습니다.

Go (고)에서는 이걸 흉내 내기가 좀 어려울 수 있지만, 모나드(Monad)의 실용적인 사용 예인 에러 처리를 한번 살펴보겠습니다.

package main

import (
    "errors"
    "fmt"
)

// Maybe는 에러 처리를 위한 모나드(Monad)를 나타냅니다.
// value와 err, 그리고 함수 f를 받습니다.
// err가 nil이 아니면, 0과 err를 반환합니다.
// 그렇지 않으면, f(value)의 결과를 반환합니다.
func Maybe(value int, err error, f func(int) (int, error)) (int, error) {
    if err != nil {
        return 0, err
    }
    return f(value)
}

func main() {
    // 실패할 수 있는 계산을 시뮬레이션합니다.
    process := func(v int) (int, error) {
        if v < 0 {
            return 0, errors.New("negative value") // 음수 값 에러
        }
        return v * v, nil
    }

    // 우리의 Maybe "모나드"를 사용해 잠재적 에러를 처리합니다.
    result, err := Maybe(5, nil, process)
    if err != nil {
        fmt.Println("Error:", err) // 에러 출력
    } else {
        fmt.Println("Success:", result) // Success: 25가 출력됩니다.
    }
}



이 임시방편으로 만든 모나드(Monad)는 코드가 패닉에 빠지거나 혼란스러워지는 것 없이, 잘못될 수 있는 계산들을 처리하는 데 도움을 줄 수 있습니다.

결론

Go (고)에서의 함수형 프로그래밍이 함수형 패러다임의 대표 주자는 아닐지라도, 전적으로 가능하며 심지어 재미있을 수도 있습니다.

누가 생각이나 했겠습니까, 그렇죠?

이제 여러분은 Go (고)도 다른 언어들처럼 함수형 프로그래밍을 달성할 수 있다는 것을 이해하셨을 겁니다.

약간의 노력만 더하면, 여러분도 깔끔하고 효율적이며 견고한 코드를 작성할 수 있습니다.