Go에서 일반적인 실패는 error 값을 반환하여 호출자가 명시적으로 처리합니다. panic과 recover도 실행 중 발생한 비정상 상황을 다루는 기능이지만, Java의 try-catch처럼 모든 오류 처리에 사용하는 예외 처리 문법은 아닙니다.
panic은 현재 함수의 정상 실행을 중단하고 호출 스택을 거슬러 올라가며, 그 과정에서 등록된 defer 함수를 실행합니다. recover는 패닉이 전파되는 도중 값을 받아 정상 흐름으로 복귀할 기회를 제공합니다.
Go에서는 예상 가능한 실패를
panic으로 처리하기보다error로 반환하는 것이 기본입니다.panic과recover는 프로그래밍 오류, 내부 불변식 위반, 반드시 성공해야 하는 초기화 실패, 서버의 요청 경계 보호처럼 제한된 상황에 사용하는 것이 적절합니다.
핵심 개념
| 구분 | 의미 |
|---|---|
panic(value) |
현재 함수의 정상 실행을 중단하고 패닉을 시작합니다. |
defer |
함수가 종료될 때 실행할 호출을 등록합니다. 패닉으로 종료되는 경우에도 실행됩니다. |
recover() |
진행 중인 패닉 값을 받아 패닉 전파를 멈춥니다. |
error |
호출자가 확인하고 처리하는 일반적인 실패 값입니다. |
panic이 발생하면 다음 순서로 진행됩니다.
- 현재 함수의 남은 코드는 실행하지 않습니다.
- 현재 함수에 등록된
defer를 역순으로 실행합니다. - 호출한 함수로 패닉을 전파합니다.
- 호출한 함수에서도
defer를 역순으로 실행합니다. - 어느 지점에서도
recover하지 않으면 패닉이 해당 고루틴의 최상위까지 전파됩니다. - 복구되지 않은 패닉은 스택 추적을 출력하고 프로그램 전체를 종료시킵니다.
panic의 기본 동작
아래 예제는 panic이 발생한 뒤 현재 함수의 나머지 코드가 실행되지 않는 것을 보여줍니다.
package main
import "fmt"
func processOrder(quantity int) {
fmt.Println("주문 처리 시작")
if quantity < 0 {
panic("수량은 음수일 수 없습니다")
}
fmt.Println("주문 처리 완료")
}
func main() {
processOrder(-1)
fmt.Println("main 함수 종료")
}
실행 결과는 다음과 비슷합니다.
주문 처리 시작
panic: 수량은 음수일 수 없습니다
goroutine 1 [running]:
...
processOrder에서 panic이 발생했기 때문에 아래 코드는 실행되지 않습니다.
fmt.Println("주문 처리 완료")
패닉이 복구되지 않았으므로 main의 다음 코드도 실행되지 않습니다.
fmt.Println("main 함수 종료")
panic에는 문자열만 전달해야 하는 것은 아닙니다. any 타입의 값을 전달할 수 있습니다.
panic("설정 오류")
panic(100)
panic(errors.New("데이터베이스 연결 실패"))
panic(ConfigError{Field: "port"})
실제 코드에서는 원인을 표현하기 쉽도록 문자열, error, 구조체 값 등을 사용할 수 있습니다.
panic이 발생해도 defer는 실행됩니다
panic이 발생하면 함수가 즉시 사라지는 것이 아니라, 해당 함수에 등록된 defer를 먼저 실행합니다.
package main
import "fmt"
func run() {
defer fmt.Println("세 번째 defer")
defer fmt.Println("두 번째 defer")
defer fmt.Println("첫 번째 defer")
fmt.Println("panic 발생 전")
panic("처리할 수 없는 상태")
}
func main() {
run()
}
실행 결과:
panic 발생 전
첫 번째 defer
두 번째 defer
세 번째 defer
panic: 처리할 수 없는 상태
defer는 등록한 순서의 반대인 후입선출 방식으로 실행됩니다.
등록 순서: 세 번째 → 두 번째 → 첫 번째
실행 순서: 첫 번째 → 두 번째 → 세 번째
이 특성 때문에 파일 닫기, 잠금 해제, 트랜잭션 롤백처럼 함수 종료 시 반드시 수행해야 하는 정리 작업을 defer로 등록할 수 있습니다.
mutex.Lock()
defer mutex.Unlock()
패닉이 발생하더라도 defer mutex.Unlock()은 실행됩니다. 다만 프로세스가 강제 종료되거나 os.Exit이 호출되는 경우에는 일반적인 함수 반환 과정이 아니므로 defer가 실행되지 않습니다.
recover로 panic 복구하기
recover는 현재 고루틴에서 전파 중인 패닉 값을 반환하고 패닉 전파를 중단합니다.
recover는 일반적으로 defer로 등록한 익명 함수 안에서 호출합니다.
package main
import "fmt"
func run() {
defer func() {
if recovered := recover(); recovered != nil {
fmt.Println("복구한 panic:", recovered)
}
}()
fmt.Println("작업 시작")
panic("예상하지 못한 내부 오류")
fmt.Println("작업 완료")
}
func main() {
run()
fmt.Println("프로그램 계속 실행")
}
실행 결과:
작업 시작
복구한 panic: 예상하지 못한 내부 오류
프로그램 계속 실행
실행 흐름은 다음과 같습니다.
run에서 복구 함수를defer로 등록합니다.panic이 발생합니다.run의 정상 실행이 중단됩니다.- 지연 함수가 실행됩니다.
recover가 패닉 값을 받습니다.- 패닉 전파가 중단됩니다.
run은 종료되고 호출자인main으로 돌아갑니다.main의 다음 코드가 계속 실행됩니다.
중요한 점은 패닉이 발생한 위치 다음부터 다시 실행되는 것이 아니라는 것입니다.
아래 코드는 실행되지 않습니다.
fmt.Println("작업 완료")
recover 이후에는 패닉이 발생한 함수를 빠져나와 호출자 쪽에서 실행이 계속됩니다.
recover는 defer 안에서 호출해야 합니다
일반 실행 흐름에서 recover를 호출하면 nil을 반환하며 아무 효과가 없습니다.
package main
import "fmt"
func main() {
recovered := recover()
fmt.Printf("recover 결과: %v\n", recovered)
}
실행 결과:
recover 결과: <nil>
다음 코드도 패닉을 복구하지 못합니다.
func run() {
recover()
panic("복구되지 않음")
}
recover가 실행될 당시에는 아직 패닉이 진행 중이지 않기 때문입니다.
패닉을 복구하려면 패닉 전파 과정에서 실행되는 지연 함수 안에서 호출해야 합니다.
func run() {
defer func() {
if recovered := recover(); recovered != nil {
fmt.Println("복구:", recovered)
}
}()
panic("복구 가능")
}
recover를 별도 함수로 감싸 간접 호출하는 패턴은 동작 여부를 헷갈리게 만들 수 있습니다. 패닉을 복구하는 함수에서는 지연된 함수 본문에서 recover를 직접 호출하는 형태가 가장 명확합니다.
recover는 같은 고루틴의 panic만 복구합니다
recover는 다른 고루틴에서 발생한 패닉을 복구할 수 없습니다.
다음 예제에서 main 고루틴에 등록된 recover는 새 고루틴의 패닉을 잡지 못합니다.
package main
import (
"fmt"
"time"
)
func main() {
defer func() {
if recovered := recover(); recovered != nil {
fmt.Println("main에서 복구:", recovered)
}
}()
go func() {
panic("작업 고루틴 오류")
}()
time.Sleep(time.Second)
}
패닉이 발생할 수 있는 고루틴은 그 고루틴 내부에 복구 경계를 두어야 합니다.
package main
import (
"fmt"
"sync"
)
func safeWorker(id int, waitGroup *sync.WaitGroup) {
defer waitGroup.Done()
defer func() {
if recovered := recover(); recovered != nil {
fmt.Printf("worker %d panic 복구: %v\n", id, recovered)
}
}()
if id == 2 {
panic("처리 중 예상하지 못한 오류")
}
fmt.Printf("worker %d 완료\n", id)
}
func main() {
var waitGroup sync.WaitGroup
for id := 1; id <= 3; id++ {
waitGroup.Add(1)
go safeWorker(id, &waitGroup)
}
waitGroup.Wait()
fmt.Println("모든 worker 종료")
}
실행 결과의 순서는 고루틴 스케줄링에 따라 달라질 수 있지만, 다음과 같은 내용을 확인할 수 있습니다.
worker 1 완료
worker 2 panic 복구: 처리 중 예상하지 못한 오류
worker 3 완료
모든 worker 종료
각 고루틴의 패닉을 복구하면 하나의 작업 실패가 전체 프로세스 종료로 이어지는 것을 막을 수 있습니다. 그러나 복구 후 상태가 안전한지 확인하지 않고 작업을 계속하면 더 큰 데이터 오류를 만들 수 있으므로, 일반적으로 해당 작업을 종료하고 오류를 기록하는 방식으로 사용합니다.
recover한 값을 error로 변환하기
패키지 내부 구현에서는 깊은 호출 단계에서 panic을 사용해 빠르게 호출 스택을 빠져나온 뒤, 외부 API 경계에서 recover하여 error로 변환할 수 있습니다.
package main
import (
"errors"
"fmt"
)
func validateQuantity(quantity int) {
if quantity < 0 {
panic(errors.New("수량은 0 이상이어야 합니다"))
}
}
func createOrder(quantity int) (err error) {
defer func() {
if recovered := recover(); recovered != nil {
switch value := recovered.(type) {
case error:
err = value
default:
err = fmt.Errorf("panic 발생: %v", value)
}
}
}()
validateQuantity(quantity)
fmt.Println("주문 생성 완료")
return nil
}
func main() {
if err := createOrder(-1); err != nil {
fmt.Println("주문 생성 실패:", err)
return
}
}
실행 결과:
주문 생성 실패: 수량은 0 이상이어야 합니다
이 예제는 패닉 값을 error로 바꾸는 방법을 보여주기 위한 것입니다. 입력값 검증처럼 충분히 예상할 수 있는 실패에는 처음부터 error를 반환하는 편이 더 적절합니다.
다음 형태가 일반적인 Go 코드에 더 가깝습니다.
func validateQuantity(quantity int) error {
if quantity < 0 {
return errors.New("수량은 0 이상이어야 합니다")
}
return nil
}
일반적인 오류는 error로 처리합니다
파일이 없거나, 입력값이 잘못되었거나, 네트워크 요청이 실패하는 상황은 대부분 프로그램에서 예상 가능한 실패입니다. 이런 상황은 panic이 아니라 error로 처리합니다.
package main
import (
"errors"
"fmt"
)
func calculatePrice(unitPrice, quantity int) (int, error) {
if unitPrice < 0 {
return 0, errors.New("단가는 음수일 수 없습니다")
}
if quantity <= 0 {
return 0, errors.New("수량은 1 이상이어야 합니다")
}
return unitPrice * quantity, nil
}
func main() {
totalPrice, err := calculatePrice(1000, 0)
if err != nil {
fmt.Println("계산 실패:", err)
return
}
fmt.Println("총 금액:", totalPrice)
}
error를 사용하면 호출자는 다음 중 하나를 선택할 수 있습니다.
- 오류를 사용자에게 전달합니다.
- 로그를 남깁니다.
- 재시도합니다.
- 기본값으로 대체합니다.
- 상위 함수로 다시 반환합니다.
반면 panic은 정상적인 반환 절차를 건너뛰고 호출 스택을 해제하므로 호출자의 선택권이 제한됩니다.
panic을 사용할 수 있는 경우
반드시 성공해야 하는 초기화
프로그램 시작 시 정규식이나 템플릿처럼 개발자가 작성한 고정 값이 잘못되었다면 실행을 계속하기 어려울 수 있습니다.
표준 라이브러리의 regexp.MustCompile은 정규식이 잘못되면 패닉을 발생시킵니다.
package main
import "regexp"
var orderCodePattern = regexp.MustCompile(`^ORD-[0-9]+$`)
func main() {
_ = orderCodePattern
}
정규식이 코드에 고정되어 있고 잘못된 정규식이 개발 단계의 오류라면, 프로그램 시작 즉시 실패하도록 하는 것이 합리적일 수 있습니다.
사용자 입력으로 받은 정규식이라면 regexp.Compile을 사용해 error를 처리해야 합니다.
pattern, err := regexp.Compile(userInput)
if err != nil {
return fmt.Errorf("잘못된 정규식: %w", err)
}
내부 불변식 위반
정상적인 코드 흐름에서는 절대 발생해서는 안 되는 상태를 만난 경우입니다.
func statusText(status int) string {
switch status {
case 1:
return "대기"
case 2:
return "처리 중"
case 3:
return "완료"
default:
panic(fmt.Sprintf("정의되지 않은 내부 상태: %d", status))
}
}
다만 외부 입력으로 전달된 상태값이라면 panic보다 error 반환이 적절합니다.
요청 또는 작업 경계 보호
HTTP 서버, 메시지 소비자, 작업 실행기처럼 하나의 요청이나 작업이 전체 프로세스를 종료시키지 않도록 가장 바깥 경계에서 패닉을 복구할 수 있습니다.
package main
import (
"log"
"net/http"
"runtime/debug"
)
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
log.Printf("panic: %v\n%s", recovered, debug.Stack())
http.Error(writer, "서버 내부 오류", http.StatusInternalServerError)
}
}()
next.ServeHTTP(writer, request)
})
}
func orderHandler(writer http.ResponseWriter, request *http.Request) {
panic("예상하지 못한 처리 오류")
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/orders", orderHandler)
server := &http.Server{
Addr: ":8080",
Handler: recoveryMiddleware(mux),
}
log.Fatal(server.ListenAndServe())
}
복구 경계에서는 단순히 패닉을 무시하지 않고 다음 작업을 수행하는 것이 좋습니다.
- 패닉 값 기록
- 스택 추적 기록
- 현재 요청 또는 작업 실패 처리
- 사용자에게 내부 구현을 노출하지 않는 응답 반환
- 데이터나 공유 상태가 안전한지 확인
Java exception과 동일한가?
완전히 동일하지 않습니다. 두 기능 모두 비정상 상황에서 정상 흐름을 중단하고 호출 스택을 거슬러 올라갈 수 있다는 점은 비슷하지만, 언어에서 맡는 역할과 사용 방식이 다릅니다.
| 비교 항목 | Go | Java |
|---|---|---|
| 일반적인 오류 처리 | error 값을 반환하고 직접 확인 |
예외를 throw하고 try-catch로 처리 |
| 비정상 흐름 시작 | panic(value) |
throw exception |
| 복구 | 지연 함수 안에서 recover() |
catch 블록 |
| 정리 작업 | defer |
finally, try-with-resources |
| 전달 값 | 모든 any 값 |
Throwable 하위 객체 |
| 검사 예외 | 없음 | checked exception 존재 |
| 고루틴 또는 스레드 경계 | 다른 고루틴의 패닉은 복구 불가 | 다른 스레드의 예외를 현재 스레드의 catch가 처리하지 못함 |
| 주된 용도 | 치명적 내부 상태와 제한적 경계 보호 | 일반 실패부터 프로그래밍 오류까지 폭넓게 사용 |
Java에서는 파일을 읽다가 발생할 수 있는 IOException도 예외로 표현합니다.
try {
String content = Files.readString(path);
System.out.println(content);
} catch (IOException exception) {
System.out.println("파일 읽기 실패: " + exception.getMessage());
}
Go에서는 같은 종류의 실패를 반환값인 error로 처리합니다.
content, err := os.ReadFile(path)
if err != nil {
fmt.Println("파일 읽기 실패:", err)
return
}
fmt.Println(string(content))
따라서 다음과 같이 대응 관계를 이해하면 됩니다.
Java의 일반적인 exception 처리
→ Go에서는 대부분 error 반환과 if err != nil 처리
Java의 RuntimeException 또는 심각한 내부 오류
→ 일부 상황에서 Go의 panic과 비슷할 수 있음
Java의 catch
→ 기능적으로 recover와 일부 유사하지만 사용 범위는 다름
Java의 finally
→ Go의 defer와 일부 유사
panic과 recover를 Java의 throw와 catch에 그대로 대응시켜 모든 오류를 처리하면 Go다운 코드가 되기 어렵습니다. Go에서 panic은 일반적인 예외 처리 수단이 아니라 정상적인 오류 반환 절차를 계속할 수 없을 때 사용하는 별도의 메커니즘입니다.
Java try-catch처럼 작성하면 안 되는 이유
Go에서 모든 오류를 panic으로 바꾼 뒤 가장 바깥에서 한 번에 recover하는 방식은 기술적으로 만들 수 있지만 일반적으로 권장되지 않습니다.
func readConfig(path string) []byte {
content, err := os.ReadFile(path)
if err != nil {
panic(err)
}
return content
}
이 방식은 호출자가 파일 없음, 권한 부족, 일시적 I/O 실패를 구분해 처리하기 어렵게 만듭니다.
명시적인 error 반환 방식이 더 적절합니다.
func readConfig(path string) ([]byte, error) {
content, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("설정 파일 읽기 실패: %w", err)
}
return content, nil
}
호출자는 오류 정책을 직접 결정할 수 있습니다.
content, err := readConfig("config.json")
if err != nil {
log.Println(err)
return
}
Go의 error는 단순히 메시지를 출력하는 값이 아니라, 호출자에게 실패를 명시적으로 전달하는 API의 일부입니다.
잘못된 recover 사용
패닉을 아무 기록 없이 무시하는 경우
defer func() {
recover()
}()
이 코드는 패닉을 복구하지만 원인과 스택 정보를 모두 잃게 만들 수 있습니다. 프로그램은 계속 실행되더라도 내부 상태가 손상되었는지 알기 어렵습니다.
최소한 패닉 값과 스택을 기록하는 것이 좋습니다.
defer func() {
if recovered := recover(); recovered != nil {
log.Printf("panic: %v\n%s", recovered, debug.Stack())
}
}()
너무 낮은 단계에서 복구하는 경우
모든 작은 함수에 recover를 넣으면 오류 발생 지점이 숨겨지고, 잘못된 상태로 작업이 계속될 수 있습니다.
복구는 보통 다음과 같은 명확한 경계에 둡니다.
- HTTP 요청 처리 경계
- 메시지 한 건의 처리 경계
- 독립적인 작업 고루틴의 시작 함수
- 패키지 내부 패닉을 공개 API의
error로 변환하는 경계
복구 후 같은 작업을 계속하는 경우
패닉은 해당 함수의 정상 흐름을 중단합니다. 복구했다고 해서 패닉 발생 지점 다음 줄부터 계속할 수 있는 것은 아닙니다.
func run() {
defer func() {
if recovered := recover(); recovered != nil {
fmt.Println("복구 완료")
}
}()
panic("오류")
fmt.Println("이 코드는 실행되지 않음")
}
복구 후에는 현재 함수가 반환되고 호출자에서 실행이 이어집니다.
전체 예제
아래 예제는 일반적인 입력 오류는 error로 반환하고, 예상하지 못한 패닉은 작업 경계에서 복구하는 구조를 보여줍니다.
package main
import (
"errors"
"fmt"
"log"
"runtime/debug"
)
type Order struct {
ID int
Quantity int
}
func validateOrder(order Order) error {
if order.ID <= 0 {
return errors.New("주문 ID는 1 이상이어야 합니다")
}
if order.Quantity <= 0 {
return errors.New("주문 수량은 1 이상이어야 합니다")
}
return nil
}
func saveOrder(order Order) error {
if err := validateOrder(order); err != nil {
return err
}
if order.ID == 999 {
panic("저장소 내부 상태가 손상되었습니다")
}
fmt.Printf("주문 저장 완료: %+v\n", order)
return nil
}
func processOrder(order Order) (err error) {
defer func() {
if recovered := recover(); recovered != nil {
log.Printf("주문 처리 panic: %v\n%s", recovered, debug.Stack())
err = fmt.Errorf("주문 처리 중 내부 오류가 발생했습니다")
}
}()
if err := saveOrder(order); err != nil {
return fmt.Errorf("주문 저장 실패: %w", err)
}
return nil
}
func main() {
orders := []Order{
{ID: 1, Quantity: 2},
{ID: 2, Quantity: 0},
{ID: 999, Quantity: 1},
}
for _, order := range orders {
if err := processOrder(order); err != nil {
fmt.Printf("처리 실패: ID=%d, error=%v\n", order.ID, err)
continue
}
fmt.Printf("처리 성공: ID=%d\n", order.ID)
}
}
이 예제에서 오류는 두 종류로 구분됩니다.
| 상황 | 처리 방식 |
|---|---|
| 주문 ID나 수량이 잘못됨 | 예상 가능한 실패이므로 error 반환 |
| 저장소 내부 상태 손상 | 정상적으로 발생해서는 안 되는 상태이므로 panic |
| 작업 경계에서 패닉 발생 | recover 후 로그와 스택 기록, error로 변환 |
이 구조에서는 하나의 주문 처리에서 패닉이 발생하더라도 반복문이 다음 주문 처리를 계속할 수 있습니다. 다만 실제 시스템에서는 패닉의 원인이 공유 데이터 손상이나 시스템 전체의 잘못된 상태라면 계속 실행하는 것이 안전하지 않을 수 있으므로, 복구 여부를 상황에 따라 판단해야 합니다.
정리
Go의 panic은 현재 함수의 정상 실행을 중단하고 호출 스택을 거슬러 올라갑니다. 이 과정에서 각 함수에 등록된 defer가 역순으로 실행됩니다.
recover는 같은 고루틴에서 진행 중인 패닉을 지연 함수 안에서 받아 전파를 중단합니다. 복구 후에는 패닉이 발생한 줄 다음으로 돌아가지 않고, 패닉이 발생한 함수가 종료된 뒤 호출자에서 실행이 계속됩니다.
Java의 exception 처리와 일부 동작은 비슷하지만 역할은 동일하지 않습니다. Java에서는 일반적인 실패도 예외로 처리하는 경우가 많지만, Go에서는 대부분의 예상 가능한 실패를 error 값으로 반환합니다.
기본 기준은 다음과 같습니다.
예상 가능한 실패
→ error 반환
정상적으로 발생해서는 안 되는 내부 상태
→ 제한적으로 panic 고려
HTTP 요청, 메시지, 작업 고루틴의 최상위 경계
→ 필요할 때 recover하여 로그와 스택 기록
panic을 Java의 throw, recover를 catch의 완전한 대체로 생각하기보다, Go의 기본 오류 처리인 error를 보완하는 제한적인 비정상 흐름 제어 기능으로 이해하는 것이 정확합니다.
참고 자료
- Go 언어 명세 - Handling panics: https://go.dev/ref/spec#Handling_panics
- Go 공식 블로그 - Defer, Panic, and Recover: https://go.dev/blog/defer-panic-and-recover
- Go 공식 블로그 - Error handling and Go: https://go.dev/blog/error-handling-and-go
- Go 공식 블로그 - Errors are values: https://go.dev/blog/errors-are-values
- Go builtin 패키지 문서 - panic과 recover: https://pkg.go.dev/builtin
- Java Language Specification - Exceptions: https://docs.oracle.com/javase/specs/jls/se25/html/jls-11.html
'프로그래밍 > Go' 카테고리의 다른 글
| Go Worker Pool, WaitGroup, Rate Limiting (0) | 2026.07.27 |
|---|---|
| Go Timer와 Ticker (0) | 2026.07.24 |
| Go defer (0) | 2026.07.22 |
| Go context (0) | 2026.07.21 |
| Go select, timeout, close, context (0) | 2026.07.20 |
댓글