[Golang] 에러 처리

2021-12-03 hit count image

Golang에서 에러를 생성하거나 처리하는 방법에 대해서 알아봅시다.

개요

이번 블로그 포스트에서는 Golang에서 에러를 다루는 방법에 대해서 알아보도록 하겠습니다. 이 블로그 포스트에서 소개하는 코드는 다음 링크를 통해 확인하실 수 있습니다.

에러 핸들링

프로그래밍에서 에러는 언제 어디서나 발생할 수 있는 문제입니다.

  • 버그: 휴먼 에러(프로그래머의 실수), 프로그램의 오동작
  • 외부 환경: 머신의 문제(메모리 부족), 물리적인 문제(전원 차단)

이런 에러를 처리할 때, 두 가지 방법이 있습니다. 하나는 실행중인 프로그램을 종료시키는 것과, 또 하나는 에러를 처리(Handling)하고 프로그램을 지속시키는 방법이 있습니다.

에러 반환

Golang에서 다음과 같이 에러를 반환하여, 코드를 사용하는 쪽에서 에러를 다룰 수 있도록 합니다.

import "os"

file, err := os.Open(filename)
file, err := os.Create(filename)

이를 확인하기 위해서 main.go 파일을 생성하고 다음과 같이 수정합니다.

package main

import (
  "bufio"
  "fmt"
  "os"
)

func ReadFile(filename string) (string, error) {
  file, err := os.Open(filename)

  if err != nil {
    return "", err
  }

  defer file.Close()

  rd := bufio.NewReader(file)
  line, _ := rd.ReadString('\n')

  return line, nil
}

func WriteFile(filename string, data string) error {
  file, err := os.Create(filename)
  if err != nil {
    return err
  }

  defer file.Close()

  _, err = fmt.Fprintf(file, data)
  return err
}

const filename string = "data.txt"

func main() {
  line, err := ReadFile(filename)
  if err != nil {
    err = WriteFile(filename, "Hello, World!\n")
    if err != nil {
      fmt.Println("Failed to create file!", err)
      return
    }
    line, err = ReadFile(filename)
    if err != nil {
      fmt.Println("Failed to read file!", err)
      return
    }
  }
  fmt.Println("File contents: ", line)
}

이를 실행하면 다음과 같은 결과를 얻을 수 있습니다.

# go run main.go
File contents:  Hello, World!

이처럼 함수를 생성하고 제공할 때, 에러를 반환하도록 하여 사용하는 쪽에서 에러를 처리하도록 하는 것이 좋습니다.

사용자 에러 반환

Golang에서는 다음과 같이 사용자 에러(Custom error)를 만들 수 있습니다.

fmt.Errorf(formatter string, ...interface {})

또는, 다음과 같이 errors 패키지를 사용하여 생성할 수 있습니다.

import "errors"

errors.New(text string) error

이를 확인하기 위해, 다음과 같이 main.go 파일을 수정합니다.

package main

import (
  "errors"
  "fmt"
)

func errorTest(i int) (int, error) {
  switch i {
  case 0:
    return i, fmt.Errorf("Error: %d", i)
  case 1:
    return i, errors.New("Error")
  default:
    return i, nil
  }
}

func main() {
  i, err := errorTest(0)
  if err != nil {
    fmt.Println(err)
  }

  i, err = errorTest(1)
  if err != nil {
    fmt.Println(err)
  }

  i, err = errorTest(2)
  if err != nil {
    fmt.Println(err)
  }
  fmt.Println(i)
}

이를 실행하면 다음과 같은 결과를 얻을 수 있습니다.

# go run main.go
Error: 0
Error
2

보통, 에러 메시지에 특정 변수값을 출력하고 싶을 때에는 fmt.Errorf를 사용하고, 단순히 문자열 에러 메시지를 출력할 때는 errors.New를 사용합니다.

에러 타입

에러 메시지에 여러 정보들을 추가하고 출력하고 싶을 때, 에러 타입을 사용할 수 있습니다.

type error interface {
  Error() string
}

Golang에서는 Error 메서드를 구현하는 객체를 에러 타입으로 사용할 수 있습니다. 예를 들어 다음과 같이 PasswordError 타입을 정의하고, Error 메소드를 구현하면, 에러 타입으로 사용할 수 있습니다.

type PasswordError struct {
  RequiredLen int
  Len         int
}

func (e PasswordError) Error() string {
  return "Password is too short."
}

이를 확인하기 위해, main.go 파일을 다음과 같이 수정합니다.

package main

import (
  "fmt"
)

