Go panic 스택트레이스를 변수로 저장하기 — runtime/debug.Stack()

1. 문제 정의

Go 프로그램이 panic을 일으키면 런타임이 기본적으로 다음과 같은 형태의 스택트레이스를 **표준 출력(stdout)**으로 출력한다.

1
2
3
4
panic: runtime error: index out of range
goroutine 1 [running]:
main.main()
    /tmp/sandbox579134920/main.go:9 +0x20

여기서 핵심은 recover()를 사용해 패닉을 회복할 때다. recover()는 스택트레이스를 전혀 반환하지 않고 패닉의 원인을 설명하는 값만 돌려준다.

1
runtime error: index out of range

이 문자열만으로는 “어느 파일의 몇 번째 라인에서 패닉이 났는지"를 알 수 없다. 실제 운영 환경에서 에러 로그를 수집하거나 버그 리포트를 남길 때, 원인 문자열만 저장하면 재현할 수 없는 문제가 된다. 이 글에서 다루는 질문은 다음과 같다.

stdout으로 출력되는 패닉 스택트레이스를 변수에 저장할 수 있는가? (출처: StackOverflow 52103182)

2. 표면 증상

격리해 보면 두 가지 증상이 나타난다.

증상recover()만 사용했을 때debug.Stack()을 함께 사용했을 때
반환되는 정보패닉 원인 값 (예: runtime error: index out of range)패닉 원인 값 + 전체 스택트레이스
패닉 발생 지점알 수 없음 (/tmp/.../main.go:9)파일명·라인 번호 포함 (main.go:9 +0x20)
디버깅 가치낮음 (무엇이 났는지만 알 수 있음)높음 (어디서 났는지 정확히 알 수 있음)

사용자가 쉽게 오해하는 지점은 “recover()가 반환하는 값이 곧 패닉 정보의 전부"라고 생각하는 것이다. 실제로 recover()는 패닉이 전달한 원인 값일 뿐이고, 스택 정보는 별도로 가져와야 한다.

빠른 판단 기준: 패닉 발생 위치(파일·라인)가 필요하면 debug.Stack()을, 원인 값만 필요한 로직 처리면 recover()만 사용한다.

3. 증상 fingerprint

  • 에러 메시지 형태: panic: runtime error: index out of range (stdout에 자동 출력)
  • 발생 단계: 런타임 패닉 발생 직후의 defer 회복 핸들러
  • 관련 도구/프레임워크: Go 런타임 (runtime 패키지, runtime/debug 패키지)
  • 핵심 저수준 원리: recover()panic에 전달된 값을 반환하며, 현재 실행 중인 goroutine의 콜 스택(frame) 정보는 runtime/debug.Stack()이 별도로 제공한다.
  • 흔한 오해: “recover() 값에 스택트레이스가 포함되어 있다”, 또는 “panic이 stdout으로 찍은 트레이스를 파일로 리다이렉트하면 된다”

4. 원인 추적

recover()만으로는 위치 정보를 얻을 수 없는지 Go 런타임 내부 관점에서 짚어 보자.

  1. panic이 발생하면 런타임은 현재 goroutine의 스택 프레임을 역추적(backtrace) 해서 스택트레이스를 만들고, 기본 팬 에러 핸들러가 이를 stdout으로 출력한다.
  2. recover()를 설치한 defer 함수는 panic 상황을 중간에 가로채지만, 이때 recover()가 노출하는 것은 panic에 넘겨진 인자 값뿐이다. 스택 프레임 정보는 별도로 구성된다.
  3. 따라서 패닉이 실제로 터진 파일·라인을 알려면, 스택 프레임 정보를 직접 조회해야 한다. 이때 사용하는 API가 runtime/debugdebug.Stack()이다.

debug.Stack()호출 시점의 현재 goroutine 스택트레이스를 []byte로 반환한다. defer 핸들러 안에서 호출하면, 그 시점에 아직 unwind되기 전의 스택이므로 패닉 지점(예: main.main() ... main.go:16)이 트레이스에 포함된다.

5. 근본 원인

질문의 근본 원인은 두 정보 채널이 분리되어 있다는 데 있다.

  • 채널 1 — recover()의 반환값: 패닉의 원인(값)만 담긴다.
  • 채널 2 — 런타임 스택트레이스: 콜 스택(frame) 정보로, 기본 출력 또는 debug.Stack()을 통해 접근 가능하다.

이 둘을 함께 묶어야만 “무엇이 났는지 + 어디서 났는지"를 모두 남길 수 있다. recover() 값만 저장하는 것은 원인 문자열만 남기고 패닉 위치 정보를 버리는 것과 같다.

6. 해결책

