Devin.KR

함수 - 여러 값 반환과 defer

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서는 for 하나로 반복을 표현하는 방법을 익혔다. 이번 장에서는 반복하는 코드를 이름 붙은 덩어리로 묶는 함수를 다룬다. 함수 자체는 어느 언어에나 있다. Go의 함수가 눈에 띄는 점은 값을 여러 개 돌려줄 수 있다는 것, 함수도 값처럼 변수에 담고 넘길 수 있다는 것, 그리고 함수가 끝나는 시점에 실행할 일을 미리 예약하는 defer가 있다는 것이다. 동네 자전거 대여소의 요금 계산과 마감 처리를 함수로 나누면서 이 세 가지를 차례로 만난다.

  • 값을 두 개 이상 돌려주는 함수를 만들고, 호출하는 쪽에서 각각 받는다.
  • 이름 붙은 반환값과 가변 인자 매개변수를 언제 쓰는지 안다.
  • 함수를 변수에 담아 넘기고, 클로저가 바깥 변수를 기억하는 방식을 설명한다.
  • defer가 실행되는 순서와 인자가 계산되는 시점을 예측한다.

문제 상황

대여소 프로그램에 요금 계산 기능을 넣는다고 하자. 규칙은 단순하다. 30분까지는 1,000원이고, 그 뒤로는 10분마다 500원이 붙는다. 10분에 못 미치는 나머지도 10분으로 올려서 센다. 그런데 이용 시간이 음수로 들어오면 어떻게 할까. 0원을 돌려주면 정상 요금인 0원과 구분되지 않는다. 특별한 숫자(예를 들어 -1)를 돌려주는 방법도 있지만, 호출하는 쪽이 그 약속을 잊는 순간 -1원이 장부에 들어간다.

또 하나의 문제는 마감이다. 하루를 마칠 때 열쇠함 잠금, 전원 끄기, 장부 저장을 해야 한다. 함수 중간에서 일찍 빠져나가는 경로가 늘어나면 뒷정리를 빠뜨리기 쉽다. 이 장의 도구들은 이 두 문제, 곧 결과와 실패를 함께 전하는 것과 뒷정리를 빠뜨리지 않는 것을 해결한다.

여러 값을 돌려주는 함수

다중 반환

Go의 함수는 반환 타입을 괄호로 묶어 여러 개 적을 수 있다. 요금 함수는 요금과 함께 "계산이 됐는가"를 알려 준다.

func fare(minutes int) (int, error) {
	if minutes < 0 {
		return 0, errors.New("이용 시간은 0 이상이어야 한다")
	}
	// ...
	return 1000, nil
}

두 번째 값의 타입 error는 "무엇이 잘못됐는지 설명하는 값"이다. 잘못된 것이 없으면 nil이라는 빈 값을 돌려준다. 오류를 다루는 방법은 뒤에서 따로 다루므로, 여기서는 "실패 정보를 담는 두 번째 값"이라고만 이해하면 된다. 호출하는 쪽은 값을 두 변수로 한꺼번에 받는다.

won, err := fare(95)
if err != nil {
	// 실패한 경우
}
함수 호출 한 번이 값 두 개를 돌려주고 받는 쪽이 각각 변수에 담는다

돌려받은 값 중 하나를 쓰지 않으려면 빈 식별자 _에 버릴 수 있다. 다만 err를 버리면 실패를 알아챌 방법이 사라지므로 아래 "자주 틀리는 것"에서 다시 다룬다. 사용하지 않는 지역 변수는 컴파일 오류이므로, 받은 값은 쓰거나 _로 버려야 한다.

이름 붙은 반환값

반환값에 이름을 붙이면 그 이름이 함수 안에서 이미 선언된 변수, 곧 제로값으로 시작하는 지역 변수가 된다. 함수가 끝날 때 그 변수의 값이 반환된다.

func splitMinutes(total int) (hours, minutes int) {
	hours = total / 60
	minutes = total % 60
	return
}

마지막의 return만 적는 형태를 이름 없는 return(naked return)이라 부른다. 짧은 함수에서는 반환값의 뜻이 이름으로 드러나 읽기 쉽다. 함수가 길어지면 무엇이 반환되는지 찾기 어려워지므로 return hours, minutes처럼 값을 적는 편이 낫다. 이름 붙은 반환값은 뒤에서 볼 defer와 함께 쓸 때 특히 쓸모가 있다.

