본문 바로가기
프로그래밍/Go

Go 함수, closure, recursion

by JLearn 2026. 7. 14.
반응형

Go의 함수는 입력값과 반환값의 타입을 함수 선언에 명시합니다. 하나의 함수가 여러 값을 반환할 수 있고, 함수 자체를 변수에 저장하거나 다른 함수의 인수와 반환값으로 사용할 수도 있습니다.

lexical scope는 변수나 함수 이름을 참조할 수 있는 범위가 프로그램 실행 순서가 아니라 소스 코드에 작성된 블록 구조에 따라 결정되는 규칙입니다. closure는 자신이 선언된 바깥 lexical scope의 변수를 참조하는 함수 값입니다. 가변 인수 ...T는 호출할 때 여러 값을 받을 수 있으며, 함수 본문에서는 []T로 다룹니다.


디렉터리 구조는 다음과 같이 구성합니다.

sources/
└── functions/
    └── main.go

sources 디렉터리에서 실행합니다.

cd sources
go run ./functions

함수 선언과 여러 반환값

함수는 func keyword로 선언합니다. 매개변수 이름 뒤에 타입을 작성하고, 반환값이 있다면 매개변수 목록 뒤에 반환 타입을 작성합니다.

func add(a int, b int) int {
    return a + b
}

연속된 매개변수의 타입이 같다면 마지막에 한 번만 작성할 수 있습니다.

func add(a, b int) int {
    return a + b
}

Go 함수는 여러 값을 한 번에 반환할 수 있습니다. 결과값과 성공 여부, 결과값과 오류를 함께 반환하는 형태가 자주 사용됩니다.

func divide(dividend, divisor int) (int, bool) {
    if divisor == 0 {
        return 0, false
    }

    return dividend / divisor, true
}

호출하는 쪽에서는 반환값 개수에 맞춰 변수를 받습니다.

quotient, ok := divide(20, 4)
if ok {
    fmt.Println("몫:", quotient)
}

일부 반환값이 필요 없다면 blank identifier인 _로 무시할 수 있습니다.

quotient, _ := divide(20, 4)
fmt.Println(quotient)

_로 값을 무시할 수 있지만, 성공 여부나 오류처럼 함수의 정상 처리 여부를 나타내는 값은 특별한 이유 없이 버리지 않는 것이 좋습니다.


함수도 값이다

Go에서 함수는 값으로 다룰 수 있습니다. 함수 값을 변수에 저장하고 호출하거나, 다른 함수의 매개변수로 전달할 수 있습니다.

func multiply(a, b int) int {
    return a * b
}

func calculate(a, b int, operation func(int, int) int) int {
    return operation(a, b)
}

calculate의 세 번째 매개변수 타입은 func(int, int) int입니다. 두 개의 int를 받고 int 하나를 반환하는 함수만 전달할 수 있습니다.

operation := multiply
result := calculate(6, 4, operation)

fmt.Println(result) // 24

이름이 없는 함수 literal을 직접 전달할 수도 있습니다.

result := calculate(10, 3, func(a, b int) int {
    return a - b
})

fmt.Println(result) // 7

함수를 인수로 받는 방식은 계산 규칙, 정렬 기준, 필터 조건처럼 동작을 외부에서 전달할 때 사용할 수 있습니다.


lexical scope와 closure

lexical scope의 정의

lexical scope는 변수의 유효 범위가 프로그램의 실행 순서가 아니라, 소스 코드에서 변수가 선언된 위치와 중괄호 블록 구조에 따라 결정되는 규칙입니다.

안쪽 block에서는 자신이 선언된 block의 변수뿐 아니라 바깥 block에서 선언된 변수에도 접근할 수 있습니다.

func printScope() {
    message := "바깥 block"

    if true {
        fmt.Println(message)
    }
}

messageif block 바깥에서 선언되었지만, if block이 그 안쪽 범위에 있으므로 접근할 수 있습니다.

반대로 안쪽 block에서 선언한 변수는 바깥 block에서 사용할 수 없습니다.

func printScope() {
    if true {
        innerMessage := "안쪽 block"
        fmt.Println(innerMessage)
    }

    // fmt.Println(innerMessage) // compile error
}

안쪽 block에서 바깥 변수와 같은 이름을 새로 선언하면 안쪽 변수는 해당 block 안에서 바깥 변수를 가립니다. 이를 shadowing이라고 합니다.

func printShadowing() {
    message := "바깥 값"

    if true {
        message := "안쪽 값"
        fmt.Println(message)
    }

    fmt.Println(message)
}

