포스트

흐름 ④ error·defer·panic/recover — 실패와 정리의 역할 분리

Go의 일반 실패는 error 값으로 흐르고, defer는 함수 종료 시 실행할 정리를 예약하며, panic/recover는 정상 error 흐름과 다른 stack-unwinding 경계를 만든다. RAII·예외와의 유사점은 발판으로만 쓰고 의미론을 구분한다.

흐름 ④ error·defer·panic/recover — 실패와 정리의 역할 분리

Go 학습 로드맵의 ④ 실패·정리 흐름 Zoom-in이다. 앞 글: ③ slice·map·string

Go에서 error, defer, panic, recover는 한 묶음처럼 자주 소개되지만 해결하는 문제가 다르다.

1
2
3
4
5
6
7
8
9
10
11
예상 가능한 실패를 호출자에게 전달
→ error value

함수를 빠져나갈 때 실행할 정리 예약
→ defer

정상 반환 경로를 계속할 수 없는 stack unwinding 시작
→ panic

특정 경계에서 진행 중인 panic을 받아 정상 반환으로 전환
→ recover

C++/Java의 exception이나 RAII를 떠올리면 출발은 쉽지만 그대로 대응시키지는 않는다.

1. error — 실패도 반환값이다

Go의 error는 특별한 제어문이 아니라 interface다.

1
2
3
type error interface {
    Error() string
}

일반적으로 실패할 수 있는 함수는 결과와 error를 함께 반환한다.

1
2
3
4
5
f, err := os.Open("config.yaml")
if err != nil {
    return err
}
defer f.Close()

핵심은 “모든 줄 뒤에 if err != nil을 쓴다”가 아니라 호출 계약에 실패 가능성이 값으로 드러난다는 점이다.

1
2
3
callee
→ result + error
→ caller가 처리 / 변환 / context 추가 / 상위 전달 중 선택

Custom error

Error() string을 구현한 타입은 error로 사용할 수 있다.

1
2
3
4
5
6
7
type NotFoundError struct {
    Key string
}

func (e *NotFoundError) Error() string {
    return "not found: " + e.Key
}

구체 타입이 필요할 때도 문자열을 parse하지 않고 타입/값 계약으로 판별하는 편이 좋다.

2. wrapping — context를 더하되 원인을 보존한다

하위 실패에 상위 작업의 의미를 덧붙일 때 %w로 wrap할 수 있다.

1
2
3
if err != nil {
    return fmt.Errorf("load config: %w", err)
}

호출자는 wrapper chain 전체에서 값이나 타입을 찾는다.

1
2
3
4
5
6
7
8
if errors.Is(err, os.ErrNotExist) {
    // 특정 error 값/동등성 계약
}

var nf *NotFoundError
if errors.As(err, &nf) {
    // 특정 error 타입 추출
}
1
2
3
4
5
errors.Is
→ target error와 일치하는지 chain을 따라 확인

errors.As
→ 원하는 error 타입으로 assign 가능한 값을 chain에서 찾음

errors.Is를 단순히 “내부에서 ==만 한다”고 외우지는 않는다. error 타입이 Is method를 제공해 자체 동등성 규칙을 정의할 수도 있다.

3. defer — object lifetime이 아니라 함수 반환 경계에 묶인다

defer는 호출을 현재 함수가 반환할 때 실행하도록 예약한다.

1
2
3
4
5
f, err := os.Open(path)
if err != nil {
    return err
}
defer f.Close()

여러 defer는 LIFO 순서로 실행된다.

1
2
3
defer fmt.Println("first")
defer fmt.Println("second")
// return 시 second → first

C++ RAII와는 다음 정도만 대응시킨다.

관점C++ RAIIGo defer
정리 누락 방지object destructor함수 반환 시 예약 호출
정리 시점의 기준object lifetime / scope현재 함수의 return/unwind
자원과 정리의 결합type이 소유하면 자동화 가능호출부가 명시적으로 예약
순서local object destruction orderdeferred call LIFO

둘은 정리 코드를 실패 경로에서도 실행시킨다는 목적은 비슷하지만 같은 메커니즘은 아니다.

defer의 인자는 언제 평가되나

defer statement를 실행할 때 함수 값과 인자가 평가되고, 실제 호출만 나중에 이루어진다.

1
2
3
x := 1
defer fmt.Println(x) // 여기서 x의 값 1이 인자로 평가됨
x = 2

이 구분은 closure를 defer할 때와 결과가 달라질 수 있어 중요하다.

