Skip to content
ioob.dev
Go back

Go 입문 3편 — 함수

· 4분 읽기
Go 시리즈 (3/12)
  1. Go 입문 1편 — 변수, 상수, 타입
  2. Go 입문 2편 — 조건문과 반복문
  3. Go 입문 3편 — 함수
  4. Go 입문 4편 — 에러 처리
  5. Go 입문 5편 — 배열, 슬라이스, 맵
  6. Go 입문 6편 — 구조체와 메서드
  7. Go 입문 7편 — 인터페이스
  8. Go 입문 8편 — 포인터
  9. Go 입문 9편 — 고루틴과 채널
  10. Go 입문 10편 — 동시성 패턴
  11. Go 입문 11편 — 패키지와 모듈
  12. Go 입문 12편 — 제네릭과 실전 패턴
Table of contents

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를 가진다.

각 클로저가 자기만의 환경(count)을 캡처하는 구조를 메모리 관점에서 그려 보면 이렇게 된다.

flowchart LR
    Call1["counter() 호출 #1"] --> Env1["환경 #1<br/>count = 0,1,2,3..."]
    Env1 --> Fn1["next 클로저<br/>환경 #1 참조"]
    Call2["counter() 호출 #2"] --> Env2["환경 #2<br/>count = 0,1..."]
    Env2 --> Fn2["another 클로저<br/>환경 #2 참조"]
    Fn1 -.독립된 상태.- Fn2

이 패턴은 상태를 캡슐화하면서도 구조체까지 만들 필요가 없을 때 유용하다. 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편: 에러 처리


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Go 입문 2편 — 조건문과 반복문
Next Post
Go 입문 4편 — 에러 처리