본문으로 건너뛰기

defer, panic, recover

이 챕터에서 다루는 것

defer는 정리 코드를 여는 코드 바로 옆에 둘 수 있게 해 준다. 개념은 단순하지만 인자를 언제 평가하는가반환값을 어떻게 고치는가에서 정확히 두 번 배신당한다. 그다음 panicrecover가 예외가 아니라는 것, 그래서 언제 써야 하고 언제 쓰면 안 되는지를 다룬다.

문제: 정리 코드는 멀어진다

파일을 열면 닫아야 하고, 잠그면 풀어야 하고, 연결하면 끊어야 한다. 문제는 그 사이에 return이 여러 개 생긴다는 것이다.

f, err := os.Open(name)
if err != nil {
return err
}

data, err := io.ReadAll(f)
if err != nil {
f.Close() // 잊기 쉽다
return err
}

if len(data) == 0 {
f.Close() // 또
return errors.New("빈 파일")
}

f.Close() // 또
return nil

Java의 try-finallytry-with-resources, Python의 with, C++의 RAII가 각자 이 문제를 푼다. Go의 답이 defer다.

f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close()
// 이후 어디서 return 하든 닫힌다

여는 코드와 닫는 코드가 붙어 있다. 이게 defer의 가장 큰 가치다.

실행 시점과 순서

defer로 등록한 호출은 함수가 반환할 때 실행된다. 블록을 벗어날 때가 아니라 함수를 벗어날 때다. 그리고 여러 개면 역순(LIFO) 이다.

examples/02-language-basics/07-defer-basics/main.go
package main

import (
"fmt"
"time"
)

func order() {
fmt.Println("함수 시작")
defer fmt.Println("defer 1")
defer fmt.Println("defer 2")
defer fmt.Println("defer 3")
fmt.Println("함수 끝")
}

func argEval() {
i := 0
defer fmt.Println("인자로 넘긴 i:", i) // 지금 0으로 확정된다
defer func() {
fmt.Println("클로저가 읽은 i:", i) // 실행 시점에 읽는다
}()
i = 42
}

// elapsedRight는 클로저로 감싸 실행 시점에 계산하게 한다.
func elapsedRight() {
start := time.Now()
defer func() {
fmt.Printf("걸린 시간(맞음): %v\n", time.Since(start).Round(10*time.Millisecond))
}()
time.Sleep(50 * time.Millisecond)
}

func main() {
order()
fmt.Println("---")
argEval()
fmt.Println("---")
elapsedRight()
}
go run ./07-defer-basics
함수 시작
함수 끝
defer 3
defer 2
defer 1
---
클로저가 읽은 i: 42
인자로 넘긴 i: 0
---
걸린 시간(맞음): 50ms

LIFO인 이유는 자원의 의존 관계 때문이다. 나중에 연 것이 먼저 닫혀야 한다. 파일을 열고 → 그 위에 버퍼를 감싸고 → 트랜잭션을 시작했다면, 정리는 반대 순서로 가야 한다.

인자는 defer를 만나는 순간 평가된다

위 출력에서 가장 중요한 두 줄이다.

클로저가 읽은 i: 42
인자로 넘긴 i: 0
  • defer fmt.Println("...", i)idefer 문을 만나는 그 순간 평가돼서 0으로 고정된다. 나중에 i = 42를 해도 소용없다. defer가 미루는 것은 호출이지 인자 평가가 아니다.
  • defer func(){ ... i ... }() — 클로저는 i라는 변수를 붙잡으므로 (2-6) 실행 시점의 값 42를 읽는다.

출력 순서가 뒤집혀 있는 것(42가 먼저)은 LIFO 때문이다.

이 규칙을 모르면 가장 자주 밟는 지뢰가 실행 시간 측정이다.

package main

import (
"fmt"
"time"
)

func main() {
start := time.Now()
defer fmt.Println("걸린 시간:", time.Since(start))
time.Sleep(50 * time.Millisecond)
}
걸린 시간: 33.25µs

50ms를 잤는데 33마이크로초가 나온다. time.Since(start)defer 문에서 즉시 계산됐기 때문이다. 다행히 go vet이 잡아 준다.

go vet ./...
main.go:10:38: call to time.Since is not deferred

1-6에서 목록으로 봤던 defers 분석기가 이것이다. 고치려면 앞 예제의 elapsedRight처럼 클로저로 감싼다.

