본문 바로가기

Golang

[Golang] Golang에서 error 처리를 어떻게 해야할까

728x90

문제 상황

Golang으로 백엔드 개발한지 거의 1년이 되어가고 있는데, 처음 접했을 때부터 지금까지 항상 애매했던게 error 처리 방식이었다.

Golang에서는 기본적으로 프로그램이 죽는 exception(a.k.a Fatal Error)가 아닌 이상, 절대 panic(타 언어에서는 exception의 개념정도)을 던지지 말고, return value로 error를 리턴하도록 가이드를 하고 있다.

그래서 대부분 아래와 같이 if 분기문이 많다.

num1, err := strconv.Atoi("100") // 문자열 "100"을 숫자 100으로 변환
if err != nil {
	fmt.Printf("error occured, message: %s", err.Error())
} else {
	fmt.Println(num1, err)          // 100 <nil>
}

이런 형식의 코드로 인해 코드량이 증가하고, 함수가 깊어질 수록 에러를 계속 위로 올려보내야하는 번거로움도 많았다. 이러한 에러들을 로깅을 해야하는데, 언제 어디서 로깅을 해야하는지도 애매했다.

자바 스프링같이 에러를 찍을 때 자동으로 함수명이나 코드 위치가 기록되지 않아, 현업에서 예전 코드들은 에러가 발생한 모든 곳에서 로그를 찍고 있었다.

하지만 문제는 해당 에러가 발생한 곳에서는 해당 에러의 로그 레벨을 확실히 정할 수 없다는 것이었다. 유저를 찾지 못했을 경우, 어떤 상황에서는 해당 상황이 정상적인 로직으로 info 혹은 스킵, 또 다른 상황에서는 비정상적인 상황이라 error로 찍어야하는데, 이를 그 에러가 발생한 함수에서는 알지 못하는 경우가 그 예시이다.

만약 에러가 확실히 error 레벨일 경우에는 error로 찍고, 나머지 하위에서는 warn으로 찍는다고 해도, error로 찍은 함수 위에서도 해당 에러를 확인하여 분기해서 error, warn을 나눠찍어야하며, 중복적인 로그는 남아있다.(똑같거나 비슷한 에러를 올리기때문에)

func a() error {
	return errors.New("error")
}

func b() error {
	err := a()
	if err != nil {
		log.warn("error in b, message: %s", err.Error())
		return err
	}
	// ...
	return nil
}

func c() error {
	err := b()
	if err != nil {
		log.error("error in c, message: %s", err.Error())
		return err
	}
	// ...
	return nil
}

func main() {
	err := c()
	if error.Is(err, ...) {
		log.error("error in main, message: %s", err.Error())
	} else if error.Is(err, ...) {
		log.warn("error in main, message: %s", err.Error())
	}
	//...
}

그렇다면 에러를 어떻게 처리해야 코드도 깔끔해지고 중복적인 로그를 찍지 않을 수 있을까?

자주 쓰이는 Error는 type을 지정하기

우선 에러를 구분할 수 있도록 자주 쓰이는 error는 custom type으로 만드는 것이다. 어떤 에러가 내가 예상한 에러이고, 로직상 정상인지 비정상인지 알아낼 수 있어야한다.

아래는 mongo db에서 not found err를 구분하기 위해 만든 예시 타입이다.

type notFoundError struct {
	document string
	filter   interface{}
}

func NotFoundError(document string, filter map[string]string) *notFoundError {
	return &notFoundError{document: document, filter: filter}
}

func (e *notFoundError) Error() string {
	return fmt.Sprintf("%s not found, filter: %+v", e.document, e.filter)
}

// repository.go
func Find(username string) (*User, error) {
	return nil, errorType.NotFoundError("User", map[string]string{"id": username})
}

// User not found, filter: map[id:bob@example.com]
  • 에러 타입으로 분기 or 에러 체크 함수 → 에러 체크 함수는 밑에서 설명
  • // if error can be binded to variable var notFoundErr *errorType.NotFoundError if errors.As(err, &notFoundErr) { ... } // if unwrapped error is type of desiered error type func IsNotFoundErr(err error) bool { for err != nil { switch err.(type) { case *notFoundError: return true } err = errors.Unwrap(err) } return false }

