Go에서 defer, panic, recover: 언제 사용할까

1. 개요

Go에는 try/catch가 없습니다. 대신 언어는 세 가지 도구를 제공합니다. defer는 자원을 정리하는 데 사용되고, panic은 복구할 수 없는 오류를 보고하며, recover는 특정 경계에서 panic을 차단합니다.

이 글에서는 각 도구의 메커니즘을 설명하고, 자주 마주치는 함정들을 다루며, 가장 중요한 것인 언제 사용해야 하고 언제 사용하지 말아야 하는지를 살펴봅니다. 읽은 후에는 defer를 올바르게 사용하는 방법을 알 수 있고, panic이 언제 적절한지 알 수 있으며, 오류가 있는 요청 때문에 서버가 다운되지 않도록 recover를 사용하는 방법을 알 수 있습니다.

2. defer

defer는 감싸는 함수가 끝날 때까지 함수 호출을 연기합니다. 함수는 정상적인 return으로 끝나거나 panic으로 인해 끝날 수 있습니다. 두 경우 모두 defer된 호출이 실행됩니다.

2.1. 실행 순서는 LIFO

package main

import "fmt"

func main() {
	for i := 1; i <= 3; i++ {
		defer fmt.Println("defer", i)
	}
	fmt.Println("done")
}

defer를 만날 때마다 Go는 호출을 함수의 개별 스택에 푸시합니다. main이 끝나면 호출들이 역순으로 꺼내집니다.

done
defer 3
defer 2
defer 1

이 LIFO 순서에는 이유가 있습니다. 나중에 열린 자원은 일반적으로 먼저 열린 자원에 의존합니다. 예를 들어, 먼저 connection을 열고 transaction을 엽니다. 그러므로 transaction을 먼저 닫아야 connection을 닫을 수 있습니다.

2.2. 인자는 즉시 계산됨

이것이 첫 번째 함정입니다. defer된 함수의 인자는 defer를 만날 때 계산되며, 호출이 실제로 실행될 때가 아닙니다.

package main

import "fmt"

func main() {
	total := 10
	defer fmt.Println("deferred total:", total)

	defer func() {
		fmt.Println("closure total:", total)
	}()

	total = 20
	fmt.Println("current total:", total)
}
current total: 20
closure total: 20
deferred total: 10

첫 번째 호출에서 defer를 만날 때 값 10이 즉시 캡처됩니다. 두 번째 호출의 클로저는 다릅니다. 변수 total을 참조하므로 실행할 때 최신 값인 20을 읽습니다.

최종 값이 필요한 경우, 예를 들어 시간을 측정하거나 상태를 로깅할 때는 클로저를 사용하십시오.

2.3. 명명된 반환값 수정하기

defer된 클로저는 함수의 명명된 반환값을 읽고 쓸 수 있습니다. 이 기법은 Close()가 오류를 반환할 수 있을 때 매우 유용합니다. 예를 들어, 파일을 쓸 때 데이터가 실제로 닫힐 때만 플러시됩니다.

package main

import (
	"errors"
	"fmt"
	"os"
)

func writeReport(path string, data []byte) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer func() {
		err = errors.Join(err, f.Close())
	}()

	_, err = f.Write(data)
	return err
}

func main() {
	err := writeReport("/tmp/report.txt", []byte("Order #42: 3 items\n"))
	fmt.Println("error:", err)
}
error: <nil>

return err 문은 값을 변수 err에 할당한 다음 defer된 클로저가 실행됩니다. 클로저는 Go 1.20부터 있는 errors.Join을 사용하여 f.Close()의 오류를 err에 병합합니다. errors.Join은 두 오류가 모두 nil이면 nil을 반환합니다.

defer f.Close()만 작성하면 파일을 닫을 때 오류가 조용히 무시됩니다.

참고: 읽기 전용 파일의 경우 defer f.Close()로 충분합니다. 읽기 전용 파일을 닫을 때의 오류는 데이터에 거의 영향을 미치지 않습니다.

2.4. 함정: 루프 내 defer