출력:

안쪽 값
바깥 값

closure의 정의

Go 언어 명세에서는 함수 literal이 주변 함수에서 정의된 변수를 참조할 수 있으며, 이러한 함수 literal을 closure라고 설명합니다. 캡처된 변수는 함수 literal과 공유되고, 해당 변수에 접근할 수 있는 동안 계속 유지됩니다.

따라서 다음처럼 이해할 수 있습니다.

closure = 함수 값 + 바깥 lexical scope 변수에 대한 참조

클로저는 함수의 형태를 가진 값입니다. 다만 반환값이 함수라는 사실만으로 클로저가 되는 것은 아닙니다. 바깥 lexical scope의 변수를 참조해야 합니다.

func subtractor() func(int, int) int {
    return func(a, b int) int {
        return a - b
    }
}

위 함수 literal은 함수를 반환하지만 바깥 변수를 참조하지 않습니다. 반면 다음 함수 literal은 바깥 변수 discount를 참조하므로 closure입니다.

discount := 3

result := calculate(10, 2, func(a, b int) int {
    return a - b - discount
})

fmt.Println(result) // 5

클로저는 반드시 바깥 함수에서 반환될 필요도 없습니다.

rate := 10

calculatePrice := func(price int) int {
    return price + rate
}

fmt.Println(calculatePrice(100)) // 110

함수가 종료된 뒤에도 상태가 유지되는 이유

클로저를 반환하는 방식은 캡처한 상태를 여러 호출에 걸쳐 유지할 때 유용합니다.

func newCounter(start int) func() int {
    count := start

    return func() int {
        count++
        return count
    }
}
counter := newCounter(0)

fmt.Println(counter()) // 1
fmt.Println(counter()) // 2
fmt.Println(counter()) // 3

newCounter 호출은 이미 끝났지만 반환된 함수가 count를 계속 참조하므로, count의 이전 값이 다음 호출에서도 이어집니다.

newCounter를 다시 호출하면 각 호출마다 별도의 변수가 만들어집니다.

firstCounter := newCounter(0)
secondCounter := newCounter(100)

fmt.Println(firstCounter())  // 1
fmt.Println(firstCounter())  // 2
fmt.Println(secondCounter()) // 101

같은 변수를 캡처한 여러 closure는 해당 상태를 공유할 수 있습니다.

func newSequence(start int) (func() int, func() int) {
    value := start

    increase := func() int {
        value++
        return value
    }

    decrease := func() int {
        value--
        return value
    }

    return increase, decrease
}
increase, decrease := newSequence(10)

fmt.Println(increase()) // 11
fmt.Println(increase()) // 12
fmt.Println(decrease()) // 11

두 함수는 같은 value 변수를 참조하므로 변경된 상태도 공유합니다.

캡처된 변수는 언제 메모리에서 사라지는가

Go에서는 변수가 stack에 있는지 heap에 있는지가 언어의 동작을 바꾸지 않습니다. Go FAQ에 따르면 변수는 참조가 존재하는 동안 유지되며, 실제 저장 위치는 compiler의 escape analysis가 결정합니다.

바깥 함수가 종료된 뒤에도 closure가 변수를 사용해야 한다면 compiler는 그 변수가 필요한 기간 동안 유효하도록 처리합니다. 일반적으로 이런 변수는 함수의 stack frame보다 오래 살아야 하므로 heap에 배치될 수 있지만, 구체적인 배치 결과를 코드만 보고 항상 단정해서는 안 됩니다.

클로저와 캡처한 값이 프로그램의 어떤 실행 경로에서도 더 이상 도달할 수 없게 되면 garbage collector의 회수 대상이 됩니다. 참조가 사라졌다고 즉시 메모리가 반환되는 것은 아니며, 실제 회수 시점은 runtime의 garbage collection 주기에 따라 결정됩니다.

func runCounter() {
    counter := newCounter(0)
    fmt.Println(counter())
}

runCounter가 끝난 뒤 counter를 다른 곳에 저장하지 않았다면 클로저와 캡처한 상태는 자연스럽게 더 이상 도달할 수 없는 상태가 됩니다.

따라서 일반적인 지역 클로저에 다음처럼 nil을 명시적으로 대입할 필요는 없습니다.

func runCounter() {
    counter := newCounter(0)
    fmt.Println(counter())

    // 함수가 곧 끝나므로 counter = nil은 일반적으로 불필요합니다.
}

