본문 바로가기
Language/Go

Error handling

by ocwokocw 2022. 6. 12.

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

- Errors are just values

error를 다루는데 있어서 많은 의견들과 조언들이 있지만 정돈된 하나의 규칙은 없는듯하다. 대신 일반적으로 Go에서 error를 다루는 3 가지 방법에 대해서 알아보도록 하자.

- Sentinel error

error를 다루는 첫번째 전략은 일명 "sentinel error"이다.
 
if err == ErrSomething { … }
 
이 이름은 더이상 처리가 불가능함을 나타내기 위해 특정값을 사용하는 컴퓨터 프로그래밍의 관행에서 따온것이다. Go에서는 오류를 나타내기 위해 특정값을 사용한다.
 
이런 예로는 io.EOF 같은 값이나 syscall package 상수값인 syscall.ENOENT와 같은 오류들이 있다. 심지어는 go/build.NoGoError과 path/filepath.Walk의 path/filepath.SkipDir 같이 error가 발생하지 않았음을 나타내는 sentinel error들도 있다.
 
sentinel 값을 사용하는 전략은 호출자가 해당 결과를 equal 연산자를 사용하여 미리 정의된 값과 비교해야 하므로 가장 유연하지 못한 전략이라고 할 수 있다. 이 전략은 더 많은 문맥이 포함된 다른 오류를 반환하면 동등성이 깨지는 문제점이 있다.
 
오류에 문맥정보를 더하기 위해 fmt.Errorf를 사용하는것과 같은 의미가 있는것조차도 호출자의 동등성 검사를 무효화한다. 호출자는 해당 오류의 Error 메소드 출력을 확인하여 특정 문자열과 일치하는지 확인해야 한다.


- error.Error의 출력 검사
error의 Error 메소드 인터페이스는 사람을 위해 존재하는것이지 코드를 위해 존재하는게 아니다. log 파일이나 화면에 표시되는 용도이다. 때문에 이를 검사해서 프로그램의 행위를 변경하는 일은 가급적 삼가해야 한다.
 

 
- Sentinel error와 public API
만약 작성한 public 함수나 메소드가 특정 error 값을 반환한다면 해당 값은 public으로 exported 되어야 하며 물론 문서에도 작성되어야 한다. 만약 API가 특정 error를 반환하는 인터페이스를 정의하고 있다면 해당 인터페이스의 모든 구현체들은 더 상세한 error를 제공해야하는 상황에서도 해당 error만 반환하도록 강제되어야 한다.
 
io.Reader에서 이런 현상을 볼 수 있다. io.Copy와 같은 함수는 호출자에게 더이상 데이터가 없다는것을 알려주기 위해 io.EOF를 반환하는 reader 구현이 필요하지만 이는 사실 error가 아니다. 
 


- Sentinel error와 package 의존성
sentinel error값을 사용할때 발생하는 최악의 문제점은 package간 의존성이 발생한다는것이다. 예를 들어 error가 io.EOF와 같은지를 확인하려면 io package를 import 해야 한다.
 
이런 상황이 문제점인지 느끼지 못할수도 있지만 프로젝트의 많은 package들이 error값을 내보내고 다른 package 들은 이런 error 조건을 검사하기 위해 이를 import 해야한다고 상상해보자.
 

- Sentinel error의 사용을 자제하자

이런 점을 생각해볼 때 sentinel error의 사용은 자제하는편이 좋다. 물론 표준라이브러리에서 일부 사용되긴 하지만 모방할만한 패턴은 아니라고 할 수 있다. 누군가 내가 작성한 package의 error 값을 exported 해달라고 요청하면 정중히 거절한 후 이후에 기술할 방법을 사용하도록 하자.


- Error types

다음으로 알아볼 전략은 error type을 이용하는것이다.
 
if err, ok := err.(SomeType); ok { … }
 
Go에서 error type을 만들 때는 error 인터페이스를 구현하면 된다. 아래 예제에서 MyError type은 어떤 일이 발생했는지에 대한 메시지와 파일 그리고 행을 추적하고 있다.
 
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는 일종의 type이기 때문에 호출자는 발생한 error로 부터 추가적인 문맥을 추출하기 위해 type assertion을 사용할 수 있게 된다.
 
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
}
 
type 방식이 error 값 방식에 비해 갖는 큰 이점은 더 많은 문맥 정보를 제공하기 위해서 기본 오류를 감쌀 수 있다는것이다.
 
대표적인 예가 os.PathError라고 할 수 있는데, 기본적으로 발생한 오류에 더불어 어떤 행위를 수행하려고 했었는지, 시도하려고 했던 파일은 무엇인지를 알 수 있게 된다.
 
// 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을 요구하는 인터페이스를 작성했다면 해당 인터페이스의 모든 구현체는 error type을 정의한 package에 의존할 수 밖에 없다.
 