질문에서 채택된 해결책은 runtime/debug 패키지의 debug.Stack()을 defer 회복 핸들러 안에서 호출해, 반환된 []bytestring으로 변환해 변수에 저장하는 것이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
    "fmt"
    "runtime/debug"
)

func main() {
    defer func() {
        if r := recover(); r != nil {
            // 스택트레이스를 변수에 저장
            stack := string(debug.Stack())
            fmt.Println("panic value:", r)
            fmt.Println("stacktrace from panic:\n" + stack)
        }
    }()

    var mySlice []int
    j := mySlice[0]  // <- 여기서 panic 발생

    fmt.Printf("Hello, playground %d", j)
}

위 코드를 실행하면 (출처의 Go Playground 검증 결과 기준) 다음과 같은 출력이 얻어진다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
panic value: runtime error: index out of range
stacktrace from panic:
goroutine 1 [running]:
runtime/debug.Stack(0x1042ff18, 0x98b2, 0xf0ba0, 0x17d048)
    /usr/local/go/src/runtime/debug/stack.go:24 +0xc0
main.main.func1()
    /tmp/sandbox973508195/main.go:11 +0x60
panic(0xf0ba0, 0x17d048)
    /usr/local/go/src/runtime/panic.go:502 +0x2c0
main.main()
    /tmp/sandbox973508195/main.go:16 +0x60

트레이스에서 main.main() ... /tmp/sandbox973508195/main.go:16 +0x60 라인이 panic이 실제로 발생한 지점임을 정확히 확인할 수 있다. 이 문자열을 변수에 담았으므로, 이를 로그 파일에 기록하거나 에러 리포팅 시스템으로 전송할 수 있다.

원인 후보 매트릭스

원인 후보확인 방법맞는 경우 관찰되는 증상해결 방향
recover()만으로 위치를 얻으려 했음recover() 반환값만 로그에 저장로그에 runtime error: index out of range만 있고 파일·라인 없음debug.Stack()을 함께 호출
스택트레이스를 수동으로 stdout 리다이렉트 영역에 의존panic 시 stdout을 그대로 두고 파일로 저장stdout과 프로덕션 로그가 분리되어 정보 유실defer 핸들러에서 debug.Stack()으로 직접 캡처
다른 goroutine의 스택이 필요했음debug.Stack()은 현재 goroutine만 반환다른 goroutine 지점이 트레이스에 안 보임필요 시 runtime.Stack(buf, true)로 전체 goroutine 조회

7. 잘못된 해결책과 주의사항

  1. recover() 값만 로그에 남기기 — 원인 문자열만 남고 패닉 지점을 알 수 없어 재현이 어렵다. 최소한 debug.Stack()을 함께 저장해야 한다.

  2. panic이 stdout으로 찍은 트레이스를 별도 로그 파일 리다이렉트에 의존 — 프로덕션에서 stdout과 애플리케이션 로그가 분리되면 정보 유실로 이어진다. 패닉 트레이스는 코드에서 직접 캡처해 명시적으로 로그·리포팅 파이프라인에 넘기는 것이 안정적이다.

  3. debug.Stack()을 defer가 아닌 곳에서 호출 — 패닉이 이미 unwind된 이후에는 해당 goroutine의 스택을 온전히 얻지 못할 수 있다. 반드시 defer 회복 핸들러 안에서 호출해야 한다.

주의: 위 “잘못된 해결책” 항목의 위험성은 일반적인 Go 패닉 처리 관행에 대한 점검 기준이며, 환경(로깅 프레임워크, 배포 방식)에 따라 구체적 영향은 달라질 수 있다.

8. 검증 방법

패닉 위치 정보가 제대로 저장되는지 확인하는 명령은 다음과 같다. (Go가 설치된 환경 기준)

1
2
3
4
5
go run main.go
# 출력에 다음 형태가 포함되어야 한다
# main.main()
#    <절대경로>/main.go:16 +0x60
# <- 이 라인에서 panic이 발생한 지점을 알 수 있다

또한 저장된 스택 문자열이 실제 에러 로그에 기록되는지 확인하려면 다음처럼 간단히 검증한다.

1
2
go run main.go 2>&1 | grep "main.go:1[0-9]"
# 패닉 지점에 해당하는 라인 번호가 출력되면 정상

5-1. 검증 환경

이 글에서 인용한 실행 출력은 출처(StackOverflow 52103182)에 포함된 Go Playground 환경의 검증 결과다.

항목
실행 환경Go Playground (출처 원문 제공)
Go 버전Playground 기본(원문 작성 시점 기준, 버전 정보 공개 안 됨)
패닉 유발 코드var mySlice []int; j := mySlice[0]
확인 항목debug.Stack()이 패닉 발생 지점(파일·라인)을 포함하는지