다만 전역 변수나 오래 유지되는 구조체 필드가 큰 데이터를 캡처한 클로저를 계속 참조한다면, 더 이상 사용하지 않을 때 참조를 제거하는 것이 의미가 있을 수 있습니다.

var processor func() int

func registerProcessor() {
    largeData := make([]byte, 10*1024*1024)

    processor = func() int {
        return len(largeData)
    }
}

func releaseProcessor() {
    processor = nil
}

processor가 유지되는 동안에는 largeData에도 도달할 수 있습니다. processor = nil이 마지막 참조를 제거한다면 이후 garbage collector가 관련 메모리를 회수할 수 있습니다.

nil 대입은 모든 클로저에 필요한 정리 절차가 아닙니다. 수명이 긴 참조가 큰 캡처 값을 불필요하게 붙잡고 있을 때만 검토하면 됩니다.

클로저가 필요한 이유

클로저는 함수에 간단한 상태나 설정을 묶어 두고, 나중에 호출할 때 그 값을 다시 전달하지 않아도 사용할 수 있게 합니다.

설정값을 미리 적용한 함수를 만들 수 있습니다.

func newTaxCalculator(taxRate float64) func(float64) float64 {
    return func(price float64) float64 {
        return price * (1 + taxRate)
    }
}
standardTax := newTaxCalculator(0.1)
reducedTax := newTaxCalculator(0.05)

fmt.Println(standardTax(10000)) // 11000
fmt.Println(reducedTax(10000))  // 10500

두 함수는 같은 계산 코드를 사용하지만 서로 다른 taxRate를 캡처합니다.

필터 조건처럼 함수를 인수로 전달할 때도 바깥 설정값을 함께 사용할 수 있습니다.

func filter(numbers []int, condition func(int) bool) []int {
    result := make([]int, 0, len(numbers))

    for _, number := range numbers {
        if condition(number) {
            result = append(result, number)
        }
    }

    return result
}
minimum := 10

filtered := filter([]int{5, 10, 15, 20}, func(number int) bool {
    return number >= minimum
})

fmt.Println(filtered) // [10 15 20]

condition 함수는 매개변수 number와 바깥 변수 minimum을 함께 사용합니다. 이처럼 정렬 기준, 필터 조건, 콜백과 이벤트 처리에서 필요한 주변 설정을 짧은 함수에 묶을 수 있습니다.

실무에서는 어떻게 사용하는가

Go 실무에서 클로저는 흔하게 사용되지만, 보통 복잡한 객체를 대신하기보다 다음과 같은 짧은 동작을 구성하는 데 사용합니다.

  • HTTP handler에 service나 logger 같은 의존성을 묶을 때
  • func(http.Handler) http.Handler 형태의 HTTP middleware를 만들 때
  • 정렬, 검색, 필터링 조건을 함수로 전달할 때
  • callback, cleanup 함수, 재시도 동작을 구성할 때
  • goroutine이 주변 값을 사용할 때
  • 테스트별 설정이 적용된 helper 함수를 만들 때

예를 들어 HTTP middleware는 다음 handler를 캡처하는 클로저 형태로 작성할 수 있습니다.

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Println(r.Method, r.URL.Path)
        next.ServeHTTP(w, r)
    })
}

반환된 http.HandlerFunc는 바깥 매개변수 next를 캡처합니다. 요청이 들어올 때마다 로깅한 뒤 캡처한 다음 handler를 호출합니다.

하지만 상태와 동작이 많아지고 여러 메서드가 필요하다면 구조체가 더 명확합니다.

type Counter struct {
    value int
}

func (c *Counter) Next() int {
    c.value++
    return c.value
}

간단한 상태 하나와 짧은 동작에는 closure가 편리할 수 있습니다. 반면 상태가 복잡하거나 여러 기능을 하나의 명확한 타입으로 표현해야 한다면 struct와 method를 사용하는 편이 읽기 쉽고 테스트하기도 좋습니다.


가변 인수 ...T

함수의 마지막 매개변수 타입 앞에 ...을 붙이면 호출할 때 인수를 여러 개 전달할 수 있습니다.

func sum(numbers ...int) int {
    total := 0

    for _, number := range numbers {
        total += number
    }

    return total
}

호출할 때 인수를 0개 이상 전달할 수 있습니다.

fmt.Println(sum())          // 0
fmt.Println(sum(10))        // 10
fmt.Println(sum(10, 20, 30)) // 60

함수 안에서 numbers의 타입은 []int입니다. 따라서 일반 slice처럼 len, range, index 접근을 사용할 수 있습니다.