4. panic — 일반 error 반환과 다른 unwind 경로

panic(v)가 실행되면 현재 함수의 정상 실행은 중단되고, 그 goroutine의 call stack을 따라 deferred function을 실행하며 unwinding한다.

1
2
3
4
5
panic 발생
→ 현재 함수의 defer 실행
→ caller로 unwind
→ caller의 defer 실행
→ ...

어디에서도 panic이 회복되지 않고 goroutine의 최상단까지 도달하면 program은 panic 정보를 출력하고 종료한다.

따라서 panic을 단순히 “즉시 프로그램 종료”라고 보지 않는다. defer 실행과 unwind 과정이 먼저 있다.

일반적으로 file-not-found, validation failure, network failure처럼 호출자가 대응할 수 있는 상황은 error로 표현한다. panic은 API 계약상 정상적인 실패 결과로 계속 진행하기 어렵거나 내부 불변식이 깨진 상황 등에 제한적으로 쓴다.

Runtime 자체도 index out of range, nil pointer dereference 같은 오류에서 panic을 발생시킬 수 있다.

5. recover — panic을 처리하는 명시적 경계

recover는 현재 goroutine에서 진행 중인 panic을 deferred function 경계에서 받아 unwinding을 멈출 수 있다.

1
2
3
4
5
6
7
8
9
10
func run() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("worker panic: %v", r)
        }
    }()

    doWork()
    return nil
}

이 패턴의 핵심은 panic을 일상 error flow로 바꾸라는 뜻이 아니다. 예를 들어 server framework가 한 request handler의 panic 때문에 전체 process가 죽지 않도록 격리 경계를 만들 때처럼 제한된 위치에서 사용한다.

또한 recover는 다른 goroutine에서 발생한 panic을 잡는 전역 handler가 아니다. panic과 recovery는 해당 goroutine의 stack unwinding 관계 안에서 이해한다.

6. 네 도구를 한 그림에 놓기

1
2
3
4
5
6
7
8
9
10
11
12
13
일반 함수 호출
   ↓
작업 수행
   ├─ 예상 가능한 실패
   │    → error 반환
   │
   ├─ 성공/실패와 상관없이 함수 종료 때 정리
   │    → defer
   │
   └─ 정상 반환 계약을 계속하기 어려운 panic
        → defer들을 실행하며 stack unwind
        → recovery boundary가 있으면 recover
        → 없으면 top까지 전파되어 program 종료

C++/Java 경험과의 경계

익숙한 개념Go에서 떠올릴 것같은 점 / 다른 점
Exception result propagationerror return실패 전달 목적은 비슷하지만 error는 명시적 반환값
RAII cleanupdefercleanup 목적은 비슷하지만 object lifetime과 함수 return이라는 기준이 다름
Throw/unwindpanicstack unwind가 있지만 일반 recoverable error API로 쓰는 관례가 아님
top-level exception boundarydeferred recover boundary격리 경계라는 역할은 유사하지만 goroutine과 Go panic 규칙을 따름

일반 실패 모델의 비교 자체는 에러 핸들링 모델에 두고, 이 글은 Go의 구체 의미론에 집중한다.

흔한 함정

  • error 무시 — _로 버리기 전에 호출자가 정말 대응할 필요가 없는지 확인한다.
  • %v로 감싸고 원인 추적 기대 — wrapper chain이 필요하면 %w를 사용한다.
  • loop에서 resource를 계속 열고 함수 끝까지 defer — 정리 시점이 함수 반환이라 resource가 오래 쌓일 수 있다. 반복 한 단위를 함수로 분리하거나 적절한 시점에 명시적으로 정리한다.
  • panic을 validation branch처럼 사용 — 예상 가능한 실패라면 error가 호출 계약을 더 명확하게 만든다.
  • recover를 전역 catch로 생각 — 다른 goroutine의 panic까지 잡아주는 기능이 아니다.

통과 기준

다음을 구분할 수 있으면 된다.

1
2
3
4
5
6
7
8
9
10
11
이 실패는 호출자가 처리할 수 있는가?
→ error

이 resource는 현재 함수가 끝날 때 정리해야 하는가?
→ defer 고려

정상 반환 경로를 계속할 수 없는가?
→ panic이 적절한 계약인지 검토

panic이 process 경계까지 나가면 안 되는 위치인가?
→ 좁은 recovery boundary 검토

다음 Zoom-in은 goroutine·channel·context다. 실행 단위·통신·공유 상태·취소 전파를 서로 다른 책임으로 나눈다.

Reference

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.