Go에서의 오류 처리: 거부에서 수용까지


원문 : Errors in Go: From denial to acceptance from evilmartians.com

Go의 오류 처리에 대한 걱정을 날려버리고 그 아름다움에 빠져보자. Overmindimgproxy의 제작자인 Sergey Alexandrovich가 자신의 경험을 거부에서 수용까지의 5단계(Kübler-Ross 모델)로 설명하고, 자신만의 Go 언어 오류 처리 기법도 공유한다.

Errors in Go: From denial to acceptance

“인간은 누구나 실수를 한다.” 한 영국 시인의 말이다. 프로그래밍도 사람이 하는 것이기 때문에 오류 처리는 필수적인 부분이다. 하지만 대부분의 주요 언어에서 후순위로 밀려 있는 것이 현실이다.

수많은 프로그래밍 언어의 대부격인 C도 처음에는 오류/예외 메커니즘을 가지고 있지 않았다. 함수가 의도한 대로 동작하는지, 아니면 (특히 정수 관련해서) 불필요하게 오류를 발생하는지 등을 파악하는 일은 전부 프로그래머의 몫이었다. 세그멘테이션 오류(segmentation fault)라면 뭐.. 어쩔 수 없는 일이고.

사실 예외 처리에 대한 아이디어는 C 이전인 LISP 1.5에서부터 시작되었지만(1962년) 80년대에 들어서서야 일반적으로 사용되기 시작했다. C++(그리고 Java)에서는 try…catch 블럭을 소개하면서 프로그래머들을 사로잡았고, 우리가 아는(그리고 사랑하는) 모든 인터프리터 언어들이 이러한 방식을 따르고 있다.

내가 경험해본 모든 언어의 오류 처리는 try 든 begin 이든 문법과 관계없이 언어 안내의 뒷부분에 소개되었다. 따라서 보통 처음 언어를 배울 때는 이를 무시하고, 진짜 프로젝트를 시작할 때가 되어서야 겨우 이해를 하게 된다. 적어도 나에게는 항상 그랬다.

그러던 어느날 새 언어가 혜성처럼 나타나 Tour of Go를 통해 그 신비를 드러내었다.

나는 Go 언어를 배우면서 사방에 널려있는 err 라는 변수 덕분에 항상 오류에 대해 생각할 수 밖에 없었다. Go에서는 프로젝트가 얼마나 크고 진지하든 간에 하나의 패턴이 사용되었다.

f, err := os.Open(filename)
if err != nil {
  // 여기에 오류 처리 구현
}

일반적으로 Go에서는 오류를 발생시킬 수 있는 모든 함수는 마지막 반환값으로 오류값을 반환하고, 각 단계에서 이를 올바르게 처리하는 것은 프로그래머의 책임이다. 따라서 err != nil 구문을 이곳저곳에서 사용하게 된다.


상황에 따라 일일히 오류를 처리하는 방식은 처음에는 실망스러웠다. 이 시점에서 많은 Go 입문자(신규 고퍼)들이 슬픔에 빠져 이전에 알던 오류 처리 방식을 그리워하게 된다.


슬픔과 상실에 대처하는 방법 중 잘 알려진 모델은 1969년에 스위스계 미국 정신과 의사인 Elisabeth Kübler-Ross가 소개한 모델이다. 이는 거부, 분노, 타협, 우울, 수용의 5단계로 이루어지며, 대중 문화를 설명할 때에 자주 인용된다. 처음에는 죽음과 애도와 관련한 내용이었지만, 이후 내적 저항에 부딪히는 여러 중대한 변화들에 추론하는데 효과가 있음이 입증되었다. 완전히 새로운 언어를 배우는 것 또한 이러한 중대한 변화 중 하나이다.

나 또한 이러한 과정을 모두 겪고 “Go 방식”을 받아들였고, 그 과정을 독자들과 함께 되짚어보려 한다.

태초에 “거부”가 있었다.

거부(Denial)

“분명 실수일거야. 이렇게 오류 검사를 많이 하는게 정상일리 없잖아?”

Go로 첫 수백줄의 코드를 짰을 때 내 머리를 맴돌았던 생각이었다. 무의식적으로 계속 예외 처리에 대해 알아봤지만, 아무것도 찾을 수 없었다. Go 제작자들은 의도적으로 예외 처리를 만들지 않았다.

