Go의 에러 처리: error 값, panic, recover

Go의 에러 처리: error 값, panic, recover

Go 프로그램에서 에러를 다루는 방식은 다른 언어와 사뭇 달라요. 예외(exception)를 던져서 바깥에서 잡는 대신, 함수가 값을 하나 더 돌려주는 방식이 기본이에요. 실패했을 때 함수는 정상 결과 옆에 error 값을 함께 돌려주고, 호출하는 쪽이 그 값을 살펴보고 판단해요. 그래서 에러는 "통제를 빼앗기는 사건"이라기보다 그냥 다뤄야 할 값처럼 보이는 것이 자연스러워요.

이 페이지는 공식 Effective Go 문서의 Errors 절을 따라가며, error 값의 관례, 구조화된 에러를 검사하는 방법, 그리고 회복이 불가능한 상황에 쓰는 panicrecover를 설명할게요.

출처: Effective Go — Errors (공식 문서)

error는 값이다

라이브러리 함수는 흔히 호출자에게 어떤 식으로든 에러를 알려줘야 해요. Go의 다중 반환 값 덕분에, 정상 반환 값 옆에 상세한 에러 설명을 함께 돌려주기 쉽죠. 관례상 에러는 error 타입이고, 이것은 간단한 내장 인터페이스예요.

type error interface {
	Error() string
}

라이브러리 작성자는 이 인터페이스를 그 뒤에서 더 풍부한 모델로 구현해, 에러를 보여줄 뿐 아니라 맥락(context)을 함께 담을 수 있어요. 예를 들어 os.Open은 성공하면 *os.Filenil 에러를 돌려주지만, 문제가 있으면 os.PathError를 돌려줘요.

// PathError records an error and the operation and
// file path that caused it.
type PathError struct {
	Op string    // "open", "unlink", etc.
	Path string  // The associated file.
	Err error    // Returned by the system call.
}

func (e *PathError) Error() string {
	return e.Op + " " + e.Path + ": " + e.Err.Error()
}

PathErrorError는 다음과 같은 문자열을 만들어요. 문제가 생긴 파일 이름·연산·그 원인이 된 OS 에러를 모두 담고 있어서, 단순한 "no such file or directory"보다 훨씬 유용해요.

open /etc/passwx: no such file or directory

가능하면 에러 문자열은 그 출처를 알 수 있게 하는 게 좋아요. 예를 들어 image 패키지는 알 수 없는 형식의 디코딩 에러를 "image: unknown format"처럼 나타내요.

구조화된 에러 검사

정확한 에러 내용을 알아야 하는 호출자는 타입 스위치나 타입 단언으로 특정 에러를 찾아 세부 정보를 뽑아낼 수 있어요. 아래 예는 *os.PathError인지 확인하고, 회복 가능한 ENOSPC(디스크 공간 부족)라면 임시 파일을 지우고 재시도해요.

for try := 0; try < 2; try++ {
	file, err = os.Create(filename)
	if err == nil {
		return
	}
	if e, ok := err.(*os.PathError); ok && e.Err == syscall.ENOSPC {
		deleteTempFiles()  // Recover some space.
		continue
	}
	return
}

여기 두 번째 if가 또 하나의 타입 단언이에요. 실패하면 okfalse, enil이 되고, 성공하면 에러가 *os.PathError 타입임을 뜻해서 e로 에러에 관한 더 많은 정보를 살펴볼 수 있어요.

에러를 돌려주는 관용구

에러를 호출자에게 알리는 평범한 방법은 에러를 추가 반환 값으로 돌려주는 것이에요. 표준 Read 메서드가 잘 알려진 예인데, 바이트 수와 에러를 함께 돌려줘요. 또 "항상 에러 반환 값을 확인하라"는 점이 강조돼요. 에러를 무시하고 버리려고 아래처럼 쓰는 것은 지양해야 해요.

// Bad! This code will crash if path does not exist.
fi, _ := os.Stat(path)
if fi.IsDir() {
	fmt.Printf("%s is a directory\n", path)
}

에러 값은 존재하는 데는 이유가 있으니, 항상 확인하는 습관이 중요해요.

패닉: 회복 불가능한 상황

에러를 호출자에게 알리는 보통 방법은 추가 반환 값으로 돌려주는 거예요. 하지만 회복 불가능한 에러라면? 가끔 프로그램이 그대로 진행될 수 없을 때가 있어요. 그럴 때 내장 함수 panic을 써요. panic은 효과적으로 실행을 멈추는 런타임 에러를 만들어요. 인자는 보통 문자열이고 프로그램이 죽을 때 출력돼요. 또 "불가능한 일이 일어났다"는 신호로도 쓸 수 있어요 — 예컨대 무한 루프에서 빠져나오는 경우처럼요.