func processAll(paths []string) error {
	for _, p := range paths {
		f, err := os.Open(p)
		if err != nil {
			return err
		}
		defer f.Close() // 잘못됨: processAll이 끝날 때만 닫습니다.
		// ... f를 읽음 ...
	}
	return nil
}

defer는 함수에 연결되며 블록이나 루프에는 연결되지 않습니다. 10,000개의 파일로 위의 코드는 10,000개의 파일 디스크립터를 동시에 열어 두고 있으며 too many open files 오류가 발생하기 쉽습니다.

해결책은 루프 본문을 별도의 함수로 분리하는 것입니다.

func processAll(paths []string) error {
	for _, p := range paths {
		if err := processFile(p); err != nil {
			return err
		}
	}
	return nil
}

func processFile(path string) error {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close() // processFile이 반환될 때 즉시 닫습니다.
	// ... f를 읽음 ...
	return nil
}

3. panic

panic은 현재 goroutine의 정상적인 실행 흐름을 중지합니다. Go는 스택을 unwind하기 시작합니다. 각 함수가 역순으로 반환되며 각 함수의 defer는 여전히 실행됩니다. goroutine의 스택 최상단에 도달하고 recover가 없으면 프로그램이 스택 트레이스를 인쇄하고 종료 코드 2로 종료됩니다.

3.1. unwind 과정 관찰하기

package main

import "fmt"

func main() {
	defer fmt.Println("main: deferred")
	processOrder()
	fmt.Println("never printed")
}

func processOrder() {
	defer fmt.Println("processOrder: deferred")
	panic("inventory service returned invalid state")
}
processOrder: deferred
main: deferred
panic: inventory service returned invalid state

goroutine 1 [running]:
main.processOrder()
	/tmp/main.go:13 +0x...
exit status 2

프로그램이 종료되기 전에 두 defer가 모두 실행되는 것을 볼 수 있습니다. 이것이 panic이 발생했을 때 defer mu.Unlock() 또는 defer tx.Rollback()이 여전히 안전한 이유입니다.

Panic은 명시적인 panic() 호출뿐만 아니라 런타임에서도 올 수 있습니다. 일반적인 경우는 범위 외 인덱스 접근, nil 포인터 역참조, nil 맵에 쓰기 또는 정수를 0으로 나누기입니다.

3.2. 언제 panic을 사용할까

Go의 일반적인 규칙: 예상 가능한 오류는 error를 반환하십시오. 예를 들어 파일이 없거나 입력 형식이 잘못되었거나 네트워크 시간 초과입니다. 다음 몇 가지 경우에만 panic을 사용하십시오.

1. 프로그래밍 오류, 즉 불변성 위반. 코드가 올바르면 “발생할 수 없는” 상태이며, 계속 실행하면 데이터가 손상됩니다.

func (s OrderStatus) String() string {
	switch s {
	case Pending:
		return "PENDING"
	case Paid:
		return "PAID"
	case Shipped:
		return "SHIPPED"
	}
	panic(fmt.Sprintf("unknown OrderStatus: %d", s))
}

2. 초기화 중에 사용되는 MustXxx 함수. 표준 라이브러리에는 regexp.MustCompile 및 template.Must가 이미 있습니다. 구성을 위해 유사한 함수를 작성할 수 있습니다.

package config

import (
	"fmt"
	"strconv"
)

func MustParsePort(s string) int {
	port, err := strconv.Atoi(s)
	if err != nil || port < 1 || port > 65535 {
		panic(fmt.Sprintf("invalid port %q", s))
	}
	return port
}

var emailPattern = regexp.MustCompile(`^[^@\s]+@[^@\s]+$`)

구성이 잘못되면 애플리케이션이 시작되지 않아야 합니다. 시작 시 panic은 서버가 손상된 상태로 실행되게 하는 대신 오류를 조기에 발견하는 데 도움이 됩니다.

참고: 사용자 또는 요청의 데이터로 MustXxx를 사용하지 마십시오. 잘못된 입력으로 인해 전체 프로세스가 다운되면 안 됩니다.

4. recover