예외 처리의 문제는 함수에서 예외 상황이 발생할지 안할지 알 수 없다는 것이다. 물론 Java는 함수 원형에 throws 키워드가 있어 그러한 상황을 커버하고는 있지만, 예외 상황이 많아지면 어마어마하게 상세한 코드로 이어지게 된다. 부실한 문서가 여전히 많기 때문에 매뉴얼에 의지하는 것도 정답은 아니다. Go 이외의 다른 언어에서는 이러한 모든 것들을 try…catch 구문(또는 비슷한)으로 감싸서 처리해야만 한다.


“Go 방식”은 일관성을 추구한다. 동작이 실패할 가능성이 있는 모든 함수는 마지막 반환값으로 오류 타입을 반환해야 하고, 프로그래머는 그 반환값을 처리한다.(끝)


예상치 못한 문제는 없다. 오류 아래에 오류 처리 코드를 추가하면 코드는 계속 동작할 것이다. 물론 오류 검사가 중요하지 않은 경우에는 이를 생략해도 된다. Go의 zero 값 개념 덕분에 오류 처리를 하지 않아도 되는 경우가 종종 있다.

// 문자열(string)을 int64로 변환해 보자.
// strconv.ParseInt은 변환이 실패하면 첫 반환값으로 0을 반환하므로, 오류를 반환하는지 신경쓸 필요도 없다.
i, _ := strconv.ParseInt(strVal, 10, 64)
log.Printf("Parsed value is: %d", i)

하지만 만약 함수의 부가 동작(side effect)만을 사용하고 반환값을 직접 사용하지 않는다면, 함수가 오류를 반환할 수 있다는 것을 잊기 쉽다. 따라서 함수 반환값에 오류 타입이 있는지 문서를 항상 확인하는 것이 좋다.

// http.ListenAndServe는 오류를 반환한다.
// 하지만 반환값을 사용하지 않을 것이므로 오류 검사를 하지 않는다.
// 다음 코드는 정상적으로 컴파일된다.
http.ListenAndServe(":8080", nil)

// 하지만 일관성 유지를 위해 반환 오류를 확인해주는 것이 좋다.
err := http.ListenAndServe(":8080", nil)
if err != nil {
  log.Fatalf("서버를 시작할 수 없습니다.: %s", err)
}

분노(Anger)

“다른 수많은 프로그래밍 언어에서는 “일반적인” 오류 처리를 하는데, 왜 “결과로서의 오류” 따위를 써야하는 거지?”

내가 한 생각이다. Go에서의 오류란 단순히 내가 익숙했던 예외 처리를 대체하는 것이 아니라는 사실을 깨닫기 전까지는 화마저 났다. Go에서 오류는 동작의 성공 여부의 지표로 보는 것이 좋다.

Rails에서 Active Record를 사용한 적이 있다면 아마도 다음과 같은 종류의 코드에 익숙할 것이다.

user = User.new(user_params)
if user.save
  head :ok
else
  render json: user.errors, status: 422
end

user.save는 User 인스턴스가 정상적으로 저장되었는지 여부를 부울값으로 반환한다. 그리고 user.errors는 발생한 오류 목록을 반환한다. 여기서 오류 객체는 user.save 함수의 부가 동작으로 이러한 패턴은 종종 피해야할 패턴으로 비판되곤 한다.

Go는 함수의 “실패 정보”를 전달하는 내장 패턴을 가지고 있을 뿐, 위와 같은 부가 동작은 없다. 사실 Go의 오류는 한 개의 메서드를 사용하는 인터페이스일 뿐이다.

type error interface {
  Error() string
}

대신 Go에서는 이 인터페이스를 사용자가 원하는 형태로 확장시킬 수 있다. 예를 들어 유효성 오류에 대한 정보를 제공하려면 다음과 같이 타입을 정의한다.

type ValidationErr struct {
  // 유효성 오류에 대한 정보를 여기에 저장한다. 
  // 키는 필드명이고 값은 유효성 검사 메시지 슬라이스이다. 

  ErrorMessages map[string][]string
}

func (e *ValidationErr) Error() string {
  return FormatErrors(e.ErrorMessages)
}

