Concurrency in Go - 에러 전파

Johnny·2021년 10월 2일
2

Saturday Night 스터디

목록 보기
6/8

📖 이 글은 Saturday Night 스터디에서 Concurrency in Go를 주제로 발표하기 위해 만들어졌습니다.


에러 전파

에러
사용자가 요청한 작업을 프로그램이 수행할 수 없는 상태에 들어갔음을 나타냅니다.

개발자들이 프로그램을 돌아가도록 기능을 만드는 것에는 집중하지만 에러가 발생했을 때 이를 처리하는 것을 크게 신경쓰지 않는 경향이 있다고 저자는 설명합니다.
프로그램을 구성할 때 에러 또한 신경써서 처리하여 사용자 친화적인 프로그램을 만들어야하는 것이 개발자의 역할입니다.

에러의 구성 요소

에러는 다음과 같이 두 가지로 분류 합니다.

  • 버그: 프로그램에서 처리되지 않은raw 에러
  • 시스템 예외상황: 네트워크 연결 끊김, 디스크 쓰기 실패 등

저자는 프로그램에서 처리되지 않은 에러들을 버그로 취급하고 이를 사용자에게 바로 전파되지 않도록 프로그램에서 처리해야 한다고 이야기합니다.

에러는 원활한 분석을 위해 아래와 같이 중요한 정보를 포함해야 합니다.

  1. 발생 이유
  • 디스크가 가득차거나, 소켓이 닫히는 등으로 개발자가 기대하지 않은 상황에 대한 정보를 포함해야 함
  1. 발생한 위치 및 시점
  • 호출이 시작된 위치부터 에러가 인스턴스화된 위치까지 전체 스택 트레이스를 포함하여 문제가 발생한 지점과 에러 원인을 파악할 수 있어야 함
  • 분산된 프로그램 환경인 경우 실행 컨텍스트와 관련된 정보를 포함하여 에러가 발생된 프로그램을 식별할 수 있어야 함
  • UTC로 에러가 인스턴스화된 시스템 시간을 포함해야 함
  1. 사용자 친화적인 메시지
  • 요청 처리 중 에러가 발생했을 시 사용자를 이해시키기 위해 메시지를 통해 피드백 할 수 있어야 함
  • 단, 프로그램 친화적인 메시지가 아닌 사용자 친화적인 메시지를 피드백해야 함
  1. 사용자를 위한 추가 정보 제공
  • 사용자는 에러가 발생했을 때 자세한 사유를 알고 싶어할 수도 있음
  • 개발자가 사용자와 상호작용하기 위해 에러가 발생한 시간 정보와 로그에 기록된 스택 트레이스를 추적할 수 있게 참조 가능한 식별값(ID)을 제공해야 함

위와 같은 정보는 기본적으로 에러에 포함되지 않기 때문에 에러 처리 시스템을 직접 구축하거나 범용적인 프레임워크를 사용하는 것을 고민해야 합니다.

에러 처리 시스템 구축


(여러 모듈로 구성된 시스템 구성도)

코어 모듈에서 발생한 에러는 코어 모듈의 컨텍스트 내에서는 올바른 형식으로 간주될 수 있지만 프로그램 전체의 컨텍스트 내에서는 알 수 없는 에러로 간주될 수 있습니다.

에러가 발생한 근본적인 원인의 세부 정보는 에러가 최초 인스턴스화 될 때 포함되지만, 프로그램 전반적으로 에러를 식별하기 위해서는 에러에 식별 정보를 포함하여 약속된 에러 형태를 반환해야 합니다.

예)

func PostReport(id string) error {
    result, err := lowlevel.DoWork();
    if err != nil {
        if _, ok := err.(lowlevel.Error); ok { // 1
            err = WrapErr(err, "cannot post report with id %q", id) // 2
        }
        return err
    }
    // do work
}
  1. 프로그램에서 정의된 에러가 발생한 것인지 확인합니다. 정의되지 않은 에러 상황인 경우 raw 에러를 그대로 전파합니다.
  2. 정의된 에러가 발생한 경우 모듈을 식별하기 위한 정보와 함께 에러를 Wrapping합니다.

이를 통해 에러의 정확성을 높혀 프로그램이 에러에 대해 대응할 수 있게 되고, 식별할 수 없는 타입의 에러가 발생하는 경우 버그로 취급할 수 있게 됩니다.

위와 같은 형태로 프레임워크를 구축해보겠습니다.

// err_ext
type MyError struct {
    Inner         error
    Message       string
    StackTrace    string
    Misc          map[string]interface{}
}

func (err MyError) Error() string {
    return err.Message
}