// A toy implementation of cube root using Newton's method.
func CubeRoot(x float64) float64 {
	z := x/3   // Arbitrary initial value
	for i := 0; i < 1e6; i++ {
		prevz := z
		z -= (z*z*z-x) / (3*z*z)
		if veryClose(z, prevz) {
			return z
		}
	}
	// A million iterations has not converged; something is wrong.
	panic(fmt.Sprintf("CubeRoot(%g) did not converge", x))
}

이건 예시일 뿐이고, 실제 라이브러리 함수는 panic을 피해야 해요. 문제를 가리거나 우회할 수 있다면, 프로그램 전체를 죽이기보다 계속 실행하게 두는 편이 항상 낫죠. 다만 초기화 중에 라이브러리가 정말 자기 설정을 못 한다면 panic이 합리적일 수도 있어요.

recover: 고루틴을 되살리기

panic이 호출되면(슬라이스 인덱스 초과·타입 단언 실패 같은 런타임 오류 포함), 현재 함수의 실행을 즉시 멈추고 스택을 되감으며(unwinding) 지연된 함수를 실행하기 시작해요. 되감기가 고루틴 스택 꼭대기까지 닿으면 프로그램이 죽어요. 하지만 내장 함수 recover로 고루틴의 제어를 되찾고 정상 실행을 재개할 수 있어요.

recover 호출은 되감기를 멈추고 panic에 넘긴 인자를 돌려줘요. 되감기 중에 실행되는 코드는 지연된 함수 안뿐이므로, recover는 지연된 함수 안에서만 쓸모가 있어요. 전형적인 쓰임은 서버가 실패한 고루틴 하나만 죽이고 나머지는 살려두는 거예요.

func server(workChan <-chan *Work) {
	for work := range workChan {
		go safelyDo(work)
	}
}

func safelyDo(work *Work) {
	defer func() {
		if err := recover(); err != nil {
			log.Println("work failed:", err)
		}
	}()
	do(work)
}

여기서 do(work)가 panic을 일으키면, 결과가 로그에 기록되고 그 고루틴만 깨끗이 종료돼 다른 고루틴을 방해하지 않아요. recover는 지연 함수에서 직접 부를 때가 아니라면 항상 nil을 돌려주므로, 지연 코드는 자기 자신이 panic·recover를 쓰는 라이브러리 루틴을 안전하게 부를 수 있어요.

defer+recover 패턴을 갖추면 do 함수(와 그게 부르는 것들)는 panic 호출로 어떤 나쁜 상황에서도 깨끗하게 빠져나올 수 있어요. 이 아이디어로 복잡한 소프트웨어의 에러 처리를 단순화하기도 해요. 예를 들어 정규식 파서가 파싱 에러를 지역 에러 타입으로 panic으로 보고하는 관용구에요.

// Error is the type of a parse error; it satisfies the error interface.
type Error string
func (e Error) Error() string {
	return string(e)
}

// error is a method of *Regexp that reports parsing errors by
// panicking with an Error.
func (regexp *Regexp) error(err string) {
	panic(Error(err))
}

// Compile returns a parsed representation of the regular expression.
func Compile(str string) (regexp *Regexp, err error) {
	regexp = new(Regexp)
	// doParse will panic if there is a parse error.
	defer func() {
		if e := recover(); e != nil {
			regexp = nil    // Clear return value.
			err = e.(Error) // Will re-panic if not a parse error.
		}
	}()
	return regexp.doParse(str), nil
}

doParse가 panic을 일으키면 복구 블록이 반환 값을 nil로 만들고(지연 함수는 명명된 반환 값을 수정할 수 있어요), 그 문제가 파싱 에러인지 지역 타입 Error인지 단언해 확인해요. 그게 아니라면 타입 단언이 실패해 런타임 오류로 이어지고, 스택 되감기가 계속돼요 — 그래서 예상치 못한 문제가 있을 때는 recover로 감춰지지 않고 그대로 드러나요.

이 패턴은 유용하지만 패키지 안에서만 써야 해요. Compile은 내부의 panic 호출을 error 값으로 바꾸고, 클라이언트에게 panic을 노출하지 않아요. 그게 따르기 좋은 규칙이에요.

더 알아보기 (Learn more)