Go Panic 및 Recover 심층 설명: 알아야 할 모든 것!
Lukas Schneider
DevOps Engineer · Leapcell

Go 언어의 panic 및 recover 키워드에 대한 자세한 설명
Go 언어에는 종종 쌍으로 나타나는 두 개의 키워드, 즉 panic과 recover가 있습니다. 이 두 키워드는 defer와 밀접한 관련이 있습니다. 둘 다 Go 언어의 내장 함수이며 상호 보완적인 기능을 제공합니다.
I. panic과 recover의 기본 기능
- panic: 프로그램의 제어 흐름을 변경할 수 있습니다. panic을 호출하면 현재 함수의 나머지 코드는 즉시 실행이 중단되고 호출자의 defer가 현재 Goroutine에서 재귀적으로 실행됩니다.
- recover: panic으로 인한 프로그램 충돌을 막을 수 있습니다. defer에서만 효력을 발휘할 수 있는 함수입니다. 다른 범위에서 호출하면 아무런 효과가 없습니다.
II. panic과 recover 사용 시 나타나는 현상
(I) panic은 현재 Goroutine의 defer만 트리거합니다.
다음 코드는 이 현상을 보여줍니다.
func main() { defer println("in main") go func() { defer println("in goroutine") panic("") }() time.Sleep(1 * time.Second) }
실행 결과는 다음과 같습니다.
$ go run main.go
in goroutine
panic:
...
이 코드를 실행하면 main 함수의 defer 구문은 실행되지 않고 현재 Goroutine의 defer만 실행되는 것을 알 수 있습니다. defer 키워드에 해당하는 runtime.deferproc은 지연된 호출 함수를 호출자가 위치한 Goroutine과 연결하므로 프로그램이 충돌하면 현재 Goroutine의 지연된 호출 함수만 호출됩니다.
(II) recover는 defer 내에서 호출될 때만 효력을 발휘합니다.
다음 코드는 이 기능을 반영합니다.
func main() { defer fmt.Println("in main") if err := recover(); err != nil { fmt.Println(err) } panic("unknown err") }
실행 결과는 다음과 같습니다.
$ go run main.go
in main
panic: unknown err
goroutine 1 [running]:
main.main()
...
exit status 2
이 과정을 주의 깊게 분석하면 recover는 panic이 발생한 후에 호출될 때만 효력을 발휘한다는 것을 알 수 있습니다. 그러나 위의 제어 흐름에서 recover는 panic 전에 호출되므로 효력을 발휘하기 위한 조건을 충족하지 않습니다. 따라서 recover 키워드는 defer에서 사용해야 합니다.
(III) panic은 defer에서 여러 번 중첩 호출될 수 있습니다.
다음 코드는 defer 함수에서 panic을 여러 번 호출하는 방법을 보여줍니다.
func main() { defer fmt.Println("in main") defer func() { defer func() { panic("panic again and again") }() panic("panic again") }() panic("panic once") }
실행 결과는 다음과 같습니다.
$ go run main.go
in main
panic: panic once
panic: panic again
panic: panic again and again
goroutine 1 [running]:
...
exit status 2
위 프로그램의 출력 결과에서 프로그램에서 panic을 여러 번 호출해도 defer 함수의 정상적인 실행에 영향을 미치지 않는다는 것을 알 수 있습니다. 따라서 일반적으로 defer를 사용하여 마무리 작업을 수행하는 것이 안전합니다.
III. panic의 데이터 구조
Go 언어의 소스 코드에서 panic 키워드는 runtime._panic 데이터 구조로 표현됩니다. panic이 호출될 때마다 다음과 같은 데이터 구조가 생성되어 관련 정보를 저장합니다.
type _panic struct { argp unsafe.Pointer arg interface{} link *_panic recovered bool aborted bool pc uintptr sp unsafe.Pointer goexit bool }
- argp: defer가 호출될 때 매개변수에 대한 포인터입니다.
- arg: panic이 호출될 때 전달되는 매개변수입니다.
- link: 이전에 호출된 runtime._panic 구조체를 가리킵니다.
- recovered: 현재 runtime._panic이 recover에 의해 복구되었는지 여부를 나타냅니다.
- aborted: 현재 panic이 강제로 종료되었는지 여부를 나타냅니다.
데이터 구조의 link 필드에서 panic 함수를 여러 번 연속적으로 호출할 수 있으며 link를 통해 연결 목록을 형성할 수 있음을 추론할 수 있습니다.
구조체의 세 필드 pc, sp, goexit는 모두 runtime.Goexit로 인해 발생하는 문제를 해결하기 위해 도입되었습니다. runtime.Goexit는 다른 Goroutine에 영향을 주지 않고 이 함수를 호출하는 Goroutine만 종료할 수 있습니다. 그러나 이 함수는 defer의 panic 및 recover에 의해 취소됩니다. 이러한 세 필드를 도입한 것은 이 함수가 반드시 효력을 발휘하도록 보장하기 위한 것입니다.
IV. 프로그램 충돌의 원리
컴파일러는 panic 키워드를 runtime.gopanic으로 변환합니다. 이 함수의 실행 과정은 다음과 같은 단계를 포함합니다.
- 새 runtime._panic을 생성하고 위치한 Goroutine의 _panic 연결 목록의 맨 앞에 추가합니다.
- 루프에서 현재 Goroutine의 _defer 연결 목록에서 runtime._defer를 계속 가져와 runtime.reflectcall을 호출하여 지연된 호출 함수를 실행합니다.
- runtime.fatalpanic을 호출하여 전체 프로그램을 중단합니다.
func gopanic(e interface{}) { gp := getg() ... var p _panic p.arg = e p.link = gp._panic gp._panic = (*_panic)(noescape(unsafe.Pointer(&p))) for { d := gp._defer if d == nil { break } d._panic = (*_panic)(noescape(unsafe.Pointer(&p))) reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz)) d._panic = nil d.fn = nil gp._defer = d.link freedefer(d) if p.recovered { ... } } fatalpanic(gp._panic) *(*int)(nil) = 0 }
위 함수에서 세 가지 비교적 중요한 코드 부분이 생략되었습니다.
-
프로그램을 복원하기 위한 recover 분기의 코드입니다.
-
인라인을 통해 defer 호출의 성능을 최적화하기 위한 코드입니다.
-
runtime.Goexit의 비정상적인 상황을 수정하기 위한 코드입니다.
-
14 버전에서 Go 언어는 runtime: 재귀적 panic/recover에 의해 Goexit가 중단되지 않도록 보장하는 제출을 통해 재귀적 panic과 recover 및 runtime.Goexit 간의 충돌을 해결했습니다.
runtime.fatalpanic은 복구할 수 없는 프로그램 충돌을 구현합니다. 프로그램을 중단하기 전에 runtime.printpanics를 통해 모든 panic 메시지와 호출 중에 전달된 매개변수를 출력합니다.
func fatalpanic(msgs *_panic) { pc := getcallerpc() sp := getcallersp() gp := getg() if startpanic_m() && msgs != nil { atomic.Xadd(&runningPanicDefers, -1) printpanics(msgs) } if dopanic_m(gp, pc, sp) { crash() } exit(2) }
충돌 메시지를 출력한 후 runtime.exit를 호출하여 현재 프로그램을 종료하고 오류 코드 2를 반환합니다. 프로그램의 정상적인 종료도 runtime.exit를 통해 구현됩니다.
V. 충돌 복구의 원리
컴파일러는 recover 키워드를 runtime.gorecover로 변환합니다.
func gorecover(argp uintptr) interface{} { gp := getg() p := gp._panic if p != nil &&!p.recovered && argp == uintptr(p.argp) { p.recovered = true return p.arg } return nil }
이 함수의 구현은 매우 간단합니다. 현재 Goroutine이 panic을 호출하지 않았다면 이 함수는 직접 nil을 반환합니다. 이것이 또한 비 defer에서 호출될 때 충돌 복구가 실패하는 이유입니다. 정상적인 상황에서는 runtime._panic의 recovered 필드를 수정하고 프로그램 복구는 runtime.gopanic 함수에서 처리합니다.
func gopanic(e interface{}) { ... for { // Execute the deferred call function, which may set p.recovered = true ... pc := d.pc sp := unsafe.Pointer(d.sp) ... if p.recovered { gp._panic = p.link for gp._panic != nil && gp._panic.aborted { gp._panic = gp._panic.link } if gp._panic == nil { gp.sig = 0 } gp.sigcode0 = uintptr(sp) gp.sigcode1 = pc mcall(recovery) throw("recovery failed") } } ... }
위 코드에서는 defer의 인라인 최적화를 생략합니다. runtime._defer에서 프로그램 카운터 pc와 스택 포인터 sp를 가져와 runtime.recovery 함수를 호출하여 Goroutine의 스케줄링을 트리거합니다. 스케줄링 전에 sp, pc 및 함수의 반환 값을 준비합니다.
func recovery(gp *g) { sp := gp.sigcode0 pc := gp.sigcode1 gp.sched.sp = sp gp.sched.pc = pc gp.sched.lr = 0 gp.sched.ret = 1 gogo(&gp.sched) }
defer 키워드가 호출되면 호출 시점의 스택 포인터 sp와 프로그램 카운터 pc가 이미 runtime._defer 구조체에 저장되어 있습니다. 여기서 runtime.gogo 함수는 defer 키워드가 호출된 위치로 다시 점프합니다.
runtime.recovery는 스케줄링 과정에서 함수의 반환 값을 1로 설정합니다. runtime.deferproc의 주석에서 runtime.deferproc 함수의 반환 값이 1이면 컴파일러에서 생성된 코드가 호출자 함수의 반환 직전으로 직접 점프하여 runtime.deferreturn을 실행한다는 것을 알 수 있습니다.
func deferproc(siz int32, fn *funcval) { ... return0() }
runtime.deferreturn 함수로 점프한 후 프로그램은 panic에서 복구되어 정상적인 로직을 실행하고 runtime.gorecover 함수는 runtime._panic 구조체에서 panic을 호출할 때 전달된 arg 매개변수를 가져와 호출자에게 반환할 수도 있습니다.
VI. 요약
프로그램의 충돌 및 복구 과정을 분석하는 것은 다소 까다롭고 코드를 이해하기도 쉽지 않습니다. 다음은 프로그램 충돌 및 복구 과정에 대한 간단한 요약입니다.
- 컴파일러는 키워드를 변환하는 작업을 담당합니다. panic과 recover를 각각 runtime.gopanic과 runtime.gorecover로 변환하고 defer를 runtime.deferproc 함수로 변환하며 defer를 호출하는 함수가 끝날 때 runtime.deferreturn 함수를 호출합니다.
- 실행 과정에서 runtime.gopanic 메서드를 만나면 Goroutine의 연결 목록에서 runtime._defer 구조체를 차례로 가져와 실행합니다.
- 지연된 실행 함수를 호출할 때 runtime.gorecover가 발생하면 _panic.recovered를 true로 표시하고 panic의 매개변수를 반환합니다.
- 이 호출이 끝나면 runtime.gopanic은 runtime._defer 구조체에서 프로그램 카운터 pc와 스택 포인터 sp를 가져와 runtime.recovery 함수를 호출하여 프로그램을 복원합니다.
- runtime.recovery는 전달된 pc와 sp에 따라 runtime.deferproc로 다시 점프합니다.
- 컴파일러에서 자동으로 생성된 코드는 runtime.deferproc의 반환 값이 0이 아님을 확인합니다. 이때 runtime.deferreturn으로 다시 점프하여 정상적인 실행 흐름으로 복원됩니다.
- runtime.gorecover가 발생하지 않으면 runtime._defer를 차례로 모두 순회하고 마지막으로 runtime.fatalpanic을 호출하여 프로그램을 중단하고 panic의 매개변수를 출력하고 오류 코드 2를 반환합니다.
분석 과정은 언어의 기본 수준에서 많은 지식을 포함하며 소스 코드를 읽기도 상대적으로 어렵습니다. 비정상적인 제어 흐름으로 가득 차 있으며 프로그램 카운터를 통해 앞뒤로 점프합니다. 그러나 프로그램의 실행 흐름을 이해하는 데는 여전히 매우 유용합니다.
Leapcell: Golang 호스팅, 비동기 작업, Redis를 위한 차세대 서버리스 플랫폼
마지막으로 가장 적합한 배포 플랫폼인 **Leapcell**을 추천하고 싶습니다.
1. 다국어 지원
- JavaScript, Python, Go 또는 Rust로 개발하십시오.
2. 무제한 프로젝트를 무료로 배포하세요
- 사용량에 대해서만 지불하십시오. 요청도 없고 요금도 없습니다.
3. 최고의 비용 효율성
- 유휴 요금 없이 사용한 만큼 지불하십시오.
- 예: $25는 평균 응답 시간 60ms에서 694만 건의 요청을 지원합니다.
4. 간소화된 개발자 경험
- 간편한 설정을 위한 직관적인 UI.
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합.
- 실행 가능한 통찰력을 위한 실시간 메트릭 및 로깅.
5. 간편한 확장성과 고성능
- 고도의 동시성을 쉽게 처리하기 위한 자동 확장.
- 운영 오버헤드가 없습니다. 구축에만 집중하십시오.
Leapcell 트위터: https://x.com/LeapcellHQ