환경에 따라 Go 버전이 다르면 runtime/debug의 내부 프레임 주소값·라인 번호는 달라질 수 있다. 핵심 동작(스택트레이스 캡처)은 동일하다.

5-2. 재현 절차

  1. 위 6번 코드를 main.go로 저장한다.
  2. go run main.go를 실행한다.
  3. defer 회복 핸들러가 동작해 panic valuestacktrace from panic: 뒤의 트레이스가 출력되는지 확인한다.
  4. 트레이스 마지막 프레임(main.main() ... main.go:16)이 실제 패닉 발생 라인인지 확인한다.

5-3. 실제 재현 결과

출처의 검증 결과에서 debug.Stack() 호출 시점의 트레이스가 다음과 같이 출력된다.

1
2
3
4
5
6
7
8
9
goroutine 1 [running]:
runtime/debug.Stack(0x1042ff18, 0x98b2, 0xf0ba0, 0x17d048)
    /usr/local/go/src/runtime/debug/stack.go:24 +0xc0
main.main.func1()
    /tmp/sandbox973508195/main.go:11 +0x60
panic(0xf0ba0, 0x17d048)
    /usr/local/go/src/runtime/panic.go:502 +0x2c0
main.main()
    /tmp/sandbox973508195/main.go:16 +0x60

main.go:16이 panic이 실제로 터진 위치로, recover() 값만으로는 얻을 수 없는 정보다.

6-1. 해결책 비교

접근장점단점적합한 상황
recover() 값만 저장코드가 간단패닉 지점(파일·라인) 정보 없음원인 값만 필요한 단순 로직
debug.Stack()으로 string 저장 (채택)파일·라인 포함, 변수로 리포팅 가능recover 핸들러 안에서 호출 필요에러 리포팅·로그에 위치 정보가 필요할 때
runtime.Stack(buf, true)전체 goroutine 스택 조회buf 관리·성능 오버헤드크래시 덤프·전체 스레드 상태 분석

6-2. 버전별 차이

  • runtime/debug.Stack()은 Go 초기 버전부터 제공되는 안정 API이다. (출처에서는 버전 명시 없음)
  • Go 1.12 이후 런타임은 패닉 시 프로세스가 완전히 죽지 않고도 defer에서 recover()로 회복할 수 있는 동작을 유지한다. (일반적인 런타임 동작)
  • 최신 Go에서 런타임 내부 라인 번호(예: runtime/panic.go의 실제 라인)는 버전마다 달라질 수 있지만, 애플리케이션 프레임(main.go:16)은 소스 기준으로 일관되게 표시된다.
  • 참고: 위 버전 관련 서술은 Go 일반 관행에 대한 범용 지식이며, 버전별 정확한 런타임 라인은 환경에 따라 다를 수 있다.

7. 근본 원인 요약 및 재발 방지

근본 원인 한 줄 정리

panic의 원인 값과 스택트레이스는 별도의 정보 채널이며, 로그에 패닉 위치를 남기려면 recover() 값뿐 아니라 debug.Stack()을 명시적으로 캡처해야 한다.

재발 방지 체크리스트

  • 패닉 처리 로직에서 recover() 값과 debug.Stack() 문자열을 함께 로그/리포팅에 저장했는가
  • debug.Stack() 호출이 defer 회복 핸들러 안에 있는가
  • stdout 리다이렉트에 의존하지 않고 애플리케이션 로그 파이프라인으로 스택을 넘기는가
  • 필요 시 전체 goroutine 스택을 살펴보기 위해 runtime.Stack(buf, true) 사용 여부를 검토했는가
  • 패닉 대신 error value를 반환하는 것(파일·라인은 error wrapping, 예: fmt.Errorf("%w", err) + 스택)으로 설계를 바꿀 수는 없는지 판단했는가

DevTrace verdict

이 문제의 핵심은 recover()가 패닉 원인 값만 반환하고 스택 프레임 정보는 별도 채널(runtime/debug.Stack())에 있다는 점이며, 패닉 위치를 정확히 남기려면 반드시 두 채널을 함께 캡처해야 한다.


출처

참고: 본문의 실행 출력(스택트레이스)은 위 StackOverflow 원문에 포함된 Go Playground 검증 결과를 인용한 것이다. go 도구 모음을 로컬에서 실행한 결과는 아니며, 실행 환경에 따라 주소값·라인 번호가 달라질 수 있다. 일반적인 회복/스택 캡처 관행에 대한 언급(“잘못된 해결책” 등)은 범용 지식에 기반한 점검 기준이다.