기존 slice를 가변 인수로 전달하려면 slice 뒤에 ...을 붙입니다.

scores := []int{80, 90, 100}
result := sum(scores...)

fmt.Println(result) // 270

가변 인수는 함수의 마지막 매개변수에만 선언할 수 있습니다.

func printTotal(label string, numbers ...int) {
    fmt.Println(label, sum(numbers...))
}

recursion과 반복문

recursion은 함수가 자기 자신을 호출하는 방식입니다. 재귀 함수에는 호출을 끝내는 종료 조건이 반드시 필요합니다.

func factorialRecursive(n int) int {
    if n <= 1 {
        return 1
    }

    return n * factorialRecursive(n-1)
}

factorialRecursive(5)는 다음 호출을 만듭니다.

factorialRecursive(5)
factorialRecursive(4)
factorialRecursive(3)
factorialRecursive(2)
factorialRecursive(1)

각 호출은 이전 호출로 돌아갈 위치와 지역 변수 등의 실행 정보를 유지해야 합니다. Go runtime의 goroutine stack은 필요에 따라 커질 수 있지만, 재귀 호출 깊이가 커질수록 stack 사용량도 증가합니다. 종료 조건이 없거나 호출 깊이가 지나치게 크면 stack overflow로 프로그램이 종료될 수 있습니다.

같은 factorial 계산은 반복문으로도 작성할 수 있습니다.

func factorialIterative(n int) int {
    result := 1

    for current := 2; current <= n; current++ {
        result *= current
    }

    return result
}
fmt.Println(factorialRecursive(5)) // 120
fmt.Println(factorialIterative(5)) // 120

트리 탐색이나 디렉터리 구조 처리처럼 문제 자체가 중첩 구조라면 재귀가 자연스러울 수 있습니다. 반면 단순 누적이나 순차 반복은 for가 호출 흐름과 자원 사용을 더 직접적으로 표현하는 경우가 많습니다.

Go compiler가 모든 꼬리 재귀를 반복문으로 바꿔 준다고 가정해서는 안 됩니다. 호출 깊이가 입력 크기에 따라 계속 증가할 수 있다면 반복문으로 바꿀 수 있는지 먼저 검토하는 것이 안전합니다.


이름 있는 반환값

반환값에도 이름을 붙일 수 있습니다. 이름 있는 반환값은 함수가 시작될 때 해당 타입의 zero value로 초기화되며, 인수가 없는 return으로 반환할 수 있습니다.

func rectangleSize(width, height int) (area int, perimeter int) {
    area = width * height
    perimeter = 2 * (width + height)
    return
}
area, perimeter := rectangleSize(5, 3)
fmt.Println(area, perimeter) // 15 16

이름 있는 반환값은 반환값의 의미를 함수 signature에서 보여 줄 수 있다는 장점이 있습니다. 하지만 함수가 길어지면 어떤 값이 마지막으로 대입되었는지 추적하기 어려워질 수 있습니다.

반환 표현식을 직접 작성하면 반환 시점의 값이 더 분명합니다.

func rectangleSize(width, height int) (int, int) {
    area := width * height
    perimeter := 2 * (width + height)

    return area, perimeter
}

짧은 함수에서 반환값의 의미가 분명해지는 경우에는 이름 있는 반환값을 사용할 수 있습니다. 함수의 제어 흐름이 길거나 여러 위치에서 반환한다면 명시적인 반환값을 사용하는 편이 읽기 쉽습니다.


전체 예제

아래 예제에는 여러 반환값, 함수 값, 함수 literal, lexical scope, closure의 상태 유지와 공유, 설정을 캡처한 함수, 필터 조건, 가변 인수, recursion, 이름 있는 반환값이 포함되어 있습니다.

sources/functions/main.go:

package main

import "fmt"

func divide(dividend, divisor int) (int, bool) {
    if divisor == 0 {
        return 0, false
    }

    return dividend / divisor, true
}

func multiply(a, b int) int {
    return a * b
}

func calculate(a, b int, operation func(int, int) int) int {
    return operation(a, b)
}

func newCounter(start int) func() int {
    count := start

    return func() int {
        count++
        return count
    }
}

func newSequence(start int) (func() int, func() int) {
    value := start

    increase := func() int {
        value++
        return value
    }

    decrease := func() int {
        value--
        return value
    }

    return increase, decrease
}

func printScope() {
    message := "바깥 값"

    if true {
        message := "안쪽 값"
        fmt.Println("scope:", message)
    }

    fmt.Println("scope:", message)
}