:::tip 메서드 리시버도 인자다 defer mu.Unlock()에서 mudefer 시점에 평가된다. 그래서 mu가 나중에 다른 뮤텍스를 가리키게 바뀌어도 원래 것이 풀린다. 보통은 이게 원하는 동작이다. :::

반환값 고치기

defer는 함수가 반환값을 정한 다음, 호출자에게 돌아가기 전에 실행된다. 그 사이에 명명된 반환값을 고칠 수 있다.

examples/02-language-basics/07-defer-return/main.go
package main

import (
"errors"
"fmt"
)

// named는 명명된 반환값을 defer가 고친다.
func named() (n int) {
defer func() { n *= 2 }()
return 5
}

// unnamed는 반환값에 이름이 없어 defer가 손댈 수 없다.
func unnamed() int {
n := 5
defer func() { n *= 2 }()
return n
}

// wrap은 실무에서 이 기능을 쓰는 전형적인 모양이다.
// 함수 어디에서 return 하든 에러에 같은 맥락이 붙는다.
func wrap(id int) (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("사용자 %d 처리: %w", id, err)
}
}()

if id < 0 {
return errors.New("음수 ID")
}
if id == 0 {
return errors.New("ID 없음")
}
return nil
}

func main() {
fmt.Println("named: ", named())
fmt.Println("unnamed:", unnamed())

for _, id := range []int{-1, 0, 7} {
fmt.Printf("wrap(%d) = %v\n", id, wrap(id))
}
}
go run ./07-defer-return
named: 10
unnamed: 5
wrap(-1) = 사용자 -1 처리: 음수 ID
wrap(0) = 사용자 0 처리: ID 없음
wrap(7) = <nil>

return 5가 실제로 하는 일을 풀어 쓰면 이렇다.

  1. 반환값 변수 n에 5를 대입한다.
  2. 등록된 defer들을 역순으로 실행한다. 여기서 n이 10이 된다.
  3. n의 현재 값을 호출자에게 돌려준다.

unnamed에서는 2단계에서 고칠 대상이 없다. n은 그냥 지역 변수이고, 반환값은 이미 익명의 자리에 복사됐다. 그래서 5가 나온다.

wrap이 이 기능의 실전 용도다. return 지점이 여러 개인 함수에 일관된 에러 맥락을 한 곳에서 붙인다. 2-5에서 "명명된 반환값을 쓸 만한 경우"로 꼽았던 바로 그것이다.

:::warning defer 안의 에러도 에러다

defer f.Close()

쓰기 파일에서는 Close의 에러가 중요하다. 버퍼가 디스크에 안 내려갔을 수 있기 때문이다. 그럴 때는 명명된 반환값과 조합한다.

func write(name string) (err error) {
f, err := os.Create(name)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr
}
}()
// ...
return nil
}

읽기 전용 파일이라면 defer f.Close()로 충분하다. :::

루프 안의 defer

defer함수가 끝날 때 실행된다. 루프가 끝날 때가 아니다.

examples/02-language-basics/07-defer-loop/main.go
package main

import "fmt"

// resource는 파일 핸들이나 커넥션 같은 것을 흉내 낸다.
type resource struct{ id int }

func open(id int) *resource {
fmt.Println("열기", id)
return &resource{id: id}
}

func (r *resource) close() {
fmt.Println("닫기", r.id)
}

// badLoop은 defer를 루프 안에 둔다. 함수가 끝날 때까지 아무것도 닫히지 않는다.
func badLoop() {
fmt.Println("[badLoop]")
for i := range 3 {
r := open(i)
defer r.close()
}
fmt.Println("루프 끝 — 아직 아무것도 닫히지 않았다")
}

// goodLoop은 반복 하나를 함수로 감싼다. 매 반복이 끝날 때 닫힌다.
func goodLoop() {
fmt.Println("[goodLoop]")
for i := range 3 {
func() {
r := open(i)
defer r.close()
}()
}
fmt.Println("루프 끝 — 전부 닫혀 있다")
}

func main() {
badLoop()
fmt.Println("---")
goodLoop()
}
go run ./07-defer-loop
[badLoop]
열기 0
열기 1
열기 2
루프 끝 — 아직 아무것도 닫히지 않았다
닫기 2
닫기 1
닫기 0
---
[goodLoop]
열기 0
닫기 0
열기 1
닫기 1
열기 2
닫기 2
루프 끝 — 전부 닫혀 있다

3회 반복이면 아무 문제 없어 보이지만, 파일 10만 개를 처리하는 루프라면 too many open files로 죽는다. 실제 운영 장애의 단골 원인이다.