package type을 알아야 한다는 점은 호출자와 강한 결합이 발생하게 되고, 이 때문에 취약한 API 생성되게 된다.

- Error type을 피하라
error type 방식은 어떤 점이 잘못되었는지에 대한 많은 문맥정보를 알 수 있기 때문에 sentinel error 전략보다는 낫긴 하지만 많은 문제점을 공유하고 있다. 그래서 가능한 error type 사용도 자제하는것이 좋으며, 어쩔 수 없이 사용해야 한다면 적어도 public API 에서 만은 사용을 피해야 한다.

- Opaque error

마지막으로 알아볼 전략은 error를 다루는 전략중 가장 유연하며 호출자와의 결합도 가장 적다. 원문 글의 작성자는 이를 Opaque error 라고 부르고 있는데 error가 발생했다는것은 알지만 해당 error의 내부를 볼 수 없기 때문이다. 호출자로서 작업결과에 대해 알고 있는것은 작동 여부 뿐이다.
 
opaque error는 해당 내용에 대해 아무것도 가정하지 않고 오류를 반환하기만 하면 된다.
 
import “github.com/quux/bar”

func fn() error {
        x, err := bar.Foo()
        if err != nil {
                return err
        }
        // use x
}
 
위의 코드에서 Foo 함수는 error의 문맥이 무엇을 반환하게 되는지를 보장해주지 않는다.

- error를 type이 아닌 행위로 다루어라
 
만약 network 문제와 같이 프로세스 외부에서 문제가 발생하게 되면, 호출자는 해당 행위를 재시도 하기에 합리적인지를 판단하기 위해 오류를 조사해야 한다.
 
이런 경우에는 해당 오류가 어떤 type이나 값인지를 확인 하는것보다는 그 오류가 어떤 행위를 구현하고 있는지 확인해야 한다. 아래 예제를 살펴보자.
 
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()
}
 
IsTemporary 함수는 어떤 error형이라도 인자로 받을 수 있으며 해당 error가 재시도될 수 있는지를 판별한다. 
 
만약 error가 temporary 인터페이스를 구현하고 있지 않으면 해당 error는 일시적인 오류가 아니라고 판단할 수 있다. 반대로 error가 Temporary를 구현하고 true를 반환하면 호출자는 해당 행위를 재시도할 수 있을것이다.
 
여기서 핵심은 이런 로직이 error를 정의한 package를 importing 하거나 err의 기본 type을 알아야할 필요 없이 단순히 행위(behavior)에만 관심을 가지면 된다는것이다.

- error를 유연하게 처리하자
 
아래 코드를 살펴보자. 뭔가 문제점이 느껴지는가?
 
func AuthenticateRequest(r *Request) error {
        err := authenticate(r.User)
        if err != nil {
                return err
        }
        return nil
}
 
우선 위의 코드는 아래 1줄로 변경될 수 있다는것이다.
 
return authenticate(r.User)
 
이렇게 1줄로 바꾸면 가독성이 좋아지긴 하지만 큰 문제점이라고 언급할만한 사항은 아니다. 진짜 문제점은 실제 error가 원래 어디에서 발생했는지를 파악할 수 없다는것이다.
 
만약 authenticate가 error를 반환하면 AuthenticateRequest는 해당 error를 호출자에게 반환하고 이렇게 error는 상위 스택까지 전파된다. 프로그램의 최상위 지점(main)에서 error는 출력하거나 log 파일에 기록할 때 "No such file or directory" 문구만 찍히게 된다.
 
error가 어디에서 발생했는지나 파일에 대한 정보가 하나도 없는것이다. 호출 스택을 추적할 수 있는 정보도 없다. 해당 문구만 가지고 문제가 되는 지점을 추적하려면 아주 긴 시간이 필요하게 된다.
 
Donovan과 Kernighan의 The Go Programming Language 에서는 fmt.Errorf를 사용해서 오류의 경로에 대한 문맥을 추가하는 방식을 추천하고 있다.
 
func AuthenticateRequest(r *Request) error {
        err := authenticate(r.User)
        if err != nil {
                return fmt.Errorf("authenticate failed: %v", err)
        }
        return nil
}
 
이 패턴을 사용하면 언급한 sentinel error값이나 type assertion 방식으로는 오류를 다룰 수 없는데, error를 문자열로 변경되어 다른 문자열과 통합되고 fmt.Errorf를 사용한 error를 다시 변경할 때 동등성을 만족할 수 없게 되고 원래 error에서의 문맥도 파괴하게 된다.
 
