GO · 심층 가이드
Go 함수 완전 정리
다중 반환값과 defer에서 시작해 클로저, 함수 옵션, 미들웨어, 에러 래핑, 제네릭 맵·필터·리듀스까지 Go 함수 설계 20가지를 실전 순서로 다룹니다.
다중 반환값 하나가 Go의 함수 설계를 통째로 규정합니다. 예외로 흐름을 끊는 대신 (값, error)를 돌려주기 때문에, 호출부는 매번 분기를 씁니다. 장황하다는 비판이 있지만 얻는 것도 명확합니다. 실패 경로가 코드에 그대로 보이고, 어느 줄에서 튀어나올지 모르는 제어 흐름이 없습니다. 여기에 defer가 붙으면서 자원 해제는 획득 지점 바로 아래에 적는 관용구가 자리 잡았습니다. 이 둘을 세트로 보는 게 Go 함수를 읽는 출발점입니다.
묶어서 볼 조합이 있습니다. 클로저 & 함수 타입과 클로저 패턴을 먼저 익히면 함수 옵션 패턴이 왜 가변 인자와 함수 값을 결합하는지 바로 이해됩니다. 그 구조를 그대로 요청 처리에 옮긴 것이 미들웨어 패턴이고요. 에러 쪽은 에러 래핑 (fmt.Errorf %w)으로 맥락을 쌓고, 커스텀 에러 타입으로 판단 근거를 담고, errors.Is와 errors.As로 그 껍질을 벗겨 내는 3단 구성입니다. 제네릭 편들은 마지막에 읽어야 값이 있습니다.
defer의 실행 시점은 블록이 아니라 함수 종료입니다. 루프 안에서 파일을 열고 defer f.Close()를 적으면 핸들이 반복 횟수만큼 열린 채 함수가 끝날 때까지 남습니다. 루프 본문을 별도 함수나 즉시 실행 클로저로 감싸는 게 정석입니다. 인자 평가 시점도 다릅니다. defer 줄의 인자는 그 줄에서 바로 평가되고 호출만 미뤄지므로, 나중에 바뀔 값을 넘기려면 클로저로 감싸야 합니다. 이 두 가지가 defer 심화 편의 핵심입니다.
01함수 — 다중 반환값 & Named Return
Go 함수의 핵심 특징: 여러 값을 반환하고, 에러를 마지막 반환값으로 처리합니다.
Go code
package main
import (
"errors"
"fmt"
"strconv"
)
// 다중 반환값 — Go의 관용구
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("0으로 나눌 수 없습니다")
}
return a / b, nil
}
// Named return values
func minMax(nums []int) (min, max int) {
min, max = nums[0], nums[0]
for _, n := range nums[1:] {
if n < min { min = n }
if n > max { max = n }
}
return // naked return
}
// 가변 인자
func sum(nums ...int) int {
total := 0
for _, n := range nums { total += n }
return total
}
// 함수를 값으로 (일급 함수)
func apply(fn func(int) int, vals []int) []int {
result := make([]int, len(vals))
for i, v := range vals {
result[i] = fn(v)
}
return result
}
func main() {
// 에러 처리 패턴
result, err := divide(10, 3)
if err != nil {
fmt.Println("에러:", err)
} else {
fmt.Printf("%.4f
", result)
}
_, err = divide(5, 0)
if err != nil { fmt.Println(err) }
// Named return
lo, hi := minMax([]int{3, 1, 4, 1, 5, 9, 2, 6})
fmt.Println("min:", lo, "max:", hi)
// 가변 인자
fmt.Println(sum(1, 2, 3, 4, 5))
nums := []int{1, 2, 3}
fmt.Println(sum(nums...)) // 슬라이스 전개
// 고차 함수
doubled := apply(func(n int) int { return n * 2 }, nums)
fmt.Println(doubled)
_ = strconv.Itoa(0)
}Named return은 짧은 함수에서만 사용하세요. 긴 함수에서 naked return은 코드를 읽기 어렵게 만듭니다.
_, err := fn()에서 에러를 무시하는 것(_)은 위험합니다. golangci-lint의 errcheck 룰로 이런 패턴을 잡을 수 있습니다.
02defer & panic & recover
defer로 리소스를 안전하게 해제하고, recover로 패닉을 복구합니다.
Go code
package main
import "fmt"
// defer — 함수 종료 시 실행 (LIFO 순서)
func readFile(name string) {
fmt.Println("파일 열기:", name)
defer fmt.Println("파일 닫기:", name) // 나중에 실행
fmt.Println("파일 읽기")
// 어떤 경로로 반환되어도 defer는 실행됨
}
// 여러 defer — LIFO (스택) 순서
func multiDefer() {
for i := 0; i < 3; i++ {
i := i // 루프 변수 캡처
defer fmt.Println("defer:", i)
}
fmt.Println("함수 실행")
}
// panic & recover
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("패닉 복구: %v", r)
}
}()
result = a / b // b==0이면 패닉
return
}
// 실용적 패턴 — 뮤텍스 해제
func processWithLock() {
// mu.Lock()
defer fmt.Println("Lock 해제") // mu.Unlock()
fmt.Println("임계 구역 실행")
}
func main() {
readFile("data.txt")
fmt.Println("--- multiDefer ---")
multiDefer() // 함수실행, defer:2, defer:1, defer:0
fmt.Println("--- safeDivide ---")
r, err := safeDivide(10, 2)
fmt.Printf("10/2 = %d, err = %v
", r, err)
r, err = safeDivide(10, 0)
fmt.Printf("10/0 = %d, err = %v
", r, err)
processWithLock()
}defer는 등록 시점의 인자값을 캡처합니다. 루프 내 defer 변수는 클로저 문제가 생길 수 있으니 i := i로 새 변수를 만드세요.
recover()는 defer 함수 내부에서만 동작합니다. 일반 함수에서 호출하면 nil을 반환합니다.
03클로저 & 함수 타입
Go의 클로저로 상태를 캡슐화하고, 함수를 일급 값으로 활용합니다.
Go code
package main
import "fmt"
// 클로저 — 외부 변수 캡처
func makeCounter(start int) func() int {
count := start
return func() int {
count++
return count
}
}
// 클로저로 미들웨어 패턴
type Handler func(string) string
func withLogging(h Handler) Handler {
return func(req string) string {
fmt.Printf("[LOG] 요청: %s
", req)
resp := h(req)
fmt.Printf("[LOG] 응답: %s
", resp)
return resp
}
}
func withAuth(h Handler) Handler {
return func(req string) string {
if req == "" {
return "401 Unauthorized"
}
return h(req)
}
}
// 함수 슬라이스
func pipeline(fns ...func(int) int) func(int) int {
return func(x int) int {
for _, fn := range fns {
x = fn(x)
}
return x
}
}
func main() {
// 카운터 클로저
c1 := makeCounter(0)
c2 := makeCounter(10)
fmt.Println(c1(), c1(), c1()) // 1 2 3
fmt.Println(c2(), c2()) // 11 12 (독립적)
// 미들웨어 체이닝
handler := func(req string) string {
return "200 OK: " + req
}
wrapped := withLogging(withAuth(handler))
fmt.Println(wrapped("GET /api/users"))
fmt.Println(wrapped(""))
// 파이프라인
double := func(n int) int { return n * 2 }
addOne := func(n int) int { return n + 1 }
square := func(n int) int { return n * n }
pipe := pipeline(double, addOne, square)
fmt.Println(pipe(3)) // (3*2+1)^2 = 49
}Go 클로저는 변수를 참조로 캡처합니다. 고루틴과 클로저를 함께 쓸 때는 루프 변수를 별도 변수에 복사하세요.
루프에서 고루틴 + 클로저 사용 시 클래식 버그: for i := range s { go func() { fmt.Println(i) }() }는 모두 같은 i를 출력합니다.
04slices 패키지 (Go 1.21+)
slices.Sort, slices.Contains 등 표준 슬라이스 유틸
Go code
<span class="cm">// slices 패키지 (Go 1.21+) 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("slices 패키지 (Go 1.21+)") }GO 공식 문서를 함께 참고하세요.
자주 발생하는 실수에 주의하세요.
05제네릭 함수 & 타입 제약
Go 1.18+의 제네릭은 타입 파라미터로 코드 재사용성을 높입니다. constraints 패키지의 인터페이스로 타입을 제약하고, ~ 틸드로 기반 타입(underlying type)까지 허용할 수 있습니다.
Go code
package main
import (
"cmp"
"fmt"
"golang.org/x/exp/constraints"
)
// 1. 기본 제네릭 함수
func Map[T any, U any](slice []T, fn func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}
func Filter[T any](slice []T, pred func(T) bool) []T {
var result []T
for _, v := range slice {
if pred(v) {
result = append(result, v)
}
}
return result
}
// 2. 타입 제약 인터페이스
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
// 3. 커스텀 타입도 ~ 틸드로 허용
type Score int
// 4. 제네릭 구조체
type Pair[T, U any] struct {
First T
Second U
}
func NewPair[T, U any](first T, second U) Pair[T, U] {
return Pair[T, U]{First: first, Second: second}
}
// 5. cmp.Ordered 활용 (Go 1.21+)
func MaxOf[T cmp.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
func main() {
// Map & Filter
nums := []int{1, 2, 3, 4, 5}
doubled := Map(nums, func(n int) int { return n * 2 })
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
fmt.Println("2배:", doubled) // [2 4 6 8 10]
fmt.Println("짝수:", evens) // [2 4]
// Number 제약
scores := []Score{85, 92, 78, 95}
fmt.Println("점수 합:", Sum(scores)) // Score 타입도 OK
// 제네릭 Pair
pair := NewPair("이름", 42)
fmt.Printf("Pair: %s=%d\n", pair.First, pair.Second)
// MaxOf
fmt.Println("최댓값:", MaxOf(3.14, 2.71))
}any는 interface{}의 별칭이며, comparable은 == 연산이 가능한 타입을 의미합니다. 가능하면 cmp.Ordered 같은 표준 제약을 활용하세요.
제네릭 타입 파라미터에 ~int 틸드를 빼면 커스텀 타입(type Score int)이 제약을 만족하지 못합니다. 기반 타입을 허용하려면 반드시 ~를 사용하세요.
06가변 인자 함수
...으로 가변 개수의 인자를 받는 함수를 정의합니다. 슬라이스를 펼쳐서 전달할 수도 있습니다.
Go code
package main
import "fmt"
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func wrap(prefix string, values ...any) string {
return fmt.Sprintf("[%s] %v", prefix, values)
}
func main() {
fmt.Println(sum(1, 2, 3)) // 6
fmt.Println(sum()) // 0
// 슬라이스를 가변 인자로 펼치기
nums := []int{10, 20, 30}
fmt.Println(sum(nums...)) // 60
fmt.Println(wrap("INFO", "서버", "시작됨"))
}가변 인자는 함수 내부에서 슬라이스로 취급됩니다. append(slice, elems...)도 같은 원리입니다.
가변 인자는 반드시 매개변수 목록의 마지막에 위치해야 합니다. func(nums ...int, s string)은 컴파일 에러입니다.
07defer 심화
defer는 LIFO(후입선출) 순서로 함수 종료 시 실행됩니다. 리소스 정리와 잠금 해제에 필수적입니다.
Go code
package main
import (
"fmt"
"sync"
)
func deferOrder() {
fmt.Println("시작")
defer fmt.Println("첫 번째 defer")
defer fmt.Println("두 번째 defer")
defer fmt.Println("세 번째 defer") // 가장 먼저 실행
fmt.Println("끝")
}
// 실전: 뮤텍스 잠금 해제
type SafeMap struct {
mu sync.Mutex
m map[string]int
}
func (s *SafeMap) Set(key string, val int) {
s.mu.Lock()
defer s.mu.Unlock() // 함수 종료 시 자동 해제
s.m[key] = val
}
func main() {
deferOrder()
// 출력: 시작, 끝, 세 번째, 두 번째, 첫 번째
sm := &SafeMap{m: make(map[string]int)}
sm.Set("count", 42)
fmt.Println(sm.m)
}defer는 Lock/Unlock, Open/Close 쌍을 가까이 배치하여 실수를 방지합니다. 잠금 직후 바로 defer Unlock()을 작성하세요.
루프 안에서 defer를 사용하면 루프가 끝나야 실행됩니다. 리소스가 누적될 수 있으므로 루프 내에서는 즉시 해제하거나 별도 함수로 분리하세요.
08클로저 패턴
클로저는 외부 변수를 캡처하는 함수입니다. 상태를 유지하는 카운터, 팩토리, 메모이제이션 등에 활용됩니다.
Go code
package main
import "fmt"
// 카운터 팩토리
func counter(start int) func() int {
n := start
return func() int {
n++
return n
}
}
// 메모이제이션
func memoize(fn func(int) int) func(int) int {
cache := map[int]int{}
return func(n int) int {
if v, ok := cache[n]; ok {
return v
}
cache[n] = fn(n)
return cache[n]
}
}
func main() {
next := counter(0)
fmt.Println(next(), next(), next()) // 1 2 3
fib := memoize(func(n int) int {
if n <= 1 { return n }
return n * (n - 1) // 단순 예시
})
fmt.Println(fib(10)) // 90
}클로저는 캡처한 변수의 참조를 보유합니다. 고루틴에서 클로저를 사용할 때 루프 변수 캡처에 주의하세요.
루프에서 고루틴과 클로저를 함께 사용할 때 루프 변수를 직접 캡처하면 마지막 값만 참조됩니다. Go 1.22+에서는 루프 변수가 매 반복 새로 생성되어 이 문제가 해결되었습니다.
09함수 옵션 패턴
Functional Options 패턴으로 유연한 설정을 제공합니다. 생성자의 매개변수가 많을 때 가독성과 확장성을 높입니다.
Go code
package main
import "fmt"
type Server struct {
Host string
Port int
TLS bool
Timeout int
}
type Option func(*Server)
func WithPort(port int) Option {
return func(s *Server) { s.Port = port }
}
func WithTLS() Option {
return func(s *Server) { s.TLS = true }
}
func WithTimeout(sec int) Option {
return func(s *Server) { s.Timeout = sec }
}
func NewServer(host string, opts ...Option) *Server {
s := &Server{Host: host, Port: 8080, Timeout: 30}
for _, opt := range opts {
opt(s)
}
return s
}
func main() {
s := NewServer("localhost",
WithPort(9090),
WithTLS(),
WithTimeout(60),
)
fmt.Printf("%+v\n", s)
}함수 옵션 패턴은 기본값을 유지하면서 필요한 것만 변경할 수 있습니다. Rob Pike가 제안한 Go의 대표적 관용구입니다.
옵션 함수에서 유효성 검사를 하지 않으면 잘못된 설정이 통과됩니다. 옵션이 에러를 반환하는 변형(func(*Server) error)을 고려하세요.
10미들웨어 패턴
함수를 감싸는 미들웨어 패턴으로 로깅, 인증, 복구 등 횡단 관심사를 분리합니다.
Go code
package main
import (
"fmt"
"time"
)
type Handler func(req string) string
type Middleware func(Handler) Handler
func logging(next Handler) Handler {
return func(req string) string {
start := time.Now()
result := next(req)
fmt.Printf("[LOG] %s → %v\n", req, time.Since(start))
return result
}
}
func recover_(next Handler) Handler {
return func(req string) (result string) {
defer func() {
if r := recover(); r != nil {
result = fmt.Sprintf("ERROR: %v", r)
}
}()
return next(req)
}
}
func chain(h Handler, mws ...Middleware) Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
func main() {
handler := func(req string) string { return "OK: " + req }
wrapped := chain(handler, logging, recover_)
fmt.Println(wrapped("/api/users"))
}미들웨어 체인은 역순으로 적용되므로 chain(h, A, B, C)는 A(B(C(h)))가 됩니다. 실행 순서는 A → B → C → h입니다.
미들웨어에서 next를 호출하지 않으면 체인이 중단됩니다. 의도적 중단(인증 실패 등)이 아니라면 반드시 next를 호출하세요.
11에러 래핑 (fmt.Errorf %w)
fmt.Errorf의 %w 동사로 에러를 래핑하여 원인 체인을 유지합니다. errors.Is와 errors.As로 검사합니다.
Go code
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func findUser(id int) error {
if id <= 0 {
return fmt.Errorf("findUser(id=%d): %w", id, ErrNotFound)
}
return nil
}
func getProfile(id int) error {
if err := findUser(id); err != nil {
return fmt.Errorf("getProfile: %w", err)
}
return nil
}
func main() {
err := getProfile(-1)
fmt.Println(err)
// getProfile: findUser(id=-1): not found
// 에러 체인 검사
fmt.Println(errors.Is(err, ErrNotFound)) // true
// Unwrap
fmt.Println(errors.Unwrap(err))
}%w는 에러를 래핑하고, %v는 단순 문자열로 변환합니다. 에러 체인을 유지하려면 반드시 %w를 사용하세요.
fmt.Errorf에 %w를 두 번 이상 사용하면 Go 1.20 미만에서는 컴파일 에러입니다. Go 1.20+에서는 errors.Join을 사용하세요.
12커스텀 에러 타입
error 인터페이스를 구현하는 커스텀 타입으로 구조화된 에러 정보를 전달합니다.
Go code
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("검증 실패 [%s]: %s", e.Field, e.Message)
}
func validateAge(age int) error {
if age < 0 || age > 150 {
return &ValidationError{
Field: "age",
Message: fmt.Sprintf("유효하지 않은 나이: %d", age),
}
}
return nil
}
func main() {
err := validateAge(-5)
fmt.Println(err)
// errors.As로 구체적 타입 추출
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("필드: %s, 메시지: %s\n", ve.Field, ve.Message)
}
}커스텀 에러 타입은 포인터 리시버로 Error()를 구현하세요. errors.As에서 포인터 비교가 올바르게 동작합니다.
errors.As의 두 번째 인자는 포인터의 포인터여야 합니다. var ve ValidationError가 아닌 var ve *ValidationError로 선언하세요.
13errors.Is와 errors.As
errors.Is는 에러 체인에서 특정 에러를 찾고, errors.As는 특정 타입을 추출합니다.
Go code
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
func main() {
// errors.Is — 센티널 에러 비교
_, err := os.Open("없는파일.txt")
if errors.Is(err, fs.ErrNotExist) {
fmt.Println("파일이 존재하지 않습니다")
}
// errors.As — 타입 추출
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
fmt.Println("경로:", pathErr.Path)
fmt.Println("작업:", pathErr.Op)
}
// 래핑된 에러에서도 동작
wrapped := fmt.Errorf("설정 로드 실패: %w", err)
fmt.Println(errors.Is(wrapped, fs.ErrNotExist)) // true
var pe *fs.PathError
fmt.Println(errors.As(wrapped, &pe)) // true
}errors.Is는 래핑된 에러 체인을 따라가며 비교합니다. == 대신 항상 errors.Is를 사용하세요.
err == ErrNotFound처럼 직접 비교하면 래핑된 에러를 감지할 수 없습니다. 항상 errors.Is를 사용하세요.
14메서드 세트와 인터페이스 충족
값 타입과 포인터 타입의 메서드 세트가 다릅니다. 인터페이스 충족 여부에 직접 영향을 미칩니다.
Go code
package main
import "fmt"
type Writer interface {
Write(data string)
}
type Logger struct {
Prefix string
}
// 포인터 리시버 메서드
func (l *Logger) Write(data string) {
fmt.Printf("[%s] %s\n", l.Prefix, data)
}
func process(w Writer) {
w.Write("처리 완료")
}
func main() {
l := &Logger{Prefix: "APP"}
process(l) // OK: *Logger는 Writer 충족
// l2 := Logger{Prefix: "APP"}
// process(l2) // 컴파일 에러!
// Logger 값은 *Logger의 메서드 세트를 포함하지 않음
// 값 리시버 메서드는 값과 포인터 모두 충족
// 포인터 리시버 메서드는 포인터만 충족
}값 리시버로 정의하면 값과 포인터 모두 인터페이스를 충족합니다. 포인터 리시버는 포인터만 충족하므로 주의하세요.
포인터 리시버 메서드를 가진 타입을 값으로 인터페이스에 전달하면 컴파일 에러가 발생합니다. 항상 &로 포인터를 전달하세요.
15인터페이스 합성
작은 인터페이스를 합성하여 큰 인터페이스를 만듭니다. Go의 "작은 인터페이스 원칙"의 핵심 패턴입니다.
Go code
package main
import "fmt"
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
type Closer interface {
Close() error
}
// 인터페이스 합성
type ReadWriter interface {
Reader
Writer
}
type ReadWriteCloser interface {
Reader
Writer
Closer
}
// 구현
type Buffer struct {
data []byte
}
func (b *Buffer) Read(p []byte) (int, error) {
n := copy(p, b.data)
return n, nil
}
func (b *Buffer) Write(p []byte) (int, error) {
b.data = append(b.data, p...)
return len(p), nil
}
func main() {
var rw ReadWriter = &Buffer{}
rw.Write([]byte("Hello"))
buf := make([]byte, 5)
rw.Read(buf)
fmt.Println(string(buf))
}Go 표준 라이브러리는 1-2개 메서드의 작은 인터페이스를 선호합니다. io.Reader, io.Writer가 대표적입니다.
너무 큰 인터페이스를 정의하면 구현하기 어렵고 모킹도 힘듭니다. 필요한 메서드만 포함하는 작은 인터페이스를 합성하세요.
16제네릭 함수 심화
복수 타입 파라미터와 제약을 조합하여 타입 안전한 유틸리티 함수를 작성합니다.
Go code
package main
import (
"cmp"
"fmt"
"slices"
)
// 복수 타입 파라미터
func GroupBy[T any, K comparable](items []T, key func(T) K) map[K][]T {
groups := make(map[K][]T)
for _, item := range items {
k := key(item)
groups[k] = append(groups[k], item)
}
return groups
}
// cmp.Ordered 활용 (Go 1.21+)
func Clamp[T cmp.Ordered](val, min, max T) T {
if val < min { return min }
if val > max { return max }
return val
}
func main() {
words := []string{"go", "rust", "go", "python", "rust", "go"}
groups := GroupBy(words, func(s string) string { return s })
for k, v := range groups {
fmt.Printf("%s: %d개\n", k, len(v))
}
fmt.Println(Clamp(15, 0, 10)) // 10
fmt.Println(Clamp(-5, 0, 10)) // 0
// slices 패키지 활용 (Go 1.21+)
nums := []int{3, 1, 4, 1, 5}
slices.Sort(nums)
fmt.Println(nums) // [1 1 3 4 5]
}Go 1.21의 slices와 maps 패키지는 제네릭 기반 유틸리티를 제공합니다. 직접 구현하기 전에 표준 라이브러리를 확인하세요.
제네릭 함수에서 타입 파라미터가 너무 많으면 가독성이 떨어집니다. 2-3개를 넘기면 별도 타입이나 인터페이스로 분리하세요.
17제네릭 맵/필터/리듀스
함수형 프로그래밍의 핵심 패턴인 Map, Filter, Reduce를 제네릭으로 구현합니다.
Go code
package main
import "fmt"
func Map[T, U any](s []T, fn func(T) U) []U {
r := make([]U, len(s))
for i, v := range s {
r[i] = fn(v)
}
return r
}
func Filter[T any](s []T, pred func(T) bool) []T {
var r []T
for _, v := range s {
if pred(v) { r = append(r, v) }
}
return r
}
func Reduce[T, U any](s []T, init U, fn func(U, T) U) U {
acc := init
for _, v := range s {
acc = fn(acc, v)
}
return acc
}
func main() {
nums := []int{1, 2, 3, 4, 5}
doubled := Map(nums, func(n int) int { return n * 2 })
fmt.Println(doubled) // [2 4 6 8 10]
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
fmt.Println(evens) // [2 4]
sum := Reduce(nums, 0, func(acc, n int) int { return acc + n })
fmt.Println(sum) // 15
}Go 1.23+에서는 iter 패키지와 range-over-func로 더 자연스럽게 함수형 패턴을 사용할 수 있습니다.
Map/Filter 체인을 길게 이으면 매번 새 슬라이스를 할당합니다. 성능이 중요하면 단일 루프로 결합하세요.
18콜백 패턴
함수를 인자로 전달하여 동작을 커스터마이징합니다. 이벤트 처리, 정렬 기준, 필터링 등에 활용됩니다.
Go code
package main
import (
"fmt"
"slices"
)
type EventHandler func(event string, data any)
type Emitter struct {
handlers map[string][]EventHandler
}
func NewEmitter() *Emitter {
return &Emitter{handlers: make(map[string][]EventHandler)}
}
func (e *Emitter) On(event string, h EventHandler) {
e.handlers[event] = append(e.handlers[event], h)
}
func (e *Emitter) Emit(event string, data any) {
for _, h := range e.handlers[event] {
h(event, data)
}
}
func main() {
em := NewEmitter()
em.On("login", func(evt string, data any) {
fmt.Printf("[%s] 사용자: %v\n", evt, data)
})
em.Emit("login", "Gopher")
// 콜백으로 정렬 기준 지정
names := []string{"Charlie", "Alice", "Bob"}
slices.SortFunc(names, func(a, b string) int {
return len(a) - len(b)
})
fmt.Println(names) // [Bob Alice Charlie]
}콜백 함수의 시그니처를 type으로 정의하면 가독성이 좋아집니다. type Handler func(string) error처럼 사용하세요.
콜백에서 패닉이 발생하면 호출자까지 전파됩니다. 외부 콜백을 실행할 때는 recover로 보호하세요.
19함수형 프로그래밍 패턴
Go에서 함수형 프로그래밍 패턴을 활용합니다. 커링, 합성, 파이프라인을 구현합니다.
Go code
package main
import (
"fmt"
"strings"
)
// 함수 합성
func Compose[T any](fns ...func(T) T) func(T) T {
return func(v T) T {
for i := len(fns) - 1; i >= 0; i-- {
v = fns[i](v)
}
return v
}
}
// 파이프 (왼쪽에서 오른쪽)
func Pipe[T any](fns ...func(T) T) func(T) T {
return func(v T) T {
for _, fn := range fns {
v = fn(v)
}
return v
}
}
func main() {
trim := strings.TrimSpace
upper := strings.ToUpper
addBang := func(s string) string { return s + "!" }
transform := Pipe(trim, upper, addBang)
fmt.Println(transform(" hello ")) // HELLO!
// Compose는 역순
transform2 := Compose(addBang, upper, trim)
fmt.Println(transform2(" world ")) // WORLD!
}Pipe는 읽는 순서(왼→오)대로 실행되어 직관적입니다. Compose는 수학적 합성(오→왼) 순서입니다.
과도한 함수형 스타일은 Go의 관용구와 맞지 않을 수 있습니다. 간단한 루프가 더 읽기 좋은 경우가 많습니다.
20제네릭 타입 제약 심화
유니온 타입, 메서드 제약, ~ 연산자를 조합하여 정교한 타입 제약을 만듭니다.
Go code
package main
import "fmt"
// 유니온 + 틸드 조합
type Numeric interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64
}
// 메서드 제약
type Stringable interface {
~int | ~string
String() string
}
func Abs[T Numeric](n T) T {
if n < 0 { return -n }
return n
}
func MinMax[T Numeric](nums []T) (T, T) {
min, max := nums[0], nums[0]
for _, n := range nums[1:] {
if n < min { min = n }
if n > max { max = n }
}
return min, max
}
type Celsius float64
type Fahrenheit float64
func main() {
fmt.Println(Abs(-42)) // 42
fmt.Println(Abs(Celsius(-10))) // 10
min, max := MinMax([]int{3, 1, 4, 1, 5, 9})
fmt.Printf("min=%d, max=%d\n", min, max)
}표준 라이브러리의 cmp.Ordered는 이미 모든 정렬 가능 타입을 포함합니다. 커스텀 제약은 특수한 경우에만 만드세요.
유니온 타입 제약에 ~를 빠뜨리면 기저 타입이 같은 커스텀 타입(type Celsius float64)이 제외됩니다.
정리하며
- defer는 블록이 아니라 함수 단위로 실행되므로 루프 안 사용은 함수로 감쌉니다
- defer에 넘긴 인자는 선언 시점에 평가되고 호출만 지연됩니다
- 에러는 %w로 감싸 맥락을 남기고 errors.Is/As로 판별해야 결합도가 낮아집니다
- 선택 인자가 늘어나면 생성자 오버로드 대신 함수 옵션 패턴으로 확장합니다
더 깊이 들어가고 싶다면 Go 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.