반환 형태별 쓰임새
형태예어울리는 경우
값 하나func f() int계산 결과만 필요할 때
값 여러 개func f() (int, error)결과와 실패 정보를 함께 전할 때
이름 붙은 반환값func f() (h, m int)각 값의 뜻을 시그니처에 드러낼 때, defer에서 고칠 때

가변 인자

마지막 매개변수 타입 앞에 ...를 붙이면 인자를 0개 이상 받을 수 있다. 함수 안에서 그 매개변수는 해당 타입의 슬라이스(같은 타입 값을 줄지어 담는 묶음, 다음 장에서 자세히 다룬다)로 보이며, for 문으로 훑을 수 있다.

func sumFares(fares ...int) int {
	total := 0
	for _, f := range fares {
		total += f
	}
	return total
}

sumFares(1000, 2000)처럼 값을 나열해 호출하고, 인자를 아예 안 넣으면 빈 묶음이 되어 0을 돌려준다. 이미 슬라이스가 있다면 sumFares(list...)처럼 뒤에 ...를 붙여 펼쳐 넘긴다. 가변 인자는 매개변수 목록의 맨 끝에만 둘 수 있다.

함수 값과 클로저

Go에서 함수는 정수나 문자열처럼 값이다. 변수에 담을 수 있고, 다른 함수의 인자로 넘길 수 있고, 반환할 수도 있다. 이름 없이 그 자리에서 만드는 함수를 함수 리터럴(function literal)이라 한다. 함수 리터럴이 자기 바깥에 선언된 변수를 사용하면, 그 변수를 함께 들고 다니는 함수 값이 된다. 이것을 클로저(closure)라 부른다.

func newDiscount(percent int) func(int) int {
	return func(won int) int {
		return won * (100 - percent) / 100
	}
}

newDiscount(20)가 돌려주는 함수는 percent가 20이라는 사실을 기억한다. newDiscount 자체는 이미 끝났지만, 안쪽 함수가 percent를 쓰고 있으므로 그 변수는 계속 살아 있다. 호출할 때마다 따로 만들어지므로 newDiscount(10)과 newDiscount(20)은 서로 다른 값을 기억한다.

기억하는 것은 값의 복사본이 아니라 변수 자체다. 그래서 클로저는 기억한 변수를 바꿀 수도 있다. 번호표 발급기가 그 예다.

func newTicketCounter() func() int {
	n := 0
	return func() int {
		n++
		return n
	}
}

발급기를 두 개 만들면 각자 자기 n을 갖는다. 한쪽을 여러 번 불러도 다른 쪽 번호는 변하지 않는다.

함수를 인자로 받으면 "고정된 흐름 중 일부만 바꾸는" 코드를 쓸 수 있다. 아래의 settle은 요금을 계산하는 흐름은 고정하고, 마지막에 요금을 어떻게 조정할지만 바깥에서 받는다.

defer로 뒷정리 예약하기

defer 뒤에 함수 호출을 적으면, 그 호출은 지금 실행되지 않고 "둘러싼 함수가 끝날 때"로 미뤄진다. 끝나는 경로가 return이든 함수 끝에 도달한 것이든 상관없이 실행된다. 열었으면 닫는다는 짝을 열자마자 옆에 적을 수 있어서, 중간에 빠져나가는 경로가 늘어나도 뒷정리를 빠뜨리지 않는다.

defer는 여러 번 쓸 수 있고, 나중에 등록한 것이 먼저 실행된다. 쌓아 둔 접시를 위에서부터 집는 것과 같아서 후입선출(LIFO) 순서라 부른다.

defer는 나중에 등록한 것부터 거꾸로 실행된다

defer에는 기억해 둘 규칙이 둘 더 있다.

defer의 세 가지 규칙
규칙뜻확인할 곳
실행 시점둘러싼 함수가 끝날 때closeStation
실행 순서나중에 등록한 것이 먼저closeStation
인자 계산 시점defer 문을 만나는 순간 한 번deferArgs

세 번째 규칙은 놓치기 쉽다. defer fmt.Println(count)에서 count의 값은 defer 문을 지나는 순간 정해진다. 이후에 count가 바뀌어도 예약된 호출은 그때의 값을 쓴다. 함수가 끝나는 시점의 값을 보고 싶다면 함수 리터럴로 감싸서 defer func() { ... }()로 쓴다. 클로저가 변수 자체를 기억하기 때문이다. 이 형태는 이름 붙은 반환값과 만나면 return이 값을 정한 뒤에 그 값을 고칠 수 있다는 점에서 유용하다. 완성 코드의 rentOnce가 그 예다.