해법은 두 가지다.

  • 반복 하나를 함수로 감싼다. 위의 goodLoop처럼 익명 함수를 쓰거나, 이름 있는 함수로 뽑아낸다. 이름 있는 함수 쪽이 대개 더 낫다.
  • defer를 쓰지 않고 명시적으로 닫는다. 에러 처리 경로가 단순하다면 이쪽이 솔직하다.

panic과 recover

panic은 예외가 아니다. 프로그램이 계속 진행할 수 없는 상태를 알리는 장치다. panic이 발생하면 현재 함수의 실행이 중단되고, 등록된 defer들을 실행하면서 호출 스택을 거슬러 올라간다. main까지 올라가면 스택 트레이스를 찍고 프로세스가 종료된다.

panic이 정당한 경우

  • 프로그래머의 실수로만 생길 수 있는 상황. 슬라이스 범위 초과, nil 맵에 쓰기, nil 포인터 역참조 — 런타임이 내는 패닉이 전부 이 부류다.
  • 초기화 실패. regexp.MustCompile, template.Must처럼 이름에 Must가 붙은 함수는 실패하면 패닉한다. 프로그램 시작 시점에 상수 패턴을 컴파일하는 용도라, 실패하면 어차피 실행할 수 없다.
  • 불변 조건이 깨졌을 때. "여기 도달하면 안 되는데 도달했다"는 상황.

panic이 정당하지 않은 경우

입력이 잘못됐다, 파일이 없다, 네트워크가 끊겼다 — 전부 에러로 반환할 일이다. Go 라이브러리는 패키지 경계를 넘어 패닉을 흘려보내지 않는다. 공개 API는 에러를 반환한다.

recover

recoverdefer된 함수 안에서 직접 호출될 때만 동작한다.

examples/02-language-basics/07-panic-recover/main.go
package main

import (
"errors"
"fmt"
)

// safeDivide는 패닉을 에러로 바꿔 호출자에게 돌려준다.
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("복구됨: %v", r)
}
}()
return a / b, nil
}

// notRecovered는 recover를 defer 안에서 직접 호출하지 않는다. 복구되지 않는다.
func notRecovered() {
defer func() {
helper() // 여기서 recover를 불러도 소용없다
}()
panic("잡히지 않는다")
}

func helper() {
if r := recover(); r != nil {
fmt.Println("helper가 복구:", r)
}
}

// runAll은 각 작업의 패닉이 전체를 죽이지 않도록 격리한다.
// 라이브러리·서버 경계에서 recover를 쓰는 정당한 예다.
func runAll(tasks []func()) (errs []error) {
for i, task := range tasks {
func() {
defer func() {
if r := recover(); r != nil {
errs = append(errs, fmt.Errorf("작업 %d 패닉: %v", i, r))
}
}()
task()
}()
}
return errs
}

func main() {
fmt.Println(safeDivide(10, 2))
fmt.Println(safeDivide(1, 0))

errs := runAll([]func(){
func() { fmt.Println("작업 0 정상") },
func() { panic("작업 1 실패") },
func() { var m map[string]int; m["x"] = 1 },
func() { panic(errors.New("작업 3 실패")) },
})
for _, err := range errs {
fmt.Println(err)
}

defer fmt.Println("main의 defer는 실행된다")
notRecovered()
}
go run ./07-panic-recover
5 <nil>
0 복구됨: runtime error: integer divide by zero
작업 0 정상
작업 1 패닉: 작업 1 실패
작업 2 패닉: assignment to entry in nil map
작업 3 패닉: 작업 3 실패
main의 defer는 실행된다
panic: 잡히지 않는다

goroutine 1 [running]:
main.notRecovered()
.../07-panic-recover/main.go:23 +0x48
main.main()
.../07-panic-recover/main.go:63 +0x1d8
exit status 2

(스택 트레이스의 경로는 줄였다. exit status 2go run이 알려 주는 종료 코드다.)

읽을 것이 네 가지 있다.

  • safeDivide는 런타임 패닉까지 잡는다. integer divide by zero도 패닉이므로 recover로 잡힌다. 명명된 반환값 err에 대입하는 것이 핵심이다.
  • runAll은 정당한 패턴이다. 남의 코드(플러그인, 핸들러)를 실행하는 경계에서 하나의 패닉이 전체를 죽이지 않도록 격리한다. net/http 서버가 요청 핸들러의 패닉을 이렇게 처리한다.
  • notRecovered는 실패한다. recover를 다른 함수(helper) 안에서 호출했기 때문이다. recoverdefer로 등록된 함수 본문에서 직접 불려야 한다. 이 규칙을 모르면 "왜 안 잡히지?"로 오래 헤맨다.
  • 패닉 중에도 defer는 실행된다. main의 defer는 실행된다가 스택 트레이스보다 먼저 찍히는 게 그 증거다. 스택을 거슬러 올라가며 각 함수의 defer를 실행한다.

