defer, panic, recover
defer, panic, recover란?
Go는 예외(Exception) 메커니즘이 없습니다. 대신 세 가지 내장 메커니즘으로 에러 처리와 리소스 정리를 담당합니다.
defer: 함수 종료 시점에 실행할 코드를 예약합니다. 리소스 정리에 주로 사용합니다.panic: 복구 불가능한 오류 상황에서 프로그램 실행을 중단시킵니다.recover:panic으로 중단된 실행을 복구합니다. 반드시defer함수 내에서만 동작합니다.
이 세 가지는 각각 독립적으로도, 조합해서도 사용합니다. 특히 defer + recover 조합은 Go의 에러 처리에서 중요한 패턴입니다.
defer 기본 동작
defer는 현재 함수가 리턴할 때 실행할 함수 호출을 예약합니다. 리턴 이유가 정상 종료이든, 에러 리턴이든, panic이든 상관없이 항상 실행됩니다.
package main
import "fmt"
func greet() {
defer fmt.Println("안녕히 계세요!") // 함수 종료 시 실행
fmt.Println("안녕하세요!")
fmt.Println("잘 지내세요?")
}
func main() {
greet()
fmt.Println("main 계속 실행")
}
실행 결과:
안녕하세요!
잘 지내세요?
안녕히 계세요!
main 계속 실행
defer에 전달하는 인자는 예약 시점에 평가 됩니다. 나중에 실행되더라도 인자 값은 defer를 만난 순간에 결정됩니다.
package main
import "fmt"
func main() {
x := 10
defer fmt.Println("defer 실행 시 x:", x) // x는 10으로 고정됨
x = 20
fmt.Println("현재 x:", x)
}
실행 결과:
현재 x: 20
defer 실행 시 x: 10
defer의 LIFO 실행 순서
여러 defer가 있을 때 LIFO(Last In, First Out) 스택 순서로 실행됩니다. 나중에 등록한 defer가 먼저 실행됩니다.
package main
import "fmt"
func main() {
fmt.Println("시작")
defer fmt.Println("defer 1 — 첫 번째 등록, 마지막 실행")
defer fmt.Println("defer 2 — 두 번째 등록")
defer fmt.Println("defer 3 — 세 번째 등록, 첫 번째 실행")
fmt.Println("끝")
}
실행 결과:
시작
끝
defer 3 — 세 번째 등록, 첫 번째 실행
defer 2 — 두 번째 등록
defer 1 — 첫 번째 등록, 마지막 실행
이 순서는 중첩 리소스를 정리할 때 자연스럽게 역순 정리가 됩니다. 예를 들어 파일 A를 열고, 파일 B를 열었다면 B → A 순으로 닫히는 것이 안전합니다.
defer + 클로저 조합
defer에 익명 함수(클로저)를 전달하면 인자 고정 없이 변수를 공유할 수 있습니다. 이를 활용해 함수 실행 시간 측정이나 반환값 수정이 가능합니다.
package main
import (
"fmt"
"time"
)
// 실행 시간 측정
func measureTime(name string) func() {
start := time.Now()
return func() {
elapsed := time.Since(start)
fmt.Printf("[%s] 실행 시간: %v\n", name, elapsed)
}
}
// 명명된 반환값 수정
func divide(a, b float64) (result float64, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("나눗셈 패닉 복구: %v", r)
result = 0
}
}()
if b == 0 {
panic("0으로 나눌 수 없습니다")
}
return a / b, nil
}
func heavyCalculation() int {
defer measureTime("heavyCalculation")() // 즉시 호출해 클로저를 defer에 등록
total := 0
for i := 0; i < 1_000_000; i++ {
total += i
}
return total
}
func main() {
result := heavyCalculation()
fmt.Printf("계산 결과: %d\n", result)
fmt.Println()
r1, err := divide(10, 3)
fmt.Printf("10/3 = %.4f, err = %v\n", r1, err)
r2, err := divide(10, 0)
fmt.Printf("10/0 = %.4f, err = %v\n", r2, err)
}
실행 결과:
[heavyCalculation] 실행 시간: 1.234ms
계산 결과: 499999500000
10/3 = 3.3333, err = <nil>
10/0 = 0.0000, err = 나눗셈 패닉 복구: 0으로 나눌 수 없습니다
panic — 복구 불가능한 오류
panic은 현재 함수 실행을 중단하고 콜 스택을 역방향으로 올라가며 defer를 실행합니다. recover로 잡지 않으면 프로그램이 종료됩니다.
package main
import "fmt"
func level3() {
fmt.Println("level3 시작")
panic("level3에서 패닉!")
fmt.Println("level3 끝 — 실행 안 됨")
}
func level2() {
defer fmt.Println("level2 defer 실행")
fmt.Println("level2 시작")
level3()
fmt.Println("level2 끝 — 실행 안 됨")
}
func level1() {
defer fmt.Println("level1 defer 실행")
fmt.Println("level1 시작")
level2()
fmt.Println("level1 끝 — 실행 안 됨")
}
func main() {
defer fmt.Println("main defer 실행")
fmt.Println("main 시작")
level1()
fmt.Println("main 끝 — 실행 안 됨")
}
실행 결과:
main 시작
level1 시작
level2 시작
level3 시작
level2 defer 실행
level1 defer 실행
main defer 실행
goroutine 1 [running]:
main.level3(...)
...
panic: level3에서 패닉!
panic이 발생하면 각 스택 프레임의 defer는 실행되지만, recover 없이는 프로그램이 종료됩니다.
recover — 패닉 복구
recover 는 panic을 잡아 프로그램이 계속 실행되게 합니다. 반드시 defer 내에서 호출해야 하며, panic 값을 반환합니다. 패닉이 없으면 nil을 반환합니다.
package main
import "fmt"
func safeDiv(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
// recover()가 panic의 값을 반환
err = fmt.Errorf("패닉 복구: %v", r)
}
}()
return a / b, nil // b == 0이면 런타임 패닉
}
func mustPositive(n int) int {
if n <= 0 {
panic(fmt.Sprintf("양수여야 합니다: %d", n))
}
return n
}
func safeOperation(n int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("%v", r)
}
}()
result = mustPositive(n)
return
}
func main() {
// 0으로 나누기 패닉 복구
r1, err := safeDiv(10, 2)
fmt.Printf("10/2 = %d, err = %v\n", r1, err)
r2, err := safeDiv(10, 0)
fmt.Printf("10/0 = %d, err = %v\n", r2, err)
// 커스텀 패닉 복구
r3, err := safeOperation(5)
fmt.Printf("safeOperation(5) = %d, err = %v\n", r3, err)
r4, err := safeOperation(-1)
fmt.Printf("safeOperation(-1) = %d, err = %v\n", r4, err)
}
실행 결과:
10/2 = 5, err = <nil>
10/0 = 0, err = 패닉 복구: runtime error: integer divide by zero
safeOperation(5) = 5, err = <nil>
safeOperation(-1) = 0, err = 양수여야 합니다: -1
실전 예제: 파일 핸들 정리
defer의 가장 일반적인 사용처는 파일, DB 연결, 뮤텍스 같은 리소스 정리입니다. 리소스를 얻은 직후 defer로 정리를 예약하면 리턴 경로가 여러 개여도 누락이 없습니다.
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
// 파일을 읽어 단어 빈도를 세는 함수
func countWords(filename string) (map[string]int, error) {
f, err := os.Open(filename)
if err != nil {
return nil, fmt.Errorf("파일 열기 실패: %w", err)
}
defer f.Close() // 함수가 어떻게 종료되든 파일은 반드시 닫힘
counts := make(map[string]int)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
words := strings.Fields(scanner.Text())
for _, word := range words {
// 소문자로 통일, 구두점 제거
word = strings.ToLower(strings.Trim(word, ".,!?\"'"))
if word != "" {
counts[word]++
}
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("파일 읽기 오류: %w", err)
}
return counts, nil
}
// 임시 파일 생성 후 처리, 항상 삭제
func withTempFile(content string, process func(string) error) error {
tmpFile, err := os.CreateTemp("", "go-defer-*.txt")
if err != nil {
return fmt.Errorf("임시 파일 생성 실패: %w", err)
}
// 함수 종료 시 임시 파일 삭제 (정리 예약)
defer func() {
tmpFile.Close()
os.Remove(tmpFile.Name())
fmt.Printf("임시 파일 삭제: %s\n", tmpFile.Name())
}()
// 내용 쓰기
if _, err := tmpFile.WriteString(content); err != nil {
return fmt.Errorf("임시 파일 쓰기 실패: %w", err)
}
tmpFile.Close() // 쓰기 완료 후 닫기 (defer의 Close와 중복이지만 안전)
return process(tmpFile.Name())
}
func main() {
content := "Go is an open source programming language. Go makes it easy to build simple reliable and efficient software."
err := withTempFile(content, func(filename string) error {
counts, err := countWords(filename)
if err != nil {
return err
}
fmt.Println("단어 빈도:")
// 출현 횟수 2 이상인 단어만 출력
for word, count := range counts {
if count >= 2 {
fmt.Printf(" %q: %d회\n", word, count)
}
}
return nil
})
if err != nil {
fmt.Println("에러:", err)
}
}
실행 결과:
단어 빈도:
"go": 2회
"simple": ...
임시 파일 삭제: /tmp/go-defer-123456.txt
실전 예제: HTTP 미들웨어에서 panic recover
웹 서버에서 요청 처리 중 패닉이 발생하면 서버 전체가 죽을 수 있습니다. recover 미들웨어로 고루틴별 패닉을 잡아 서버를 보호합니다.
package main
import (
"fmt"
"log"
"net/http"
"runtime/debug"
)
// 패닉 복구 미들웨어
func recoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
// 스택 트레이스 로깅
log.Printf("패닉 복구: %v\n%s", err, debug.Stack())
// 클라이언트에게 500 에러 반환
http.Error(w, "서버 내부 오류가 발생했습니다", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// 로깅 미들웨어
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("요청: %s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
// 핸들러 — 의도적으로 패닉 발생
func dangerousHandler(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("panic") == "true" {
panic("의도적 패닉!")
}
fmt.Fprintln(w, "정상 응답")
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", dangerousHandler)
// 미들웨어 체인: logging → recover → handler
handler := loggingMiddleware(recoverMiddleware(mux))
fmt.Println("서버 시작: http://localhost:8080")
fmt.Println("테스트:")
fmt.Println(" curl http://localhost:8080/")
fmt.Println(" curl 'http://localhost:8080/?panic=true'")
if err := http.ListenAndServe(":8080", handler); err != nil {
log.Fatal(err)
}
}
?panic=true로 요청하면 핸들러에서 패닉이 발생하지만 recover 미들웨어가 잡아내어 서버는 계속 동작합니다.
고수 팁
1. panic은 정말 예외적인 상황에만
// 적절한 panic 사용 — 프로그래머 실수 (초기화 오류)
func mustCompile(pattern string) *regexp.Regexp {
re, err := regexp.Compile(pattern)
if err != nil {
panic(fmt.Sprintf("잘못된 정규식: %q: %v", pattern, err))
}
return re
}
// 부적절한 panic — 예측 가능한 에러는 error로 리턴
func openFile(path string) (*os.File, error) {
f, err := os.Open(path)
if err != nil {
return nil, err // panic이 아닌 error 반환
}
return f, nil
}
2. recover 후에는 반드시 에러로 변환
func safeExec(fn func()) (err error) {
defer func() {
if r := recover(); r != nil {
// 패닉을 error로 변환해 호출자에게 알림
err = fmt.Errorf("패닉 발생: %v", r)
}
}()
fn()
return nil
}
3. defer 비용 인식
defer는 함수 호출 오버헤드가 있습니다. 수백만 번 호출되는 핫패스 함수에서는 프로파일링 후 defer 제거를 고려하세요. 단, 99%의 코드에서는 성능 차이가 무시할 수 있는 수준입니다.
4. 루프 안 defer — 함정 주의
루프 안에 defer를 쓰면 루프가 끝나도 함수가 끝날 때까지 실행되지 않습니다. 파일을 여러 개 열면서 루프 안에서 defer f.Close()를 쓰면 파일 핸들이 함수 종료까지 모두 열려 있게 됩니다. 자세한 내용은 pro-tips.md를 참고하세요.