func newTaxCalculator(taxRate float64) func(float64) float64 {
    return func(price float64) float64 {
        return price * (1 + taxRate)
    }
}

func filter(numbers []int, condition func(int) bool) []int {
    result := make([]int, 0, len(numbers))

    for _, number := range numbers {
        if condition(number) {
            result = append(result, number)
        }
    }

    return result
}

func sum(numbers ...int) int {
    total := 0

    for _, number := range numbers {
        total += number
    }

    return total
}

func factorialRecursive(n int) int {
    if n <= 1 {
        return 1
    }

    return n * factorialRecursive(n-1)
}

func factorialIterative(n int) int {
    result := 1

    for current := 2; current <= n; current++ {
        result *= current
    }

    return result
}

func rectangleSize(width, height int) (area int, perimeter int) {
    area = width * height
    perimeter = 2 * (width + height)
    return
}

func main() {
    quotient, ok := divide(20, 4)
    fmt.Println("나눗셈:", quotient, ok)

    multiplyResult := calculate(6, 4, multiply)
    fmt.Println("함수 값:", multiplyResult)

    discount := 3
    subtractResult := calculate(10, 2, func(a, b int) int {
        return a - b - discount
    })
    fmt.Println("함수 literal과 closure:", subtractResult)

    counter := newCounter(0)
    fmt.Println("counter:", counter())
    fmt.Println("counter:", counter())

    increase, decrease := newSequence(10)
    fmt.Println("sequence:", increase())
    fmt.Println("sequence:", increase())
    fmt.Println("sequence:", decrease())

    printScope()

    standardTax := newTaxCalculator(0.1)
    reducedTax := newTaxCalculator(0.05)
    fmt.Println("세율 10%:", standardTax(10000))
    fmt.Println("세율 5%:", reducedTax(10000))

    minimum := 10
    filtered := filter([]int{5, 10, 15, 20}, func(number int) bool {
        return number >= minimum
    })
    fmt.Println("필터:", filtered)

    scores := []int{80, 90, 100}
    fmt.Println("가변 인수:", sum(scores...))

    fmt.Println("재귀 factorial:", factorialRecursive(5))
    fmt.Println("반복 factorial:", factorialIterative(5))

    area, perimeter := rectangleSize(5, 3)
    fmt.Println("사각형:", area, perimeter)
}

실행합니다.

cd sources
go run ./functions

출력 예:

나눗셈: 5 true
함수 값: 24
함수 literal과 closure: 5
counter: 1
counter: 2
sequence: 11
sequence: 12
sequence: 11
scope: 안쪽 값
scope: 바깥 값
세율 10%: 11000
세율 5%: 10500
필터: [10 15 20]
가변 인수: 270
재귀 factorial: 120
반복 factorial: 120
사각형: 15 16

정리

Go 함수는 하나 이상의 값을 반환할 수 있고, 함수 자체를 값으로 저장하거나 전달할 수 있습니다. lexical scope는 변수의 유효 범위를 선언 위치와 block 구조에 따라 결정합니다. 함수 literal이 바깥 lexical scope의 변수를 참조하면 closure가 되며, 해당 변수의 상태를 호출 사이에 유지하거나 여러 closure가 공유할 수 있습니다. 캡처된 값은 클로저를 통해 계속 도달할 수 있는 동안 유지되며, 일반적인 지역 클로저에는 명시적인 nil 대입이 필요하지 않습니다.

가변 인수 ...T는 호출할 때 여러 값을 받고 함수 안에서는 []T로 처리합니다. 기존 slice를 전달할 때는 slice... 형태를 사용합니다.

재귀는 중첩 구조를 표현할 때 유용하지만 호출 깊이에 따라 stack 사용량이 증가합니다. 단순 반복으로 명확하게 표현할 수 있다면 for가 더 직접적인 경우가 많습니다.

이름 있는 반환값은 짧은 함수의 반환 의미를 보여 줄 때 사용할 수 있지만, 긴 함수에서는 명시적으로 값을 반환하는 방식이 흐름을 이해하기 쉽습니다.


참고 자료

반응형

'프로그래밍 > Go' 카테고리의 다른 글

Go struct, method, interface, enum 패턴과 embedding  (0) 2026.07.15
Go 포인터, string, rune  (0) 2026.07.14
Go 배열, slice, map, range  (0) 2026.07.13
Go if, for, switch와 break, continue  (0) 2026.07.11
Go fmt의 %v와 %q  (0) 2026.07.11

댓글