Skip to content

에러 처리

Go 에서의 예외는 세 가지 수준이 있습니다:

  • error: 정상적인 플로우 에러로, 처리가 필요하며 직접 무시해도 프로그램이 충돌하지 않습니다
  • panic: 매우 심각한 문제로, 프로그램은 문제 처리 후 즉시 종료되어야 합니다
  • fatal: 매우 치명적인 문제로, 프로그램은 즉시 종료되어야 합니다

정확히 말하면, Go 언어에는 예외가 없으며 에러를 통해 나타냅니다. 마찬가지로 Go 에는 try-catch-finally와 같은 문장이 없으며, Go 창시자는 에러를 통제 가능하게 하기를 원했고, 무슨 일을 하든 많은 try-catch를 중첩해야 하는 것을 원하지 않았습니다. 따라서 대부분의 경우 함수의 반환 값으로 반환합니다. 예를 들어 아래 코드 예제와 같습니다.

go
func main() {
  // 파일 열기
  if file, err := os.Open("README.txt"); err != nil {
    fmt.Println(err)
        return
  }
    fmt.Println(file.Name())
}

이 코드의 의도는 매우 명확합니다. README.txt라는 파일을 여는 것이며, 열기에 실패하면 함수가 에러를 반환하여 에러 메시지를 출력하고, 에러가 nil이면 열기에 성공한 것이므로 파일 이름을 출력합니다.

try-catch보다 간결해 보이지만, 함수 호출이特别多한 경우 if err != nil과 같은 판단 문장이 곳곳에 퍼지게 됩니다. 아래 예제를 살펴보겠습니다. 이는 파일 해시 값을 계산하는 데모로, 이 작은 코드 조각에서 if err != nil이 세 번 나타납니다.

go
func main() {
  sum, err := checksum("main.go")
  if err != nil {
    fmt.Println(err)
    return
  }
  fmt.Println(sum)
}

func checksum(path string) (string, error) {
  file, err := os.Open(path)
  if err != nil {
    return "", err
  }
  defer file.Close()

  hash := sha256.New()
  _, err = io.Copy(hash, file)
  if err != nil {
    return "", err
  }

  var hexSum [64]byte
  sum := hash.Sum(nil)
  hex.Encode(hexSum[:], sum)

  return string(hexSum[:]), nil
}

이러한 이유로 Go 에 대한 외부의 가장 비난받는 점은 에러 처리에 있으며, Go 소스 코드에서 if err != nil이 상당 부분을 차지합니다. Rust 도 마찬가지로 에러 값을 반환하지만, 아무도 그 점을 말하지 않습니다. 왜냐하면 Rust 는 문법 설탕 방식으로 이러한 문제를 해결했기 때문입니다. 이에 비해 Go 의 문법 설탕은 많지 않고 거의 없다고 할 수 있습니다.

하지만 사물을 볼 때는 변증법적으로 봐야 합니다. 모든 것에는 장단점이 있습니다. Go 의 에러 처리에는 몇 가지 장점이 있습니다.

  • 정신적 부담이 적음: 에러가 있으면 처리하고, 처리하지 않으면 반환합니다
  • 가독성: 처리 방식이 매우 간단하여 대부분의 경우 코드를 쉽게 이해할 수 있습니다
  • 디버깅 용이: 모든 에러는 함수 호출의 반환 값에서 발생하므로, 한 층 한 층 거슬러 올라가 찾을 수 있어 갑자기 에러가 발생했지만 어디서 왔는지 모르는 경우가 거의 없습니다.

하지만 단점도 많습니다.

  • 에러에 스택 정보가 없음 (서드파티 패키지로 해결하거나 직접 래핑해야 함)
  • 추함, 반복 코드가 많음 (개인 취향에 따라 다름)
  • 사용자 정의 에러는 var로 선언되며, 변수이지 상수가 아님 (실제로 그래서는 안 됨)
  • 변수 쉐도잉 문제

Go 에러 처리에 대한 제안과 토론은 Go 가 탄생한 이래로 끊임없이 이어져 왔습니다. 이런 농담이 있습니다: Go 의 에러 처리를 받아들일 수 있다면, 당신은 진정한 Gopher 입니다.

TIP

여기 Go 팀의 에러 처리에 관한 두 편의 글이 있습니다. 관심이 있으면 읽어보시기 바랍니다.

error