type PasswordError struct {
  RequiredLen int
  Len         int
}

func (e PasswordError) Error() string {
  return "Password is too short."
}

func RegisterAccount(name, password string) error {
  if len(password) < 8 {
    return PasswordError{RequiredLen: 8, Len: len(password)}
  }
  // register account logic
  // ...
  return nil
}

func main() {
  err := RegisterAccount("john", "12345")
  if err != nil {
    if errInfo, ok := err.(PasswordError); ok {
      fmt.Printf("%v (Required: %d Len: %d)\n", errInfo, errInfo.RequiredLen, errInfo.Len)
    }
  }
}

이를 실행하면 다음과 같은 결과를 얻을 수 있습니다.

# go run main.go
Password is too short. (Required: 8 Len: 5)

이처럼 에러에 여러 부가 정보를 추가할 때, 에러 타입을 사용하게 됩니다.

에러 랩핑

Golang에서는 에러를 랩핑(Wrapping)하여 여러 에러들을 추가하여 하나의 에러를 만들 수 있습니다. 에러 랩핑에는 다음과 같이 fmt.Errorf을 사용합니다.

fmt.Errorf("%w", err)

또한, errors 패키지의 IsAs를 사용하여, 랩핑된 에러인지 확인하거나 변환하여 사용할 수 있습니다.

errors.Is(err, StrError)
errors.As(err, &strError)

이를 확인하기 위해, main.go 파일을 다음과 같이 수정합니다.

package main

import (
  "errors"
  "fmt"
)

type StrError struct {
  Msg string
}

func (e *StrError) Error() string {
  return fmt.Sprintf("Message: %s", e.Msg)
}

func msgError(msg string) error {
  return &StrError{Msg: msg}
}

func WrapError(msg string) error {
  err := msgError(msg)
  return fmt.Errorf("(Wrapping) %w", err)
}

func main() {
  err1 := msgError("Error")
  fmt.Println("[Normal Error]", err1)

  err2 := WrapError("Test Error Message")
  fmt.Println("[Wrapping Error]", err2)

  var strErr *StrError
  if errors.As(err2, &strErr) {
    fmt.Printf("[Failed] %s\n", strErr)
  }
}

이를 실행하면 다음과 같은 결과를 얻을 수 있습니다.

# go run main.go
[Normal Error] Message: Error
[Wrapping Error] (Wrapping) Message: Test Error Message
[Failed] Message: Test Error Message

이와 같이 Golang에서는 에러를 랩핑하여 하나의 에러로 처리할 수 있습니다.

패닉

Golang에서는 패닉(Panic)을 사용하여 에러와 함께 프로그램을 종료시킬 수 있습니다. 패닉은 프로그램을 빠르게 종료시켜, 프로그래머가 오류를 인식하게 하고 오류를 해결하도록 유도하도록 합니다.

이를 확인하기 위해 main.go 파일을 다음과 같이 수정합니다.

package main

import "fmt"

func divide(a, b int) {
  if b == 0 {
    panic("divide by zero")
  }
  fmt.Printf("%d / %d = %d\n", a, b, a/b)
}

func main() {
  divide(4, 2)
  divide(4, 0)
  divide(4, 3)
}

이를 실행하면 다음과 같은 결과를 얻을 수 있습니다.

# go run main.go
4 / 2 = 2
panic: divide by zero

goroutine 1 [running]:
main.divide(0x4, 0x2)
        /study-golang/error-handling/5.panic/main.go:7 +0x10b
main.main()
        /study-golang/error-handling/5.panic/main.go:14 +0x31
exit status 2

두번째 divide 함수 호출에서 패닉이 발생하여 프로그램이 종료되었고, 그로 인해 세번째 divide 함수가 호출되지 않았음을 알 수 있습니다.

패닉은 다음과 같이 어떠한 타입도 전달 받을 수 있습니다.

func panic(v interface{})

따라서 다음과 같이 패닉을 사용할 수 있습니다.

panic(42)
panic("error")
panic(fmt.Errorf("error num: %d", 42))
panic(SomeType{SomeData})

패닉 전파 그리고 복구

프로그램을 개발할 때에는 문제를 빠르게 파악하고 해결하는 것이 중요합니다. 따라서 패닉을 사용하여 문제가 발생하였을 때, 프로그램을 종료시키고 빠르게 문제를 해결할 수 있도록 하는 것이 좋습니다.

하지만, 개발한 프로그램을 외부에 공개하고 서비스할 때에는 프로그램을 최대한 종료되지 않게 만들어야 합니다. 조금만 사용하면 프로그램이 강제 종료된다면 어느 누구도 그 프로그램을 사용하려 하지 않을 것입니다.

