Skip to main content

Golang, 그대들은 어떻게 할 것인가 - 3. error 래핑

About 3 minGoArticle(s)blogd2.naver.comgogolang

Golang, 그대들은 어떻게 할 것인가 - 3. error 래핑 관련

Go > Article(s)

Article(s)

Golang, 그대들은 어떻게 할 것인가 - 3. error 래핑 | NAVER D2
Golang, 그대들은 어떻게 할 것인가 - 3. error 래핑

앞 글에서는 각 서버에 산재해 있던 MongoDB 관련 코드를 공통화 및 추상화하고, DB error 정보를 담을 수 있는 구조를 만들었습니다. 이번에는 이러한 error를 어떻게 상위 함수에 반환했는지에 대해 이야기해보려 합니다.


기존 문제점

Golang에서 error는 caller가 처리하는 것을 권장하고 있습니다.

Avoid duplication. If you return an error, it's usually better not to log it yourself but rather let the caller handle it. The caller can choose to log the error, or perhaps rate-limit logging using rate.

출처: Logging errorsopen in new window

error를 상위 함수로 올리면, 상위 함수에서 해당 error에 대해 로그를 남기거나 다른 처리를 하도록 하는 것입니다.

하지만 다음과 같이 error를 단순히 올리기만 한다면 디버깅할 때 정보가 많이 부족합니다.

func checkName(name string) error {  
  newError := errors.New("Invalid Name")
  if name != "valid name" {
    log.Error(err)

    return newError
  }
  return nil
}

func main() {  
  name := "Hello"
  err := checkName(name)

  if err != nil {
    log.Error(err)
  }
}
Invalid Name  

error message만 보일 뿐, 어느 함수에서 발생했는지 알기 어렵습니다. 특히, 재사용이 많은 함수에서 error가 발생하면 해당 error가 어떤 호출 스택으로 불려 발생했는지 찾기 힘들 것입니다.

그래서 기존 V1 코드에서는 error가 발생한 지점을 포함하여, 상위 함수로 올리면서 모두 로그를 남겨서 호출 스택을 찾을 수 있게 했었습니다. 하지만 이러한 방식은 함수 이름 정도만이 추가적인 정보일 뿐, 나머지는 모두 중복이었습니다. 또한, 매번 로그를 남겨야하는 불편함도 컸습니다.


사전 조사

위와 같은 비효율을 없애면서 편하게 호출 스택을 남기는 방법을 고민했고, 두 가지 방법을 찾았습니다.

1. pkg/errorsopen in new window Wrap()

Go 1.13 미만 버전에서는 표준 라이브러리에서 error 래핑 및 스택 기능을 제공하지 않아, 이를 제공하는 pkg/errorsopen in new window라는 서드파티 라이브러리를 많이 사용했습니다. 현재는 추가 개발 없이 유지하는 중입니다(이 라이브러리에서 제공하는 기능이 Go 2에 포함될 예정).

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  
    ...
*/

간단히 보면 다음과 같습니다.

  • errors.Wrap(err, message): 호출 스택 추가
  • errors.WithMessage(err, message): 호출 스택은 추가하지 않고 message만 추가

이렇게 래핑된 err는 상위 함수에서 다음과 같은 형식으로 로그를 출력할 수 있습니다.

  • %v: 호출 스택의 순서대로 모든 context 텍스트를 포함하는 한 줄 문자열
  • %+v: 전체 호출 스택

2. fmt.Errorf(”%w”, err)

Go 1.13 이상 버전에서는 표준 라이브러리에 error 래핑 기능이 추가되어, 호출 스택이 필요 없는 경우에는 다음과 같이 간편하게 사용할 수 있습니다.

func foo() error {  
    return errors.New("foo error!!")
}
func bar() error {  
    return fmt.Errorf("%s, %w", "bar", foo())
}