FormatErrors() 함수의 정의는 주제에서 벗어나므로 생략한다. 그저 오류 메시지들을 단일 메시지로 합쳐주는 역할을 한다고 해두자.

이제 레일즈와 비슷한 가상의 Go 프레임워크를 이용한다고 가정해보자. 그러면 액션 핸들러는 다음과 같을 것이다.

func (a * Action) Handle() {
  user := NewUser(a.Params["user"])
  if err := user.Save(); err == nil {
    // 오류 없음! 200 상태 코드를 반환한다.
    a.Respond(200, "OK", "text/plain")
  } else if verr, ok := err.(*ValidationErr); ok {
    // err 를 ValidationErr 타입으로 변환했다.
    // 이제 422 상태 코드를 반환한다.
    resp, _ := json.Marshal(verr.ErrorMessages)
    a.Respond(422, string(resp), "application/json")
  } else {
    // 예상치 못한 오류, 500 상태 코드를 반환한다.
    a.Respond(500, err.Error(), "text/plain")
  }
}

여기서 유효성 오류는 함수 반환값의 일부이며, user.Save()의 부가 동작은 줄어들었다. 또한 모든 예상치 못한 오류를 명확히 처리하므로 프레임워크 안에 숨어버린 오류는 없다. 만약 뭔가 잘못되면 그에 필요한 대응을 하면 될뿐이다.

오류에 추가 정보를 덧붙이는 것은 좋은 선택이다. 많은 유명한 go 패키지들이 고유한 오류 인터페이스를 가지고 있으며 imgproxy 또한 마찬가지이다. imgproxy에서는 사용자에게 보여주거나 로그로 남겨야 하는 메시지와 http 핸들러에 전달할 상태 코드를 imgproxyError 타입에 저장한다.(imgproxy는 이미지 크기 변환과 형식 변환을 위한 빠르고 보안성이 높은 원격 서버로 단순, 속도, 보안에 초점을 맞추고 있다.)

type imgproxyError struct {
  StatusCode    int
  Message       string
  PublicMessage string
}

func (e *imgproxyError) Error() string {
  return e.Message
}

아래는 사용법이다.

if ierr, ok := err.(*imgproxyError); ok {
  respondWithError(ierr)
} else {
  msg := fmt.Sprintf("Unexpected error: %s", err)
  respondWithError(&imgproxyError{500, msg, "Internal error"})
}

오류가 내가 지정한 타입인지 확인하여, 해당 타입이 아니면 예상치 못한 오류로 판단한다. 그리고 이를 imgproxyError 인스턴스로 변환하여 500 상태 코드와 함께 http 핸들러로 전달한다. 동시에 오류 메시지를 로그에 남긴다.

잠깐 초보자들이 종종 헷갈려하는 Go의 타입변환에 관해 설명하자면, 인터페이스를 두 가지 방식으로 타입 변환을 할 수 있는데, 안전한 방식을 사용하는 것이 좋다.

// 안전하지 않음. err 가 *imgproxyError 타입이 아니면 패닉 발생 
ierr := err.(*imgproxyError)

// 안전한 방법. ok 변수를 이용해 타입 변환이 성공적으로 이루어졌는지를 알 수 있다.
// 이 경우 타입 변환이 실패하더라도 패닉이 발생하지 않는다.
ierr, ok := err.(*imgproxyError)

지금까지 Go의 오류 처리가 상당히 유연하다는 점을 살펴보았다. 이제 다음 단계인 타협으로 이동해 보자.

타협(Bargaining)

“즉시(Just-in-place) 오류 처리 방식은 여전히 낯설다. 내가 선호하는 언어와 비슷한 방식으로 오류를 처리할 수는 없을까?”

사실 오류가 발생할만한 곳마다 오류 처리 로직을 넣는 일은 꽤 번거로운 일이어서, 모든 오류를 한 곳에 몰아넣고 한꺼번에 처리하고 싶다는 생각이 들기 마련이다. 이 경우 가장 명확한 방법은 중첩 함수 호출을 사용하는 것이다. 이는 바깥 함수에서 먼저 호출된 함수에서 내부 함수의 모든 오류를 처리하는 것이다.

함수에서 다른 함수를 호출하는 예제를 살펴보자. 우리는 여기서 최상단 함수에서 모든 오류를 처리하려고 한다.

