nil을 nil이라 부를 수 없는 커스텀 에러

computerphilosopher·2021년 12월 26일
2

커스텀 에러란?

golang은 함수의 반환 값으로 에러를 다룬다. 따라서 다음과 같은 코드 패턴이 매우 흔하다.

func main() {
    err := foo
    if err != nil {
        logrus.Error(err)
    } 
}

func foo() error {
    str, err := bar()
    if err != nil {
        return err
    }
    //.....
}

func bar() (string, error){
   err := baz()
   if err != nil {
       return "", err
   }
   //.....
}

자바는 예외가 발생하였을 때 스택트레이스를 남기므로 원인을 쉽게 추적할 수 있다. 그러나 golang에서는 오로지 리턴된 error interface가 출력하는 문자열만 확인할 수 있다.

예제에서 foo, bar, baz 세 번의 함수 호출이 있었지만 로그를 남기는 것은 main 함수 뿐이다. 로그에는 어느 함수가 어느 상황에서 baz를 호출하였는지 등의 문맥이 전혀 남아 있지 않다. 오직 baz의 리턴 값만 남아있을 것이다.

치명적인 장애 발생 당시의 로그가 no such host 한 줄 뿐이라면 매우 당황스러울 것이다. 따라서 보통은 fmt.Errorf를 이용해 에러를 래핑하게 된다.

func foo() error {
    str, err := bar()
    if err != nil {
        return fmt.Errorf("bar() didn't work: %w", err)
    }
    //.....
}

func bar() (string, error){
   err := baz()
   if err != nil {
       return fmt.Errorf("baz didn't work: %w", err)
   }
   //.....
}

이보다 더욱 정교한 로깅을 원할 경우 error interface를 구현한 구조체를 만들면 된다. 예를 들어 예제의 에러는 모두 특정 함수가 제대로 작동하지 않았다는 내용을 담고 있으므로 다음과 같이 추상화할 수 있다.

type CustomError struct {
	funcName string
}

func (c *CustomError) Error() string {
	return c.funcName + "didn't work"
}

커스텀 에러가 nil이어도 error 값은 nil이 아닐 수 있다.

다음 예시의 bar 함수는 입력 문자열이 "valid"라면 nil을 리턴한다. foo 함수는 bar 함수의 핸들링 결과를 그대로 리턴한다. main 함수는 error가 nil이 아닐 때에만 panic을 발생하므로, 문제없이 작동하는 코드로 보인다.

package main

type CustomError struct {
	funcName string
}

func (c *CustomError) Error() string {
	return c.funcName + "didn't work"
}

func foo() error {
	return bar("valid")
}

func bar(input string) *CustomError {
	if input == "valid" {
		return nil
	}
	return &CustomError{
		funcName: "bar",
	}
}

func main() {
	err := foo()
	if err != nil {
		panic("Error is not nil")
	}
}

https://go.dev/play/p/a548cLFQhQl

그러나 위 코드를 실제로 실행시켜보면 panic이 발생한다.

panic: Error is not nil

goroutine 1 [running]:
main.main()
	/tmp/sandbox937955996/prog.go:27 +0x27

Program exited.

bar 함수는 *CustomError 타입을 리턴하고, foo 함수는 이 값을 error interface로 변환(coversion) 한다. 문제는 구조체의 포인터가 nil이었다고 해서, 이를 변환한 error interface의 값도 nil이 되는 것은 아니라는 것이다.

인터페이스는 타입(T)과 값(V)의 쌍으로 구성된다. 예를 들어 정수 3을 인터페이스로 저장할 경우 (T=int, V=3)의 형태로 저장된다. interface는 T와 V가 모두 nil일 때에만 nil이다. 그러나 예제의 상황은 (T=*CustomError, V=nil)이다. 따라서 커스텀 에러의 값이 nil인데도 에러 핸들링 코드가 실행되어 버린 것이다.

해결 방법

권장하고 싶은 방법은 커스텀 에러를 직접 리턴하지 말고 무조건 error interface를 리턴하는 것이다. 어차피 에러가 알려줘야 할 메세지는 Error() method에 구현 되어 있으므로 에러 핸들링에는 지장이 없다.

func bar(input string) error {
	if input == "valid" {
		return nil
	}
	return &CustomError{
		funcName: "bar",
	}
}

https://go.dev/play/p/E8LUUX9_crp

두 번째 방식은 *CustomError 타입의 값을 직접 확인하는 것이다.

func foo() error {
	custom := bar("valid")
	if custom == nil {
		return nil
	}
	return custom

}

https://go.dev/play/p/uh-aaWlfmiO

참고 문헌

0개의 댓글