원문 글에서는 %v를 사용하고 있지만 go 1.13부터는 %w로 error를 wrapping 할 수 있게 되었다. (https://go.dev/blog/go1.13-errors) 이후에 나오는 error에 대하여 주석을 추가할 때 errors package를 이용하지만 1.13 이후 버전에서는 fmt.Errorf를 사용하면 된다. 따라서 이후에 나오는 저자의 Annotating errors는 fmt.Errorf에서 %w를 지원하지 않을 때 라는 점을 참고해서 보도록 하자.
 

- Annotating errors

원문 블로그의 필자는 error에 문맥을 추가하는 방식을 추천하고 있는데, 이를 위해 2가지 주요 함수를 소개하고 있다.
 
// Wrap annotates cause with a message.
func Wrap(cause error, message string) error
 
처음으로 소개할 함수는 Wrap인데, error와 메시지를 인자로 받아 새로운 error를 생성한다.
 
// Cause unwraps an annotated error.
func Cause(err error) error
 
두번째 함수는 Cause인데, 감싸진 error를 인자로 받아서 이를 다시 벗긴 다음 원래 error로 복구한다.
 
이 함수들을 사용하면 어떤 error에 대해서도 주석을 달 수 있으며 검사할 필요가 있는 오류들은 원래 오류로 복구를 할 수 있다. 파일의 내용을 메모리로 읽어오는 함수를 생각해보자.
 
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 함수에서 path에 대한 오류가 발생하게 되면 주석이 달린 아래와 같은 오류를 얻을 수 있다.
 
could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory
 
errors.Wrap은 error 스택을 생성하기 때문에 추가적인 디버깅 정보를 위한 stack을 검사할 수 있게 된다. 동일한 예제이지만 이번에는 fmt.Println대신 erros.Print로 변경해보자.
 
func main() {
        _, err := ReadConfig()
        if err != nil {
                errors.Print(err)
                os.Exit(1)
        }
}
 
출력 결과는 아래와 같다.
 
readfile.go:27: could not read config
readfile.go:14: open failed
open /Users/dfc/.settings.xml: no such file or directory
 
첫번째 행은 ReadConfig로 부터 발생했고, 두번째 행은 ReadFile의 os.Open으로 부터 발생했으며 나머지는 os package 에서 발생한 오류를 출력해주고 있다.
 
이제 스택 생성을 위해 error에 대한 wrapping 개념을 알게 되었으니 그 반대인 un-wrapping에 대해서도 얘기해보자. 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가 특정 값이나 type과 일치하는지 확인해야할 때 errors.Cause 함수를 사용해서 원래 오류를 복구시켜야 한다.
 

- error는 한번만 다루자

 
마지막으로 오류는 한번만 다루어야 한다. 오류를 다룬다는것은 값을 검사해서 어떤 판단을 한다는 의미이다.
 
func Write(w io.Writer, buf []byte) {
        w.Write(buf)
}
 
위에서 w.Write으로 부터 발생한 error는 무시되었다. 이와 반대로 하나의 error에 대한 응답으로 1가지 이상의 결정을 하게 되는것도 문제가 된다.
 
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가 발생하면, error가 발생한 파일과 행에 대한 사항이 log 파일에 기록되며 error는 호출자에게도 반환된다. error를 반환받은 호출자는 이를 log로 한번 더 기록하거나 다시 상위로 반환하는등 프로그램의 최상위 지점(main)까지 이 행위가 반복될 수 있다.
 
이렇게 되면 log 파일에는 중복된 행이 표시되지만 프로그램의 최상위 지점에는 어떤 문맥없이 원래 error만 표시된다.
 
func Write(w io.Write, buf []byte) error {
        _, err := w.Write(buf)
        return errors.Wrap(err, "write failed")
}
 
error package를 사용하면 error 값에 대한 문맥정보를 더해줄 수 있게 되어 사람과 기계가 모두 error를 확인할 수 있게 된다.

- Conclusion

error는 작성한 package의 public API를 이루는 하나의 부분이라고 볼 수 있으며 따라서 public API의 다른 부분만큼 중요하므로 가능하면 세심하게 다루어야 한다.
 
추천하는 방법은 가장 유연한 opaque 방식으로 오류를 다루는것이다. 때로는 해당 방식을 적용할 수 없는 상황이 있을때에는 type이나 값이 아닌 행위로 error를 확인해야 한다.
 
프로그램에서 sentinel error 값을 줄이고 erros.Wrap으로 감싸서 error를 opaque error로 변환해보자. 또한 오류를 검사하기 위해 원래 오류로 복구할때에는 errors.Cause를 유용하게 사용할 수 있다.
 
 

'Language > Go' 카테고리의 다른 글

Error handling을 간단하게  (0) 2022.06.16
Exception  (0) 2022.06.14
Effective Go - Errors  (0) 2022.06.05
Effective Go - Concurrency - 3  (0) 2022.06.02
Effective Go - Concurrency - 2  (0) 2022.06.01

댓글