본문으로 건너뛰기

일급 함수와 클로저

이 챕터에서 다루는 것

Go에서 함수는 값이다. 변수에 담고, 인자로 넘기고, 반환할 수 있다. 여기서 나오는 것이 클로저이고, 클로저가 값이 아니라 변수를 붙잡는다는 사실에서 Go 역사상 가장 유명한 함정이 나왔다. Go 1.22가 그 함정을 언어 차원에서 없앴는데, 그 전에 쓰인 코드는 아직 곳곳에 남아 있으므로 둘 다 읽을 수 있어야 한다.

함수는 값이다

examples/02-language-basics/06-func-types/main.go
package main

import (
"fmt"
"slices"
"strings"
)

// transform은 함수를 인자로 받는다. 매개변수 f의 타입이 func(string) string이다.
func transform(items []string, f func(string) string) []string {
out := make([]string, len(items))
for i, s := range items {
out[i] = f(s)
}
return out
}

// prefixer는 함수를 반환한다. 반환 타입 자체가 함수다.
func prefixer(prefix string) func(string) string {
return func(s string) string {
return prefix + s
}
}

// Middleware는 함수 타입에 이름을 붙인 것이다. 길어지는 시그니처를 정리한다.
type Middleware func(string) string

// chain은 미들웨어를 순서대로 적용하는 하나의 함수로 합친다.
func chain(ms ...Middleware) Middleware {
return func(s string) string {
for _, m := range ms {
s = m(s)
}
return s
}
}

func main() {
words := []string{"go", "rust", "zig"}

// 이름 있는 함수를 그대로 값으로 넘긴다.
fmt.Println(transform(words, strings.ToUpper))

// 익명 함수를 그 자리에서 넘긴다.
fmt.Println(transform(words, func(s string) string {
return s + "!"
}))

// 함수가 만들어 준 함수를 넘긴다.
fmt.Println(transform(words, prefixer("lang/")))

// 함수 타입은 값이므로 변수에 담고 비교(nil 여부)할 수 있다.
var f func(string) string
fmt.Println(f == nil)
f = prefixer(">> ")
fmt.Println(f("go"))

pipeline := chain(strings.ToUpper, prefixer("["), func(s string) string { return s + "]" })
fmt.Println(transform(words, pipeline))

// 표준 라이브러리도 함수를 인자로 받는다.
nums := []int{3, 1, 2}
slices.SortFunc(nums, func(a, b int) int { return b - a })
fmt.Println(nums)
}
go run ./06-func-types
[GO RUST ZIG]
[go! rust! zig!]
[lang/go lang/rust lang/zig]
true
>> go
[[GO] [RUST] [ZIG]]
[3 2 1]

몇 가지를 짚는다.

  • 함수 타입은 func(매개변수 타입) 반환 타입 형태로 쓴다. 매개변수 이름은 필요 없다.
  • type Middleware func(string) string 처럼 이름을 붙일 수 있다. 시그니처가 길어질 때 유용하고, 문서에서도 의도가 드러난다. http.HandlerFunc가 이 패턴이다.
  • 함수 값의 제로값은 nil 이다. nil 함수를 호출하면 패닉이 난다.
  • 함수끼리는 ==로 비교할 수 없다. nil과의 비교만 허용된다. 함수 동등성이 정의 불가능하기 때문이다.

slices.SortFunc처럼 표준 라이브러리 곳곳이 함수를 인자로 받는다. 정렬 기준을 함수로 넘기는 것이 Go에서 "전략을 주입하는" 가장 가벼운 방법이다.

클로저 — 함수가 바깥 변수를 붙잡는다

함수 리터럴이 자기 바깥의 변수를 참조하면 클로저가 된다. 그 변수는 함수가 살아 있는 동안 함께 살아남는다.

examples/02-language-basics/06-closures/main.go
package main

import "fmt"

// counter는 호출할 때마다 증가하는 함수를 만든다.
// n은 counter가 반환된 뒤에도 살아 있다. 클로저가 붙잡고 있기 때문이다.
func counter() func() int {
n := 0
return func() int {
n++
return n
}
}

// accountOps는 같은 변수를 공유하는 함수 두 개를 반환한다.
func accountOps(initial int) (deposit func(int), balance func() int) {
amount := initial
deposit = func(d int) { amount += d }
balance = func() int { return amount }
return deposit, balance
}

