고 기초 #4 함수, 다중 반환, error 타입

6 분 소요

#3 제어 흐름에서 분기와 반복을 봤습니다. 이번엔 — 그 코드를 묶어 재사용하는 도구인 Go의 함수를 정리합니다.

함수 정의 #

기본 함수
func add(a int, b int) int {
	return a + b
}

func main() {
	result := add(2, 3)
	fmt.Println(result)   // 5
}

문법: func 이름(매개변수) 반환타입 { 본문 }. 매개변수마다 타입을 적습니다.

같은 타입 매개변수 묶기 #

타입 묶기
func add(a, b int) int {
	return a + b
}

같은 타입이 연속이면 마지막에 한 번만 적습니다.

반환값 없음 #

void 같은 함수
func greet(name string) {
	fmt.Println("안녕,", name)
}

반환 타입을 비웁니다. C의 void와 같은 역할입니다.

다중 반환 — Go의 시그니처 #

Go의 가장 두드러진 특징 — 함수가 여러 값을 한 번에 반환할 수 있습니다.

다중 반환
func divide(a, b int) (int, int) {
	q := a / b
	r := a % b
	return q, r
}

func main() {
	quot, rem := divide(10, 3)
	fmt.Println(quot, rem)   // 3 1
}

JS의 디스트럭처링이나 Python의 튜플 분해와 비슷하지만 — Go는 진짜 다중 값입니다. 호출하는 쪽에서 두 변수에 동시에 받습니다.

일부 무시 — _ #

무시
quot, _ := divide(10, 3)   // 나머지는 무시

_는 “이 값은 안 쓸 거야” 의미. 가독성 좋고, 사용하지 않는 변수 컴파일 에러도 피할 수 있습니다.

명명된 반환값 #

반환값에 미리 이름을 줄 수 있습니다.

명명된 반환
func divide(a, b int) (q, r int) {
	q = a / b
	r = a % b
	return    // q, r 자동 사용 (naked return)
}

q, r이 함수 시작에서 자동으로 선언되고, return에 인자가 없으면 그 값들이 자동 반환됩니다.

가독성 측면에서 호불호가 갈립니다. 짧은 함수에서 명확함이 늘어나면 OK, 길어지면 명시적인 return q, r이 더 좋습니다.

error 타입 — Go의 에러 처리 #

다른 언어가 try/catch로 예외를 던지는 상황에서, Go는 error를 반환값으로 다룹니다.

에러를 반환값으로
import (
	"fmt"
	"strconv"
)

func main() {
	n, err := strconv.Atoi("42")
	if err != nil {
		fmt.Println("실패:", err)
		return
	}
	fmt.Println(n)
}

Atoi는 두 값을 반환합니다 — 변환된 정수와 error. error가 nil이면 성공이고, nil 아니면 그 안에 에러 정보.

if err != nil { ... } 패턴이 Go 코드의 절반쯤이라고 해도 과언이 아닙니다.

error는 인터페이스 #

error의 정체
type error interface {
	Error() string
}

error는 빌트인 인터페이스로, Error() string 메서드 하나만 가집니다. 어떤 타입이든 이 메서드가 있으면 error가 됩니다.

자기 함수에서 에러 반환 #

에러를 만들어 반환
import "errors"

func divide(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("0으로 나눌 수 없음")
	}
	return a / b, nil
}

func main() {
	r, err := divide(10, 0)
	if err != nil {
		fmt.Println("에러:", err)
		return
	}
	fmt.Println(r)
}

errors.New(...)가 가장 단순한 에러 생성. 메시지에 값을 끼워넣으려면 fmt.Errorf가 더 흔합니다.

fmt.Errorf
return 0, fmt.Errorf("0으로 나누려는 a=%d", a)

Sentinel 에러 — 미리 정의된 에러 #

라이브러리들이 자주 쓰는 패턴 — 미리 변수로 정의해 둔 에러.

sentinel
var ErrNotFound = errors.New("not found")

func lookup(key string) (string, error) {
	if key == "" {
		return "", ErrNotFound
	}
	return "value", nil
}

// 호출자가 비교 가능
if errors.Is(err, ErrNotFound) {
	// ...
}

errors.Is중급 #2 에러 처리에서 자세히 다룹니다.

defer — 함수 끝나기 직전에 실행 #

리소스 정리(파일 닫기, 락 해제, DB 연결 닫기)에 거의 표준으로 쓰는 도구.

defer 기본
func readFile() {
	file, err := os.Open("data.txt")
	if err != nil {
		return
	}
	defer file.Close()   // 함수 끝날 때 자동 호출

	// ... 파일 사용
}

defer는 그 줄을 즉시 실행하는 게 아니라, 함수가 return 직전 실행되도록 예약합니다. 어떤 경로(정상 return, panic 등)로 함수를 빠져나가도 호출됩니다.

여러 defer는 LIFO #

LIFO 순서
func main() {
	defer fmt.Println("1")
	defer fmt.Println("2")
	defer fmt.Println("3")

	fmt.Println("main")
}
// main
// 3
// 2
// 1