func wrapError(err error, msgF string, msgArgs ...interface{}) MyError {
    return MyError {
        Inner:      err, // 1
        Message:    fmt.SprintF(msgF, msgArgs...), 
        StackTrace: string(debug.Stack()), // 2
        Misc:       make(map[string]interface{}), // 3
    }
}
  1. 에러를 Wrapping합니다.
  2. 에러가 발생했을 때 스택 트레이스를 기록합니다.
  3. 디버깅을 위한 기타 정보를 저장합니다.
// lowlevel 모듈 (코어 구성 요소)
type LowLevelErr struct {
    error
}

func isGloballyExec(path string) (bool, error) {
    info, err := os.Stat(path)
    if err != nil {
        return false, LowLevelErr{(wrapError(err, err.Error()))} // 1
    }
    return info.Mode().Perm() & 0100 == 0100, nil
}
  1. os.Stat 호출로 발생한 원시 에러를 커스텀 타입으로 Wrapping 합니다.
// intermediate 모듈 (중개자 구성 요소)
type IntermediateErr struct {
    error
}

func runJob(id string) error {
    const jobBinPath = "/bad/job/binary"
    isExecutable, err := isGloballyExec(jobBinPath)
    if err != nil {
        return err // 1
    } else if isExecutable == false {
        return wrapError(nil, "job binary is not executable")
    }
    return exec.Command(jobBinPath, "--id=" + id).Run() // 1
}
  1. lowlevel 모듈에서 발생한 에러를 CLI로 전달합니다. 이 시점에서 커스텀 에러 타입으로 Wrapping하지 않은 에러는 버그 요소raw error로 판단합니다.
// main (CLI 구성 요소)
func main() {
    log.SetOutput(os.Stdout)
    log.SetFlags(log.Ltime | log.LUTC)
    err := runJob("1")
    if err != nil {
        msg := "There was an unexpected issue; please report this as a bug."
        if _, ok := err.(IntermediateErr); ok { // 1
            msg = err.Error()
        }
        handleError(1, err, msg) // 2
    }
}

func handleError(key int, err error, msg string) {
    log.SetPrefix(fmt.Sprintf("[logID: %v]: ", key))
    log.Printf("%#v", err) // 3 
    fmt.Printf("[errorID: %v] %v", key, msg)
}
  1. 에러가 정의된 에러 타입인지 확인합니다. 약속된 형식의 에러라는 것을 식별할 수 있기 때문에 사용자에게 메세지를 피드백합니다.
    타입을 알 수 없는 raw 에러라면 "알 수 없는 오류가 발생했다."는 메시지를 피드백 합니다.
  2. 로그에 에러 아이디를 부여합니다. 사용자 메시지와 로그에 기록하여 추후 에러가 발생할 경우 분석을 진행하는데 도움이 됩니다.
  3. 에러 발생 시 디버깅을 위해 전체 에러를 로깅합니다.
// log
[logID: 1]: 03:16:32 lowlevel.LowLevelErr{error:err_ext.MyError{
    Inner:(*fs.PathError)(0xc000098180), 
    Message:"stat /bad/job/binary: no such file or directory%!(EXTRA []interface {}=[])", 
    StackTrace:"goroutine 1 [running]:
    runtime/debug.Stack(0xc0000b0030, 0x2f, 0xc000092d48)
        /Users/johnny/.gvm/gos/go1.16/src/runtime/debug/stack.go:24 +0x9f
    saturday-night/src/err_ext.WrapError(0x1101e68, 0xc000098180, 0xc0000b0030, 0x2f, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, ...)
        /Users/johnny/git_workspace/saturday-night/src/err_ext/my_error.go:23 +0xef
    saturday-night/src/lowlevel.IsGloballyExec(0x10e0965, 0xf, 0x3, 0x1005ecb, 0xc000056058)
        /Users/johnny/git_workspace/saturday-night/src/lowlevel/lowlevel.go:16 +0xc5
    saturday-night/src/intermediate.RunJob(0x10df028, 0x1, 0x1018301, 0x0)
        /Users/johnny/git_workspace/saturday-night/src/intermediate/intermediate.go:15 +0x48 // 에러 인스턴스 생성
    main.main()
        /Users/johnny/git_workspace/saturday-night/src/main.go:14 +0x70", 
    Misc:map[string]interface {}{}}
}
    
// message
[errorID: 1] There was an unexpected issue; please report this as a bug.

작성한 프로그램을 실행시켜보면 위와 같이 로그에는 에러의 원인을 분석할 수 있도록 스택 트레이스를 기록하며, 사용자에게는 사용자 친화적인 메시지를 전달합니다.

