2025-12-17
프로그래밍에서 **함수(Function)**는 코드 재사용의 기본 단위이자, 복잡한 로직을 구조화하는 핵심 도구입니다. Go 언어의 함수는 문법적으로 단순해 보이지만, 다중 반환값을 통한 에러 처리, 일급 객체(First-class Citizen)로서의 함수, 그리고 defer를 통한 리소스 관리 등 Go만의 독특한 철학이 깊이 녹아 있습니다.
이 글에서는 func 키워드의 기본 문법부터 시작하여, 실무에서 가장 빈번하게 사용되는 다중 반환값 패턴, 클로저(Closure)의 메모리 캡처 원리, 그리고 defer의 LIFO 실행 순서까지 실전적으로 다룹니다.
func 키워드Go 언어에서 모든 함수는 func 키워드로 시작합니다. 함수 선언의 기본 형태는 다음과 같습니다.
func 함수이름(매개변수 목록) 반환타입 {
// 함수 내용
}
다른 언어와 가장 두드러지는 차이는 반환 타입이 가장 뒤에 온다는 점입니다. 이는 앞서 변수 선언에서 배운 것처럼, “주체 → 속성” 순서의 Go 철학을 따릅니다.
package main
import "fmt"
func add(a int, b int) int {
return a + b
}
func main() {
result := add(3, 4)
fmt.Println("3 + 4 =", result) // 출력: 3 + 4 = 7
}
여러 매개변수가 같은 타입일 경우, 마지막에 한 번만 타입을 명시할 수 있습니다.
// 두 방식 모두 유효
func multiply(x int, y int) int { ... }
func multiply(x, y int) int { ... } // ← 간결한 형태
🛠️ 실무 관점
필자는 가독성을 위해 타입을 각각 명시하는 편입니다. 특히 함수 시그니처가 복잡해질수록, 각 매개변수의 타입을 명확히 보는 것이 유지보수에 유리합니다. 하지만 2~3개의 동일 타입 매개변수라면 간결한 형태도 충분히 읽기 쉽습니다.
Go 언어 함수의 가장 강력한 특징 중 하나는 여러 개의 값을 동시에 반환할 수 있다는 점입니다. 이는 Go의 에러 처리 철학의 근간이 됩니다.
func divide(dividend, divisor int) (int, int) {
quotient := dividend / divisor
remainder := dividend % divisor
return quotient, remainder
}
func main() {
q, r := divide(9, 4)
fmt.Println("몫:", q, "나머지:", r) // 출력: 몫: 2 나머지: 1
}
(result, error) 관용구Go 언어에는 예외(Exception)가 없습니다. 대신 함수가 결과 값과 에러를 함께 반환하는 것이 표준 관용구입니다.
func openFile(filename string) (*os.File, error) {
file, err := os.Open(filename)
if err != nil {
// 파일 열기 실패 시 nil과 에러를 반환
return nil, fmt.Errorf("파일 열기 실패: %w", err)
}
// 성공 시 유효한 파일 객체와 nil 에러를 반환
return file, nil
}
호출자는 항상 두 번째 반환값(에러)을 먼저 검사하는 것이 Go의 관례입니다.
file, err := openFile("data.txt")
if err != nil {
log.Fatal(err) // 에러 처리
}
defer file.Close() // 성공 시 리소스 해제 예약
Go는 반환값에 이름을 지정할 수 있으며, 이 경우 return 키워드만 쓰면 해당 변수가 자동으로 반환됩니다. 이를 Naked Return이라고 합니다.
func rectangleArea(length, width int) (area int) {
area = length * width
return // area 변수가 자동으로 반환됨
}
문법적으로는 허용되지만, 실무에서는 권장되지 않습니다. 다음 코드를 보세요.
func rectangleArea(length, width int) (area int) {
area = length * width
another := 999
return another // ✅ 컴파일 성공! area가 아닌 another가 반환됨
}
fmt.Println(rectangleArea(5, 7)) // 출력: 999 (예상과 다름!)
Named Return을 선언했음에도 return 뒤에 다른 값을 명시하면 그 값이 반환됩니다. 이는 가독성을 심각하게 해치고, 코드 리뷰 시 버그를 찾기 어렵게 만듭니다.
🛠️ 실무 Best Practice
- **짧은 함수 (5줄 이하)**에서만 Named Return을 고려합니다.
- Naked Return은 가급적 사용하지 않고, 명시적으로
return area처럼 씁니다.- 복잡한 로직에서는 일반적인
return value1, value2형태가 훨씬 명확합니다.
... 연산자Go 함수는 가변 개수의 인자를 받을 수 있습니다. ... 키워드를 타입 앞에 붙이면 됩니다.
func sum(nums ...int) int {
total := 0
for _, num := range nums {
total += num
}
return total
}
func main() {
fmt.Println(sum(1, 2, 3, 4, 5)) // 출력: 15
fmt.Println(sum(10, 20)) // 출력: 30
}
nums ...int는 함수 내부에서 []int 슬라이스로 변환됩니다. 따라서 len(), cap(), for range 등 슬라이스 연산을 모두 사용할 수 있습니다.
func printArgs(prefix string, values ...int) {
fmt.Printf("%s: 개수=%d, 값=%v\n", prefix, len(values), values)
}
printArgs("숫자들", 1, 2, 3) // 출력: 숫자들: 개수=3, 값=[1 2 3]
💡 제약사항: 가변 인자는 매개변수 목록의 가장 마지막에만 올 수 있습니다.
Go에서 함수는 **일급 객체(First-class Citizen)**입니다. 즉, 변수에 할당하거나 다른 함수의 인자로 전달할 수 있습니다.
func main() {
// 익명 함수를 변수에 할당
add := func(a, b int) int {
return a + b
}
fmt.Println("10 + 20 =", add(10, 20)) // 출력: 10 + 20 = 30
}
함수를 인자로 받거나 반환하는 함수를 고차 함수라고 합니다.
func applyOperation(x, y int, op func(int, int) int) int {
return op(x, y)
}
func main() {
result := applyOperation(5, 3, func(a, b int) int {
return a * b
})
fmt.Println("결과:", result) // 출력: 결과: 15
}
클로저는 함수가 선언될 당시의 외부 환경(스코프)을 캡처하여, 함수 호출 시마다 그 환경에 접근할 수 있는 기능입니다.
func makeCounter() func() int {
counter := 0 // 외부 함수의 지역 변수
return func() int { // 익명 함수(클로저)
counter++ // 외부 변수 counter를 캡처하여 수정
return counter
}
}
func main() {
increment := makeCounter()
fmt.Println(increment()) // 출력: 1
fmt.Println(increment()) // 출력: 2
fmt.Println(increment()) // 출력: 3
newCounter := makeCounter() // 새로운 클로저 생성
fmt.Println(newCounter()) // 출력: 1 (독립된 counter)
}
makeCounter()가 종료된 후에도 counter 변수는 **힙(Heap)**에 남아있으며, 반환된 클로저가 이를 참조합니다. 이는 Go의 Escape Analysis가 자동으로 처리합니다.
🛠️ 실무 활용 사례
클로저는 설정 함수(옵션 패턴), 미들웨어, 이벤트 핸들러 등에서 빈번히 사용됩니다. 특히 HTTP 라우터에서 요청별 컨텍스트를 캡처하는 데 유용합니다.
defer: 리소스 해제의 예술defer 키워드는 함수가 종료될 때 실행할 코드를 예약합니다. 주로 파일, 네트워크 연결, 뮤텍스 등 리소스 해제에 사용됩니다.
func main() {
defer fmt.Println("world")
fmt.Println("hello")
}
// 출력:
// hello
// world
여러 defer 문이 있으면 스택(Stack) 구조로 쌓이며, 함수 종료 시 역순으로 실행됩니다.
func main() {
defer fmt.Println("1")
defer fmt.Println("2")
defer fmt.Println("3")
fmt.Println("시작")
}
// 출력:
// 시작
// 3
// 2
// 1
func processFile(filename string) error {
file, err := os.Open(filename)
if err != nil {
return err
}
defer file.Close() // ✅ 함수가 어떻게 종료되든 반드시 실행됨
// 파일 처리 로직...
// 에러가 발생해도, 정상 종료해도 defer가 Close() 호출을 보장
return nil
}
defer는 선언 시점에 인자를 평가합니다.
func main() {
x := 10
defer fmt.Println("defer:", x) // x의 값(10)을 즉시 평가하여 저장
x = 20
fmt.Println("main:", x)
}
// 출력:
// main: 20
// defer: 10 ← defer 선언 시점의 값(10)이 출력됨
해결책: 클로저를 사용하여 최종 값을 참조하도록 합니다.
defer func() { fmt.Println("defer:", x) }() // 클로저로 감싸면 종료 시점의 x(20) 출력
🛠️ 실무 Best Practice
- 리소스를 얻은 직후
defer로 해제를 예약하여 누수(leak) 방지- 여러
defer가 있으면 역순 실행을 고려하여 의존성 순서 설정- 인자 평가 시점 차이를 이해하고, 필요시 클로저 활용
Go 언어에서 함수는 단순한 코드 블록이 아니라, 에러 처리, 리소스 관리, 상태 캡처 등 언어의 핵심 철학이 구현되는 공간입니다.
오늘 배운 핵심을 요약하면:
result, error 패턴).다음 글에서는 Go 언어의 **구조체(Struct)**와 **메서드(Method)**를 다루며, 함수를 넘어 타입 중심의 설계 방식을 알아보겠습니다. Go가 클래스 없이 어떻게 객체지향적 설계를 구현하는지 확인해보세요!