error 는 정상적인 플로우 에러로, 발생이 허용되며 대부분의 경우 처리해야 하지만 무시해도 됩니다. error 의 심각도는 전체 프로그램 실행을 중지시키기에 충분하지 않습니다. error 자체는 사전 정의된 인터페이스로, 해당 인터페이스에는 Error() 메서드 하나만 있으며, 이 메서드의 반환 값은 문자열로 에러 정보를 출력하는 데 사용됩니다.

go
type error interface {
   Error() string
}

error 는 역사적으로 큰 개편을 겪었습니다. 1.13 버전에서 Go 팀은 체인 에러를 도입하고 더욱 완벽한 에러 검사 메커니즘을 제공했습니다.接下来 모두 소개하겠습니다.

생성

error 를 생성하는 방법에는 몇 가지가 있습니다. 첫 번째는 errors 패키지의 New 함수를 사용하는 것입니다.

go
err := errors.New("이것은 에러입니다")

두 번째는 fmt 패키지의 Errorf 함수를 사용하는 것으로, 포맷팅된 매개변수가 있는 error 를 얻을 수 있습니다.

go
err := fmt.Errorf("이것은%d 개의 포맷팅된 매개변수가 있는 에러입니다", 1)

아래는 완전한 예제입니다.

go
func sumPositive(i, j int) (int, error) {
   if i <= 0 || j <= 0 {
      return -1, errors.New("양수여야 합니다")
   }
   return i + j, nil
}

대부분의 경우 더 나은 유지보수를 위해 임시로 error 를 생성하지 않고, 일반적으로 자주 사용하는 error 를 전역 변수로 사용합니다. 아래는 os\erros.go 파일에서 발췌한 코드입니다.

go
var (
  ErrInvalid = fs.ErrInvalid // "invalid argument"

  ErrPermission = fs.ErrPermission // "permission denied"
  ErrExist      = fs.ErrExist      // "file already exists"
  ErrNotExist   = fs.ErrNotExist   // "file does not exist"
  ErrClosed     = fs.ErrClosed     // "file already closed"

  ErrNoDeadline       = errNoDeadline()       // "file type does not support deadline"
  ErrDeadlineExceeded = errDeadlineExceeded() // "i/o timeout"
)

이들이 모두 var로 정의된 변수임을 알 수 있습니다.

사용자 정의 에러

Error() 메서드를 구현하면 쉽게 error 를 사용자 정의할 수 있습니다. 예를 들어 erros 패키지의 errorString은 매우 간단한 구현입니다.

go
func New(text string) error {
   return &errorString{text}
}

// errorString 구조체
type errorString struct {
   s string
}

func (e *errorString) Error() string {
   return e.s
}

errorString 구현이 너무 간단하여 표현력이 부족하기 때문에, 많은 오픈소스 라이브러리와 공식 라이브러리를 포함하여 다양한 에러 요구사항을 충족하기 위해 error 를 사용자 정의합니다.

전달

어떤 상황에서는 호출자가 호출한 함수가 에러를 반환했지만, 호출자 자체가 에러를 처리할 책임이 없으므로 에러를 반환 값으로 반환하여 상위 호출자에게 전달합니다. 이 과정을 전달이라고 하며, 에러는 전달 과정에서 여러 번 래핑될 수 있습니다. 상위 호출자가 에러 유형을 판단하여 다르게 처리하려 할 때, 에러의类别를 판단할 수 없거나 잘못 판단할 수 있습니다. 체인 에러는 바로 이러한 상황을 해결하기 위해 등장했습니다.

go
type wrapError struct {
   msg string
   err error
}

func (e *wrapError) Error() string {
   return e.msg
}

func (e *wrapError) Unwrap() error {
   return e.err
}

wrappErrorerror 인터페이스를 구현했으며, Unwrap 메서드가 추가되어 내부의 원래 error 에 대한 참조를 반환합니다. 여러 번 래핑하면 에러 체인이 형성되며, 체인을 따라 쉽게 원래 에러를 찾을 수 있습니다. 해당 구조체는 외부에 노출되지 않으므로 fmt.Errorf 함수를 사용하여 생성해야 합니다. 예를 들어

go
err := errors.New("이것은 원래 에러입니다")
wrapErr := fmt.Errorf("에러,%w", err)

사용 시 반드시 %w 포맷 동사를 사용해야 하며, 매개변수는 하나의 유효한 error 여야 합니다.

처리

에러 처리의 마지막 단계는 에러를 처리하고 검사하는 방법입니다. errors 패키지는 에러 처리를 위한 몇 가지 편리한 함수를 제공합니다.

