Error는 검사만 하지말고, 우아하게 처리하세요.

본 문서는 Dave Cheney 의 블로그 글이 좋아서 제가 개인적으로 번역해본 것입니다.
Don’t just check errors, handle them gracefully
이 글을 읽고 error 처리에 대해 좀 더 깊이 생각해보는 계기가 된 것 같습니다.

블로그 글이 2016 년에 올라온 글이라서 세부적으로는 현재와 약간 맞지 않는 부분이 있습니다만(github.com/pkg/errors 패키지 사용방법 등), 기본 맥락을 이해하면 insight 를 얻을 수 있는 글이라고 생각합니다.



이 글은 일본에서 개최된 GoCon spring conference 에서 제가 발표한 자료로부터 추출한 것입니다.

Error 는 그냥 값일 뿐입니다.

저는 Go 프로그램에서 error handling 하는 가장 좋은 방법이 무엇일까하고 정말 오랫동안 생각해 왔습니다.
Go 프로그래머들에게 error 를 처리하는 유일한 방법, 수학이나 알파벳을 배우듯이 기계적인 방법으로 가르칠 수 있기를 정말 간절히 원했어요.
하지만, error 를 handling 하는 유일한 방법은 존재하지 않는다는 결론을 내렸습니다.
그 대신, 저는 Go 의 error 처리가 3 가지 정도의 카테고리로 분류될 수 있다고 믿습니다.

Sentinel errors

첫 번째 에러 처리 카테고리를 저는 sentinel errors(파수꾼 에러)라고 부릅니다.

if err == ErrSomething { ... }

sentinel 이라는 이름은, 컴퓨터 프로그래밍에서 더 이상의 처리가 불가능할 때, 이를 나타내는 특정 값을 사용한다는 것에서 유래된 것입니다.
Go 에서도 우리는 error 를 나타내는 특정 값들을 사용합니다.
io.EOF 와 같은 값이나, syscall 패키지에서 사용하는 syscall.ENOENT 같은 low level 의 error 들이 이러한 예에 해당됩니다.