recover()는 unwind 프로세스를 중지하고 panic에 전달된 값을 반환합니다. 이 함수는 defer된 함수 내에서 직접 호출될 때만 작동합니다. 다른 곳에서 호출하면 nil을 반환하고 아무것도 하지 않습니다.

4.1. panic을 error로 변환하기

package main

import "fmt"

func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("safeDivide(%d, %d): %v", a, b, r)
		}
	}()
	return a / b, nil
}

func main() {
	fmt.Println(safeDivide(10, 2))
	fmt.Println(safeDivide(10, 0))
}
5 <nil>
0 safeDivide(10, 0): runtime error: integer divide by zero

b == 0일 때, 런타임 panic이 발생합니다. defer된 클로저가 recover()로 panic을 캐치한 다음 명명된 반환값 err에 할당합니다. 함수는 정상적으로 반환되고 result는 0입니다.

이 예제는 단지 설명용입니다. 실제로는 b == 0을 확인하고 직접 error를 반환해야 합니다.

4.2. 함정: recover는 직접 호출되어야 함

func logPanic() {
	if r := recover(); r != nil {
		log.Println("recovered:", r)
	}
}

func handlerA() {
	defer logPanic() // 괜찮음: logPanic이 바로 defer된 함수입니다.
	panic("boom A")
}

func handlerB() {
	defer func() {
		logPanic() // 작동하지 않음: recover가 한 단계 더 깊이 있습니다.
	}()
	panic("boom B")
}

handlerA는 성공적으로 recover됩니다. handlerB는 여전히 프로그램을 충돌시킵니다. recover가 logPanic 내에서 호출되고, logPanic은 defer된 함수에 의해 호출되지만 그 자체로는 defer된 함수가 아니기 때문입니다.

4.3. 함정: recover는 다른 goroutine의 panic을 캐치하지 못함

각 goroutine은 자체 스택을 가집니다. 자식 goroutine의 panic은 전체 프로세스를 충돌시키며, main이 recover를 가지고 있는지 여부와 상관없습니다.

func main() {
	defer func() { recover() }() // 아래 goroutine에 대해 쓸모없습니다.

	go func() {
		panic("worker crashed") // 프로세스는 여전히 죽습니다.
	}()
	time.Sleep(time.Second)
}

배경 작업을 실행하지만 내부 코드를 완전히 제어할 수 없는 경우 래핑하십시오.

func SafeGo(name string, fn func()) {
	go func() {
		defer func() {
			if r := recover(); r != nil {
				log.Printf("goroutine %s panicked: %v\n%s", name, r, debug.Stack())
			}
		}()
		fn()
	}()
}

debug.Stack()은 runtime/debug 패키지에 있습니다. 나중에 디버그할 수 있도록 충분한 스택 트레이스를 로깅하는 데 도움이 됩니다.

4.4. HTTP 경계에서의 recover

recover를 배치하기 좋은 장소는 요청 경계입니다. 오류가 있는 요청이 다른 요청을 끌어당기지 않아야 합니다.

func RecoverMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if rec := recover(); rec != nil {
				if rec == http.ErrAbortHandler {
					panic(rec) // net/http가 설계대로 abort를 처리하도록 합니다.
				}
				log.Printf("panic on %s %s: %v\n%s", r.Method, r.URL.Path, rec, debug.Stack())
				http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

net/http는 이미 각 연결에 대해 panic을 recover하지만, 로그만 하고 연결을 닫으므로 클라이언트는 정상적인 응답을 받지 못합니다. 위의 미들웨어는 명확한 500 코드를 반환하고 추적하기 쉽도록 메서드와 경로를 로깅합니다.

5. panic과 recover를 위한 테스트 작성

Go는 표준 testing 패키지를 사용하며 외부 의존성이 필요하지 않습니다.

package config

import (
	"strings"
	"testing"
)

func TestSafeDivide_ByZero_ReturnsError(t *testing.T) {
	result, err := safeDivide(10, 0)

	if err == nil {
		t.Fatal("오류가 예상되었지만 nil을 받았습니다.")
	}
	if result != 0 {
		t.Errorf("예상된 결과 0, 받은 %d", result)
	}
	if !strings.Contains(err.Error(), "divide by zero") {
		t.Errorf("예상치 못한 오류 메시지: %v", err)
	}
}

func TestMustParsePort_InvalidInput_Panics(t *testing.T) {
	defer func() {
		r := recover()
		if r == nil {
			t.Fatal("panic이 예상되었지만 없습니다.")
		}
		if msg, _ := r.(string); !strings.Contains(msg, `"abc"`) {
			t.Errorf("예상치 못한 panic 값: %v", r)
		}
	}()

	MustParsePort("abc")
}

func TestMustParsePort_ValidInput(t *testing.T) {
	if got := MustParsePort("8080"); got != 8080 {
		t.Errorf("8080이 예상되었지만 %d를 받았습니다.", got)
	}
}
go test ./... -v
=== RUN   TestSafeDivide_ByZero_ReturnsError
--- PASS: TestSafeDivide_ByZero_ReturnsError (0.00s)
=== RUN   TestMustParsePort_InvalidInput_Panics
--- PASS: TestMustParsePort_InvalidInput_Panics (0.00s)
=== RUN   TestMustParsePort_ValidInput
--- PASS: TestMustParsePort_ValidInput (0.00s)
PASS

두 번째 테스트는 defer와 recover를 사용하여 함수가 panic하고 올바른 메시지로 panic하는지 확인합니다. testify를 사용하면 assert.PanicsWithValue가 더 적은 코드로 유사한 결과를 제공합니다.

6. 언제 사용하지 말까

  • panic/recover를 예외로 사용하지 마십시오. panic(ErrNotFound)를 작성한 다음 위에서 recover하면 제어 흐름을 읽기 어렵게 만들고 Go 규약에 어긋납니다. error를 반환하십시오.
  • public 라이브러리에서 panic하지 마십시오. 오류가 호출자의 입력에서 비롯된 경우, 라이브러리 사용자는 자신의 프로세스가 죽을 것으로 기대하지 않습니다.
  • recover한 후 조용히 무시하지 마십시오. 최소한 panic 값과 스택 트레이스를 로깅하십시오. 상태가 손상되었다면(예: 공유 데이터가 부분적으로 쓰여진 경우) 프로세스를 죽이고 orchestrator가 다시 시작하도록 하십시오.
  • 긴 루프에서 defer하지 마십시오. (섹션 2.4 참조)
  • defer의 성능에 대해 과도하게 걱정하지 마십시오. Go 1.14부터 open-coded defer는 대부분의 경우 거의 비용이 들지 않습니다. profiler가 병목을 나타낼 때만 최적화하십시오.

참고: Go 1.21부터 panic(nil)은 더 이상 recover()가 nil을 반환하게 하지 않습니다. 대신 recover()는 *runtime.PanicNilError를 반환합니다. “panic이 없다”를 확인하기 위해 recover() == nil을 사용하는 이전 코드는 이제 더 정확합니다.

7. 결론

이 세 가지 도구는 매우 다른 역할을 합니다.

  • defer는 자원을 정리하기 위한 일상적인 도구입니다. 두 가지를 기억하십시오: 인자는 defer를 만날 때 계산되고, defer는 함수에 연결되며 루프에는 연결되지 않습니다. 명명된 반환값과 결합하면 defer는 Close()의 오류를 놓치지 않도록 합니다.
  • panic은 프로그래밍 오류와 복구할 수 없는 초기화 오류를 위한 것입니다. 다른 오류는 error를 반환하십시오.
  • recover는 경계에서만 나타나야 합니다: HTTP 미들웨어, worker goroutine 또는 제어할 수 없는 코드를 호출하는 곳. defer된 함수 내에서 직접 호출되어야 하며, 그 goroutine 자신의 panic만 캐치합니다.

이러한 경계를 이해하면 안전하고 Go의 정신에 맞는 코드를 작성할 수 있습니다: 오류는 값이고, panic은 진정한 예외입니다.

golangdeferpanicrecovererror-handling