import (
  "errors"
  "log"
  "math"
  "strconv"
)

// 모든 오류가 종료될 때 호출되는 기본 함수
// 문자열 형식으로 된 수를 받아서, 제곱근을 구한다.
func LogSqrt(str string) {
  f, err := StringToSqrt(str)
  if err != nil {
    HandleError(err) // 모든 오류를 처리할 함수
    return
  }

  log.Printf("Sqrt of %s is %f", str, f)
}

// 문자열을 float64 타입으로 변환하여 제곱근을 반환한다.
func StringToSqrt(str string) (float64, error) {
  f, err := strconv.ParseFloat(str, 64)
  if err != nil {
    return 0, err
  }

  f, err = Sqrt(f)
  if err != nil {
    return 0, err
  }

  return f, nil
}

// float 타입 수의 제곱근을 계산한다.
func Sqrt(f float64) (float64, error) {
  if f < 0 {
    return 0, errors.New("음수의 제곱근은 계산할 수 없다.")
  } else {
    return math.Sqrt(f), nil
  }
}

위와 같은 Go의 오류 처리 방식은 꽤 번잡해 보인다. 다행히도 Go 언어 개발자들도 이러한 문제는 인정하는 것 같다. Go 2 에서는 다른 방식으로 오류를 확인하고 처리할 것으로 보이는데, 어떤 방식을 사용할지는 아직 논의 중이다. 공식적으로 발표된 오류 처리 방식 초안은 check.. handle 구조로, 초안에 따르면 다음과 같이 동작한다.

  • check 문은 타입 오류 표현식에 사용하거나 타입 오류 값으로 끝나는 목록을 반환하는 함수 호출에 사용한다. 오류가 nil이 아니면 오류 값이 있는 핸들러 체인을 호출한 결과를 반환하여 인클로징 함수의 검사 결과를 반환한다.
  • handle 문은 check 에서 발견한 오류를 처리하는 handler 라는 블럭으로 정의된다. handler 의 return 문은 바깥 함수가 주어진 반환값을 즉시 반환하도록 한다. 빈 반환값은 바깥 함수의 결과가 없거나 명명된 결과를 사용할 때만 허용된다. 다음 예시에서 함수는 현재 결과값을 반환한다.

초안이 그대로 적용된 Go 2 가 존재하는 다른 우주로 날아가보자.

import (
  "errors"
  "log"
  "math"
  "strconv"
)

func LogSqrt(str string) {
  handle err { HandleError(err) } // 여기가 바로 마법이 일어나는 곳이다.
  log.Printf("Sqrt of %s is %f", str, check StringToSqrt(str))
}

func StringToSqrt(str string) (float64, error) {
  handle err { return 0, err } // if...else 라고 명시할 필요가 없다.
  return check math.Sqrt(check strconv.ParseFloat(str, 64)), nil
}

func Sqrt(f float64) (float64, error) {
  if f < 0 {
    return 0, errors.New("음수의 제곱근은 계산할 수 없다.")
  } else {
    return math.Sqrt(f), nil
  }
}

훨씬 나아보인다. 하지만 현재 시점에서 Go 2는 아직 먼 이야기이다.


이와는 별개로 다른 if…else 문을 상당히 줄이는 동시에 단일 장애 지점을 이용한 오류 처리를 할 수 있는 다른 방법이 있다. 나는 이를 “패닉기반 오류 처리(Panic-Driven Error Handling)“라고 부른다.”


“패닉 기반” 오류 처리를 하려면, Go의 내장 키워드 defer, panic, recover에 대해 알아야 한다.

  • defer : defer가 사용된 함수는 바깥 함수가 종료되기 직전에 실행된다. 이는 정리작업 등에 유용한데 여기서는 panic 후 recover에 사용할 것이다.
func Foo() {
f, _ := os.Open("filename")
// defer 를 이용하면 Foo 함수가 종료되기 직전에 f.Close()가 실행되는 것을 보장할 수 있다.
defer f.Close()
// ...
}
  • panic : 패닉이 발생되면 정상적인 프로그램 진행을 멈춘다. 함수가 패닉을 일으키면 해당 함수의 실행을 중지하고 모든 defer 함수들을 모두 실행하는 호출 스택으로 이동한다. 그리고 현재 고루틴의 부모 프로그램은 오류를 발생하며 실행 종료(충돌, crash)가 된다.

  • revocer : 패닉이 발생한 고루틴의 제어권을 다시 가져오고 패닉에서 전달된 인터페이스를 반환한다. 이는 defer 함수 내에서만 유용되며, 그 외에는 nil을 반환한다.

