PHpullh

GO · 심층 가이드

Go 함수형 완전 정리

제네릭이 들어온 Go에서 Map·Filter·Reduce와 파이프라인, Result 타입을 어디까지 쓰는 게 실용적인지 7개 주제로 선을 그어 봅니다.

주제 7개 · 예제 코드 포함 · 최종 수정 2026-08-30 · 작성 pullh 편집팀

Go는 함수형 언어가 아니지만 함수를 값으로 다루는 데는 처음부터 제약이 없었습니다. 달라진 건 1.18의 제네릭입니다. 그 전에는 interface{}와 타입 단언 없이 범용 Map을 쓸 방법이 없었고, 지금은 타입 안전하게 작성할 수 있습니다. 다만 한계가 하나 남아 있습니다. 메서드에는 자체 타입 파라미터를 붙일 수 없어서, 다른 언어처럼 .map().filter() 형태로 이어 붙이는 체이닝 API를 만들 수 없습니다. Go의 함수형 코드가 중첩 호출이나 채널 파이프라인 모양이 되는 이유입니다.

함수형 패턴 기초에서 일급 함수와 고차 함수 감각을 잡고, 제네릭 활용 FP에서 변환·접기·그룹핑 유틸을 직접 만들어 보면 표준 라이브러리의 slices, maps 패키지가 어디까지 대신해 주는지 감이 옵니다. 그다음 불변 데이터 설계는 값 타입 복사가 동시성 안전성으로 직결되는 Go 특유의 이점을 다루고, 파이프라인 패턴Result 타입 (에러 모나드)은 각각 채널과 에러 처리에 함수형 발상을 얹는 응용편입니다.

주의할 지점은 두 가지입니다. 첫째, Result 타입은 매력적이지만 Go 생태계 전체가 if err != nilerrors.Is, errors.As를 전제로 돌아갑니다. 패키지 경계 밖으로 Result를 노출하면 호출자가 래핑을 풀어야 하므로, 내부 파이프라인에서만 쓰고 경계에서는 평범한 error로 되돌리는 편이 낫습니다. 둘째, 채널 파이프라인은 각 단계가 고루틴이라 상류가 멈추면 하류가 그대로 남습니다. context 취소와 채널 닫기를 단계마다 짝지어 두지 않으면 조용한 누수가 됩니다.

01함수형 패턴 — Map, Filter, Reduce (제네릭)

Go 1.18+ 제네릭을 사용한 함수형 프로그래밍 유틸리티.

Go code

package main

import "fmt"

// 제네릭 Map
func Map[T, U any](s []T, f func(T) U) []U {
	r := make([]U, len(s))
	for i, v := range s { r[i] = f(v) }
	return r
}

// 제네릭 Filter
func Filter[T any](s []T, f func(T) bool) []T {
	var r []T
	for _, v := range s { if f(v) { r = append(r, v) } }
	return r
}

// 제네릭 Reduce
func Reduce[T, U any](s []T, init U, f func(U, T) U) U {
	acc := init
	for _, v := range s { acc = f(acc, v) }
	return acc
}

// 제네릭 ForEach
func ForEach[T any](s []T, f func(int, T)) {
	for i, v := range s { f(i, v) }
}

// 제네릭 Contains
func Contains[T comparable](s []T, target T) bool {
	for _, v := range s { if v == target { return true } }
	return false
}

func main() {
	nums := []int{1, 2, 3, 4, 5}

	doubled := Map(nums, func(n int) int { return n * 2 })
	fmt.Println("doubled:", doubled)

	evens := Filter(nums, func(n int) bool { return n%2 == 0 })
	fmt.Println("evens:", evens)

	sum := Reduce(nums, 0, func(acc, n int) int { return acc + n })
	fmt.Println("sum:", sum)

	// 타입 변환 Map
	strs := Map(nums, func(n int) string {
		return fmt.Sprintf("%02d", n)
	})
	fmt.Println("strs:", strs)

	// 체이닝
	result := Reduce(
		Filter(Map(nums, func(n int) int { return n * n }),
			func(n int) bool { return n > 5 }),
		0,
		func(acc, n int) int { return acc + n },
	)
	fmt.Println("chain result:", result) // 9+16+25 = 50

	fmt.Println(Contains(nums, 3)) // true
}
알아두면 좋은 점

Go의 slices 패키지(Go 1.21+)에 slices.Map, slices.Filter 같은 함수가 추가됐습니다. 표준 라이브러리를 먼저 확인하세요.

자주 하는 실수

제네릭 함수의 타입 파라미터가 복잡해지면 타입 추론이 실패할 수 있습니다. 이 경우 명시적으로 Map[int, string](nums, ...)처럼 타입을 지정하세요.

02Go Workspace 모드

go work init으로 멀티 모듈 로컬 개발

Go code

<span class="cm">// Go Workspace 모드 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("Go Workspace 모드") }
알아두면 좋은 점

GO 공식 문서를 함께 참고하세요.

