Go 언어 panic과 recover 동작 원리와 실무 패턴

Go 언어 panic과 recover: 발생 원리, defer 관계, 실무 패턴

Go 언어는 예외(Exception)를 사용하지 않습니다. 대신 일반적인 에러는 error 값으로, 회복 불가능한 치명적 오류는 panic으로 구분하여 처리합니다. 이 두 가지를 명확히 구분하는 것이 Go 프로그래밍의 핵심입니다.

이 글에서는 panic의 발생 원리와 내부 동작, defer와의 깊은 관계, recover의 올바른 사용법, 그리고 실무에서 자주 쓰이는 must 패턴HTTP 미들웨어 복구 패턴까지 심층적으로 다룹니다.

🛠️ 필자의 경험

처음 Go를 사용할 때, 인덱스 범위를 벗어난 슬라이스에 접근했다가 프로그램이 완전히 멈춰버린 경험이 있었습니다. panic: runtime error: index out of range라는 메시지와 함께 스택 트레이스가 출력되었는데, 처음에는 당황스러웠지만 이것이 오히려 버그의 위치를 정확히 알려준다는 것을 깨달았습니다. panic은 “이건 절대 일어나면 안 되는 일”을 알려주는 알람입니다.


1. panic이란 무엇인가: 회복 불가능한 오류의 신호

panic은 프로그램이 정상적인 실행을 계속할 수 없는 상태에 빠졌을 때 발생합니다. panic이 발생하면 다음 순서로 실행됩니다.

1. 현재 함수의 실행 즉시 중단
2. 호출 스택을 거슬러 올라가며 defer 함수 실행 (스택 언와인딩)
3. recover를 만나면 복구, 없으면 프로그램 종료
4. 종료 시 스택 트레이스(Stack Trace) 출력
func main() {
    fmt.Println("시작")
    panic("치명적 오류 발생!")
    fmt.Println("이 줄은 실행되지 않음") // 도달 불가
}
시작
goroutine 1 [running]:
main.main()
    /tmp/main.go:5 +0x68
exit status 2

2. 런타임이 자동으로 발생시키는 panic 유형

개발자가 직접 호출하지 않아도 Go 런타임이 자동으로 panic을 발생시키는 상황들이 있습니다.

2-1. 인덱스 범위 초과

s := []string{"Alice", "Bob"}
fmt.Println(s[2]) // panic: runtime error: index out of range [2] with length 2

2-2. nil 포인터 역참조

var p *int
fmt.Println(*p) // panic: runtime error: invalid memory address or nil pointer dereference

2-3. 타입 단언 실패

var i interface{} = "hello"
n := i.(int) // panic: interface conversion: interface {} is string, not int

2-4. 닫힌 채널에 전송

ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel

2-5. 스택 오버플로우

func infinite() { infinite() }
infinite() // panic: stack overflow

🛠️ 런타임 panic 방지 체크리스트


3. defer와 panic: 반드시 실행되는 정리 코드

panic이 발생해도 defer로 등록된 함수는 반드시 실행됩니다. 이는 리소스 정리에 중요한 보장입니다.

func riskyOperation() {
    defer fmt.Println("3. defer 실행됨 (panic 후에도!)")
    defer fmt.Println("2. 두 번째 defer")

    fmt.Println("1. 함수 시작")
    panic("예상치 못한 오류")
    fmt.Println("이 줄은 실행 안 됨")
}

func main() {
    riskyOperation()
}
1. 함수 시작
2. 두 번째 defer
3. defer 실행됨 (panic 후에도!)
goroutine 1 [running]: ...

defer는 LIFO(Last In, First Out) 순서로 실행되므로, 나중에 등록된 defer가 먼저 실행됩니다.

3-1. 파일 핸들러 안전 정리

func processFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close() // panic이 발생해도 파일은 반드시 닫힘

    // 파일 처리 중 panic이 발생해도...
    // f.Close()는 항상 실행된다
    return nil
}

4. recover: panic에서 프로그램 구하기

recoverpanic을 가로채어 프로그램 종료를 방지합니다. 단, 반드시 defer 함수 안에서만 작동합니다.