“순수주의자” 관점에서 보면 아래 예제 코드는 Go다운 코드가 아닐 수도 있다는 점을 언급해 둔다. 아래 코드는 Go 세계에서 유명한 웹 프레임워크인 Gin 에서 영감받은 부분이 많다. Gin에서는 요청 처리 중에 치명적인 오류가 발생하면 handler 안의 panic(err)을 호출할 수 있다. 그러면 Gin이 알아서 복구하고 오류 메시지를 로그로 남긴 뒤에 사용자에게 500 상태 코드를 반환한다.

“패닉 기반 오류 처리”의 개념은 단순하다. 중첩된 호출 내에서 오류가 반환될 때마다 패닉을 일으키고 이를 단일 위치에서 복구한다.

import (
  "errors"
  "log"
  "math"
  "strconv"
)

// 오류 발생 시 패닉을 일으키는 함수
func checkErr(err error) {
  if err != nil {
    panic(err)
  }
}

func LogSqrt(str string) {
  // 오류 처리를 하는 익명 함수를 defer 로 지정한 것이 중요 포인트이다.
  defer func() {
    if r := recover(); r != nil {
      if err, ok := r.(error); ok {
        // recover가 오류를 반환하면, 이를 처리한다.
        HandleError(err)
      } else {
        // recover가 오류가 아닌 값을 반환하면 다시 패닉을 일으킨다.
        panic(r)
      }
    }
  }()

  // 오류가 발생할 수 있는 이벤트 시작
  log.Printf("Sqrt of %s is %f", str, StringToSqrt(str))
}

func StringToSqrt(str string) float64 {
  f, err := strconv.ParseFloat(str, 64)
  checkErr(err)

  f, err = Sqrt(f)
  checkErr(err)

  return f
}

func Sqrt(f float64) (float64, error) {
  if f < 0 {
    return 0, errors.New("Can't calculate sqrt of a negative number")
  } else {
    return math.Sqrt(f), nil
  }
}

분명 다른 언어에서 사용하는 try…catch 와 비슷한 모습은 아니다. 하지만 복잡한 호출 체인 상에서 단일 지점 오류 처리를 할 수 있다.

imgproxy에서는 타임아웃이 된 경우 이미지 처리를 중지하는 부분에 이러한 방식을 사용하였다.(참고1, 참고2) 이로서 각 함수에서 타임아웃 오류를 반환하는 것을 신경쓸 필요없이 필요한 때 한 줄의 타임아웃 확인 함수만 사용하면 되었다.

각 고루틴은 자신의 레벨 안에서 panic 을 발생시키므로, 패닉은 같은 고루틴 안에서 복구해야 한다.

추가 정보

오류 내용에 추가 정보를 넣고 싶을 수도 있다. 하지만 Go 언어의 표준 오류 타입은 스택 추적을 제공하지 않는다. 그러므로 내장 “errors” 패키지 대신 스택 추적 기능이 있는 “github.com/pkg/errors” 패키지를 임포트하자. 단 이제부터는 표준 오류 타입을 다루는 것이 아님을 명심하자. 아래는 github.com/pkg/errors 패키지에서 사용할 수 있는 기능들이다.

  • func New(message string) : 내장 오류 패키지에 있는 같은 이름의 함수와 유사하다. 다만 스택 추적이 포함된 특수 오류 타입을 반환한다.
  • func WithMessage(err error, message string) error : 오류를 추가 메시지가 포함된 타입으로 감싼다.
  • func WithStack(err error) error : 오류를 스택 추적이 포함된 타입으로 감싼다. 직접 만든 오류 타입을 사용하거나 다른 패키지의 오류에 스택 추적을 추가하고 싶을 때 유용하다.
  • func Wrap(err error, message string) error : WithStack + WithMessage

위 함수들을 이용해 코드를 개선해보자.

import (
  "log"
  "math"
  "strconv"

  "github.com/pkg/errors"
)