완성 코드

아래 프로그램을 main.go 한 파일로 저장한다.

package main

import (
	"errors"
	"fmt"
)

const (
	baseMinutes = 30
	baseWon     = 1000
	unitMinutes = 10
	unitWon     = 500
)

// fare는 이용 시간(분)으로 요금과 오류를 함께 돌려준다.
func fare(minutes int) (int, error) {
	if minutes < 0 {
		return 0, errors.New("이용 시간은 0 이상이어야 한다")
	}
	if minutes <= baseMinutes {
		return baseWon, nil
	}
	units := (minutes - baseMinutes + unitMinutes - 1) / unitMinutes
	return baseWon + units*unitWon, nil
}

func showFare(minutes int) {
	won, err := fare(minutes)
	if err != nil {
		fmt.Printf("%3d분: 계산 실패 (%v)\n", minutes, err)
		return
	}
	fmt.Printf("%3d분: %d원\n", minutes, won)
}

func splitMinutes(total int) (hours, minutes int) {
	hours = total / 60
	minutes = total % 60
	return
}

func sumFares(fares ...int) int {
	total := 0
	for _, f := range fares {
		total += f
	}
	return total
}

func newDiscount(percent int) func(int) int {
	return func(won int) int {
		return won * (100 - percent) / 100
	}
}

func settle(minutes int, adjust func(int) int) int {
	won, err := fare(minutes)
	if err != nil {
		return 0
	}
	return adjust(won)
}

func newTicketCounter() func() int {
	n := 0
	return func() int {
		n++
		return n
	}
}

func closeStation(name string) {
	fmt.Println("마감 시작:", name)
	defer fmt.Println("정리: 열쇠함 잠금")
	defer fmt.Println("정리: 전원 끄기")
	defer fmt.Println("정리: 장부 저장")
	fmt.Println("마감 작업 중")
}

func deferArgs() {
	count := 1
	defer fmt.Println("defer 시점 값:", count)
	count = 5
	fmt.Println("현재 값:", count)
}

func rentOnce(bike string) (log string) {
	defer func() {
		log += " -> 반납 완료"
	}()
	log = bike + " 대여"
	return log
}

func main() {
	fmt.Println("== 다중 반환 ==")
	showFare(20)
	showFare(45)
	showFare(95)
	showFare(-5)

	fmt.Println("== 이름 붙은 반환값 ==")
	h, m := splitMinutes(95)
	fmt.Printf("95분은 %d시간 %d분\n", h, m)

	fmt.Println("== 가변 인자 ==")
	fmt.Println("하루 합계:", sumFares(1000, 2000, 4500))
	fmt.Println("빈 호출:", sumFares())
	list := []int{1000, 2000, 4500, 1500}
	fmt.Println("슬라이스 펼치기:", sumFares(list...))

	fmt.Println("== 함수 값과 클로저 ==")
	student := newDiscount(20)
	none := func(won int) int { return won }
	fmt.Println("정산(할인 없음):", settle(95, none))
	fmt.Println("정산(청소년 20%):", settle(95, student))
	next := newTicketCounter()
	fmt.Println("발급 번호:", next(), next(), next())
	other := newTicketCounter()
	fmt.Println("다른 창구:", other())

	fmt.Println("== defer ==")
	closeStation("행복동 대여소")
	deferArgs()
	fmt.Println(rentOnce("B-07"))
}

줄별 해설

상수 블록. 요금 규칙의 숫자를 이름 붙은 상수로 모았다. 규칙이 바뀌면 이 블록만 고치면 된다.

fare. 반환 타입 (int, error)가 "요금과 오류"를 뜻한다. 음수면 0, 오류를 돌려주고 바로 끝낸다. 30분 이하면 기본 요금과 nil을 돌려준다. 초과분은 (초과 분 + 9) / 10으로 올림한다. 정수 나눗셈은 소수점 아래를 버리므로, 나누기 전에 단위보다 하나 작은 9를 더하면 올림과 같아진다. 95분이면 초과가 65분이고 74/10=7단위, 곧 1000 + 7×500 = 4500원이다.