사용자는 프로그램을 이용하다가 에러가 발생하면 errorID를 첨부하여 원활하지 못했던 서비스 이용에 대한 원인 분석을 요구할 수 있고, 개발자는 해당 식별값을 통해 로그를 확인하여 분석 결과를 제공하고 프로그램을 개선할 수 있습니다.

버그 퇴치

개발자는 사용자와의 커뮤니케이션을 통해 버그를 찾아 퇴치하여 프로그램을 개선할 수 있습니다.
위의 로그를 분석하면 intermediate.go 15번 라인에서 에러가 발생했음을 알 수 있습니다.

// intermediate 모듈

func runJob(id string) error {
    const jobBinPath = "/bad/job/binary"
    isExecutable, err := isGloballyExec(jobBinPath)
    if err != nil {
        return IntermediateErr{
            wrapError(err, "cannot run job %q: requisite binaries not available", id)
        } // 1
    } else if isExecutable == false {
        return wrapError(nil, "cannot run job %q: requisite binaries are not executable", id)
    }
    return exec.Command(jobBinPath, "--id=" + id).Run() // 1
}
  1. raw 에러를 Wrapping합니다. 에러가 발생한 구체적인 이유는 사용자가 알아야할 필요가 없으므로 숨기고, 대신 피드백할 메시지를 작성합니다.
//log 
[logID: 1]: 04:19:25 intermediate.IntermediateErr{error:err_ext.MyError{
  Inner:lowlevel.LowLevelErr{
    error:err_ext.MyError{
      Inner:(*fs.PathError)(0xc000098180), 
      Message:"stat /bad/job/binary: no such file or directory%!(EXTRA []interface {}=[])", 
      StackTrace:"goroutine 1 [running]:
        runtime/debug.Stack(0xc0000b0030, 0x2f, 0xc000092d18)
            /Users/johnny/.gvm/gos/go1.16/src/runtime/debug/stack.go:24 +0x9f
        saturday-night/src/err_ext.WrapError(0x1102148, 0xc000098180, 0xc0000b0030, 0x2f, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, ...)
            /Users/johnny/git_workspace/saturday-night/src/err_ext/my_error.go:23 +0xef
        saturday-night/src/lowlevel.IsGloballyExec(0x10e0bbe, 0xf, 0x55, 0x13bec40, 0x1012c1b)
            /Users/johnny/git_workspace/saturday-night/src/lowlevel/lowlevel.go:16 +0xc5
        saturday-night/src/intermediate.RunJob(0x10df288, 0x1, 0x1018301, 0x0)
            /Users/johnny/git_workspace/saturday-night/src/intermediate/intermediate.go:15 +0x48
        main.main()
            /Users/johnny/git_workspace/saturday-night/src/main.go:14 +0x70", 
      Misc:map[string]interface {}{}
    }
  }, 
  Message:"cannot run job ["1"]: requisite binaries not available", 
  StackTrace:"goroutine 1 [running]:
    runtime/debug.Stack(0x10e69b4, 0x33, 0xc000092df0)
        /Users/johnny/.gvm/gos/go1.16/src/runtime/debug/stack.go:24 +0x9f
    saturday-night/src/err_ext.WrapError(0x11022c8, 0xc000096280, 0x10e69b4, 0x33, 0xc000096290, 0x1, 0x1, 0x0, 0x0, 0x0, ...)
        /Users/johnny/git_workspace/saturday-night/src/err_ext/my_error.go:23 +0xef
    saturday-night/src/intermediate.RunJob(0x10df288, 0x1, 0x1018301, 0x0)
        /Users/johnny/git_workspace/saturday-night/src/intermediate/intermediate.go:18 +0x319
    main.main()
        /Users/johnny/git_workspace/saturday-night/src/main.go:14 +0x70", 
  Misc:map[string]interface {}{}}
}

// message
[errorID: 1] cannot run job ["1"]: requisite binaries not available
// 1번 작업을 실행할 수 없음: 필요한 바이너리를 사용할 수 없습니다.

이전의 raw 에러를 Wrapping하여 추가적인 처리를 한 내역을 로그를 통해 알 수 있으며, 메세지 또한 사용자가 최소한의 원인은 알 수 있도록 변경되었습니다.

이러한 에러 처리 접근방식의 패키지들이 존재하지만github.com/pkg/errors, 위와 같이 유기적인 시스템을 직접 구축할 수도 있습니다.
중요한건 기능을 만드는 것과 같은 수준의 관심으로 에러를 다루어야하며 버그와 올바른 에러를 구분짓고 점진적으로 버그가 없는 시스템으로 개선해 나아가야 합니다.

profile
배우면 까먹는 개발자 😵‍💫

0개의 댓글