함수
이 챕터에서 다루는 것
Go의 함수에는 오버로딩도, 기본 인자도, 이름 있는 인자도 없다. 대신 다중 반환값이라는 카드가 있고, Go의 에러 처리 전체가 그 위에 세워져 있다. 여기서는 선언 문법과 다중 반환값, 명명된 반환값의 득실, 가변 인자, 그리고 "모든 인자는 값으로 전달된다"는 규칙을 다룬다.
선언 문법
func add(a int, b int) int {
return a + b
}
타입이 이름 뒤에 온다. C 계열과 반대다. 이유는 복잡한 타입을 왼쪽에서 오른쪽으로 읽히게 하기 위해서다. C의 함수 포인터 선언이 왜 어려운지 떠올려 보면 이 선택이 이해된다.
연속된 매개변수의 타입이 같으면 마지막에 한 번만 적는다.
func add(a, b int) int
package main
import (
"errors"
"fmt"
)
// add는 매개변수 타입이 같을 때 뒤쪽에 한 번만 적는 형태를 보여 준다.
func add(a, b int) int {
return a + b
}
// divide는 값과 에러를 함께 반환한다. Go에서 가장 흔한 시그니처다.
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("0으로 나눌 수 없다")
}
return a / b, nil
}
// minMax는 반환값에 이름을 붙여 무엇이 무엇인지 드러낸다.
func minMax(xs []int) (lo, hi int, err error) {
if len(xs) == 0 {
return 0, 0, errors.New("빈 슬라이스")
}
lo, hi = xs[0], xs[0]
for _, x := range xs[1:] {
lo = min(lo, x)
hi = max(hi, x)
}
return lo, hi, nil
}
func main() {
fmt.Println(add(2, 3))
q, err := divide(7, 2)
if err != nil {
fmt.Println("에러:", err)
return
}
fmt.Println(q)
if _, err := divide(1, 0); err != nil {
fmt.Println("에러:", err)
}
lo, hi, err := minMax([]int{5, 3, 9, 1})
if err != nil {
fmt.Println("에러:", err)
return
}
fmt.Println(lo, hi)
// 필요 없는 반환값은 블랭크 식별자로 버린다.
_, hi2, _ := minMax([]int{2, 8})
fmt.Println(hi2)
}
go run ./05-basics
5
3.5
에러: 0으로 나눌 수 없다
1 9
8
없는 것들
| 다른 언어의 기능 | Go의 대응 |
|---|---|
| 오버로딩 | 이름을 다르게 짓는다 (Parse, ParseInt, ParseFloat) |
| 기본 인자 | 옵션 구조체나 함수형 옵션 패턴 |
| 이름 있는 인자 | 구조체를 인자로 받는다 |
| 예외 | 에러를 값으로 반환한다 |
오버로딩이 없는 게 처음에는 불편하지만, "이 호출이 어느 함수로 가는가"를 이름만 보고 알 수
있다는 이점이 있다. 표준 라이브러리의 strconv.ParseInt, ParseFloat, ParseBool이
그 결과물이다.
다중 반환값 — 예외 없는 세계의 기반
func divide(a, b float64) (float64, error)
Go에는 예외가 없다. 오류는 반환값이다. Java의 try/catch나 Python의 raise에
익숙하다면 이게 가장 큰 문화 충격이다.
q, err := divide(7, 2)
if err != nil {
return err
}
if err != nil 세 줄이 코드 곳곳에 반복되는 게 Go의 대표적인 비판 지점이다. Go 팀의
답은 "오류 처리 경로가 코드에 보이는 편이 낫다"이다. 예외는 어디로 튈지 모르는
비지역 점프이고, 어떤 함수가 어떤 예외를 던지는지는 시그니처만 봐서는 알 수 없다.
Go에서는 시그니처에 error가 있으면 실패할 수 있는 것이고, 없으면 아니다.
에러 자체의 설계는 Part 4에서 본격적으로 다룬다. 지금 지켜야 할 관례는 두 가지다.
error는 항상 마지막 반환값이다.- 에러를
_로 버리지 않는다. 무시해도 되는 경우가 없지는 않지만(예:strings.Builder의Write), 그럴 때는 왜 무시하는지 주석으로 남긴다.
반환값이 여러 개면 괄호로 묶는다. 필요 없는 값은 블랭크 식별자 _로 버린다.
_, hi2, _ := minMax(...)가 그 예다.
명명된 반환값
반환값에도 이름을 붙일 수 있다. func minMax(xs []int) (lo, hi int, err error) 형태다.
이름을 붙이면 두 가지 일이 벌어진다.
- 함수 시작 시점에 그 이름의 변수가 제로값으로 선언된다.
return에 값을 안 적어도 된다. 이를 벌거벗은 return(naked return) 이라 한다.
두 번째가 함정이다.
package main
import (
"fmt"
"strconv"
)
// parseAll은 벌거벗은 return을 쓴 나쁜 예다. 어떤 값이 나가는지 읽어 내기 어렵다.
func parseAll(items []string) (nums []int, err error) {
for _, s := range items {
var n int
n, err = strconv.Atoi(s)
if err != nil {
return // nums는 지금까지 채워진 상태 그대로 나간다
}
nums = append(nums, n)
}
return
}
// parseAllClear는 같은 일을 하지만 무엇을 반환하는지 매번 명시한다.
func parseAllClear(items []string) ([]int, error) {
nums := make([]int, 0, len(items))
for _, s := range items {
n, err := strconv.Atoi(s)
if err != nil {
return nil, fmt.Errorf("%q 파싱 실패: %w", s, err)
}
nums = append(nums, n)
}
return nums, nil
}
// withDefault는 명명된 반환값이 실제로 유용한 경우다.
// defer가 반환값을 손볼 수 있는 것은 이름이 있기 때문이다.
func withDefault(s string) (n int, err error) {
defer func() {
if err != nil {
n = -1
}
}()
return strconv.Atoi(s)
}
func main() {
nums, err := parseAll([]string{"1", "2", "x", "4"})
fmt.Println("parseAll: ", nums, err)
nums, err = parseAllClear([]string{"1", "2", "x", "4"})
fmt.Println("parseAllClear:", nums, err)
n, err := withDefault("abc")
fmt.Println("withDefault: ", n, err)
}
go run ./05-named-returns
parseAll: [1 2] strconv.Atoi: parsing "x": invalid syntax
parseAllClear: [] "x" 파싱 실패: strconv.Atoi: parsing "x": invalid syntax
withDefault: -1 strconv.Atoi: parsing "abc": invalid syntax
첫 줄이 문제다. parseAll은 에러가 났는데도 [1 2]를 함께 반환한다. 벌거벗은
return이 "지금 nums에 들어 있는 것"을 그대로 내보냈기 때문이다. 호출자가 에러를
확인하지 않으면 부분적으로 채워진 결과를 정상 결과로 오해한다.
parseAllClear는 return nil, ...이라고 명시했으니 그런 일이 없다. 게다가 에러에
%q 파싱 실패라는 맥락까지 붙었다.
명명된 반환값을 쓰는 기준은 이렇게 잡으면 된다.
- ✅ 문서화 목적:
(lo, hi int, err error)처럼 같은 타입이 여럿일 때 이름이 있으면go doc출력만으로 순서를 알 수 있다. 이름만 붙이고return에는 값을 적는다. - ✅
defer로 반환값을 조작할 때:withDefault가 그 경우다. 이름이 없으면defer가 반환값에 접근할 수 없다. 2-7에서 자세히 다룬다. - ❌ 타이핑을 줄이려고: 벌거벗은
return은 함수가 길어질수록 읽기 어려워진다. gofmt도, vet도 막지 않으니 스스로 자제해야 한다.
:::warning 명명된 반환값과 섀도잉
func f() (err error) {
if err := doSomething(); err != nil {
return // 여기서 반환되는 err는 안쪽 err가 아니라 제로값 nil이다
}
return nil
}
if err := ...가 새로운 err를 만들고, 벌거벗은 return은 바깥의 명명된 err를
내보낸다. 결과는 항상 nil이다. 명명된 반환값을 쓸 때는 같은 이름을 :=로 다시 선언하지
않도록 주의하자.
:::
가변 인자
마지막 매개변수에 ...을 붙이면 개수가 정해지지 않은 인자를 받는다. 함수 안에서는
슬라이스다.
package main
import (
"fmt"
"strings"
)
// sum은 int를 몇 개든 받는다. 함수 안에서 xs의 타입은 []int다.
func sum(xs ...int) int {
total := 0
for _, x := range xs {
total += x
}
return total
}
// join은 가변 인자 앞에 일반 매개변수를 둘 수 있음을 보여 준다.
// 가변 인자는 반드시 마지막 하나여야 한다.
func join(sep string, parts ...string) string {
return strings.Join(parts, sep)
}
func main() {
fmt.Println(sum())
fmt.Println(sum(1, 2, 3))
// 슬라이스를 넘길 때는 ... 로 전개한다.
nums := []int{4, 5, 6}
fmt.Println(sum(nums...))
fmt.Println(join("-", "a", "b", "c"))
// 인자가 하나도 없으면 xs는 nil이다. 길이 0 슬라이스와는 다르다.
fmt.Println(isNil())
fmt.Println(isNil([]int{}...))
fmt.Println(isNil(1))
// 전개해서 넘긴 슬라이스는 복사되지 않는다. 함수가 고치면 원본이 바뀐다.
data := []int{1, 2, 3}
zeroFirst(data...)
fmt.Println("전개 후 원본:", data)
}
// isNil은 가변 인자가 nil인지 보고한다.
func isNil(xs ...int) bool {
return xs == nil
}
// zeroFirst는 받은 슬라이스의 첫 요소를 0으로 만든다.
func zeroFirst(xs ...int) {
if len(xs) > 0 {
xs[0] = 0
}
}
go run ./05-variadic
0
6
15
a-b-c
true
false
false
전개 후 원본: [0 2 3]
sum(nums...)처럼 슬라이스를 전개한다.sum(nums)는 컴파일 에러다.- 인자가 없으면
nil슬라이스다.len은 0이라range는 안전하게 돌지만,xs == nil은true다. - 전개한 슬라이스는 복사되지 않는다. 마지막 줄이
[0 2 3]인 이유다. 컴파일러는nums...전개에서 새 슬라이스를 만들지 않고 그대로 넘긴다. 가변 인자 함수가 인자를 수정한다면 이건 사고의 원인이 된다.append(a, b...)가 안전한 것도append가 두 번째 인자를 수정하지 않기 때문이다.
fmt.Println(a ...any)가 이 문법의 대표 사례다. any 가변 인자는 어떤 타입이든 받는데,
그만큼 타입 검사가 사라진다. 그래서 2-3에서 본
go vet의 printf 분석기가 존재한다.
모든 인자는 값으로 전달된다
Go에는 참조 전달(pass by reference)이 없다. 예외 없이 전부 값 복사다. C++의 &
참조 매개변수나 Java의 객체 참조와 다르다.
그런데도 슬라이스를 함수에 넘기고 내용을 고치면 호출자에게 반영된다. 모순처럼 보이지만 아니다.
package main
import "fmt"
// Counter는 값으로 전달되면 통째로 복사된다.
type Counter struct {
Name string
N int
}
// bumpValue는 복사본을 고친다. 호출자에게 아무 영향이 없다.
func bumpValue(c Counter) {
c.N++
}
// bumpArray도 마찬가지다. 배열은 값이라 통째로 복사된다.
func bumpArray(a [3]int) {
a[0] = 100
}
// bumpSlice는 다르다. 슬라이스 값 자체는 복사되지만
// 그 안의 포인터가 같은 백킹 배열을 가리킨다.
func bumpSlice(s []int) {
s[0] = 100
}
// appendSlice는 append가 재할당을 일으키면 호출자와 연결이 끊긴다.
func appendSlice(s []int) {
s = append(s, 999)
s[0] = -1
}
func main() {
c := Counter{Name: "hits", N: 0}
bumpValue(c)
fmt.Println("구조체:", c)
arr := [3]int{1, 2, 3}
bumpArray(arr)
fmt.Println("배열: ", arr)
sl := []int{1, 2, 3}
bumpSlice(sl)
fmt.Println("슬라이스:", sl)
sl2 := []int{1, 2, 3}
appendSlice(sl2)
fmt.Println("append 후:", sl2)
}
go run ./05-pass-by-value
구조체: {hits 0}
배열: [1 2 3]
슬라이스: [100 2 3]
append 후: [1 2 3]
네 줄을 하나씩 읽자.
- 구조체와 배열은 통째로 복사된다. 함수 안의 수정이 밖에 보이지 않는다. 배열이 값이라는 건 다른 언어에서 온 사람에게 특히 낯선 지점이다.
- 슬라이스는
[100 2 3]이 됐다. 값 전달 규칙을 어긴 게 아니다. 슬라이스 값은(포인터, 길이, 용량)세 필드짜리 작은 구조체이고, 그 구조체가 복사된 것이다. 복사본의 포인터도 원본과 같은 배열을 가리키니 요소 수정이 공유된다. appendSlice는 원본을 못 바꿨다.append가 용량을 넘겨 새 배열을 할당했고, 그 순간s는 다른 배열을 가리키게 됐다. 이후의s[0] = -1은 호출자와 무관한 곳을 고친 것이다.
:::info 이 이야기의 정확한 그림은 Part 3에
슬라이스 헤더의 구조와 append의 재할당 규칙, 그리고 언제 포인터를 넘겨야 하는지는
3-1, 3-2, 3-5에서 다룬다. 지금 가져갈 규칙은 하나다 — Go는 항상 값을 복사한다.
공유처럼 보이는 것은 복사된 값 안에 포인터가 들어 있기 때문이다.
:::
재귀
Go의 재귀에는 특별한 것이 없다. 다만 꼬리 호출 최적화가 없다는 점은 알아 둬야 한다. 깊은 재귀는 스택을 소모한다.
package main
import "fmt"
// fib는 순진한 재귀다. 같은 값을 몇 번이나 다시 계산한다.
func fib(n int) int {
if n < 2 {
return n
}
return fib(n-1) + fib(n-2)
}
// fibIter는 같은 결과를 반복문으로 구한다.
func fibIter(n int) int {
a, b := 0, 1
for range n {
a, b = b, a+b
}
return a
}
// depth는 중첩된 슬라이스의 최대 깊이를 구한다. 재귀가 자연스러운 구조다.
func depth(v any) int {
list, ok := v.([]any)
if !ok {
return 0
}
deepest := 0
for _, item := range list {
deepest = max(deepest, depth(item))
}
return deepest + 1
}
func main() {
for i := range 10 {
fmt.Print(fib(i), " ")
}
fmt.Println()
for i := range 10 {
fmt.Print(fibIter(i), " ")
}
fmt.Println()
nested := []any{1, []any{2, []any{3, 4}}, 5}
fmt.Println("깊이:", depth(nested))
}
go run ./05-recursion
0 1 1 2 3 5 8 13 21 34
0 1 1 2 3 5 8 13 21 34
깊이: 3
fib와 fibIter는 결과가 같지만 성격이 완전히 다르다. 피보나치처럼 선형으로 풀리는
문제에 재귀를 쓰는 것은 교재용이고, 실제로는 반복문이 맞다. 재귀는 depth처럼 자료
구조 자체가 재귀적일 때 쓴다 — 트리, 중첩된 JSON, 디렉터리 순회.
:::note 고루틴 스택은 자란다
Go의 고루틴은 작은 스택(2KB 남짓)으로 시작해서 필요하면 자동으로 늘어난다. 그래서 웬만한
재귀 깊이는 견딘다. 하지만 무한하지는 않아서, 한계에 닿으면
runtime: goroutine stack exceeds 1000000000-byte limit과 함께 죽는다. 사용자 입력으로
깊이가 결정되는 재귀에는 깊이 제한을 두자.
:::
a, b = b, a+b 형태의 다중 대입도 눈여겨보자. 오른쪽이 전부 먼저 평가된 뒤 왼쪽에
대입되므로 임시 변수가 필요 없다. 값 교환 a, b = b, a도 같은 원리다.
흔히 하는 실수
1. 벌거벗은 return으로 부분 결과를 흘린다
앞서 본 parseAll이다. 함수가 20줄만 넘어가도 "여기서 뭘 반환하지?"를 위로 스크롤해서
찾아야 한다. 이름은 붙이되 return에는 값을 적자.
2. 에러를 _로 버린다
n, _ := strconv.Atoi(userInput) // 실패하면 n은 0. 그리고 아무도 모른다
"abc"가 들어와도 n은 0이 되고 프로그램은 계속 돈다. 나중에 "왜 0이지?"를 추적하는
데 몇 시간이 든다. 최소한 로그라도 남기자.
3. 큰 구조체를 값으로 계속 넘긴다
값 전달이므로 매번 복사가 일어난다. 필드가 수십 개인 구조체를 루프 안에서 넘기면 실측 가능한 비용이 된다. 반대로 필드 두세 개짜리는 포인터보다 값이 빠른 경우도 많다. 판단 기준은 3-5에서 다룬다.
4. 가변 인자에 슬라이스를 그냥 넘긴다
nums := []int{1, 2, 3}
sum(nums) // 컴파일 에러
sum(nums...) // 이렇게
반대로 []any가 필요한 자리(fmt.Println 등)에 []string을 전개하려 하면 안 된다.
[]string은 []any가 아니다. 하나씩 변환해서 옮겨 담아야 한다.
5. 함수 이름에 패키지 이름을 반복한다
package user
func UserCreate(...) // user.UserCreate 가 된다
func Create(...) // user.Create
호출부에서는 항상 패키지 이름이 앞에 붙는다는 걸 염두에 두고 이름을 짓는다.
http.HTTPServer가 아니라 http.Server인 이유다.
정리
- 타입은 이름 뒤에. 같은 타입이 연속되면 마지막에 한 번만.
- 오버로딩·기본 인자·예외가 없다. 대신 다중 반환값이 있다.
error는 마지막 반환값._로 버리지 않는다.- 명명된 반환값은 문서화와
defer조작용. 벌거벗은return은 피한다. - 가변 인자는 함수 안에서 슬라이스다. 전개는
xs..., 인자가 없으면nil. - 전개해서 넘긴 슬라이스는 복사되지 않는다.
- 모든 인자는 값으로 복사된다. 슬라이스가 공유되는 것은 복사된 헤더 안의 포인터 때문이다.
- 꼬리 호출 최적화는 없다. 재귀는 자료 구조가 재귀적일 때 쓴다.
연습문제
-
문자열 슬라이스에서 빈 문자열을 걸러 내는
compact(items []string) []string을 쓰고, 원본이 바뀌는지 확인해 보자. 바뀐다면 왜 그런지 설명하고, 바뀌지 않게 고쳐 보자. -
parseAll을 벌거벗은return없이 다시 쓰되, 부분 결과와 에러를 둘 다 의미 있게 반환하도록 만들어 보자. 어떤 시그니처가 호출자에게 오해를 주지 않는가? 힌트:(nums []int, failedIndex int, err error)도 하나의 답이다. -
정수 슬라이스의 평균을 구하는 함수를 가변 인자 버전과 슬라이스 인자 버전으로 각각 쓰고, 빈 입력에 대한 동작을 정해 보자. 어느 쪽 시그니처가 호출자에게 더 정직한가?