func main() {
c1 := counter()
c2 := counter()

fmt.Println(c1(), c1(), c1())
fmt.Println(c2()) // c2는 자기만의 n을 가진다

dep, bal := accountOps(100)
dep(50)
dep(25)
fmt.Println(bal())

// 클로저는 값이 아니라 변수를 붙잡는다.
x := 1
show := func() { fmt.Println("x =", x) }
x = 42
show()
}
go run ./06-closures
1 2 3
1
175
x = 42
  • counter()를 두 번 호출하면 서로 다른 n이 두 개 생긴다. 클로저가 붙잡는 것은 "그 호출에서 만들어진 변수"다.
  • 여러 클로저가 같은 변수를 공유할 수 있다. accountOpsdepositbalance가 같은 amount를 본다. 이게 객체지향 언어의 "필드를 공유하는 메서드"와 사실상 같은 일을 한다.
  • 마지막 세 줄이 핵심이다. show를 만든 시점의 x는 1이지만, 호출 시점에는 42다. 클로저는 변수를 붙잡지 값을 복사하지 않는다.

:::info 클로저가 붙잡은 변수는 힙으로 간다 countern은 함수가 반환된 뒤에도 살아야 하므로 스택에 둘 수 없다. 컴파일러의 이스케이프 분석이 이를 판단해 힙에 할당한다. go build -gcflags=-m으로 확인할 수 있고, 자세한 내용은 3-6에서 다룬다. :::

루프 변수 캡처 — Go 1.22가 바꾼 것

"클로저는 변수를 붙잡는다"와 "루프 변수는 하나뿐이다"가 만나면 무슨 일이 벌어질까. Go 1.21까지는 이랬다.

var fs []func()
for _, v := range []int{0, 1, 2} {
fs = append(fs, func() { fmt.Print(v, " ") })
}
for _, f := range fs {
f()
}

go.modgo 1.21이라고 적힌 모듈에서 이 코드를 돌리면 이렇게 나온다.

2 2 2

v라는 변수가 루프 전체에 하나뿐이었고, 세 클로저가 전부 그 하나를 붙잡았기 때문이다. 루프가 끝난 시점의 값은 2다. 이 동작은 Go 초창기부터 있던 것으로, FAQ와 위키에 별도 항목이 있을 만큼 흔한 실수였다.

고전적인 우회법은 같은 이름으로 다시 선언하는 것이었다.

for _, v := range []int{0, 1, 2} {
v := v // 반복마다 새로운 v를 만든다
fs = append(fs, func() { fmt.Print(v, " ") })
}

또는 익명 함수에 인자로 넘겼다.

for _, v := range []int{0, 1, 2} {
go func(v int) { fmt.Print(v, " ") }(v)
}

Go 1.22에서 이 문제가 언어 차원에서 사라졌다. 루프 변수가 반복마다 새로 선언되도록 사양이 바뀌었다. range 형태뿐 아니라 3절 for도 마찬가지다.

examples/02-language-basics/06-loopvar/main.go
package main

import (
"fmt"
"sync"
)

func main() {
// Go 1.22부터 루프 변수는 반복마다 새로 만들어진다.
// range 형태든 3절 형태든 마찬가지다.
var fs []func()
for i := range 3 {
fs = append(fs, func() { fmt.Print(i, " ") })
}
for _, f := range fs {
f()
}
fmt.Println()

var gs []func()
for i := 0; i < 3; i++ {
gs = append(gs, func() { fmt.Print(i, " ") })
}
for _, g := range gs {
g()
}
fmt.Println()

// 루프 밖에서 선언한 변수는 여전히 공유된다. 이게 1.21 이하의 동작이었다.
var hs []func()
var j int
for j = 0; j < 3; j++ {
hs = append(hs, func() { fmt.Print(j, " ") })
}
for _, h := range hs {
h()
}
fmt.Println()

// 고루틴에서도 각자 자기 값을 본다. 출력 순서는 무작위이므로 인덱스에 기록한다.
got := make([]int, 3)
var wg sync.WaitGroup
for i := range 3 {
wg.Go(func() { got[i] = i * 10 })
}
wg.Wait()
fmt.Println(got)
}
go run ./06-loopvar
0 1 2
0 1 2
3 3 3
[0 10 20]
  • 첫 두 줄이 0 1 2다. range든 3절 for든 반복마다 새 변수다.
  • 셋째 줄은 여전히 3 3 3이다. var j int로 루프 밖에서 선언했기 때문이다. 바뀐 것은 "for 문이 선언하는 변수"의 수명이지, 클로저의 동작이 아니다. 루프가 끝난 뒤 j는 조건이 거짓이 된 값, 즉 3이다.
  • 마지막 줄에서 고루틴이 각자 자기 i를 본다. wg.Go는 고루틴을 띄우고 Add/Done을 대신 처리해 주는 현재의 관용구다. 자세한 건 Part 7에서 다룬다.

버전 전환은 어떻게 일어나는가

이 변경은 go.modgo 지시자로 제어된다. go 1.22 이상이면 새 동작, go 1.21 이하면 옛 동작이다. 같은 툴체인(여기서는 go1.26.5)으로 빌드해도 그렇다. Go 1 호환성 약속을 지키면서 사양을 바꾸기 위한 장치다. 1-2에서 본 go 지시자의 역할이 바로 이것이다.