go
func Unwrap(err error) error

errors.Unwrap() 함수는 에러 체인을 언래핑하는 데 사용되며, 내부 구현은 매우 간단합니다.

go
func Unwrap(err error) error {
   u, ok := err.(interface { // 타입 단언, 해당 메서드 구현 여부
      Unwrap() error
   })
   if !ok { // 구현하지 않았으므로 기본 error 임
      return nil
   }
   return u.Unwrap() // 그렇지 않으면 Unwrap 호출
}

언래핑 후 현재 에러 체인이 래핑한 에러를 반환합니다. 래핑된 에러는 여전히 에러 체인일 수 있습니다. 에러 체인에서 해당 값이나 유형을 찾으려면 재귀적으로 검색하여 일치시킬 수 있지만, 표준 라이브러리에서 이미 유사한 함수를 제공합니다.

go
func Is(err, target error) bool

errors.Is 함수의 역할은 에러 체인에 지정된 에러가 포함되어 있는지 판단하는 것입니다. 예제는 다음과 같습니다.

go
var originalErr = errors.New("this is an error")

func wrap1() error { // 원래 에러 래핑
   return fmt.Errorf("wrapp error %w", wrap2())
}

func wrap2() error { // 원래 에러
   return originalErr
}

func main() {
   err := wrap1()
   if errors.Is(err, originalErr) { // if err == originalErr 사용 시 false 가 됨
      fmt.Println("original")
   }
}

따라서 에러를 판단할 때 == 연산자를 사용해서는 안 되며, errors.Is() 를 사용해야 합니다.

go
func As(err error, target any) bool

errors.As() 함수의 역할은 에러 체인에서 첫 번째로 유형이 일치하는 에러를 찾아传入된 err 에 값을 할당하는 것입니다. 어떤 경우에는 error 유형의 에러를 구체적인 에러 구현 유형으로 변환하여 더 자세한 에러 세부 정보를 얻어야 합니다. 에러 체인에 타입 단언을 사용하는 것은 무효입니다. 원래 에러가 구조체로 래핑되어 있기 때문입니다. 이것이 As 함수가 필요한 이유입니다. 예제는 다음과 같습니다.

go
type TimeError struct { // 사용자 정의 error
   Msg  string
   Time time.Time // 에러 발생 시간 기록
}

func (m TimeError) Error() string {
   return m.Msg
}

func NewMyError(msg string) error {
   return &TimeError{
      Msg:  msg,
      Time: time.Now(),
   }
}

func wrap1() error { // 원래 에러 래핑
   return fmt.Errorf("wrapp error %w", wrap2())
}

func wrap2() error { // 원래 에러
   return NewMyError("original error")
}

func main() {
   var myerr *TimeError
   err := wrap1()
   // 에러 체인에 *TimeError 유형의 에러가 있는지 확인
   if errors.As(err, &myerr) { // TimeError 의 시간 출력
      fmt.Println("original", myerr.Time)
   }
}

target은 반드시 error 를 가리키는 포인터여야 합니다. 구조체를 생성할 때 구조체 포인터를 반환하므로, error 는 실제로 *TimeError 유형입니다. 따라서 target은 반드시 **TimeError 유형이어야 합니다.

하지만 공식에서 제공하는 errors 패키지는 실제로 충분하지 않습니다. 스택 정보가 없고 위치를 특정할 수 없기 때문입니다. 일반적으로 공식의 또 다른 강화 패키지를 사용하는 것을 권장합니다.

github.com/pkg/errors

예제

go
import (
  "fmt"
  "github.com/pkg/errors"
)

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

func main() {
  if err := Do(); err != nil {
    fmt.Printf("%+v", err)
  }
}

출력

some unexpected error happened
main.Do
        D:/WorkSpace/Code/GoLeran/golearn/main.go:9
main.main
        D:/WorkSpace/Code/GoLeran/golearn/main.go:13
runtime.main
        D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/proc.go:267
runtime.goexit
        D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/asm_amd64.s:1650

포맷팅 출력을 통해 스택 정보를 볼 수 있습니다. 기본적으로는 스택을 출력하지 않습니다. 이 패키지는 표준 라이브러리 errors 패키지의 강화 버전으로, 모두 공식에서 작성한 것이지만 왜 표준 라이브러리에 포함되지 않았는지 알 수 없습니다.

panic

