
Go (고) 언어 panic (패닉) 및 recover (리커버) 심층 해부: 알아야 할 모든 것!
Go (고) 언어의 panic (패닉) 및 recover (리커버) 키워드 상세 설명
Go (고) 언어에는 종종 쌍으로 나타나는 두 가지 키워드, 즉 panic (패닉)과 recover (리커버)가 있습니다.
이 두 키워드는 defer (디퍼)와 밀접하게 관련되어 있으며, 모두 Go (고) 언어의 내장 함수로서 상호 보완적인 기능을 제공합니다.
1. panic (패닉) 및 recover (리커버)의 기본 기능
panic (패닉): 프로그램의 제어 흐름을 변경할 수 있습니다.panic (패닉)을 호출하면 현재 함수의 나머지 코드는 즉시 실행이 중단되고, 현재 고루틴(Goroutine)에서 호출자의 defer (디퍼)가 재귀적으로 실행됩니다.recover (리커버): panic (패닉)으로 인해 발생하는 프로그램 충돌을 중지시킬 수 있습니다.defer (디퍼) 내에서만 효과가 있는 함수이며, 다른 범위에서 호출하면 아무런 효과가 없습니다.
2. panic (패닉) 및 recover (리커버) 사용 시 현상
(1) panic (패닉)은 현재 고루틴(Goroutine)의 defer (디퍼)만 트리거합니다.
다음 코드는 이 현상을 보여줍니다.
func main() {
defer println("in main") // main 함수의 defer 문
go func() { // 새로운 고루틴 시작
defer println("in goroutine") // 고루틴 내의 defer 문
panic("") // 패닉 발생
}()
time.Sleep(1 * time.Second) // 고루틴이 실행될 시간을 줍니다.
}
실행 결과는 다음과 같습니다.
$ go run main.go
in goroutine
panic:
...
이 코드를 실행하면 main (메인) 함수의 defer (디퍼) 문은 실행되지 않고 현재 고루틴(Goroutine)의 defer (디퍼)만 실행되는 것을 알 수 있습니다.defer (디퍼) 키워드에 해당하는 runtime.deferproc (런타임 점 디퍼프록)은 지연된 호출 함수를 호출자가 있는 고루틴(Goroutine)과 연결하기 때문에 프로그램이 충돌하면 현재 고루틴(Goroutine)의 지연된 호출 함수만 호출됩니다.
(2) recover (리커버)는 defer (디퍼) 내에서 호출될 때만 효과가 있습니다.
다음 코드는 이 특징을 반영합니다.
func main() {
defer fmt.Println("in main") // main 함수의 defer 문
// panic이 발생하기 전에 recover를 호출합니다.
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 (패닉)이 발생한 후에 호출될 때만 효과가 있다는 것을 알 수 있습니다.
그러나 위 제어 흐름에서는 panic (패닉) 전에 recover (리커버)가 호출되어 효과가 발생하기 위한 조건을 충족하지 못합니다.
따라서 recover (리커버) 키워드는 defer (디퍼) 내에서 사용해야 합니다.
(3) panic (패닉)은 defer (디퍼) 내에서 여러 번 중첩 호출될 수 있습니다.
다음 코드는 defer (디퍼) 함수 내에서 panic (패닉)을 여러 번 호출하는 방법을 보여줍니다.
func main() {
defer fmt.Println("in main") // 가장 마지막에 실행될 defer
defer func() { // 두 번째로 실행될 defer
defer func() { // 가장 먼저 실행될 내부 defer (패닉 발생 시)
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 (디퍼)를 사용하는 것은 안전합니다.
3. panic (패닉)의 데이터 구조
Go (고) 언어 소스 코드의 panic (패닉) 키워드는 runtime._panic (런타임 점 언더바패닉) 데이터 구조로 표현됩니다.panic (패닉)이 호출될 때마다 다음과 같은 데이터 구조가 생성되어 관련 정보를 저장합니다.
// _panic 구조체는 패닉 상태를 나타냅니다.
type _panic struct {
argp unsafe.Pointer // defer 호출 시 매개변수에 대한 포인터
arg interface{} // panic 호출 시 전달된 매개변수
link *_panic // 이전에 호출된 runtime._panic 구조체를 가리킴
recovered bool // 현재 runtime._panic이 recover에 의해 복구되었는지 여부
aborted bool // 현재 panic이 강제로 종료되었는지 여부
pc uintptr // 프로그램 카운터 (runtime.Goexit 문제 해결용)
sp unsafe.Pointer // 스택 포인터 (runtime.Goexit 문제 해결용)
goexit bool // runtime.Goexit 호출 여부 (runtime.Goexit 문제 해결용)
}
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 (리커버)에 의해 취소될 수 있습니다.
이 세 필드의 도입은 이 함수가 반드시 효과를 발휘하도록 보장하기 위한 것입니다.
4. 프로그램 충돌 원리
컴파일러는 panic (패닉) 키워드를 runtime.gopanic (런타임 점 고패닉)으로 변환합니다.
이 함수의 실행 과정에는 다음 단계가 포함됩니다.
새로운 runtime._panic (런타임 점 언더바패닉)을 생성하고 해당 고루틴(Goroutine)의 _panic (언더바패닉) 연결 리스트 맨 앞에 추가합니다.
현재 고루틴(Goroutine)의 _defer (언더바디퍼) 연결 리스트에서 runtime._defer (런타임 점 언더바디퍼)를 루프에서 계속 가져와 runtime.reflectcall (런타임 점 리플렉트콜)을 호출하여 지연된 호출 함수를 실행합니다.runtime.fatalpanic (런타임 점 페이탈패닉)을 호출하여 전체 프로그램을 중단합니다.
// gopanic은 panic 키워드의 구현입니다.
func gopanic(e interface{}) {
gp := getg() // 현재 고루틴을 가져옵니다.
...
var p _panic // _panic 구조체를 생성합니다.
p.arg = e // 패닉 인자를 설정합니다.
p.link = gp._panic // 현재 고루틴의 이전 패닉에 연결합니다.
gp._panic = (*_panic)(noescape(unsafe.Pointer(&p))) // 현재 고루틴의 패닉 정보를 업데이트합니다.
for { // defer 스택을 역순으로 실행합니다.
d := gp._defer // 현재 고루틴의 가장 최근 defer를 가져옵니다.
if d == nil { // defer가 없으면 루프를 종료합니다.
break
}
// 현재 실행 중인 defer가 어떤 패닉을 처리하는지 표시합니다.
d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
// defer 함수를 호출합니다.
reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz))
// defer 함수 실행 후 패닉 정보를 초기화합니다.
d._panic = nil
d.fn = nil // 재실행 방지를 위해 함수 포인터를 nil로 설정합니다.
gp._defer = d.link // 다음 defer로 이동합니다.
freedefer(d) // 현재 defer 구조체를 해제합니다.
if p.recovered { // 만약 recover에 의해 패닉이 복구되었다면,
... // 복구 로직 (아래에서 설명)
}
}
// 모든 defer가 실행된 후에도 복구되지 않았다면, 치명적인 패닉으로 프로그램을 종료합니다.
fatalpanic(gp._panic)
*(*int)(nil) = 0 // 도달 불가능한 코드 (프로그램이 확실히 종료되도록 함)
}
위 함수에서는 세 가지 비교적 중요한 코드 부분이 생략되었습니다.
프로그램 복구를 위한 recover (리커버) 분기의 코드.
인라이닝을 통한 defer (디퍼) 호출 성능 최적화 코드.runtime.Goexit (런타임 점 고엑시트)의 비정상적인 상황 수정 코드.
1.14 버전에서 Go (고) 언어는 "runtime: ensure that Goexit cannot be aborted by a recursive panic/recover" (런타임: Goexit가 재귀적 panic/recover에 의해 중단될 수 없도록 보장) 제출을 통해 재귀적 panic (패닉) 및 recover (리커버)와 runtime.Goexit (런타임 점 고엑시트) 간의 충돌을 해결했습니다.
runtime.fatalpanic (런타임 점 페이탈패닉)은 복구할 수 없는 프로그램 충돌을 구현합니다.
프로그램을 중단하기 전에 runtime.printpanics (런타임 점 프린트패닉스)를 통해 모든 panic (패닉) 메시지와 호출 중 전달된 매개변수를 출력합니다.
// fatalpanic은 복구 불가능한 패닉을 처리하고 프로그램을 종료합니다.
func fatalpanic(msgs *_panic) {
pc := getcallerpc() // 호출자의 프로그램 카운터를 가져옵니다.
sp := getcallersp() // 호출자의 스택 포인터를 가져옵니다.
gp := getg() // 현재 고루틴을 가져옵니다.
// startpanic_m은 이 고루틴이 패닉 메시지를 출력할 책임이 있는지 확인합니다.
// msgs가 nil이 아니어야 실제 패닉 정보가 있습니다.
if startpanic_m() && msgs != nil {
atomic.Xadd(&runningPanicDefers, -1) // 실행 중인 패닉 defer 카운터를 감소시킵니다.
printpanics(msgs) // 모든 패닉 메시지를 출력합니다.
}
// dopanic_m은 현재 고루틴이 실제 크래시를 수행할지 결정합니다.
if dopanic_m(gp, pc, sp) {
crash() // 프로그램을 크래시 시킵니다.
}
exit(2) // 오류 코드 2로 프로그램을 종료합니다.
}
충돌 메시지를 출력한 후 runtime.exit (런타임 점 엑시트)를 호출하여 현재 프로그램을 종료하고 오류 코드 2를 반환합니다.
프로그램의 정상적인 종료도 runtime.exit (런타임 점 엑시트)를 통해 구현됩니다.
5. 충돌 복구 원리
컴파일러는 recover (리커버) 키워드를 runtime.gorecover (런타임 점 고리커버)로 변환합니다.
// gorecover는 recover 키워드의 구현입니다.
// argp는 deferproc 호출 시 스택 프레임 포인터입니다.
// 현재 defer가 현재 패닉을 복구할 수 있는지 확인하는 데 사용됩니다.
func gorecover(argp uintptr) interface{} {
gp := getg() // 현재 고루틴을 가져옵니다.
p := gp._panic // 현재 고루틴의 패닉 정보를 가져옵니다.
// 패닉이 존재하고, 아직 복구되지 않았으며, 현재 defer가 이 패닉을 처리하도록 지정된 경우
if p != nil && !p.recovered && argp == uintptr(p.argp) {
p.recovered = true // 패닉을 복구됨으로 표시합니다.
return p.arg // 패닉 시 전달된 인자를 반환합니다.
}
return nil // 복구할 패닉이 없거나 조건에 맞지 않으면 nil을 반환합니다.
}
이 함수의 구현은 매우 간단합니다.
현재 고루틴(Goroutine)이 panic (패닉)을 호출하지 않았다면 이 함수는 직접 nil (닐)을 반환하며, 이것이 defer (디퍼)가 아닌 곳에서 호출될 때 충돌 복구가 실패하는 이유이기도 합니다.
정상적인 상황에서는 runtime._panic (런타임 점 언더바패닉)의 recovered (리커버드) 필드를 수정하며, 프로그램 복구는 runtime.gopanic (런타임 점 고패닉) 함수에 의해 처리됩니다.
// gopanic 함수의 일부 (복구 관련 로직)
func gopanic(e interface{}) {
... // 이전 gopanic 코드
for {
// 지연된 호출 함수를 실행하며, 이 과정에서 p.recovered = true로 설정될 수 있습니다.
... // reflectcall(d.fn, ...) 부분
pc := d.pc // defer 호출 시점의 프로그램 카운터
sp := unsafe.Pointer(d.sp) // defer 호출 시점의 스택 포인터
...
if p.recovered { // 패닉이 복구되었다면
gp._panic = p.link // 현재 고루틴의 패닉 정보를 이전 패닉으로 되돌립니다.
// 중단된(aborted) 패닉들은 건너뜁니다.
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) // recovery 함수를 호출하여 스케줄링을 통해 실행 흐름을 복원합니다.
throw("recovery failed") // mcall이 반환되면 복구 실패로 간주하고 예외를 발생시킵니다.
}
}
... // 이후 fatalpanic 호출 부분
}
위 코드는 defer (디퍼)의 인라이닝 최적화를 생략했습니다.runtime._defer (런타임 점 언더바디퍼)에서 프로그램 카운터 pc (피씨)와 스택 포인터 sp (에스피)를 가져와 runtime.recovery (런타임 점 리커버리) 함수를 호출하여 고루틴(Goroutine) 스케줄링을 트리거합니다.
스케줄링 전에 함수의 sp (에스피), pc (피씨) 및 반환 값을 준비합니다.
// recovery 함수는 패닉으로부터 복구된 고루틴의 실행을 재개합니다.
// 이 함수는 mcall을 통해 호출되므로, 시스템 스택에서 실행됩니다.
func recovery(gp *g) {
// gp.sigcode0와 gp.sigcode1은 gopanic에서 설정한
// defer 호출 지점의 스택 포인터(sp)와 프로그램 카운터(pc)입니다.
sp := gp.sigcode0
pc := gp.sigcode1
// 고루틴의 스케줄링 컨텍스트(sched)를 복구 지점으로 설정합니다.
gp.sched.sp = sp // 스택 포인터 설정
gp.sched.pc = pc // 프로그램 카운터 설정
gp.sched.lr = 0 // 링크 레지스터 (arm에서 사용)
gp.sched.ret = 1 // 반환 값 설정 (deferproc이 1을 반환하도록 함)
gogo(&gp.sched) // 설정된 스케줄링 컨텍스트로 점프하여 실행을 재개합니다.
}
defer (디퍼) 키워드가 호출될 때 호출 시점의 스택 포인터 sp (에스피)와 프로그램 카운터 pc (피씨)는 이미 runtime._defer (런타임 점 언더바디퍼) 구조체에 저장되어 있습니다.
여기서 runtime.gogo (런타임 점 고고) 함수는 defer (디퍼) 키워드가 호출된 위치로 다시 점프합니다.
runtime.recovery (런타임 점 리커버리)는 스케줄링 과정에서 함수의 반환 값을 1로 설정합니다.runtime.deferproc (런타임 점 디퍼프록)의 주석에서 runtime.deferproc (런타임 점 디퍼프록) 함수의 반환 값이 1일 때 컴파일러가 생성한 코드가 호출자 함수의 반환 직전으로 직접 점프하여 runtime.deferreturn (런타임 점 디퍼리턴)을 실행한다는 것을 알 수 있습니다.
// deferproc 함수는 defer 키워드 호출 시 실행됩니다.
// 컴파일러는 이 함수 호출 후 반환 값을 확인하여 분기합니다.
func deferproc(siz int32, fn *funcval) { // siz는 인자 크기, fn은 defer될 함수입니다.
... // defer 구조체 설정 및 연결 리스트 추가 로직
return0() // 일반적으로 0을 반환하지만, recovery 시에는 1이 되도록 조작됩니다.
}
runtime.deferreturn (런타임 점 디퍼리턴) 함수로 점프한 후 프로그램은 panic (패닉)으로부터 복구되어 정상적인 로직을 실행하며, runtime.gorecover (런타임 점 고리커버) 함수는 panic (패닉) 호출 시 전달된 arg (아그) 매개변수를 runtime._panic (런타임 점 언더바패닉) 구조체에서 가져와 호출자에게 반환할 수도 있습니다.
6. 요약
프로그램의 충돌 및 복구 과정을 분석하는 것은 다소 까다로우며 코드가 특별히 이해하기 쉽지는 않습니다.
다음은 프로그램 충돌 및 복구 과정에 대한 간단한 요약입니다.
컴파일러는 키워드 변환 작업을 담당합니다.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를 반환합니다.
분석 과정에는 언어의 기본 수준에 대한 많은 지식이 포함되며 소스 코드도 읽기가 비교적 모호합니다.
비정상적인 제어 흐름으로 가득 차 있으며 프로그램 카운터를 통해 앞뒤로 점프합니다.
그러나 프로그램의 실행 흐름을 이해하는 데는 여전히 매우 유용합니다.
'Go' 카테고리의 다른 글
| Go (고): 다양한 시나리오에서 RWMutex (알더블유뮤텍스)와 Mutex (뮤텍스)의 성능 비교 (0) | 2025.05.17 |
|---|---|
| Go (고) 컴파일러 성능 최적화 팁과 트릭 (0) | 2025.05.17 |
| ErrGroup (에러그룹): Go (고) 동시성 프로그래밍의 숨겨진 보석 (0) | 2025.05.17 |
| Go (고) 언어에서 고루틴 풀(Goroutine Pool)을 구현하는 방법은? (0) | 2025.05.17 |
| Go 언어 완벽 마스터: 함수형 프로그래밍이 최고의 선택인 이유 (0) | 2025.05.17 |