하다 못해 실제로는 발생하지 않은 error 를 나타내는 sentinel error 도 있습니다.
go/build.NoGoError 라든가, path/filepath.Walk 함수를 사용할 때 리턴되는 path/filepath.SkipDir 가 그렇습니다.
(역자. https://golang.org/pkg/path/filepath/#WalkFunc)

이렇게 sentinel value 를 사용하면, 호출하는 쪽에서 ==(equal) 연산자를 이용하여 미리 정의된 값과 비교를 해야하므로 유연하지 못합니다.
제일 유연하지 못한 방법이예요.
여러분이 좀 더 많은 로직을 제공하고자 할 때는 또 다른 에러를 리턴하게 될 수도 있는데, 이런 상황이 되면 기존의 ==(equal)을 통한 처리가 불완전해질 수 있습니다.

호출하는 쪽에서의 ==(equal) 테스트의 불완전성을 극복하기 위해 fmt.Errorf 를 이용하여 context 정보를 좀더 추가할 수도 있습니다.
하지만, 호출하는 쪽에서는 특정 string 과 match 되는지를 확인하기 위해 error.Error() 메소드의 결과물(output)을 들여다봐야만 하겠죠.

절대 error.Error 의 output 을 검사하지 마세요 !

저는 여러분이 error.Error 메소드의 output 을 절대 검사하지 말아야한다고 믿습니다.
error interface 에서의 Error 메소드는 code 가 아니라 사람을 위해 존재하는 것입니다.

Error 메소드에서 내뱉는 string 은 log file 에 기록될 것, 또는 screen 에 보여질 것일 뿐.
여러분은 그것을 검사해서 여러분의 프로그램의 행동을 바꾸는 일은 하지 말아야 합니다.

때로는 이것을 지키는 것이 불가능하다는 것을 알고 있어요.
누군가가 트위터에서 지적했듯이, 이러한 충고는 테스트 작성에는 필요하지 않을 수도 있구요.
하지만, 그럼에도 불구하고 error 의 string 을 비교하는 것은 악취나는 코드를 만드는 일이고, 되도록이면 피해야하는 일이라고 생각합니다.

Sentinel error 도 여러분이 만든 public API 의 한 부분입니다.

여러분의 public 함수나 메소드가 특정 값의 error 를 리턴한다면, 그 값도 public 이 되어야 합니다. 그리고, 당연히 문서화되어야하죠.
여러분 API 의 노출 부분에 추가되는거예요.

만약 여러분의 API 가 특정 error 를 리턴하는 interface 를 정의한다면, 그 interface 의 모든 구현들은 오로지 그 error 만을 리턴하도록 제약이 되는거예요.
설령 그 구현들이 더 상세한 error 을 제공할 수 있다고 하더라도 말이예요.

우리는 이러한 예를 io.Reader 에서 볼 수 있어요.
io.Copy 와 같은 함수들은 정확히 io.EOF 를 리턴하는 reader 구현을 요구하는데요.
이는 호출하는 측에 "no more data, but that isn't an error" 상황을 알려주기 위한 것입니다.

Sentinel error 는 두 패키지간 의존성을 유발합니다.

Sentinel error 값을 사용할 때 가장 큰 문제점은 두 패키지 간의 소스 코드 의존성이 생긴다는 것입니다.
예를 들면, 에러가 io.EOF 와 equal 인지를 검사하기 위해, 여러분의 코드는 io 패키지를 import 해야 합니다.

이러한 특수한 예는 너무 흔해서 별로 크게 나빠 보이지는 않습니다.
하지만, 여러분의 프로젝트에서 수많은 패키지가 존재하고, 프로젝트 내 다른 패키지들에서 특정 에러 조건을 검사하기 위해 import 를 수행해야하는 복잡한 coupling 상황을 상상해 보세요.

여러분이 대규모 프로젝트에서 이런 패턴으로 작업을 하게 되면, import loop 에서 오는 공포를 가까이서 제대로 느끼시게 될거예요.

결론 : sentinel error 사용을 피하세요.

결론적으로, 제 충고는 여러분의 코드에서 되도록 sentinel error 의 사용을 피하라는 것입니다.
standard library 에서 사용되고 있는 몇몇 case 가 있습니다만, 이건 여러분이 흉내내야하는 패턴은 아닙니다.

만약 누군가가 여러분의 패키지에서 error 값을 export 해달라고 요청하면, 여러분은 정중히 거절한 후, 대신에 대체 메소드를 제안해야 합니다.
이 부분은 다음에 말씀드리겠습니다.

Error types

Error types 는 제가 논의드릴 Go 에러 처리의 두번째 유형(카테고리)입니다.

if err, ok := err.(SomeType); ok { … }

error type 은 여러분이 만드시는 타입 중에서 error interface 를 구현한 것입니다.
아래의 예에서, MyError 타입은 무슨 일이 발생했는지를 설명하는 메시지 뿐 아니라, file 과 line 을 트래킹합니다.

type MyError struct {
        Msg string
        File string
        Line int
}

func (e *MyError) Error() string { 
        return fmt.Sprintf("%s:%d: %s”, e.File, e.Line, e.Msg)
}

return &MyError{"Something happened", “server.go", 42}

MyError error 는 type 이기 때문에, 호출하는 쪽에서는 error 의 추가 정보를 뽑아내기 위해 type assertion (역자: x.(T) 형식의 표기법) 을 사용할 수 있습니다.

err := something()
switch err := err.(type) {
case nil:
        // call succeeded, nothing to do
case *MyError:
        fmt.Println(“error occurred on line:”, err.Line)
default:
// unknown error
}

첫번째 카테고리에서 보았던 "error 값"보다 "error 타입"을 통해 크게 개선된 점이라면, 더 많은 context 정보를 제공할 수 있도록 원래 error 를 wrapping 할 수 있다는 점입니다.

이에 대한 훌륭한 예는 바로 os.PathError 타입인데요.
사용하려고 했던 파일에 대한 정보나, 수행하려고 했던 operation 에 대한 정보를 기본 error 에 추가해주는 타입입니다.

// PathError records an error and the operation
// and file path that caused it.
type PathError struct {
        Op   string
        Path string
        Err  error // the cause
}

func (e *PathError) Error() string

error type 의 문제점들

그래서, 호출하는 쪽에서는 type assertion 을 사용하거나 type switch 구문을 사용할 수 있고, error type 은 public 으로 작성되어야 합니다.

만약 여러분의 코드가 특정 error type 을 요구하는 interface 를 구현한다면, 그 interface 를 구현하는 모든 것들은 error type 을 정의한 패키지에 의존성을 가지게 될 것입니다.

이렇게 패키지의 type 들에 너무 잘 알게 되면 호출자와 강한 결합 관계를 만들고, 결국은 불안정한 API 를 양산하게 됩니다.

결론 : error type 사용을 피하세요.

무엇이 문제인지에 대한 정보 등 좀더 많은 context 정보를 포함할 수 있는 error type 이 sentinel error value 보다는 그래도 낫지만, error type 은 error value 의 많은 문제점을 그대로 가집니다.

그래서, 역시 제 충고는 error type 사용을 피하라는 것입니다. 또는, 적어도 error type 을 여러분의 public API 의 일부로 포함시키지 말라는 것입니다.

Opaque errors

자~ 이제 에러 처리의 세번째 카테고리를 봅시다.
제 생각에는 이것이 가장 유연한 에러 처리 전략입니다. 왜냐면, 여러분의 코드와 호출자 사이의 결합도가 가장 적거든요.

저는 이런 스타일을 불투명(Opaque) 에러 처리(error handling)라고 부릅니다.
내용물에 대해서는 어떤 추측이나 가정도 없이 그냥 error 를 리턴하는 것이죠.
여러분이 이 자세를 취한다면 error handling 은 디버깅 도우미로서 굉장히 유용해집니다.

import “github.com/quux/bar”

func fn() error {
        x, err := bar.Foo()
        if err != nil {
                return err
        }
        // use x
}

예를 들어 위의 코드에서 Foo()의 계약(contract)은 error context 에서 반환되는 내용에 대해 보장할 필요가 없습니다.
(역자. contract 는 함수와 호출자간의 정해진 약속을 의미함. 호출방법과 리턴 방법 등의 인터페이스에 대한 약속.)
Foo() 의 작성자는 이제 호출자와의 계약을 위반하지 않고, error 를 통해 추가적인 context 정보를 전달할 수 있습니다.

타입이 아니라 행위(behavior)에 대해 error assertion 을 하세요.

어떤 경우에는 에러 처리에 대해 이분법적 접근만으로는 충분하지가 않습니다.

예를 들면, 여러분 process 의 바깥 세상과의 상호 작용(network 을 통한 동작같은)에서는, 호출자가 작업을 재수행하는 것이 좋은지를 판단하기 위해 error 를 조사해야할 경우가 있습니다.

이러한 경우에는 error 가 특정 타입이나 값인 것을 보고 assertion(역자: x.(T) 표기법)하는 것이 아니라, error 가 특정 행위를 구현하고 있는지를 보고 assert 할 수 있습니다.

아래 예를 한번 보세요.

type temporary interface {
        Temporary() bool
}
 
// IsTemporary returns true if err is temporary.
func IsTemporary(err error) bool {
        te, ok := err.(temporary)
        return ok && te.Temporary()
}

위 코드를 보면 error 가 retry 될 수 있는지 판단하기 위해, 우리는 IsTemporary 함수에 어떤 error 든지 전달할 수 있습니다.

만약 error 가 temporary interface 를 구현하고 있지 않으면, (즉, Temporary() 메소드를 가지고 있지 않으면), 이 에러는 temporary 가 아닌 것입니다.

반면에, 만약 error 가 Temporary 매소드를 구현하고 있다면, 아마도 호출자는 Temporary 가 true 를 리턴하는 경우 작업을 재수행할 수 있을거예요.

여기에서 중요한 점은 이러한 로직이 error 를 정의하는 패키지를 import 할 필요없이 구현될 수 있다는 것입니다. err 의 기초가 되는 타입이 뭔지 몰라도 된다는거죠.
단지 우리는 그것의 행위에만 관심을 두면 되는겁니다.

단지 error 를 검사만 하지 말고, 우아하게 처리하세요.

제가 말하고 싶은 두번째 Go 격언입니다.
"단지 error 를 검사만 하지 말고, 우아하게 처리하세요."

여러분은 아래의 코드에서 문제점이 무엇인지 말씀해주실 수 있나요?

func AuthenticateRequest(r *Request) error {
        err := authenticate(r.User)
        if err != nil {
                return err
        }
        return nil
}

확실히 말할 수 있는 것은 함수 구현의 5 줄이 아래 한줄로 대체될 수 있다는 거예요.

return authenticate(r.User)

하지만, 이것은 코드 리뷰에서 누구나 잡아낼 수 있는 간단한 것이구요.
이 코드에서 보다 근본적인 문제점은 우리가 최초의 error 가 어디서부터 비롯된 것인지를 알 수가 없다는 것입니다.

만약 authenticate 함수가 error 를 리턴하면, AuthenticateRequest 함수는 그 error 를 호출자에게 그대로 리턴할 것입니다.
그러면, 호출자도 아마 똑같이 그냥 리턴할 수도 있겠죠.
프로그램의 꼭대기, main 에서는 그 error 를 화면에 출력할 수도 있고 log file 에 기록할 수도 있을 것입니다.
그리고, 그 출력이나 기록 내용은 아마도 "No such file or directory" 이 전부일 수도 있습니다.

https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully

최초에 error 가 어디에서 생성된 것인지 file 이나 line 에 대한 아무 정보도 없습니다.
error 의 호출 path 를 알 수 있는 call stack 의 stack trace 정보도 없습니다.
이 코드 작성자는 아마도 error 발생의 근원을 찾기 위해 코드를 분해하는 기나긴 여정으로 내몰릴 것입니다.

Donovan 과 Kernighan 의 책 "The Go Programming Language"에서는 error path 상에서 fmt.Errorf 를 이용하여 context 정보를 추가하라고 권장하고 있습니다.

func AuthenticateRequest(r *Request) error {
        err := authenticate(r.User)
        if err != nil {
                return fmt.Errorf("authenticate failed: %v", err)
        }
        return nil
}

하지만, 이렇게 처리하는 것은 우리가 앞에서 했던 얘기랑 모순이 되죠.
fmt.Errorf 를 이용해서 원래 error string 을 변형하는 것은 equality 를 깨뜨리고, 원래 error 의 context 정보를 파괴할 수 있기 때문이죠.

Error 에 내용 추가하기

저는 error 에 context 정보를 추가하는 방법으로 메소드를 추천하고 싶습니다.
이를 위해 간단한 패키지를 소개할께요.
이 패키지의 코드는  github.com/pkg/errors 에서 확인하실 수 있어요.
이 errors 라는 패키지는 두 개의 주요 함수가 있는데요.

// Wrap annotates cause with a message.
func Wrap(cause error, message string) error

첫번째 함수는 Wrap 이라는 함수인데요.
이 함수는 error 와 message 를 이용해서 새로운 error 를 만들어냅니다.

// Cause unwraps an annotated error.
func Cause(err error) error

두번째 함수는 Cause 예요.
이 함수는 Wrap 함수에 의해 wrapping 된 error 를 가져와서, 원래의 error 를 복원하기 위해 unwrapping 을 수행하는 함수입니다.

이 두 함수를 이용해서, 우리는 이제 어떤 error 이든 context 정보를 추가할 수도 있고, 만약 그 error 의 내부를 조사할 필요가 있으면 복원(recover, unwrap)를 해볼 수도 있습니다.

func ReadFile(path string) ([]byte, error) {
        f, err := os.Open(path)
        if err != nil {
                return nil, errors.Wrap(err, "open failed")
        } 
        defer f.Close()
 
        buf, err := ioutil.ReadAll(f)
        if err != nil {
                return nil, errors.Wrap(err, "read failed")
        }
        return buf, nil
}

우리는 위의 함수를 config file 을 읽기 위한 함수에서 사용할 수 있고, 또 이것을 main 에서 호출하도록 작성할 수 있겠네요.
아래 코드를 보세요.

package main

import (
    "github.com/pkg/errors"
    "fmt"
    "io/ioutil"
    "os"
    "path/filepath"
)

func ReadFile(path string) ([]byte, error) {
        f, err := os.Open(path)
        if err != nil {
                return nil, errors.Wrap(err, "open failed")
        }
        defer f.Close()

        buf, err := ioutil.ReadAll(f)
        if err != nil {
                return nil, errors.Wrap(err, "read failed")
        }
        return buf, nil
}

func ReadConfig() ([]byte, error) {
    home := os.Getenv("HOME")
    config, err := ReadFile(filepath.Join(home, ".settings.xml"))
    return config, errors.Wrap(err, "could not read config")
}

func main() {
    _, err := ReadConfig()
    if err != nil {
            fmt.Println(err)
            os.Exit(1)
    }
}

만약 ReadConfig 함수에서 fail 이 발생하면, 우리는 errors.Wrap 을 사용했기 때문에 아주 nice 하게 추가 정보를 얻을 수 있습니다.

could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory

errors.Wrap 함수가 error 들의 stack 을 만들어내기 때문에, 우리는 추가적인 디버깅 정보의 stack 을 확인할 수 있습니다.
아래 코드도 똑같은 예이긴 합니다만, 이번에는 fmt.Println 대신 errors.Print 를 사용해 봅니다.

func main() {
        _, err := ReadConfig()
        if err != nil {
                errors.Print(err)
                os.Exit(1)
        }
}

errors.Print 를 사용하면 아래와 같은 결과를 얻을 수 있습니다.

readfile.go:27: could not read config
readfile.go:14: open failed
open /Users/dfc/.settings.xml: no such file or directory

첫번째 line 은 ReadConfig 함수로부터 생긴 것이고, 두번째 line 은 ReadFile 함수의 os.Open 으로부터 올라온 것이고, 나머지는 os 패키지 자체가 뱉어내는 것입니다.

지금까지 stack 을 만들어내기 위해 error 를 wrapping 하는 개념에 대해 소개했는데요.
그 반대에 대해서도 얘기해봐야겠죠.
error 를 unwrapping 하는 것 말이죠.
이것이 errors.Cause 함수의 역할입니다.

// IsTemporary returns true if err is temporary.
func IsTemporary(err error) bool {
        te, ok := errors.Cause(err).(temporary)
        return ok && te.Temporary()
}

error 가 특정 값이나 타입과 match 되는지를 검사하려면, errors.Cause 함수를 사용하여 원래 error 를 먼저 복원해야 합니다.

error 를 딱 한번만 처리하세요.

마지막으로, 여러분에게 error 처리를 딱 한번만 하라고 말씀드리고 싶습니다.
error 를 처리한다는 것은 error 값을 조사하고 어떻게 처리할지 결정을 내린다는 의미입니다.

func Write(w io.Writer, buf []byte) {
        w.Write(buf)
}

여러분이 결정내릴 일이 없다면 error 는 무시하면 될 것입니다.
위 코드에서 보시는 것처럼 w.Write 로부터 리턴되는 에러는 버려질 것입니다.

하나의 error 에 대해 두개 이상의 결정을 내리는 것도 문제입니다.

func Write(w io.Writer, buf []byte) error {
        _, err := w.Write(buf)
        if err != nil {
                // annotated error goes to log file
                log.Println("unable to write:", err)
 
                // unannotated error returned to caller
                return err
        }
        return nil
}

위의 예에서 Write 를 수행할 동안 error 가 발생하면 log file 에 line 과 file 등을 남길 것이고, 또한 호출자에게 리턴도 될 것입니다.
그리고, 그 호출자 또한 log 를 남기고 리턴할 것이구요.
이러한 과정으로 프로그램의 최상단까지 올라가겠죠.

그러니까 여러분은 여러분의 log file 에 line 정보가 중복되어 쌓일거예요.
그러면서도 프로그램의 최상단에서는 어떤 추가적인 context 정보도 없이 원래 error 를 받게 되겠죠.

func Write(w io.Write, buf []byte) error {
        _, err := w.Write(buf)
        return errors.Wrap(err, "write failed")
}

errors 패키지를 이용하면 여러분은 error 값에 context 정보를 추가할 수 있는 능력을 갖게 됩니다. 그것도 인간과 컴퓨터가 둘다 이해하고 점검할 수 있는 방식으로요.

결론

결론적으로, error 는 여러분 패키지의 public API 의 한 부분이예요.
그러니까, error 도 여러분이 public API 의 다른 파트들을 다루듯이 다루어야 합니다.

최대한의 유연함을 위해 여러분이 모든 error 를 불투명(Opaque)하게 처리하려는 노력을 하라고 말씀드리고 싶습니다.
여러분이 그렇게 처리할 수가 없는 상황에서는 타입이나 값 말고 행위(behavior)에 대해 assertion 을 하세요.

여러분 프로그램에서 sentinel error 사용 횟수를 최소한으로 줄이고, 되도록 error 들을 errors.Wrap 으로 감싸서 불투명(opaque) error로 바꾸세요.

마지막으로, 여러분이 error 를 점검할 필요가 있을 경우에는 원래의 error 를 복원하기 위해 errors.Cause 를 사용하세요.

You may also like...

0 0 vote
Article Rating
Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x
()
x