showFare. won, err := fare(minutes)로 두 값을 받고, err가 nil이 아니면 실패를 출력하고 return으로 나간다. %3d는 정수를 폭 3칸에 맞춰 오른쪽 정렬하고, %v는 값을 기본 형식으로 출력한다. 실행 결과에서 " 20분"처럼 앞에 공백이 하나 붙는 이유가 이것이다.

splitMinutes. hours와 minutes는 함수가 시작될 때 이미 0으로 선언되어 있다. 대입한 뒤 return만 적으면 두 변수의 현재 값이 반환된다.

sumFares. fares는 함수 안에서 int 슬라이스다. 인자가 없으면 빈 슬라이스여서 for 문이 한 번도 돌지 않고 0이 반환된다. main에서 sumFares(list...)는 이미 만든 슬라이스를 그대로 넘긴다.

newDiscount와 settle. newDiscount는 함수를 반환하고, 그 함수는 percent를 기억한다. 4500 × 80 / 100 = 3600이다. settle의 두 번째 매개변수 adjust func(int) int는 "int 하나를 받아 int 하나를 돌려주는 함수"를 받는다는 뜻이다. main의 none은 이름 없는 함수를 변수에 담은 것으로, 받은 값을 그대로 돌려준다.

newTicketCounter. n은 newTicketCounter가 호출될 때마다 새로 만들어지고, 안쪽 함수가 그것을 이어서 늘린다. next()를 세 번 부르면 1, 2, 3이 나온다. Println의 인자는 왼쪽부터 차례로 계산된다. other는 별개의 n을 가지므로 1부터 다시 시작한다.

closeStation. defer 세 개는 등록만 되고, "마감 작업 중"이 먼저 출력된다. 함수가 끝날 때 등록의 역순으로 장부 저장, 전원 끄기, 열쇠함 잠금이 실행된다.

deferArgs. defer 문을 지나는 순간 count는 1이므로 예약된 출력도 1을 쓴다. 그 뒤 count를 5로 바꾸고 "현재 값"을 출력한 다음, 함수가 끝나며 "defer 시점 값: 1"이 나온다.

rentOnce. return log는 log에 값을 정한 뒤 함수를 끝낸다. 그 직후에 defer의 함수 리터럴이 실행되어 같은 변수 log에 문구를 덧붙인다. 반환값에 이름이 있기 때문에 가능한 동작이다.

실행 결과

$ go run main.go
== 다중 반환 ==
 20분: 1000원
 45분: 2000원
 95분: 4500원
 -5분: 계산 실패 (이용 시간은 0 이상이어야 한다)
== 이름 붙은 반환값 ==
95분은 1시간 35분
== 가변 인자 ==
하루 합계: 7500
빈 호출: 0
슬라이스 펼치기: 9000
== 함수 값과 클로저 ==
정산(할인 없음): 4500
정산(청소년 20%): 3600
발급 번호: 1 2 3
다른 창구: 1
== defer ==
마감 시작: 행복동 대여소
마감 작업 중
정리: 장부 저장
정리: 전원 끄기
정리: 열쇠함 잠금
현재 값: 5
defer 시점 값: 1
B-07 대여 -> 반납 완료

실무에서 자주 틀리는 것

1. 오류 값을 _ 로 버린다

틀린 코드는 다음과 같다.

won, _ := fare(-5)
fmt.Println("요금:", won)

컴파일은 되지만 "요금: 0"이 출력된다. 실패했다는 사실이 사라지고 0원이라는 정상처럼 보이는 값만 남는다. 고친 코드는 err를 받아 확인한다.

won, err := fare(-5)
if err != nil {
	fmt.Println("계산 실패:", err)
	return
}
fmt.Println("요금:", won)

2. 반복문 안에서 defer를 쓴다

defer는 반복 한 번이 아니라 함수가 끝날 때 실행된다. 아래 코드는 자물쇠를 열 때마다 잠그기를 예약하지만, 실제로는 모두 열린 뒤 마지막에 몰아서 잠긴다.

func returnAll() {
	for i := 1; i <= 3; i++ {
		fmt.Println("자물쇠 열기", i)
		defer fmt.Println("자물쇠 잠그기", i)
	}
}

출력은 열기 1, 2, 3 다음에 잠그기 3, 2, 1이다. 반복 한 번마다 정리하고 싶다면 한 번의 처리를 별도 함수로 뺀다.

func handle(i int) {
	fmt.Println("자물쇠 열기", i)
	defer fmt.Println("자물쇠 잠그기", i)
}