func main() {  
    err := fmt.Errorf("%s, %w", "main!!", bar())

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

string formatting 방식과 비슷하며, %w를 사용하여 message가 아니라 error를 직접 래핑할 수 있습니다.

그리고 마지막에 %+v로 err를 출력하면, 래핑된 error들이 출력됩니다.

main!!, bar, foo error!!  

고민

Golang은 Java나 Python처럼 많은 사람들이 사용하는 언어가 아니어서, 저를 비롯하여 저희 팀에 합류하신 분들 중에서도 Golang을 처음 접하는 경우가 많았습니다. 처음 접하고 개발하다 보면 error 처리에서 어려움을 많이 겪습니다. 그래서 저는 error를 직관적이고 편하게 처리하는 방법을 만들고 싶었습니다.

1번 방식의 errors.Wrap()은 분명히 편했습니다. 그러나 무작정 Wrap()을 실행하다 보면 호출 스택이 재귀적으로 쌓이는 문제가 있었습니다.

문제 코드 예
func foo() error {  
    return errors.New("foo Error!")
}
func bar() error {  
    return errors.Wrap(foo(), "bar message")
}

func main() {  
    err := errors.Wrap(bar(), "")

    fmt.Printf("%+v\n", err)
}
/*
foo Error!  
main.foo  
    main.go:15
main.bar  
    main.go:18
main.main  
    main.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
bar message  
main.bar  
    main.go:18

main.main  
    main.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
main.main  
    main.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
*/

이 문제를 해결하기 위해 Wrap()을 최하위 함수에서 1번만 사용하고 나머지에서는 WithMessage()를 사용하도록 규칙을 정하면 어떨지 생각해보았습니다.

여러 명이 개발하고 새로운 사람이 보더라도 직관적으로 이해할 수 있을지 고민해보았을 때, 그에 대한 대답은 '아닐 것 같다'였습니다. 매번 Wrap이 되었는지 하위 함수로 내려가 확인하는 데 시간이 소모되고, 추가 message를 적고 싶지 않더라도 중복 스택을 쌓지 않기 위해 WithMessage()에 빈 문자열("")이라도 넣어야 하는 불편함이 있었습니다. 결국 사용하는 사람이 라이브러리에 대한 이해가 선행되어야 했고, 이는 편하게 사용할 수 있는 방향이 아니라고 생각했습니다.

반면 2번의 방식은 호출 스택이 없다는 단점이 있었습니다. 그러나 formatting 방식으로 error가 래핑되고, message도 쉽게 추가할 수 있었고, 호출 스택을 직접 넣어볼 수도 있겠다는 생각이 들었습니다. 이렇게 개발만 된다면 1번과 같이 라이브러리에 대한 이해가 없더라도 쉽게 이해할 수 있도록 만들 수 있을 것 같았습니다.

그래서 저는 2번의 방식을 택하고, 직접 스택 트레이스를 개발해보기로 했습니다.(직접 만들어 쓰는 게 Golang이죠!)


개발 과정

우선 스택 트레이스에 어떤 내용이 필요할지 생각해보았습니다.

  • 함수의 위치 및 이름
  • 추가 message 및 이전 error 내용

위 2가지가 필요하다고 판단했고 이를 직접 하나씩 구현해 나갔습니다.

위치 및 이름

// {filename:line} [func name]
var funcInfoFormat = "{%s:%d} [%s]"

func getFuncInfo() string {  
    pc, file, line, _ := runtime.Caller(1)
    f := runtime.FuncForPC(pc)
    if f == nil {
        return fmt.Sprintf(funcInfoFormat, file, line, "unknwon")
    }
    return fmt.Sprintf(funcInfoFormat, file, line, f.Name())
}

runtime.Caller(skip int) 함수를 통하여, 현재 호출되는 곳에서 skip만큼 상위 호출 스택의 정보를 가져올 수 있습니다. 제공하는 값은 pc(프로그램 카운터), file(함수가 위치한 파일 이름), line(파일 안의 함수 위치)입니다.

runtime.FuncForPC(pc int) 함수로, 앞에서 얻은 상위 함수의 pc 정보를 넘겨, 해당 함수의 정보를 획득할 수 있습니다.

그리고 마지막으로 f.Name() 메서드로 패키지이름.함수이름 형식의 정보를 가져올 수 있습니다.

사용 예
package main

func main() {  
    hi()
}

func hi() {  
    fmt.Println(getFuncInfo())
}

/*
{main.go:28} [main.hi]
*/

추가 message 및 이전 error 내용

error가 발생하면 어떤 처리가 필요할지 생각해보았습니다.

  • 단순히 error를 상위로 올린다.
  • 기존 error에 추가 message를 담아서 상위로 올린다.

위 2가지 경우가 있었고, 이에 따라 단순히 이전 error를 래핑하는 함수와 추가 message를 함께 래핑하는 함수, 두 개의 함수를 만들었습니다.

var wrapFormat = "%s\n%w"  // "{file:line} [func name] msg \n error"

func Wrap(err error) error {  
    pc, file, line, ok := runtime.Caller(1)

    if !ok {
        return fmt.Errorf(wrapFormat, "", err)
    }

    // {file:line} [funcName] msg
    stack := fmt.Sprintf("%s %s", getFuncInfo(pc, file, line), "")

    return fmt.Errorf(wrapFormat, stack, err)
}

func WrapWithMessage(err error, msg string) error {  
    pc, file, line, ok := runtime.Caller(1)

    if !ok {
        return fmt.Errorf(wrapFormat, msg, err)
    }

    stack := fmt.Sprintf("%s %s", getFuncInfo(pc, file, line), msg)

    return fmt.Errorf(wrapFormat, stack, err)
}

runtime.Caller의 skip 조정이 중요하기에 getFuncInfo()는 파라미터로 받도록 조정했고, message \n error의 형식으로 래핑하여 로깅 시 스택의 상단에서부터 error를 출력하도록 했습니다.

작업을 마친 후 코드를 다시 확인해보니 중복되는 부분이 상당히 많았습니다. 중복 코드를 함수로 추출하고 skip을 한 번 더 조정하여 공통화했습니다.

func wrap(err error, msg string) error {  
    pc, file, line, ok := runtime.Caller(2)

    if !ok {
        return fmt.Errorf(wrapFormat, msg, err)
    }

    // {file:line} [funcName] msg
    stack := fmt.Sprintf("%s %s", getFuncInfo(pc, file, line), msg)
    return fmt.Errorf(wrapFormat, stack, err)
}

위와 같이 skip을 조정하고 공통화할 수 있는 영역을 추출하고, 추가 message가 있는 함수와 없는 함수를 만들었습니다.

func WrapWithMessage(err error, msg string) error {  
    return wrap(err, msg)
}

func Wrap(err error) error {  
    return wrap(err, "")
}

이렇게 만든 함수는 다음과 같이 사용할 수 있습니다.

func main() {  
    if err := foo(); err != nil {
        err = Wrap(err) // 추가 message가 필요 없을 때
        log.Errorf("%+v\n", err)
    }
}

func foo() error {  
    if err := bar(); err != nil { // 하위 함수에서 error 발생
        return WrapWithMessage(err, "foo message") // 추가 message가 필요할 때
    }
    return nil
}

func bar() error {  
    return errors.New("bar Error!")
}
{main.go:24} [main.main]
{main.go:17} [main.foo] foo message
bar Error!  

마치며

여기까지 DB 레이어 공통화 및 error 분류, error 래핑까지 완료했습니다.

기존 V1 코드에서 error를 상위로 보낼 때마다 매번 로깅을 하던 불편함을 없애고, 단순히 Wrap(err)만으로 호출 스택을 추가하여 보낼 수 있게 되었습니다.

다음으로는 이렇게 쌓아올린 error를 상위에서 어떻게 처리했는지에 대해 이야기해보려 합니다.


이찬희 (MarkiiimarK)
Never Stop Learning.