:::danger recover는 고루틴을 넘지 못한다

go func() {
panic("여기서 죽는다")
}()

바깥 함수의 recover는 이 패닉을 잡지 못한다. 고루틴 하나가 패닉하면 프로세스 전체가 죽는다. 고루틴 안에서 남의 코드를 실행한다면 그 고루틴 안에 defer recover()를 직접 넣어야 한다. Part 7에서 다시 다룬다. :::

흔히 하는 실수

1. defer의 인자가 나중에 평가될 거라 기대한다

앞서 본 time.Since 사례다. defer 문에서 값이 확정된다. go vetdefers 분석기가 time.Since는 잡아 주지만, 일반적인 경우는 못 잡는다. 실행 시점 값이 필요하면 클로저다.

2. 루프 안에서 defer를 쓴다

핸들이 쌓인다. 반복 하나를 함수로 감싸자.

3. recover를 예외 처리처럼 쓴다

func doWork() {
defer func() { recover() }() // 모든 패닉을 삼킨다
// ...
}

이러면 버그가 있어도 프로그램이 조용히 잘못된 상태로 계속 돈다. 패닉은 "여기서 멈춰야 한다"는 신호이고, 그걸 삼키는 것은 신호를 끄는 것이다. recover를 쓴다면 반드시 로그를 남기고, 무엇을 복구했는지 호출자에게 알려야 한다.

4. 에러 대신 패닉을 반환한다

func ParseConfig(s string) Config {
if s == "" {
panic("빈 설정") // 호출자가 처리할 수 있는 상황이다
}
// ...
}

(Config, error)를 반환하자. 패닉은 호출자에게 선택권을 주지 않는다.

5. defer f.Close()의 에러를 항상 무시한다

읽기 전용이면 괜찮지만, 쓰기 파일에서는 Close가 실제 디스크 쓰기 실패를 보고한다. 앞의 admonition에 있는 패턴을 쓰자.

6. 성능을 걱정해서 defer를 피한다

예전에는 defer에 눈에 띄는 오버헤드가 있었지만, Go 1.14 이후 대부분의 경우 인라인 수준으로 최적화됐다. 초당 수백만 번 호출되는 아주 좁은 경로가 아니라면 가독성을 택하는 게 맞다. 걱정된다면 벤치마크로 확인한다(Part 8-5).

정리

  • defer함수가 반환할 때, 등록의 역순으로 실행된다.
  • 인자는 defer 문을 만나는 순간 평가된다. 실행 시점 값이 필요하면 클로저로 감싼다. go vetdefers 분석기가 time.Since 같은 대표 사례를 잡는다.
  • defer명명된 반환값을 고칠 수 있다. 에러에 맥락을 일괄로 붙이는 데 쓴다.
  • 루프 안의 defer는 함수가 끝날 때까지 쌓인다. 반복 하나를 함수로 감싼다.
  • panic은 예외가 아니다. 복구 불가능한 상태와 초기화 실패에만 쓴다.
  • recoverdefer된 함수 본문에서 직접 호출해야 하고, 고루틴을 넘지 못한다.
  • 패닉을 조용히 삼키지 않는다.

연습문제

  1. defer를 세 개 등록하고 그중 하나에서 패닉을 일으켜 보자. 나머지 defer는 실행되는가? 그다음 그 패닉을 다른 defer에서 recover해 보자. 어느 순서로 실행되는가?

  2. 함수의 실행 시간을 재는 trace(name string) func()를 만들어 보자. defer trace("작업")()처럼 끝에 괄호를 붙여 쓰는 형태여야 한다. 힌트: trace는 즉시 실행되고, 그것이 반환한 함수가 defer된다. 왜 이 형태가 동작하는지가 이 문제의 핵심이다.

  3. runAll을 고쳐서, 패닉이 아니라 원래 에러를 반환하는 작업도 함께 다루도록 만들어 보자. 시그니처를 []func() error로 바꾸면 어떻게 되는가? 패닉과 에러를 호출자가 구분할 수 있어야 하는가, 아니면 같은 것으로 취급해도 되는가?