func checkErr(err error, msg string) {
  if err != nil {
    panic(errors.WithMessage(err, msg))
  }
}

func checkErrWithStack(err error, msg string) {
  if err != nil {
    panic(errors.Wrap(err, msg))
  }
}

func LogSqrt(str string) {
  defer func() {
    if r := recover(); r != nil {
      if err, ok := r.(error); ok {
        // 오류를 처리하기 전에 로그로 남긴다.
        // %+v 는 추가 메시지와 스택 추적이 포함된 오류를 형식과 함께 표시한다.
        //
        // 제곱근 계산 실패: 음수의 제곱근은 계산할 수 없다.
        // main.main /app/main.go:14
        // runtime.main /goroot/libexec/src/runtime/proc.go:198
        // runtime.goexit /goroot/libexec/src/runtime/asm_amd64.s:2361
        log.Printf("%+v", err)
        HandleError(err)
      } else {
        panic(r)
      }
    }
  }()

  log.Printf("Sqrt of %s is %f", str, StringToSqrt(str))
}

func StringToSqrt(str string) float64 {
  f, err := strconv.ParseFloat(str, 64)
  checkErrWithStack(err, "파싱 실패")

  f, err = Sqrt(f)
  checkErr(err, "제곱근 계산 실패")

  return f
}

func Sqrt(f float64) (float64, error) {
  if f < 0 {
    // https://github.com/pkg/errors 패키지의 New 메서드를 사용하였으므로
    // 오류에 스택 추적이 포함된다.
    return 0, errors.New("음수의 제곱근은 계산할 수 없다.")
  } else {
    return math.Sqrt(f), nil
  }
}

중요 알림 : 이미 눈치챘겠지만 errors.WithMessage 와 errors.WithStack 메서드는 표준 오류를 새로운 타입으로 감싼다. 즉, 직접 구현한 오류 타입으로 바로 타입 변환할 수 없고, errors.Cause 함수를 이용해 오류를 추출한 뒤 타입변환을 해야한다.

err := PerformValidation()
if verr, ok := errors.Cause(err).(*ValidationErr); ok {
  // 유효성 오류와 관련된 작업
}

지금까지 모든 관련된 오류를 한 곳에서 처리할 수 있는 강력한 기술에 대해 알아봤다. 하지만 이 방법은 Go의 최대 강점인 고루틴을 사용하기 시작하면 쓸모없게 된다.

어떤가? 우울해지지 않는가?

우울(Depression)

나는 단일 장애 지점 처리를 구현하는데 많은 노력을 기울였다. 하지만 고루틴을 실행한 순간 모든게 망가졌다. 이 깔끔한 오류 처리 로직이 아무 짝에도 쓸모없게 된 것이다.

사실 고루틴 안에서는 여전히 단일점에서 문제를 처리할 수 있다. 그러니까 당황하지 말고 일단 패닉을 내버려두자. 이 문제는 두 가지 방법으로 처리할 수 있다.

채널과 sync.WaitGroup

Go 채널과 sync.WaitGroup을 이용하여 고루틴의 비동기 처리가 끝날 때마다 하나씩 전용 채널에 오류를 보고하고 처리하도록 만들 수 있다.

errCh := make(chan error, 2)

var wg sync.WaitGroup
// 두 개의 고루틴을 실행한다.
wg.Add(2)

// 고루틴 #1
go func(){
  // 반환 전 실행할 코드
  defer wg.Done()

  // 오류가 발생하면 채널로 전달한다.
  if err := dangerous.Action(); err != nil {
    errCh <- err
    return
  }
}()

// 고루틴 #2
go func(){
  defer wg.Done()

  if err := dangerous.Action(); err != nil {
    errCh <- err
    return
  }
}()

// 모든 고루틴이 완료될때까지 기다린 후 채널을 닫는다.
wg.Wait()
close(errCh)

// 채널에서 루프를 돌면서 수집된 모든 오류를 처리한다.
for err := range errCh {
  HandleErr(err)
}

이 방법은 여러 고루틴에서 발생한 “모든 오류를 한 자리에 모을 때” 유용하다.