func safeOperation() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Printf("panic 복구: %v\n", r)
        }
    }()

    panic("오류 발생!")
}

func main() {
    safeOperation()
    fmt.Println("프로그램이 계속 실행됩니다") // 이 줄이 실행됨!
}
panic 복구: 오류 발생!
프로그램이 계속 실행됩니다

4-1. recover가 작동하지 않는 경우

// ❌ defer 없이 호출하면 작동 안 함
func wrongRecover() {
    if r := recover(); r != nil { // 아무 효과 없음
        fmt.Println("복구됨")
    }
    panic("오류!")
}

// ❌ 다른 함수에서 호출해도 작동 안 함
func helper() {
    if r := recover(); r != nil { // panic을 발생시킨 함수의 defer가 아님
        fmt.Println("복구됨")
    }
}

func wrong() {
    defer helper() // 이 방식은 작동하지 않음
    panic("오류!")
}

recoverpanic을 발생시킨 고루틴의 defer 스택에서만 작동합니다.


5. recover 후 error로 변환: 핵심 실무 패턴

recover로 panic을 잡은 후, 이를 error로 변환하여 반환하는 패턴이 실무에서 자주 사용됩니다.

func safeDiv(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            // panic 값을 error로 변환
            err = fmt.Errorf("panic 복구: %v", r)
        }
    }()

    result = a / b
    return result, nil
}

func main() {
    result, err := safeDiv(10, 0)
    if err != nil {
        fmt.Println("에러:", err) // 에러: panic 복구: runtime error: integer divide by zero
    } else {
        fmt.Println("결과:", result)
    }
}

Named Return(result, err)을 사용하면 defer 함수에서 반환값을 수정할 수 있습니다.


6. 개발자가 직접 호출하는 panic: 언제 사용할까

6-1. 초기화 실패: 프로그램을 시작할 이유가 없을 때

func init() {
    dbURL := os.Getenv("DATABASE_URL")
    if dbURL == "" {
        panic("DATABASE_URL 환경 변수가 설정되지 않았습니다. 프로그램을 시작할 수 없습니다")
    }

    jwtSecret := os.Getenv("JWT_SECRET")
    if len(jwtSecret) < 32 {
        panic("JWT_SECRET은 최소 32자 이상이어야 합니다")
    }
}

DB 연결 없이, JWT 시크릿 없이 API 서버를 실행하는 것은 의미가 없습니다. 이런 경우 panic으로 즉시 중단하는 것이 올바른 선택입니다.

6-2. “절대 일어나면 안 되는” 논리 오류

func direction(d string) int {
    switch d {
    case "north": return 0
    case "south": return 1
    case "east":  return 2
    case "west":  return 3
    }
    // 이 코드에 도달했다면 개발자의 실수 - 버그를 즉시 드러냄
    panic(fmt.Sprintf("알 수 없는 방향: %s", d))
}

7. must 패턴: 라이브러리 초기화의 관용구

Go 표준 라이브러리와 많은 오픈소스 라이브러리에서 사용하는 must 패턴입니다. 에러 반환 대신 실패 시 panic하는 함수에 Must 접두사를 붙이는 관례입니다.

// 일반 버전: 에러 반환
func parseTemplate(content string) (*template.Template, error) {
    return template.New("").Parse(content)
}

// Must 버전: 실패 시 panic
func mustParseTemplate(content string) *template.Template {
    t, err := parseTemplate(content)
    if err != nil {
        panic(fmt.Sprintf("템플릿 파싱 실패: %v", err))
    }
    return t
}
// 프로그램 초기화 시점에 사용
var (
    tmpl     = mustParseTemplate(`<h1>{{.Title}}</h1>`)
    emailTpl = mustParseTemplate(`안녕하세요, {{.Name}}님`)
)

표준 라이브러리 예시:

// regexp.MustCompile: 정규식 컴파일 실패 시 panic
var validEmail = regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`)

// template.Must: 템플릿 파싱 실패 시 panic
var homeTmpl = template.Must(template.ParseFiles("home.html"))

🛠️ Must 패턴 사용 기준


8. HTTP 서버 미들웨어에서 recover 패턴

Go로 웹 서버를 구축할 때 goroutine panic으로 서버 전체가 다운되는 것을 방지하는 핵심 패턴입니다.

// panic 복구 미들웨어
func recoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                // 스택 트레이스 로깅
                buf := make([]byte, 1<<16)
                n := runtime.Stack(buf, false)
                stackTrace := string(buf[:n])

                log.Printf("panic 복구:\n%v\n%s", err, stackTrace)

                // 클라이언트에게 500 응답
                http.Error(w, "서버 내부 오류가 발생했습니다", http.StatusInternalServerError)
            }
        }()

        next.ServeHTTP(w, r)
    })
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", homeHandler)

    // 모든 핸들러에 panic 복구 적용
    http.ListenAndServe(":8080", recoveryMiddleware(mux))
}

이 패턴 덕분에 특정 요청에서 panic이 발생해도 서버 전체가 중단되지 않고 해당 요청에만 500 에러를 반환합니다.


9. goroutine과 panic: 전파되지 않는다

고루틴에서 발생한 panic은 다른 고루틴으로 전파되지 않습니다. 고루틴 내부에서 처리되지 않은 panic은 전체 프로그램을 종료시킵니다.

func worker() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Printf("고루틴 panic 복구: %v\n", r)
        }
    }()

    panic("고루틴에서 panic 발생")
}

func main() {
    go worker() // 고루틴 내부에서 recover 필수

    time.Sleep(time.Second)
    fmt.Println("main은 계속 실행됩니다")
}

⚠️ 고루틴 시작 패턴

고루틴을 시작할 때는 항상 내부에 panic recover를 포함하는 것이 안전합니다. 단 하나의 고루틴이라도 recover 없이 panic이 발생하면 전체 프로그램이 종료됩니다.


10. panic vs error: 결정 기준 체크리스트

상황panicerror
파일을 찾을 수 없음
DB 연결 실패 (초기화 시)
DB 쿼리 실패 (런타임 시)
잘못된 환경 변수 (시작 시)
사용자 입력 오류
개발자 논리 오류 (버그)
API 요청/응답 실패
nil 역참조 방지❌ (검증 후 error)

🛠️ 필자의 의사결정 원칙

  1. “이 상황에서도 프로그램이 계속 실행되어야 하는가?” → Yes: error 반환 / No: panic
  2. “이 오류를 호출자가 처리할 수 있는가?” → Yes: error 반환 / No: panic
  3. “이것은 개발자의 버그인가, 예상 가능한 런타임 상황인가?” → 버그: panic / 런타임: error

필자는 .env 파일 누락이나 DB 스키마 초기화 실패처럼 “시작 자체가 불가능한” 상황에서만 panic을 사용하고, 그 외 모든 상황은 error로 처리합니다.


마치며: panic은 마지막 수단이다

Go에서 panic은 강력하지만 예외적인 도구입니다. 일반적인 오류는 error로, 프로그램 실행 자체가 불가능한 상황에서만 panic을 사용하는 것이 Go의 철학입니다.

오늘 배운 핵심을 요약하면:

  1. panic은 현재 함수를 중단하고 defer를 실행하며 스택을 거슬러 올라간다(스택 언와인딩).
  2. defer는 panic 상황에서도 반드시 실행되어 리소스 정리를 보장한다.
  3. recover는 반드시 defer 함수 안에서 호출해야 panic을 가로챌 수 있다.
  4. must 패턴은 프로그램 초기화 시점에서만 사용하는 panic 관용구다.
  5. HTTP 미들웨어의 recover로 서버 전체가 panic으로 다운되는 것을 방지한다.
  6. 고루틴 내 panic은 전파되지 않으므로 각 고루틴이 자체적으로 recover해야 한다.

다음 글에서는 Go 1.18에서 도입된 제네릭(Generic)을 다루며, 타입 파라미터(Type Parameter), 타입 제약(Type Constraint), 그리고 제네릭을 활용한 재사용 가능한 함수와 자료구조 설계 패턴을 알아보겠습니다. Go 제네릭이 기존 interface{}와 어떻게 다른지, 그리고 실무에서 어떻게 활용하는지 확인해보세요!