자주 하는 실수

자주 발생하는 실수에 주의하세요.

03함수형 패턴 기초

Go에서 일급 함수, 고차 함수, 순수 함수 개념을 활용한 함수형 프로그래밍 패턴입니다.

Go code

package main

import "fmt"

// 순수 함수: 부수 효과 없음
func double(n int) int { return n * 2 }
func isEven(n int) bool { return n%2 == 0 }

// 고차 함수: 함수를 인자로 받음
func apply(nums []int, fn func(int) int) []int {
	result := make([]int, len(nums))
	for i, n := range nums { result[i] = fn(n) }
	return result
}

// 술어 합성
func and[T any](p1, p2 func(T) bool) func(T) bool {
	return func(v T) bool { return p1(v) && p2(v) }
}

func not[T any](p func(T) bool) func(T) bool {
	return func(v T) bool { return !p(v) }
}

func main() {
	nums := []int{1, 2, 3, 4, 5, 6}
	fmt.Println(apply(nums, double))   // [2 4 6 8 10 12]

	isPositive := func(n int) bool { return n > 0 }
	isPositiveEven := and(isPositive, isEven)
	fmt.Println(isPositiveEven(4))  // true
	fmt.Println(isPositiveEven(-2)) // false

	isOdd := not(isEven)
	fmt.Println(isOdd(3)) // true
}
알아두면 좋은 점

순수 함수는 테스트하기 쉽고 동시성 안전합니다. 가능하면 부수 효과를 함수 경계 밖으로 밀어내세요.

자주 하는 실수

클로저가 외부 변수를 변경하면 순수 함수가 아닙니다. 함수형 스타일에서는 값을 반환하여 상태를 전달하세요.

04제네릭 활용 FP

제네릭으로 타입 안전한 함수형 유틸리티를 구현합니다. 슬라이스 변환, 접기, 그룹핑 등을 다룹니다.

Go code

package main

import "fmt"

func FlatMap[T, U any](s []T, fn func(T) []U) []U {
	var result []U
	for _, v := range s {
		result = append(result, fn(v)...)
	}
	return result
}

func Partition[T any](s []T, pred func(T) bool) (yes, no []T) {
	for _, v := range s {
		if pred(v) { yes = append(yes, v) } else { no = append(no, v) }
	}
	return
}

func Chunk[T any](s []T, size int) [][]T {
	var chunks [][]T
	for i := 0; i < len(s); i += size {
		end := i + size
		if end > len(s) { end = len(s) }
		chunks = append(chunks, s[i:end])
	}
	return chunks
}

func Unique[T comparable](s []T) []T {
	seen := make(map[T]struct{})
	var result []T
	for _, v := range s {
		if _, ok := seen[v]; !ok {
			seen[v] = struct{}{}
			result = append(result, v)
		}
	}
	return result
}

func main() {
	words := []string{"hello world", "go lang"}
	chars := FlatMap(words, func(s string) []byte { return []byte(s) })
	fmt.Println(string(chars))

	nums := []int{1, 2, 3, 4, 5, 6}
	even, odd := Partition(nums, func(n int) bool { return n%2 == 0 })
	fmt.Println("짝수:", even, "홀수:", odd)

	fmt.Println("청크:", Chunk(nums, 2))
	fmt.Println("중복제거:", Unique([]int{1, 2, 2, 3, 3}))
}
알아두면 좋은 점

Go 1.23+의 iter 패키지와 range-over-func를 사용하면 지연 평가(lazy evaluation)도 가능합니다.

자주 하는 실수

FlatMap이나 Chunk에서 빈 슬라이스를 처리하지 않으면 nil 반환으로 인한 버그가 발생합니다. 항상 빈 입력을 고려하세요.

05불변 데이터 설계

값 타입과 복사를 활용하여 불변 데이터 구조를 설계합니다. 동시성 안전성을 보장합니다.

Go code

package main

import "fmt"

// 불변 구조체: 모든 메서드가 새 인스턴스 반환
type Point struct{ X, Y float64 }

func (p Point) Move(dx, dy float64) Point {
	return Point{X: p.X + dx, Y: p.Y + dy}
}

func (p Point) Scale(factor float64) Point {
	return Point{X: p.X * factor, Y: p.Y * factor}
}

// 불변 슬라이스: 항상 복사 후 수정
type ImmutableList[T any] struct {
	items []T
}

func NewList[T any](items ...T) ImmutableList[T] {
	cp := make([]T, len(items))
	copy(cp, items)
	return ImmutableList[T]{items: cp}
}

func (l ImmutableList[T]) Append(v T) ImmutableList[T] {
	newItems := make([]T, len(l.items)+1)
	copy(newItems, l.items)
	newItems[len(l.items)] = v
	return ImmutableList[T]{items: newItems}
}

func (l ImmutableList[T]) Items() []T {
	cp := make([]T, len(l.items))
	copy(cp, l.items)
	return cp
}

