일급 함수와 클로저
이 챕터에서 다루는 것
Go에서 함수는 값이다. 변수에 담고, 인자로 넘기고, 반환할 수 있다. 여기서 나오는 것이 클로저이고, 클로저가 값이 아니라 변수를 붙잡는다는 사실에서 Go 역사상 가장 유명한 함정이 나왔다. Go 1.22가 그 함정을 언어 차원에서 없앴는데, 그 전에 쓰인 코드는 아직 곳곳에 남아 있으므로 둘 다 읽을 수 있어야 한다.
함수는 값이다
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에서 "전략을 주입하는" 가장 가벼운 방법이다.
클로저 — 함수가 바깥 변수를 붙잡는다
함수 리터럴이 자기 바깥의 변수를 참조하면 클로저가 된다. 그 변수는 함수가 살아 있는 동안 함께 살아남는다.
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이 두 개 생긴다. 클로저가 붙잡는 것은 "그 호출에서 만들어진 변수"다.- 여러 클로저가 같은 변수를 공유할 수 있다.
accountOps의deposit과balance가 같은amount를 본다. 이게 객체지향 언어의 "필드를 공유하는 메서드"와 사실상 같은 일을 한다. - 마지막 세 줄이 핵심이다.
show를 만든 시점의x는 1이지만, 호출 시점에는 42다. 클로저는 변수를 붙잡지 값을 복사하지 않는다.
:::info 클로저가 붙잡은 변수는 힙으로 간다
counter의 n은 함수가 반환된 뒤에도 살아야 하므로 스택에 둘 수 없다. 컴파일러의
이스케이프 분석이 이를 판단해 힙에 할당한다. 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.mod에 go 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도 마찬가지다.
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.mod의 go 지시자로 제어된다. 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 fix의 forvar 모더나이저가 자동으로 지워 준다.
go fix -diff ./...
go vet의 loopclosure 분석기도 아직 남아 있다. 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.mod의go지시자로 결정된다. 옛 프로젝트를 열면 반드시 확인한다. - 옛 코드의
v := v와func(v int){...}(v)는 이제 불필요한 방어 코드다.go fix가 지운다. - 루프 밖에서 선언한 변수는 여전히 공유된다. 바뀐 것은 for 문의 변수 수명뿐이다.
연습문제
-
counter()를 고쳐서 증가 폭을 인자로 받는counterBy(step int) func() int를 만들어 보자. 그다음reset기능까지 있는 버전을 만들어 보자. 힌트: 함수 두 개를 반환하거나 구조체에 담는다. -
06-loopvar의 셋째 블록(var j int)이3 3 3을 출력하는 이유를 한 문장으로 쓰고,0 1 2가 나오도록 최소한의 수정을 해 보자. -
임시 모듈을 하나 만들어
go.mod의go지시자를1.21로 낮춘 뒤, 루프 변수를 캡처하는 클로저를 돌려 보자. 출력이 어떻게 달라지는가? 그다음go vet이 무엇을 보고하는지 확인하고,go get go@1.26.5로 올린 뒤 다시 실행해 비교하자.