panic은 중국어로 공포로 번역되며, 매우 심각한 프로그램 문제를 나타냅니다. 프로그램은 해당 문제를 즉시 처리해야 하며, 그렇지 않으면 프로그램이 즉시 중지되고 스택 정보가 출력됩니다. panic은 Go 의 런타임 예외 표현 형태로, 일반적으로 위험한 작업에서 나타나며, 주로 적시에 손실을 막아 더욱 심각한 결과를 초래하는 것을 방지하기 위한 것입니다. 하지만 panic은 종료 전에 프로그램의 사후 처리 작업을 수행하며, panic은 복구되어 프로그램이 계속 실행될 수 있도록 할 수도 있습니다.

아래는 nil map 에 값을 쓰는 예제로, 분명히 panic 을 트리거합니다.

go
func main() {
   var dic map[string]int
   dic["a"] = 'a'
}
panic: assignment to entry in nil map

TIP

프로그램에 여러 코루틴이 있는 경우,任一코루틴에서 panic이 발생하면 이를 복구하지 않으면 전체 프로그램이 충돌합니다.

생성

명시적으로 panic을 생성하는 것은 매우 간단하며, 내장 함수 panic을 사용하면 됩니다. 함수 시그니처는 다음과 같습니다.

go
func panic(v any)

panic 함수는 any 유형의 매개변수 v 를 받으며, 에러 스택 정보를 출력할 때 v 도 출력됩니다. 사용 예제는 다음과 같습니다.

go
func main() {
  initDataBase("", 0)
}

func initDataBase(host string, port int) {
  if len(host) == 0 || port == 0 {
    panic("잘못된 데이터 연결 매개변수")
  }
    // ...다른 로직
}

데이터베이스 연결 초기화에 실패하면 프로그램이 시작되어서는 안 됩니다. 데이터베이스가 없으면 프로그램이 실행되는 것이 무의미하므로, 여기서 panic을 던져야 합니다.

panic: 잘못된 데이터 연결 매개변수

사후 처리

프로그램이 panic으로 종료되기 전에 일부 사후 처리 작업을 수행합니다. 예를 들어 defer 문을 실행합니다.

go
func main() {
   defer fmt.Println("A")
   defer fmt.Println("B")
   fmt.Println("C")
   panic("panic")
   defer fmt.Println("D")
}

출력은 다음과 같습니다.

C
B
A
panic: panic

상위 함수의 defer 문도 마찬가지로 실행됩니다. 예제는 다음과 같습니다.

go
func main() {
   defer fmt.Println("A")
   defer fmt.Println("B")
   fmt.Println("C")
   dangerOp()
   defer fmt.Println("D")
}

func dangerOp() {
   defer fmt.Println(1)
   defer fmt.Println(2)
   panic("panic")
   defer fmt.Println(3)
}

출력

C
2
1
B
A
panic: panic

defer 중에도 panic을 중첩할 수 있습니다. 아래는 비교적 복잡한 예제입니다.

go
func main() {
  defer fmt.Println("A")
  defer func() {
    func() {
      panic("panicA")
      defer fmt.Println("E")
    }()
  }()
  fmt.Println("C")
  dangerOp()
  defer fmt.Println("D")
}

func dangerOp() {
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
}

defer 중첩된 panic의 실행 순서도 동일합니다. panic이 발생하면 후속 로직을 실행할 수 없습니다.

C
2
1
A
panic: panicB
        panic: panicA

종합하면, panic이 발생하면 해당 함수가 즉시 종료되고 현재 함수의 사후 처리 작업 (예: defer) 이 실행된 후, 층층이 위로 전달되어 상위 함수도 사후 처리 작업을 수행하며, 프로그램이 실행을 중지할 때까지 계속됩니다.

서브 코루틴에서 panic이 발생하면 현재 코루틴의 사후 처리 작업이 트리거되지 않습니다. 서브 코루틴이 종료될 때까지 panic을 복구하지 않으면 프로그램이 직접 실행을 중지합니다.

go
var waitGroup sync.WaitGroup

func main() {
  demo()
}

func demo() {
  waitGroup.Add(1)
  defer func() {
    fmt.Println("A")
  }()
  fmt.Println("C")
  go dangerOp()
  waitGroup.Wait() // 부모 코루틴이 서브 코루틴 실행 완료 대기
  defer fmt.Println("D")
}
func dangerOp() {
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
  waitGroup.Done()
}

출력은 다음과 같습니다.

C
2
1
panic: panicB