Error wrapping을 통해 에러 스택을 쌓기

에러 체크하는 것까지 가능하다면, 에러를 그대로 리턴하면 되기도 하다. 하지만 golang에서는 기본적으로 어느 위치에서 에러가 발생해서 올라왔는지 stack trace가 되지 않기때문에, trace를 하기 위해서는 별도로 에러를 wrapping하여 올려야한다.

Error wrapping을 하는 방법

  1. errors.Wrap()
    • go 1.13 이전 버전에서는 표준 라이브러리에 Error를 래핑(스택)하는 기능이 제공되지 않았다.
    • github.com/pkg/errors : 에러 래핑 및 스택 기능을 제고하던 서드파티 라이브러리, 하지만 현재 유지만 되고, 추가적인 개발은 되지 않고 있다. 이유는 Golang 2 버전에서 해당 패키지에서 제공하는 기능이 들어갈 것이라는 설명이다.
      • %v로 사용하면 call stack의 순서대로 모든 context 텍스트를 포함하는 한 줄 출력 문자열을 얻을 수 있음
      • %+v 로 변경하면 전체 call stack 생성하여 보여준다.
      • 하지만 문제점은 error를 계속해서 Wrap()으로 올릴 경우, Wrap을 한 위치에서도 stack이 쌓이기때문에 call stack이 중복으로 찍힌다.
      func a() error {
      	return errors.New("tesT")
      }
      func b() error {
      	return errors.Wrap(a(), "b")
      }
      
      func main() {
      	err := errors.Wrap(b(), "c")
      
      	fmt.Printf("%+v\\n", err)
      }
      /*
      tesT
      main.a
      	/tmp/sandbox1249317048/prog.go:15
      main.b
      	/tmp/sandbox1249317048/prog.go:18
      main.main
      	/tmp/sandbox1249317048/prog.go:22
      runtime.main
      	/usr/local/go-faketime/src/runtime/proc.go:250
      runtime.goexit
      	/usr/local/go-faketime/src/runtime/asm_amd64.s:1594
      b
      main.b
      	/tmp/sandbox1249317048/prog.go:18
      main.main
      	/tmp/sandbox1249317048/prog.go:22
      runtime.main
      	/usr/local/go-faketime/src/runtime/proc.go:250
      runtime.goexit
      	/usr/local/go-faketime/src/runtime/asm_amd64.s:1594
      c
      main.main
      	/tmp/sandbox1249317048/prog.go:22
      runtime.main
      	/usr/local/go-faketime/src/runtime/proc.go:250
      runtime.goexit
      	/usr/local/go-faketime/src/runtime/asm_amd64.s:1594
      */
      
    • func foo() error { return errors.Wrap(sql.ErrNoRows, "foo failed") // attach the call stack // return errors.WithStack(sql.ErrNoRows) // attach the call stack without message } func bar() error { return errors.WithMessage(foo(), "bar failed") // without attaching call stack } func main() { err := bar() if errors.Cause(err) == sql.ErrNoRows { fmt.Printf("data not found, %v\\n", err) fmt.Printf("%+v\\n", err) return } } /*Output: data not found, bar failed: foo failed: sql: no rows in result set sql: no rows in result set foo failed main.foo /usr/three/main.go:11 main.bar /usr/three/main.go:15 main.main /usr/three/main.go:19 runtime.main ... */
  2. golang.org/x/xerrors
    func foo() error {
       return xerrors.Errorf("foo failed: %w", sql.ErrNoRows)
    }
    
    func bar() error {
       if err := foo(); err != nil {
          return xerrors.Errorf("bar failed: %w", foo())
       }
       return nil
    }
    
    func main() {
       err := bar()
       if xerrors.Is(err, sql.ErrNoRows) {
          fmt.Printf("data not found, %v\\n", err)
          fmt.Printf("%+v\\n", err)
          return
       }
       if err != nil {
          // unknown error
       }
    }
    /* Outputs:
    data not found, bar failed: foo failed: sql: no rows in result set
    bar failed:
        main.bar
            /usr/four/main.go:12
      - foo failed:
        main.foo
            /usr/four/main.go:18
      - sql: no rows in result set
    */
    
    • xerrors.Errorf , xerrors.Is 는 사용은 가능하나 Deprecated되었다.
    • xerrors.FormatError()를 사용해야함 → 얼핏 보기에 구현이 약간 복잡해보이고 사용 예제 및 사례가 거의 없어서 사용하기에는 어려움이 있을 것 같다.
  3. fmt.Errorf(”%w”, err)
    • 호출 스택이 필요 없을 경우, 가장 간편하게 1.13버전 이후에 포함된 표준 라이브러리를 사용할 수 있다.
    • errors.Wrap() 을 내장하고 있다. (call stack만 없음)
    func a() error {
    	return errors.New("tesT")
    }
    func b() error {
    	return fmt.Errorf("%s, %w", "b", a())
    }
    
    func main() {
    	err := fmt.Errorf("%s, %w", "c", b())
    
    	fmt.Printf("%+v\\n", err)
    }
    /*
    c, b, tesT
    */
    

그래서 내가 생각한 괜찮은 후보는 1, 3번인데, 1번의 메시지에서 call stack 중복이 보기에 좋지는 않아 3번으로 가되, 직접 trace message를 만들기로 했다.

func a() error {
	return errors.New("fasd")
}
func b() error {
	return Wrap(a())
}

func main() {
	err := Wrap(b())

	fmt.Printf("%+v\\n", err)
}

// {filename:line}
var fileFormat = "{%s:%d}"
// file [func name] message
var prefix = "%s [%s] %s"

func Wrap(err error) error {
	pc, file, line, _ := runtime.Caller(1)
	if false {
		return fmt.Errorf("%w\\n%s", err, "")
	}

	sprintf := fmt.Sprintf(prefix, trimPath(file, line), getFuncName(pc), "")

	return fmt.Errorf("%w\\n%s", err, sprintf)
}

func trimPath(file string, line int) string {
	idx := strings.LastIndexByte(file, '/')
	if idx == -1 {
		return file
	}
	// Find the penultimate separator.
	idx = strings.LastIndexByte(file[:idx], '/')
	if idx == -1 {
		return file
	}
	return fmt.Sprintf(fileFormat, file[idx+1:], line)
}

func getFuncName(pc uintptr) string {
	f := runtime.FuncForPC(pc)
	if f == nil {
		return "unknown"
	}
	return f.Name()
}
/*
fasd
{sandbox2987286214/prog.go:17} [main.b] 
{sandbox2987286214/prog.go:21} [main.main]
*/

에러를 딱 1번만 처리하자

에러를 wrapping하여 올렸으면, 이 에러를 언제 어디서 처리할지만 고민하면 된다. 나는 아래와 같은 원칙을 정하면 되지 않을까 싶었다.

  • 더이상 올려보내지 않는 경우
  • 해당 에러를 가지고 분기하여 다른 로직을 탈 경우

해당 에러가 진짜 에러인지 알 수 있는 함수까지 error를 올려보내고, 해당 함수에서 log level을 정하여 에러 메시지를 찍으면 좋을 것 같았다.

func foo() {
   err := bar()
   if err != nil {
			// do something and not return err to caller
			log.Warn("%s err: %s", funcName, err)
			return
   }
	 ...
}

위와 같이 함수 본인이 에러를 확인하여 분기가 필요하다면 맨위에서 말했던 custom error check 함수를 통해 분기하여, 로그 메시지를 찍거나 각 상황에 맞는 비즈니스 로직을 구현하면 될 것 같다.

정리하자면,

  • custom error type을 만들어 에러 체크가 가능하도록 하고,
  • 에러 발생 위치와 함께 상위 함수로 error를 올려 보내고,
  • 해당 에러가 진짜 에러인지 확인 가능한 함수에서 에러를 처리한다.
728x90

'Golang' 카테고리의 다른 글

Golang, 그대들은 어떻게 할 것인가 - Error Wrapping, Handling  (0) 2024.04.04
[Golang] MongoDB Wrapper - 2  (0) 2022.08.20
[Golang] MongoDB wrapper  (0) 2022.08.15
[Golang] Memory Leak 예방하기  (1) 2022.06.27
[Golang] golang validator  (0) 2022.06.27