하지만 실제로는 오류를 처리할 필요가 없는 경우가 많다. 대부분의 경우 고루틴이 실패했는지 아닌지를 아는 것이 더 중요하다. Golang 공식 하위 저장소에서 errgroup 패키지를 가져와 사용해보자.

var g errgroup.Group

// g.Go는 오류를 반환하는 함수를 인수로 사용한다.

// Goroutine #1
g.Go(func() error {
  // 오류가 발생하면 오류를 반환한다.
  if err := dangerous.Action(); err != nil {
    return err
  }
  // ...
  return nil
})

// Goroutine #2
g.Go(func() error {
  if err := dangerous.Action(); err != nil {
    return err
  }
  // ...
  return nil
})

// g.Wait는 모든 고루틴이 완료될 때까지 기다린 후,
// 첫번째 오류만 반환한다.
if err := g.Wait(); err != nil {
  HandleErr(err)
}

여기서는 errgroup.Group의 서브 루틴에서 발생한 첫번째 오류만을 반환하고 나머지 오류들은 무시한다.

자신만의 PanicGroup 만들기

앞서 말했듯이 모든 고루틴은 각자의 레벨 안에서 패닉을 발생시킨다. 이러한 고루틴에서 “패닉 기반 오류 처리” 기법을 사용하려면, 약간 손을 더 봐야한다. 안타깝게도 errgroup은 여기서 별 쓸모가 없으므로 직접 PanicGroup을 만든다. 다음은 완성된 코드이다.

type PanicGroup struct {
  wg      sync.WaitGroup
  errOnce sync.Once
  err     error
}

func (g *PanicGroup) Wait() error {
  g.wg.Wait()
  return g.err
}

func (g *PanicGroup) Go(f func()) {
  g.wg.Add(1)

  go func() {
    defer g.wg.Done()
    defer func(){
      if r := recover(); r != nil {
        if err, ok := r.(error); ok {
          // 첫번째 오류만 필요하다. 이럴 경우 sync.Once를 사용한다.
          g.errOnce.Do(func() {
            g.err = err
          })
        } else {
          panic(r)
        }
      }
    }()

    f()
  }()
}

구현한 PanicGroup을 다음과 같이 사용한다.

func checkErr(err error) {
  if err != nil {
    panic(err)
  }
}

func Foo() {
  var g PanicGroup

  // 고루틴 #1
  g.Go(func() {
    // 오류가 발생하면 패닉을 일으킨다.
    checkErr(dangerous.Action())
  })

  // 고루틴 #2
  g.Go(func() {
    checkErr(dangerous.Action())
  })

  if err := g.Wait(); err != nil {
    HandleErr(err)
  }
}

위와 같은 방식으로 여러 고루틴을 사용하는 경우에도 코드를 간결하고 깔끔하게 유지할 수 있다.

수용 (그리고 행복~*)

여기까지 읽어준 것에 감사를 표한다. 이제 왜 Go에서 독특한 방식으로 오류를 처리하는지, 가장 일반적인 고민들이 무엇인지, 그리고 그 고민들을 어떻게 해결할지 알게 되었을 것이다. 앞서 언급한 Go 2는 여전히 먼 미래의 이야기이므로 이 정도에서 글을 마치도록 한다.


슬픔의 5단계를 거치면서 Go의 오류 처리 방식을 고통스러운 것이 아닌 유연하고 강력한 흐름 제어 방식으로 인식해야 한다는 것을 깨달았다.


오류가 발생한 시점에 바로 해당 오류를 처리하려면 일반적인 방식인 if err != nil 을 쓰는 것이 가장 좋다. 만약 모든 오류를 단일점에서 처리하려면 각 오류에 부가 정보를 추가하는 것이 좋다. 그러한 정보를 이용해 무슨 일이 일어났는지 파악하고 각 상황에 맞게 적절하게 처리할 수 있기 때문이다.

오류가 발생할 경우 전체 프로그램을 중지시켜야 한다면 앞서 설명한 “패닉 기반 오류 처리”도 좋은 선택이다.

그 외 다양한 경험들을 트위터를 통해 공유해 주었으면 한다.

마지막으로 정말 문제가 심각하게 될 수 있는 경우에는 log.Fatal을 쓰는 것을 잊지 말기 바란다.


이 글은 Evil Martians 사의 허락 하에 번역/게시한 것임을 밝힙니다.


See also