func returnAll() {
	for i := 1; i <= 3; i++ {
		handle(i)
	}
}

3. 슬라이스를 가변 인자에 ... 없이 넘긴다

list := []int{1000, 2000}
fmt.Println(sumFares(list))

이 코드는 list를 int 하나로 넘기려는 것이므로 컴파일되지 않는다. 오류 메시지는 "cannot use list (variable of type []int) as int value in argument to sumFares" 형태다. 고친 코드는 sumFares(list...)다.

4. defer 인자가 나중 값을 볼 것이라 기대한다

left := 10
defer fmt.Println("남은 자전거:", left)
left = 7

함수가 끝날 때 출력되는 값은 7이 아니라 10이다. 인자는 defer 문을 지나는 순간 계산되기 때문이다. 끝나는 시점의 값을 원하면 함수 리터럴로 감싼다.

left := 10
defer func() {
	fmt.Println("남은 자전거:", left)
}()
left = 7

한눈에 보기

이 장에서 다룬 문법과 동작
주제문법핵심
다중 반환func f() (int, error)a, err := f()로 각각 받는다
이름 붙은 반환값func f() (h, m int)제로값으로 선언된 변수이며, 짧은 함수에서 쓴다
가변 인자func f(xs ...int)안에서는 슬라이스이며, 넘길 때는 xs...로 펼친다
함수 값var g func(int) int변수에 담고, 인자로 넘기고, 반환할 수 있다
클로저return func() int { ... }바깥 변수 자체를 기억하며 바꿀 수도 있다
deferdefer f(x)함수가 끝날 때 역순으로 실행되며, 인자는 등록 시점에 계산된다

연습 문제

  1. 두 정수 a, b를 받아 몫과 나머지를 이름 붙은 반환값으로 돌려주는 함수 divmod를 작성하라. divmod(17, 5)의 결과는 무엇인가.
  2. 첫 값 하나와 나머지 여러 값을 받아 가장 큰 값을 돌려주는 가변 인자 함수 maxWon을 작성하라. 인자가 하나뿐인 호출도 동작해야 한다.
  3. 호출할 때마다 step씩 커진 값을 돌려주는 함수를 반환하는 newCounterBy(step int)를 작성하라. step이 10이면 세 번 호출해서 무엇이 나오는가.
  4. 다음 함수가 출력하는 내용을 예측하라.
    func f() {
    	for i := 1; i <= 3; i++ {
    		defer fmt.Print(i, " ")
    	}
    	fmt.Print("끝 ")
    }

정답과 해설

1. 다음과 같이 작성한다.

func divmod(a, b int) (q, r int) {
	q = a / b
	r = a % b
	return
}

divmod(17, 5)는 몫 3, 나머지 2를 돌려준다. 정수 나눗셈은 소수점 아래를 버린다. b가 0이면 실행 중에 오류가 나므로, 실제 프로그램에서는 앞서 본 fare처럼 error를 함께 돌려주는 방식을 고려한다.

2. 첫 값을 따로 받으면 "값이 0개인 호출"을 컴파일 단계에서 막을 수 있다.

func maxWon(first int, rest ...int) int {
	best := first
	for _, v := range rest {
		if v > best {
			best = v
		}
	}
	return best
}

인자가 하나뿐이면 rest는 비어 있어 반복이 돌지 않고 first가 그대로 반환된다. 변수 이름을 max로 하지 않은 것은, max가 Go에 이미 있는 내장 함수의 이름이라 가리게 되기 때문이다.

3. 다음과 같이 작성한다.

func newCounterBy(step int) func() int {
	n := 0
	return func() int {
		n += step
		return n
	}
}

step이 10이면 호출 결과는 10, 20, 30이다. step과 n을 모두 클로저가 기억한다. 카운터를 새로 만들면 n은 0에서 다시 시작한다.

4. 출력은 끝 3 2 1 이다. 반복문 안의 defer 세 개는 함수가 끝날 때까지 미뤄지므로 "끝 "이 먼저 나온다. 그 뒤 나중에 등록한 것부터 실행되어 3, 2, 1이 출력된다. 각 defer의 i는 등록 시점에 계산되어, 그때의 값 1, 2, 3을 그대로 쓴다.

공식 언어 명세의 Defer statements 절에서 defer의 정확한 규칙을 확인할 수 있다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.