스택처럼 — 나중에 적은 게 먼저 실행됩니다.

defer의 인자는 즉시 평가됨 #

자주 헷갈리는 함정.

인자는 즉시 평가
i := 10
defer fmt.Println(i)   // 인자 i가 이때 평가 → 10
i = 20

// 함수 끝 — 출력: 10

fmt.Println(i)의 i가 defer 시점에 평가되어 10으로 박힙니다. 그 뒤에 i가 바뀌어도 영향 없습니다.

이걸 피하려면 클로저로 감싸세요.

클로저로 미루기
i := 10
defer func() { fmt.Println(i) }()    // 실행 시점에 i 평가
i = 20
// 출력: 20

가변 인자 — ... #

variadic 함수
func sum(nums ...int) int {
	total := 0
	for _, n := range nums {
		total += n
	}
	return total
}

sum(1, 2)              // 3
sum(1, 2, 3, 4, 5)     // 15
sum()                   // 0

nums ...int — 임의 개수의 int를 받아 슬라이스로 모음. JS의 rest 매개변수 (기초 #4)와 같은 역할입니다.

슬라이스 펼쳐 넘기기 #

이미 슬라이스가 있으면 — ...으로 펼쳐 인자로 보낼 수 있습니다.

슬라이스 펼치기
nums := []int{1, 2, 3}
sum(nums...)   // 6 — 슬라이스를 인자들로 풀어서

함수도 값 (1급 객체) #

함수를 변수에 담거나, 다른 함수에 인자로 넘기거나, 반환할 수 있습니다.

함수 값
add := func(a, b int) int {
	return a + b
}

result := add(2, 3)

함수 타입 #

함수 타입
type BinaryOp func(int, int) int

func apply(op BinaryOp, a, b int) int {
	return op(a, b)
}

apply(add, 2, 3)   // 5

함수 시그니처를 타입 별칭으로 만듭니다. 콜백을 받는 API에서 자주 등장하는 모양입니다.

클로저 — 함수가 환경을 들고 다님 #

JS의 클로저와 같은 개념. Go에서도 그대로.

클로저로 카운터
func makeCounter() func() int {
	count := 0
	return func() int {
		count++
		return count
	}
}

counter := makeCounter()
fmt.Println(counter())   // 1
fmt.Println(counter())   // 2
fmt.Println(counter())   // 3

반환된 익명 함수가 count를 캡처해 살아남습니다. JS와 동일한 동작입니다.

메서드 — 다음 글 미리보기 #

함수가 특정 타입에 묶여 있으면 메서드라 부릅니다.

메서드 미리보기
type Counter struct {
	count int
}

func (c *Counter) Increment() {
	c.count++
}

c := &Counter{}
c.Increment()
c.Increment()
fmt.Println(c.count)   // 2

func (c *Counter) Increment()(c *Counter) 부분이 리시버. 이 함수는 *Counter의 메서드입니다. #6 구조체와 메서드에서 자세히.

자주 쓰는 패턴들 #

1) 에러를 위로 흘리기 #

에러 전파
func processFile(path string) error {
	file, err := os.Open(path)
	if err != nil {
		return fmt.Errorf("파일 열기 실패: %w", err)
	}
	defer file.Close()

	if err := parse(file); err != nil {
		return fmt.Errorf("파싱 실패: %w", err)
	}
	return nil
}

%w verb가 핵심 — 원래 에러를 wrapping합니다. 호출자가 errors.Is/errors.As로 안의 에러까지 검사할 수 있게 됩니다.

2) 에러와 결과를 한 번에 반환 #

결과 + 에러
func parseUser(input string) (*User, error) {
	parts := strings.Split(input, ":")
	if len(parts) != 2 {
		return nil, fmt.Errorf("잘못된 형식: %q", input)
	}
	return &User{Name: parts[0], ID: parts[1]}, nil
}

성공이면 (결과, nil), 실패면 (zero값, 에러)가 표준입니다.

3) 함수 분해 — 작게 자르기 #

Go 코드의 일반적인 모양은 함수를 작게 자르고, 각 함수가 한 가지 일을 하게 하는 것입니다. early return + 에러 wrapping으로 흐름이 깔끔해집니다.

마무리 #

이번 글에서 정리한 내용:

  • func 이름(매개변수) 반환타입 { }
  • 같은 타입은 묶어 적기, 명명된 반환값 가능
  • 다중 반환 — Go의 시그니처
  • error는 인터페이스 — Error() string 메서드만 있으면 됨
  • if err != nil { return err }가 표준 패턴
  • errors.New / fmt.Errorf로 에러 생성, %w로 wrapping
  • defer로 정리 코드 — LIFO 순서, 인자는 즉시 평가
  • 가변 인자 ..., 슬라이스 펼치기 nums...
  • 함수도 1급 값, 클로저 동작
  • 메서드는 #6에서

다음 글(#5 컬렉션)에서는 Go의 세 가지 컬렉션 — 배열, 슬라이스, 맵 — 의 사용법과 자주 만나는 함정을 다룹니다.

X