demo()defer 문이 하나도 실행되지 않고 프로그램이 직접 종료된 것을 볼 수 있습니다. 주의할 점은, waitGroup으로 부모 코루틴을 블로킹하지 않으면 demo() 의 실행 속도가 서브 코루틴 실행 속도보다 빠를 수 있어, 출력 결과가 매우 혼란스러워질 수 있습니다. 아래 코드를 약간 수정해 보겠습니다.

go
func main() {
  demo()
}

func demo() {
  defer func() {
    // 부모 코루틴 사후 처리 작업에 20ms 소요
    time.Sleep(time.Millisecond * 20)
    fmt.Println("A")
  }()
  fmt.Println("C")
  go dangerOp()
  defer fmt.Println("D")
}
func dangerOp() {
  // 서브 코루틴 로직 실행에 1ms 소요
  time.Sleep(time.Millisecond)
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
}

출력은 다음과 같습니다.

C
D
2
1
panic: panicB

본 예제에서 서브 코루틴이 panic을 발생시켰을 때, 부모 코루틴은 이미 함수 실행을 완료하고 사후 처리 작업에 들어갔으며, 마지막 defer를 실행할 때 우연히 서브 코루틴이 panic을 발생시켜 프로그램이 직접 실행을 중지했습니다.

복구

panic이 발생하면 내장 함수 recover() 를 사용하여 적시에 처리하고 프로그램이 계속 실행되도록 할 수 있습니다. 반드시 defer 문에서 실행해야 합니다. 사용 예제는 다음과 같습니다.

go
func main() {
   dangerOp()
   fmt.Println("프로그램 정상 종료")
}

func dangerOp() {
   defer func() {
      if err := recover(); err != nil {
         fmt.Println(err)
         fmt.Println("panic 복구")
      }
   }()
   panic("panic 발생")
}

호출자는 dangerOp() 함수 내부에서 panic이 발생했는지 전혀 알 수 없으며, 프로그램은 나머지 로직을 실행한 후 정상적으로 종료됩니다. 따라서 출력은 다음과 같습니다.

panic 발생
panic 복구
프로그램 정상 종료

하지만 실제로 recover() 사용에는 많은 함의된 함정이 있습니다. 예를 들어 defer 에서 다시 클로저를 사용하여 recover 를 사용하는 경우입니다.

go
func main() {
  dangerOp()
  fmt.Println("프로그램 정상 종료")
}

func dangerOp() {
  defer func() {
    func() {
      if err := recover(); err != nil {
        fmt.Println(err)
        fmt.Println("panic 복구")
      }
    }()
  }()
  panic("panic 발생")
}

클로저 함수는 함수를 호출한 것으로 볼 수 있으며, panic은 아래로 전달되는 것이 아니라 위로 전달되므로 클로저 함수는 자연스럽게 panic을 복구할 수 없습니다. 따라서 출력은 다음과 같습니다.

panic: panic 발생

그 외에도 매우 극단적인 상황이 있습니다. 바로 panic() 의 매개변수가 nil인 경우입니다.

go
func main() {
   dangerOp()
   fmt.Println("프로그램 정상 종료")
}

func dangerOp() {
   defer func() {
      if err := recover(); err != nil {
         fmt.Println(err)
         fmt.Println("panic 복구")
      }
   }()
   panic(nil)
}

이 경우 panic은 실제로 복구되지만 에러 정보가 출력되지 않습니다.

출력

프로그램 정상 종료

종합하면, recover 함수에는 몇 가지 주의점이 있습니다.

  1. 반드시 defer 에서 사용해야 합니다
  2. 여러 번 사용해도 하나만 panic을 복구할 수 있습니다
  3. 클로저 recover 는 외부 함수의 어떤 panic 도 복구하지 않습니다
  4. panic 의 매개변수는 nil을 사용할 수 없습니다

fatal

fatal은 매우 심각한 문제로, fatal이 발생하면 프로그램은 즉시 실행을 중지해야 하며 사후 처리 작업을 수행하지 않습니다. 일반적으로는 os 패키지의 Exit 함수를 호출하여 프로그램을 종료합니다. 아래와 같습니다.

go
func main() {
  dangerOp("")
}

func dangerOp(str string) {
  if len(str) == 0 {
    fmt.Println("fatal")
    os.Exit(1)
  }
  fmt.Println("정상 로직")
}

출력

fatal

fatal 수준의 문제는 일반적으로 명시적으로 트리거하는 경우가 드물며, 대부분의 경우 수동적으로 트리거됩니다.

Golang by www.golangdev.cn edit