func main() {
	p := Point{1, 2}
	p2 := p.Move(3, 4).Scale(2)
	fmt.Println(p)  // {1 2} — 원본 변경 없음
	fmt.Println(p2) // {8 12}

	list := NewList(1, 2, 3)
	list2 := list.Append(4)
	fmt.Println(list.Items())  // [1 2 3]
	fmt.Println(list2.Items()) // [1 2 3 4]
}
알아두면 좋은 점

값 리시버 + 새 인스턴스 반환 패턴은 불변성을 보장합니다. 메서드 체이닝도 자연스럽게 지원됩니다.

자주 하는 실수

슬라이스를 직접 반환하면 외부에서 수정할 수 있습니다. 항상 copy로 방어적 복사를 수행하세요.

06파이프라인 패턴

채널과 함수 합성으로 데이터 처리 파이프라인을 구현합니다. 각 단계가 독립적으로 실행됩니다.

Go code

package main

import "fmt"

// 함수 파이프라인 (동기)
type Pipeline[T any] struct {
	steps []func([]T) []T
}

func NewPipeline[T any]() *Pipeline[T] {
	return &Pipeline[T]{}
}

func (p *Pipeline[T]) Add(step func([]T) []T) *Pipeline[T] {
	p.steps = append(p.steps, step)
	return p
}

func (p *Pipeline[T]) Execute(data []T) []T {
	result := data
	for _, step := range p.steps {
		result = step(result)
	}
	return result
}

func main() {
	pipe := NewPipeline[int]().
		Add(func(nums []int) []int { // 필터: 짝수만
			var r []int
			for _, n := range nums { if n%2 == 0 { r = append(r, n) } }
			return r
		}).
		Add(func(nums []int) []int { // 변환: 2배
			r := make([]int, len(nums))
			for i, n := range nums { r[i] = n * 2 }
			return r
		}).
		Add(func(nums []int) []int { // 필터: 10 이하
			var r []int
			for _, n := range nums { if n <= 10 { r = append(r, n) } }
			return r
		})

	result := pipe.Execute([]int{1, 2, 3, 4, 5, 6, 7, 8})
	fmt.Println(result) // [4 8]
}
알아두면 좋은 점

파이프라인의 각 단계를 순수 함수로 만들면 테스트와 재사용이 쉽습니다.

자주 하는 실수

파이프라인 단계에서 입력 슬라이스를 직접 수정하면 이전 단계의 결과가 오염됩니다. 항상 새 슬라이스를 반환하세요.

07Result 타입 (에러 모나드)

에러 처리를 함수형으로 체이닝하는 Result 타입을 구현합니다. Then으로 에러 전파를 자동화합니다.

Go code

package main

import (
	"fmt"
	"strconv"
)

type Result[T any] struct {
	Value T
	Err   error
}

func Ok[T any](v T) Result[T]     { return Result[T]{Value: v} }
func Fail[T any](err error) Result[T] { return Result[T]{Err: err} }

func Then[T, U any](r Result[T], fn func(T) Result[U]) Result[U] {
	if r.Err != nil {
		return Fail[U](r.Err) // 에러 전파
	}
	return fn(r.Value)
}

func (r Result[T]) Map(fn func(T) T) Result[T] {
	if r.Err != nil { return r }
	return Ok(fn(r.Value))
}

func parseAge(s string) Result[int] {
	n, err := strconv.Atoi(s)
	if err != nil { return Fail[int](fmt.Errorf("파싱 실패: %w", err)) }
	return Ok(n)
}

func validateAge(n int) Result[int] {
	if n < 0 || n > 150 { return Fail[int](fmt.Errorf("유효하지 않은 나이: %d", n)) }
	return Ok(n)
}

func main() {
	r := Then(parseAge("25"), validateAge)
	fmt.Println(r) // {25 <nil>}

	r2 := Then(parseAge("abc"), validateAge)
	fmt.Println(r2.Err) // 파싱 실패

	r3 := Then(parseAge("-5"), validateAge)
	fmt.Println(r3.Err) // 유효하지 않은 나이
}
알아두면 좋은 점

Result 타입은 에러 체크 보일러플레이트를 줄여줍니다. 다만 Go의 관용적 if err != nil과 혼용할 때 주의하세요.

자주 하는 실수

Go 커뮤니티에서 모나드 패턴은 논란이 있습니다. 팀 컨벤션을 확인하고, 표준 에러 처리가 더 적합할 수 있습니다.

정리하며

  • 제네릭 함수는 만들 수 있어도 제네릭 메서드는 불가하므로 체이닝 대신 합성 함수로 설계합니다
  • 직접 유틸을 만들기 전에 표준 slices·maps 패키지가 이미 제공하는지 먼저 확인합니다
  • Result 타입은 패키지 내부용으로 두고, 공개 API 경계에서는 관용적인 error로 되돌립니다
  • 파이프라인 단계마다 context 취소와 채널 닫기를 짝지어 고루틴 누수를 막습니다

더 깊이 들어가고 싶다면 Go 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.