Table of contents
함수 선언의 기본
Go에서 함수는 func 키워드로 선언한다. 매개변수 타입은 이름 뒤에 오고, 반환 타입은 매개변수 목록 뒤에 적는다.
package main
import "fmt"
func add(a int, b int) int {
return a + b
}
func main() {
result := add(3, 5)
fmt.Println(result) // 8
}
같은 타입의 매개변수가 연속되면 마지막 하나만 타입을 적어도 된다.
func add(a, b int) int {
return a + b
}
C나 Java에서는 타입이 이름 앞에 오는데(int add(int a, int b)), Go는 반대다. 처음에는 어색할 수 있지만, 복잡한 함수 시그니처에서는 오히려 Go 방식이 읽기 편하다.
다중 반환값
Go 함수의 가장 큰 특징은 값을 여러 개 반환할 수 있다는 것이다. Python의 튜플 반환과 비슷하지만, Go에서는 이게 언어의 핵심 패턴이다.
package main
import (
"errors"
"fmt"
)
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("0으로 나눌 수 없다")
}
return a / b, nil
}
func main() {
result, err := divide(10, 3)
if err != nil {
fmt.Println("에러:", err)
return
}
fmt.Printf("결과: %.2f\n", result) // 결과: 3.33
}
이 패턴이 Go 코드 전체를 관통한다. 함수가 (값, error)를 반환하고, 호출하는 쪽에서 에러를 확인하는 구조다. Java의 예외(exception)를 던지는 방식과는 정반대의 철학이다. 에러를 무시할 수 없게 만드는 것이 핵심인데, 이건 4편에서 더 깊이 다룬다.
네임드 반환
반환값에 이름을 붙일 수 있다. 함수 시작 시점에 해당 타입의 zero value로 초기화되고, return만 적으면 그 변수들이 반환된다.
package main
import "fmt"
func swap(a, b string) (first, second string) {
first = b
second = a
return // first, second가 반환됨
}
func main() {
x, y := swap("hello", "world")
fmt.Println(x, y) // world hello
}
짧은 함수에서는 깔끔해 보이지만, 함수가 길어지면 어떤 값이 반환되는지 추적하기 어려워진다. Go 커뮤니티에서도 네임드 반환은 짧은 함수에서만 쓰라는 의견이 주류다.
다만 한 가지 확실히 유용한 경우가 있다. godoc에서 반환값의 의미를 문서화할 때다. (quotient, remainder int) 같은 선언은 그 자체로 문서 역할을 한다.
가변 인자
함수에 인자를 몇 개든 받을 수 있게 하려면 ...을 쓴다.
package main
import "fmt"
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func main() {
fmt.Println(sum(1, 2, 3)) // 6
fmt.Println(sum(10, 20, 30, 40)) // 100
// 슬라이스를 풀어서 전달
numbers := []int{5, 10, 15}
fmt.Println(sum(numbers...)) // 30
}
nums ...int는 함수 안에서 []int 슬라이스로 취급된다. 반대로 슬라이스를 가변 인자 함수에 넘기려면 슬라이스... 형태로 풀어줘야 한다.
가변 인자는 매개변수 목록의 마지막에만 올 수 있다. fmt.Println이 대표적인 가변 인자 함수다.
일급 함수
Go에서 함수는 일급 시민이다. 변수에 담을 수 있고, 다른 함수의 인자로 전달할 수 있으며, 반환값으로 돌려줄 수도 있다.
package main
import "fmt"
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 double(n int) int {
return n * 2
}
func main() {
nums := []int{1, 2, 3, 4}
doubled := apply(nums, double)
fmt.Println(doubled) // [2 4 6 8]
}
apply 함수는 슬라이스와 변환 함수를 받아서 각 요소에 적용한다. func(int) int가 함수 타입이다. 이런 패턴은 콜백, 미들웨어, 전략 패턴 등 다양한 곳에서 활용된다.
함수 타입이 길어지면 type으로 별칭을 만들 수 있다.
type transformer func(int) int
func apply(nums []int, fn transformer) []int {
// ...
}
이렇게 하면 시그니처가 훨씬 깔끔해진다.
익명 함수와 클로저
이름 없는 함수를 그 자리에서 바로 만들 수 있다. 선언과 동시에 호출하거나, 변수에 담아둘 수 있다.
package main
import "fmt"
func main() {
// 변수에 담기
greet := func(name string) string {
return "안녕, " + name
}
fmt.Println(greet("gopher")) // 안녕, gopher
// 선언과 동시에 호출
result := func(a, b int) int {
return a + b
}(3, 5)
fmt.Println(result) // 8
}
익명 함수가 진짜 힘을 발휘하는 건 클로저로 동작할 때다. 바깥 스코프의 변수를 캡처해서 계속 사용할 수 있다.
package main
import "fmt"
func counter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
next := counter()
fmt.Println(next()) // 1
fmt.Println(next()) // 2
fmt.Println(next()) // 3
// 새 카운터는 독립적
another := counter()
fmt.Println(another()) // 1
}
counter()가 반환한 함수는 바깥의 count 변수를 캡처하고 있다. 호출할 때마다 count가 증가하는데, 이 변수는 함수가 살아있는 한 사라지지 않는다. another는 별개의 클로저이므로 자기만의 count를 가진다.
이 패턴은 상태를 캡슐화하면서도 구조체까지 만들 필요가 없을 때 유용하다. HTTP 핸들러에서 설정값을 주입하거나, 테스트에서 목(mock) 함수를 만들 때 자주 활용된다.
한 가지 주의할 점이 있다. 루프 안에서 클로저를 만들 때 루프 변수를 캡처하면 의도치 않은 결과가 나올 수 있다.
package main
import "fmt"
func main() {
fns := make([]func(), 3)
for i := 0; i < 3; i++ {
fns[i] = func() {
fmt.Print(i, " ")
}
}
for _, fn := range fns {
fn()
}
// Go 1.22부터: 0 1 2
// Go 1.21 이하: 3 3 3
}
Go 1.22부터 루프 변수의 스코프가 반복마다 새로 생기도록 변경되어 이 문제가 해결됐다. 그 전에는 루프 변수를 지역 변수로 복사해야 했다. 오래된 Go 코드를 읽을 때 i := i라는 이상해 보이는 패턴이 보이면, 이 이유 때문이다.
다음 편에서는 Go의 에러 처리를 다룬다. error 인터페이스부터 defer, panic/recover까지, Go가 에러를 다루는 독특한 방식을 살펴보자.
-> 4편: 에러 처리
Loading comments...