본문 바로가기
Language/Go

Effective Go - Errors

by ocwokocw 2022. 6. 5.

- 출처: https://go.dev/doc/effective_go#errors

- Errors

라이브러리 함수들을 사용하다보면 호출자에게 오류를 반환하는 경우를 본적이 있을것이다. Go의 다중 값 반환을 이용하면 일반적인 반환 값과 함께 자세한 error도 같이 반환할 수 있다. 자세한 error 정보를 반환하기 위해 다중 값 반환을 사용하는것은 좋은 습관이다. 예를 들어 os.Open은 실패시 nil pointer만 반환하지 않고 무엇이 잘못되었는지를 기술하는 error 값도 같이 반환한다.
 
Convention에 의해 error 들은 간단한 빌트인 인터페이스인 error type을 갖는다.
 
type error interface {
    Error() string
}
 
라이브러리 작성자는 풍부한 모델을 사용하여 이 인터페이스를 자유롭게 구현할 수 있는데 단순히 오류를 보는것뿐만 아니라 일부 context도 제공할 수 있다. 위에서 언급했듯이 os.Open은 *os.File 반환값과 함께 오류값도 반환한다. 만약 파일이 성공적으로 열렸다면 error는 nil이 되지만, 만약 문제가 생기면 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()
}
 
PathError의 Error는 아래와 같은 문자열을 생성한다.
 
open /etc/passwx: no such file or directory
 
이 오류는 문제가 되는 파일명과 연산 그리고 발생된 OS 오류를 포함하고 있는데, 이 오류를 일으킨 지점으로 부터 함수 호출 경로를 많이 타고 올라가서 출력 되더라도 원인 파악이 쉽다. 단순하게 "no such file or directory"와 같이 출력하는것보다 훨씬 많은 정보를 담고 있기 때문이다.
 
가능하면 error 문자열은 예를 들면 어떤 연산을 했는지나 오류가 발생한 package 정보를 접두사로 붙여줌으로써 어디에서 발생했는지를 알 수 있어야 한다. 예를 들면 image package 에서 알수 없는 형식에 의해 decoding 오류가 발생했을 때는 "image: unknown format"와 같이 표시해줄 수 있다.
 
오류 세부사항을 처리하려고 하는 호출자는 특정 에러나 정확한 세부사항을 확인하기 위해 type switch 나 type assertion 을 사용하는 경우도 있다. PathErrors 의 경우 어떤 처리를 통해 복구가능한 오류에 대한 내부 Err field를 확인해볼 수 있다.
 
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 문에서 type assertion을 사용했다. 만약 해당 오류가 *os.PathError가 아닌 경우 ok 변수는 false가 되고, e는 nil이 된다. 만약 성공해서 ok가 true인 경우, 해당 오류 e가 *os.PathError 형이라는것을 의미하며 error에 관한 더 세부사항을 알 수 있게 된다.
 

- Panic

일반적으로 error를 호출자에게 알려줄 때는 반환값과 함께 error를 반환한다. byte count와 error를 반환하는 표준 Read 메소드가 이런 동작을 하는 전형적인 예라고 할 수 있다. 하지만 error가 복구 불가능한 경우라면 어떨까? 때로는 이런 경우 프로그램이 더이상 동작이 불가능할 수도 있다.
 
이런 상황에서 사용하기 위해 런타임 오류를 생성하여 프로그램 구동을 멈추게 하는 panic 이라는 내장 함수가 있다. 이 함수는 프로그램이 죽을 때 출력할 임시 type(대게 string)을 인자로 받는다. 또한 무한 loop를 종료하는것과 같이 무언가 불가능한 일이 발생했음을 나타내는 방법이기도 하다.
 
// 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을 사용해도 합리적인 상황일 수 있다.
 
var user = os.Getenv("USER")

func init() {
    if user == "" {
        panic("no value for $USER")
    }
}
 

- Recover

panic이 호출될 때 slice의 범위밖의 index를 참조하거나 type assertion 실패와 같이 암묵적인 런타임 오류를 포함하고 있다면 즉시 현재 함수의 실행을 멈추고 goroutine 스택을 해제하기 시작하며 deferred된 함수를 실행한다. 만약 스택을 해제하다가 goroutine 스택의 끝에 도달하게 되면 프로그램은 죽게 된다. 그러나 내장 함수 recover를 사용하면 goroutine의 제어권을 다시 얻거나 실행을 재개할 수 있다.
 
recover를 호출하면 해제를 중지하고 panic으로 전달된 인자를 반환한다. 해제하는 도중 실행되는 유일한 코드는 deferred 함수의 내부이기 때문에 recover는 deferred 함수의 내부에서만 유효하다. (recover 설명 참조 - https://pkg.go.dev/builtin#recover)
 
recover를 응용하면 다른 실행되고 있는 goroutine을 종료하지 않고 서버 내의 실패한 goroutine만 종료할 수 있다.
 
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이 발생하면 결과 로그가 기록되고 goroutine 은 다른 작업에 영향을 주지 않고 깔끔하게 종료된다. deferred 클로저에서 recover를 호출하는것말고는 어떤 다른 조치도 취해줄 필요가 없다.
 
recover는 deferred 함수에서 직접 호출하지 않으면 언제나 nil을 반환하기 때문에 deferred 코드는 panic을 사용하거나 실패 없이 recover를 사용하는 라이브러리를 호출할 수 있다. 예제에서 safelyDo 내의 deferred 함수는 recover를 호출하기 전에 로깅 함수를 호출할것이며 로깅 코드는 panick 상태의 영향을 받지 않고 실행된다.
 
recover 패턴을 사용하면 do 함수는 예상하지 못하거나 나쁜 상황에서 panic을 호출하여 깔끔하게 벗어날 수 있다. 이를 응용하면 복잡한 소프트웨어에서 error 처리를 쉽게할 수 있다. regexp package의 이상적인 버전을 살펴보자. 아래 코드에서 local error type을 panic으로 전달하고 호출해서 파싱 오류를 발생시키고 있다. 또한 Error의 정의, error 메소드, Compile 함수도 있다.
 
// 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이 발생하면 recovery 블락은 반환 값(regexp)을 nil로 설정할것이다. deferred 함수는 네이밍된 반환값을 설정할 수 있다. 그리고 err에 할당하는 부분에서 local type Error가 맞는지 확인해서 해당 문제가 parse 오류 였는지 검사한다. 만약 아니라면 type assertion은 실패하고 아무 것도 인터럽트 하지 않은 것처럼 런타임 오류를 일으켜서 stack 해제가 계속된다. 이렇 방식으로 체크해주면 만약 index의 범위 밖 참조와 같이 기대하지 않은 상황이 발생했을 때 parse 오류를 다루기 위해 panic과 recover를 사용하더라도 코드는 실패하게 된다.
 
오류 처리가 있는 경우 error 메소드는 손수 stack을 파싱하여 해제할 필요없이 parse 오류를 손쉽게 발생시킬 수 있다.
 
if pos == 0 {
    re.error("'*' illegal at start of expression")
}
 
이런 패턴이 유용하긴 하지만 package 내에서만 사용되어야 한다. Parse는 내부 panic 호출을 error 값으로 변환하므로 panics를 사용자에게 노출시키지 않는다.
 
 

 

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

Exception  (0) 2022.06.14
Error handling  (0) 2022.06.12
Effective Go - Concurrency - 3  (0) 2022.06.02
Effective Go - Concurrency - 2  (0) 2022.06.01
Effective Go - Concurrency - 1  (0) 2022.06.01

댓글