즉, 오래된 프로젝트를 열었을 때 어떤 동작인지 알려면 go.mod를 봐야 한다.

옛 코드를 읽는 법

앞으로 만날 코드에서 이런 줄을 보게 된다.

for _, item := range items {
item := item // 이제 불필요하다
go process(item)
}
for i := range n {
go func(i int) {
process(i)
}(i)
}

둘 다 틀린 코드가 아니다. 그냥 필요 없어진 방어 코드다. 의미를 바꾸지 않으므로 그대로 둬도 동작하고, 정리하고 싶으면 1-6에서 본 go fixforvar 모더나이저가 자동으로 지워 준다.

go fix -diff ./...

go vetloopclosure 분석기도 아직 남아 있다. go 1.21 이하 모듈에서 고루틴이 루프 변수를 캡처하면 잡아 준다.

main.go:14:14: loop variable v captured by func literal

go.mod가 1.22 이상이면 이 분석기는 아무것도 보고하지 않는다. 문제 자체가 사라졌기 때문이다.

흔히 하는 실수

1. 루프 밖 변수를 캡처한다

Go 1.22가 고친 것은 for 문이 선언한 변수뿐이다. 다음은 여전히 공유된다.

total := 0
for _, v := range nums {
total += v
fs = append(fs, func() { fmt.Println(total) }) // 전부 최종 합계를 본다
}

total을 반복마다 고정하고 싶으면 루프 안에서 새 변수를 만든다.

snapshot := total
fs = append(fs, func() { fmt.Println(snapshot) })

2. 고루틴이 결과를 같은 변수에 쓴다

var result int
for i := range 3 {
go func() { result = i }() // 경합. 무슨 값이 남을지 모른다
}

루프 변수는 이제 안전하지만 result는 아니다. 여러 고루틴이 같은 변수에 쓰는 것은 데이터 경합이다. 앞의 예제처럼 인덱스별로 다른 자리에 쓰거나 채널로 모아야 한다. Part 7에서 -race로 이런 경합을 잡는 법을 다룬다.

3. 클로저가 큰 값을 붙잡아 놓는다

클로저가 참조하는 변수는 클로저가 살아 있는 동안 해제되지 않는다. 거대한 슬라이스에서 값 하나만 필요한데 슬라이스 전체를 캡처하면, 그 슬라이스는 메모리에 남는다.

func handler(data []byte) func() int {
return func() int { return len(data) } // data 전체가 살아남는다
}

필요한 값만 미리 꺼내 두면 된다.

func handler(data []byte) func() int {
n := len(data)
return func() int { return n }
}

4. nil 함수를 호출한다

var f func()
f() // panic: runtime error: invalid memory address or nil pointer dereference

구조체 필드로 콜백을 받는 설계에서 특히 잘 걸린다. 호출 전에 if f != nil을 확인하거나, 제로값에 기본 동작을 넣어 두는 편이 낫다.

정리

  • 함수는 값이다. 변수에 담고, 인자로 넘기고, 반환한다. 제로값은 nil이다.
  • 함수끼리는 nil 비교만 가능하다.
  • 클로저는 값이 아니라 변수를 붙잡는다. 같은 변수를 여러 클로저가 공유할 수 있다.
  • Go 1.22부터 for 문이 선언한 변수는 반복마다 새로 만들어진다. range와 3절 for 둘 다 해당한다.
  • 이 동작은 go.modgo 지시자로 결정된다. 옛 프로젝트를 열면 반드시 확인한다.
  • 옛 코드의 v := vfunc(v int){...}(v)는 이제 불필요한 방어 코드다. go fix가 지운다.
  • 루프 밖에서 선언한 변수는 여전히 공유된다. 바뀐 것은 for 문의 변수 수명뿐이다.

연습문제

  1. counter()를 고쳐서 증가 폭을 인자로 받는 counterBy(step int) func() int를 만들어 보자. 그다음 reset 기능까지 있는 버전을 만들어 보자. 힌트: 함수 두 개를 반환하거나 구조체에 담는다.

  2. 06-loopvar의 셋째 블록(var j int)이 3 3 3을 출력하는 이유를 한 문장으로 쓰고, 0 1 2가 나오도록 최소한의 수정을 해 보자.

  3. 임시 모듈을 하나 만들어 go.modgo 지시자를 1.21로 낮춘 뒤, 루프 변수를 캡처하는 클로저를 돌려 보자. 출력이 어떻게 달라지는가? 그다음 go vet이 무엇을 보고하는지 확인하고, go get go@1.26.5로 올린 뒤 다시 실행해 비교하자.