Golang은 이를 위해 패닉을 복구하는 recover를 제공하고 있습니다.

func recover() interface {}

recoverdefer와 함께 사용하여 패닉을 복구할 수 있습니다. 그럼 이를 확인하기 위해 main.go 파일을 다음과 같이 수정합니다.

package main

import (
  "fmt"
)

func firstCall() {
  fmt.Println("(2) firstCall function start")
  defer func() {
    if r := recover(); r != nil {
      fmt.Println("Recovered in firstCall", r)
    }
  }()

  group()
  fmt.Println("(2) firstCall function end")
}

func group() {
  fmt.Println("(3) group function start")
  fmt.Printf("4/2 = %d\n", divide(4, 2))
  fmt.Printf("4/0 = %d\n", divide(4, 0))
  fmt.Println("(3) group function end")
}

func divide(a, b int) int {
  if b == 0 {
    panic("divide by zero")
  }
  return a / b
}

func main() {
  fmt.Println("(1) main function start")
  firstCall()
  fmt.Println("(1) main function end")
}

이를 실행하면 다음과 같은 결과를 얻을 수 있습니다.

# go run main.go
(1) main function start
(2) firstCall function start
(3) group function start
4/2 = 2
Recovered in firstCall divide by zero
(1) main function end

패닉을 복구하기 위해 recover를 사용한 함수는 firstCall임을 알 수 있습니다. 패닉이 발생하는 곳은 divide 함수입니다. 이처럼 패닉은 divide() > group() > firstCall() > main() 순으로 전파됩니다. 여기서는 firstCall함수에서 복구를 하였으므로, main함수까지는 전파되지 않고 firstCall에서 복구되는 것을 확인할 수 있습니다.

프로그램을 개발할 때에는 복구를 사용하지 않는 것을 추천드립니다. 복구를 사용하게 된다면 프로그램이 죽지 않기 때문에 문제의 원인을 찾거나 문제를 인식하는 것이 늦어질 수 있습니다.

배포된 상태에서는 복구를 사용할 수 밖에 없습니다. 그럼 위와 같이 문제의 원인을 찾거나 인식하는 것이 늦어질 수 있으므로, 패닉이 발생하면 이메일로 알리거나 디비에 저장하여 이를 빨리 인식할 수 있도록 하는 것이 좋습니다.

Golang은 SHE를 지원하지 않는다

Golang은 구조화된 에러 처리(SHE, Structured Error Handling)를 지원하지 않습니다.

try {
  ...
} catch (Exception ex) {
  ...
} finally {
  ...
}

다른 프로그래밍 언어에서는 try-catch 문을 사용하여 에러를 처리하지만, Golang은 이를 지원하지 않습니다. SHE는 다음과 같은 문제점을 가지고 있습니다.

  1. 성능 문제: 정상일 때에도 이 구조를 가지기 위해 성능이 저하된다.
  2. 에러를 인지하기 어렵다. 이로 인해 에러 처리를 하지 않거나 방치하는 경우가 발생한다.

완료

이것으로 Golang에서 에러를 처리하는 방법에 대해서 알아보았습니다. 프로그램을 지속하면서 에러를 발생시키는 방법과 패닉을 이용해 프로그램을 종료시키는 방법, 그리고 패닉을 복구하는 방법에 대해서 알아보았습니다.

에러는 프로그래밍에서 굉장히 중요한 역할을 합니다. 그러므로 에러를 반환하는 함수가 있다면, 빈칸지시자(_)로 무시하지 말고 꼭 처리를 하는 것이 좋습니다. 패닉 역시 프로그램의 종료를 막기 위해 recover를 사용하는 것은 좋지만, 이 문제를 인식하고 처리할 수 있는 장치(이메일, 디비 등)를 함께 사용해야 합니다.

제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!

앱 홍보

책 홍보

스무디 한 잔 마시며 끝내는 React Native 책을 출판한지 벌써 2년이 다되었네요.
이번에도 좋은 기회가 있어서 스무디 한 잔 마시며 끝내는 리액트 + TDD 책을 출판하게 되었습니다.

아래 링크를 통해 제가 쓴 책을 구매하실 수 있습니다.
많은 분들에게 도움이 되면 좋겠네요.

스무디 한 잔 마시며 끝내는 React Native, 비제이퍼블릭
스무디 한 잔 마시며 끝내는 리액트 + TDD, 비제이퍼블릭
Posts