Learning Go 정리 6일차 - Error

0

Learning Go

목록 보기
7/12

Error

오류 처리 방법 basic

go는 함수의 마지막 반환값으로 error타입의 값을 반환하여 오류를 처리한다. 만약, 함수가 정상적으로 동작하였다면 errornil을 넣어 전달하면 된다.

새로운 오류는 error패키지에 있는 New함수를 호출하면서 문자열과 함께 생성된다. 오류 메시지는 대문자를 사용하거나 구두점 혹은, 줄바꿈으로 마무리되어서는 안 된다. 또한, 대부분의 경우 오류를 반환할 때 같이 반환하는 값이 있다면 제로 값을 설정해야한다.

Exception(예외)와 달리 error는 검출하는 특별한 구문이 없기 때문에 반드시 if문을 사용하여 error반환값이 nil이 아닌지 검사해야한다.

func main() {
    numerator := 20
    denominator := 3
    remainder, mod, err := calcRemainerAndMod(numerator, denominator)
    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
    fmt.Println(remainer, mod)
}

위와 같이 calcRemainerAndMod의 반환 값에서 error변수인 errif문으로 확인해주어야 한다.

error는 단일 메서드를 저으이하는 내장 인터페이스로 정의되어있다.

type error interface {
    Error() string
}

해당 인터페이스를 구현하는 모든 것은 error인터페이스로 받을 수 있기 때문에 오류로 간주된다. 또한, error가 인터페이스이기 때문에 오류가 발생하지 않았다는 것을 나타내기 위해서 함수에서 nil을 반환해야하는 것이다.

go에서 Exception을 발생시키는 대신에 error을 반환하는 이유는 두 가지로 볼 수 있다.

  1. Exception의 경우 handling하지 않은 예외의 경우 심가한 crush를 발생시키거나, 코드가 미완된 상태에서 구동시켜 디버깅을 어렵게 한다.
  2. error를 함수에서 반환하게 되면, 변수로 받게되고 go에서의 변수는 모두 사용해야만 한다. 따라서, error를 받은 변수를 반드시 처리할 수 밖에 없다.

물론, 함수에서 반환된 값들 중 어떤 것들은 생략하여 받을 수도 있다. _ 과 같이 말이다. 이것은 error도 통용되는 사실이다. 다만 error를 명시적으로 무시하는 것은 되도록 피해야만하는 경우이라는 것을 확인해두도록 하자.

추가적으로 error를 반환하는 방식은 발생하는 error가 어떤 것들이 있는 지 코드를 더욱 가독성있게 만들 수 있고, 정상적으로 코드가 실행되는 루트를 파악하기 쉽게해준다. 단순히 코드의 길이가 길어진다해서 디버깅이 더 어려워지고, 유지 보수가 편한 것은 아니기 때문이다.

단순 오류에 문자열 사용

go의 표준 라이브러리는 문자열로 오류를 생성하는 두 가지 방법을 제공한다.

  1. errors.New를 사용하여 문자열을 받아 error 인터페이스 반환한다. Error메서드를 호출하면 문자열이 반환된다. 만약, fmt.Println에 넘기면 자동으로 Error 메서드를 호출한다.
func doubleEven(i int) (int , error) {
    if i % 2 != 0 {
        return 0, errors.New("only even numbers are processed")
    }
    return i * 2, nil
}
  1. 두번째 방법은 fmt.Errorf함수를 사용하는 것으로 fmt.Printf와 동일한 방법으로 error를 만들어낸다. errors.New와 동일하게 Error메서드 호출 시에 문자열리 반환된다.
func doubleEven(i int) (int , error) {
    if i % 2 != 0 {
        return 0, fmt.Errorf("%d isn't an even number", i)
    }
    return i * 2, nil
}

센티넬 오류(sentinel error)

센티넬 오류는 특정 에러 값을 사용하여 에러 상태를 나타내는 것인데, 어떤 오류는 단순히 무시하거나, 다른 경우의 수로 변경하여 처리할 수 없는 것들이 있다. 즉, 더 이상 현 문제를 처리할 수 없고 지속할 수 없는 상태를 표현하는 에러가 있는데 이를 센티넬 오류라고 한다. 재밌는 것은 실제로 발생하지 않는 error를 나타내는 sentinel error도 있다.

해당 이름은 컴퓨터 프로그램에서 특정 값을 사용하여 더 이상 처리할 수 없다는 것을 나타내는 관행에서 유래했다. 그래서 go에서는 특정 값을 사용하여 오류를 나타낸다. - 데이브 체니

센티넬 오류는 딱히 엄청 특별한 것은 아니고, 패키지 레벨에 선언된 변수이다. 관례에서 따라 이름은 Err로 시작한다. 센티넬 오류는 읽기 전용으로 취급되며, 컴파일러가 이를 강제할 수는 없지만 해당 값을 변경하는 것은 프로그래밍 오류가 된다.

센티넬 오류는 대게 처리를 시작하거나 지속하는 것에 대해서 문제가 발생하여 더 이상 진행할 수 없을 때 사용된다. 이때 다른 에러들과 차이가 있다면, 다른 에러들은 유저들에게 제공되지 않는다는 것이다. 센티넬 에러는 유저들에게 접근 가능하도록 Err~라는 접두사로 시작하기 때문에 유저들이 어떤 치명적인 상태에서 에러가 발생한 것인지 쉽게 디버깅하게 해줄 수 있다. 가령, 표준 라이브러리인 archive/zip에서는 zip파일 포맷이 아닌 경우에 ErrFormat을 포함하는 몇 가지 오류를 정의한다.

func main() {
    data := []byte("This is not a zip file")
    notAZipFile := bytes.NewReader(data)
    _, err := zip.NewReader(notAZipFile, int64(len(data)))
    if err == zip.ErrFormat {
        fmt.Println("Told you so")
    }
}

다음과 같이 zip.NewReaderzip파일 포맷이 아닌 값이 들어가면 더 이상 실행할 수 없고 지속할 수 없는 상태이기 때문에 센티넬 에러를 반환하고, 이 때의 센티넬 에러가 바로 zip.ErrFormat이다.

센티넬 오류를 정의할 때는 항상 정의하기 전에 센티넬 오류가 어떤 것들이 필요한 지 알아보는 것이 좋다. 왜냐하면 하나를 정의하게 되면 그것은 공개 API의 일부가 되며 이후에 모든 이전 버전과 호환되는 배포에서 사용할 수 있도록 해야하기 때문이다. 즉, 사용자에게 노출된다는 것이다. 따라서, 센티넬 오류를 바로 사용하는 것보다는 표준 라이브러리에 있는 기존 것 중 하나를 재사용하거나 오류를 만든 조건에 대한 정보를 포함하는 error를 정의하여 반환하는 것이 더 좋다. 하지만 더 이상 처리가 가능하지 않고 오류 상태를 설명하는 문맥적 정보를 더 이상 사용할 필요가 없는 특정 상태에 도달했을 때는 센티넬 오류로 처리하는 것이 올바른 선택이다.

사실 sentinel error를 사용하는 것은 그닥 좋은 방법이 아니다. sentinel error가 많으면 사용자의 코드와 모듈 간의 의존성이 긴밀해진다. 또한, error==으로 처리해야한다는 사실은 그닥 유연하지 못하다.

그럼 언제 sentinel error를 사용하는 것이 좋을까? sentinel error는 모듈 입장에서 더 이상 처리할 수 없는 상태를 표현하기 위한 error이지 사용자 입장은 아닐 수 있다. 즉, 위의 예제에서 zip.NewReader.zip파일이 아닌 다른 포맷으로 입력값을 넣으니 zip.ErrFormat이 발생하였다. 이는 사용자 입장에서 해당 파일이 .zip파일이 아니라는 정보를 준다. 이에 따라 다른 모듈을 사용하여 해당 입력 파일을 읽어낼 수 있다. 또한, io.EOF라는 sentinel error는 입력 파일을 모두 읽었을 때, 파일의 끝을 알려주는 에러로 파일에서 데이터를 읽거나 쓸 때 사용한다. 즉, 더 이상 파일을 읽을 수 없어서 io.EOF라는 sentinel error를 발생시켰지만, 파일은 다 읽은 정상적인 상태를 나타내는 것이다. 따라서, sentinel error는 모듈에서 발생한 에러를 디버깅을 하기위해 존재한다기 보다는 사용자가 모듈을 사용하면서 발생한 특정 상태를 sentinel error로 받아 특별하게 처리하겠다는 것에서 큰 힘을 발휘한다.

오류는 값이다.

오류는 인터페이스이기 때문에 로깅이나, 오류 처리를 위한 추가적 정보를 초함하여 자신만의 오류를 정의할 수 있다. 가령, 오류의 메시지와 오류에 대한 상태 코드를 추가할 수 있다. 이렇게하면 오류 원인을 결정하기 위한 문자열 비교를 피할 수 있다. 즉, == 단순 비교에서 나아갈 수 있다.

type Status int

const (
    InvalidLogin Status = iota + 1
    NotFound
)

type StatusErr struct {
    Status Status
    Message string
}

func (se StatusErr) Error() string {
    return se.Message
}

이제 문제가 발생하면 더 자세한 정보를 제공하기 위해 StatusErr을 사용할 수 있다.

func LoginAndGetData(uid, pwd, file string) ([]byte, error) {
	err := login(uid, pwd)
	if err != nil {
		return nil, StatusErr{
			Status:  InvalidLogin,
			Message: fmt.Sprintf("Invalid credentials for user %s", uid),
		}
	}
	data, err := getData(file)
	if err != nil {
		return nil, StatusErr{
			Status:  NotFound,
			Message: fmt.Sprintf("file %s not found", file),
		}
	}
	return data, nil
}

다음과 같이 자신만의 오류 타입을 사용하여 오류를 반환할 수 있다. 다만 자신만의 오류 타입을 사용한다면 초기화되지 않은 인스턴스를 반환하지 않도록 하자. 초기화되지 않은 구조체를 반환하면 혼란이 더 가중될 뿐만 아니라, nil도 아니기 때문에 오류가 발생한 건지 아닌지 구분이 안간다.

func GenerateError(flag bool) error {
	var genErr StatusErr
	if flag {
		genErr = StatusErr{
			Status: NotFound,
		}
	}
	return genErr
}

func main() {
	err := GenerateError(true)
	fmt.Println(err != nil) // true
	err = GenerateError(false)
	fmt.Println(err != nil) // true
}

그럼 var genErr StatusErrvar genErr *StatusErr로 선언하면 되지 않는가? 어차피 pointer의 제로값은 nil이니까라고 생각할 수 있다. var genErr *StatusErr로 바꾸어도 똑같은 결과가 나올 것이다.

왜냐하면 errorinterfaceinterfacenil판정은 단순히 구조체가 nil을 가진다고 nil을 반환하는 것이 아니라, 타입과 값 두 가지 특성에 의해 결정된다는 것을 알고 있다. var genErr *StatusErr의 경우는 타입이 *StatusErr라는 것을 명확히 알 수 있고, 단순히 값만 없는 것이기 때문에 nil이 아니다. 인터페이스가 nil이기 위해서는 그냥 nil이어야 하는 것이지 구조체가 nil이라고 해서 nil을 반환하는 것은 아니다.

오류 wrapping

오류가 코드를 통해 전달될 때, 해당 오류에 추가적인 정보를 추가하려고 하는 경우가 있다. 원래의 오류에 추가 정보를 넣다보면 원래 오류 정보가 사라지거나 훼손되는 경우가 았다. 이런 경우없이 추가 정보를 추가하면서 오류를 유지하는 것을 오류 래핑(wrapping error)이라고 한다. 일련의 래핑된 오류를 가질 때, 그것은 오류 체인(erorr chain)이라고 한다.

go 표준 라이브러리에는 오류를 래핑하는 함수가 있고, 이미 앞서 보았던 fmt.Errorf함수로 가능하다. fmt.Errorf 함수는 특수한 서식 문자로 %w를 가지고 있다. 다른 오류의 형식 지정된 문자열과 원본 오류를 포함하는 형식 지정된 문자열의 오류를 생성하는데 사용할 수 있다. 작성하는 관례는 %w를 지정 문자열의 마지막에 두고, fmt.Errorf의 마지막 파라미터로 전달된 래핑할 오류를 넣는다.

표준 라이브러리는 또한 오류를 언래핑(unwrapping)하기 위해서 errors 패키지에 Unwrap 함수를 제공한다. 오류를 전달하면 래핑된 오류가 있는 경우 이를 반환한다. 만약 없다면 nil을 반환한다. 여기에 fmt.Errorf를 래핑하고 errors.Unwrap으로 언래핑하는 간단한 예제를 확인해보도록 하자.

func fileChecker(name string) error {
	f, err := os.Open(name)
	if err != nil {
		return fmt.Errorf("in fileChecker: %w", err)
	}
	f.Close()
	return nil
}

func main() {
	err := fileChecker("not_here.txt")
	if err != nil {
		fmt.Println(err)
		if wrappedErr := errors.Unwrap(err); wrappedErr != nil {
			fmt.Println(wrappedErr)
		}
	}
}

다음의 결과를 얻는다.

in fileChecker: open not_here.txt: no such file or directory
open not_here.txt: no such file or directory

다음과 같이 fmt.Errorf%w를 사용하여 에러를 랩핑할 수 있고, errors.Unwrap을 사용하여 내부의 에러를 확인할 수 있다. 단, Unwrap을 직접사용하는 일은 거의없을 것이다.

만약 사용자 정의 error 타입에서 Unwrap을 사용하고 싶다면 Unwrap 메서드를 구현하면 된다. 왜냐하면 errors.Unwrap은 다음과 같이 구현되어있기 때문이다.

func Unwrap(err error) error {
	u, ok := err.(interface {
		Unwrap() error
	})
	if !ok {
		return nil
	}
	return u.Unwrap()
}

interface{ Unwrap() error }으로 타입 단언을 한 다음 Unwrap을 호출한다. 따라서, 사용자 정의 error타입에 Unwrap 메서드만 구현해주면 되는 것이다.

type StatusErr struct {
	Status  Status
	Message string
	Err     error
}

func (se StatusErr) Error() string {
	return se.Message
}

func (se StatusErr) Unwrap() error {
	return se.Err
}

이제 StatusErr로 기본 오류를 래핑하여 사용할 수 있다.

func LoginAndGetData(uid, pwd, file string) ([]byte, error) {
	err := login(uid, pwd)
	if err != nil {
		return nil, StatusErr{
			Status:  InvalidLogin,
			Message: fmt.Sprintf("Invalid credentials for user %s", uid),
			Err:     err,
		}
	}
	data, err := getData(file)
	if err != nil {
		return nil, StatusErr{
			Status:  NotFound,
			Message: fmt.Sprintf("file %s not found", file),
			Err:     err,
		}
	}
	return data, nil
}

단, 모든 오류가 래핑될 필요는 없다. 또한, 오류를 래핑하기보다는 한 줄의 오류 메시지만 만들기를 선호할 때도 있다. 이런 경우에는 fmt.Errorf로 오류를 생성하되 %w대신에 %s%v로 생성하면 된다.

Is와 As

오류 래핑을 하다보면 원래의 오류에 대한 정보를 읽거나 원치 않는 문제가 발생할 때가 있다. 가령, 센티널 오류가 래핑되면 ==을 사용해도 확인이 안되며 래핑된 사용자 지정 오류와 일치시키기 위해 타입 단언이나 타입 스위치를 사용할 수도 없다. go는 이러한 문제를 해결하기 위해 errors패키지에 IsAs를 제공한다.

반환된 오류나 래핑된 모든 오류를 센티널 오류 인스턴스와 일치하는 지 확인하기 위해서 errors.Is를 사용한다. 해당 함수는 확인될 오류와 대응되는 인스턴스를 파라미터로 받는다. errors.Is함수는 제공된 센티넬 오류와 일치하는 오류 체인에 해당 오류가 있다면 true를 반환한다. errors.Is함수를 간단히 사용해보도록 하자.

func fileChecker(name string) error {
    f, err := os.Open(name)
    if err != nil {
        return fmt.Errorf("in fileChecker: %w", err)
    }
    f.Close()
    return nil
}

func main() {
    err := fileChecker("not_here.txt")
    if err != nil {
        if errors.Is(err, os.ErrNotExist) {
            fmt.Println("That file dosen't exist")
        }
    }
}

해당 프로그램을 실행하면 다음과 같은 결과를 얻을 수 있다.

That file dosen't exist

위와 같이 errors.Is로 wrapping하고 있는 err가 어떤 sentinel error가 있는 지 확인할 수 있다. 기본적으로 errors.Is는 지정된 오류와 래핑된 오류를 비교하기 위해 ==을 사용한다. 사용자 정의 error 타입의 경우에는 Is메서드를 구현하도록 하자.

type MyErr struct {
    Codes []int
}

func (me MyErr) Error() string {
    return fmt.Sprintf("codes: %v", me.Codes)
}

func (me MyErr) Is(target error) bool {
    if me2, ok := target.(MyErr); ok {
        return reflect.DeepEqual(me, m2)
    }
    return false
}

위와 같이 사용자 정의 error 타입의 경우에 Is메서드를 구현해주면 된다. 참고로 target은 sentinel error이다. 해당 메서드는 Codes슬라이스를 포함한 모든 것을 비교할 수 있다.

자체 Is 메서드를 정의하는 것의 다른 용도는 동일한 인스턴스가 아닌 오류에 대해서 비교가 가능하게 한다. ResourceErr라는 새로운 오류를 정의해보자.

type ResourceErr struct {
	Resource string
	Code     int
}

func (re ResourceErr) Error() string {
	return fmt.Sprintf("%s: %d", re.Resource, re.Code)
}

서로 다른 두 ResourceErr 인스턴스가 있을 때 이들이 일차하는 내용을 같고 있는 지 확인하기 위해서 Is를 사용할 수 있다.

func (re ResourceErr) Is(target error) bool {
	if other, ok := target.(ResourceErr); ok {
		ignoreResource := other.Resource == ""
		ignoreCode := other.Code == 0
		matchResource := other.Resource == re.Resource
		matchCode := other.Code == re.Code
		return matchResource && matchCode ||
			matchResource && ignoreCode ||
			ignoreResource && matchCode
	}
	return false
}

예를 들어, 이제 어떤 코드에서도 데이터베이스를 참조하는 모든 오류를 찾을 수 있게 된다.

if errors.Is(err, ResourceErr{Resource: "Database"}) {
    fmt.Println("The database is broken:", err)
    // 처리해야할 코드
}

다음과 같이 errors.Is는 wrapping된 error를 찾아낼 때도 쓸 수 있고, 사용자 정의 error 타입에서 서로 다른 인스턴스를 비교할 때도 사용할 수 있다.

다음으로 errors.As함수가 있는데, 반환된 오류가 특정 타입과 일치하는 지 오류가 있는 지 확인하고, 있을 경우 해당 타입의 인스턴스에 오류를 넣어준다. errors.As 함수의 첫번째 파라미터는 검사할 오류이고, 두 번째는 찾고자하는 타입의 변수를 가리키는 포인터이다. 함수가 true를 반환하면 오류 체인에 있는 오류와 일치하는 것을 찾았다는 것이고 일치하는 오류는 두 번째 파라미터로 할당된다. 함수가 false를 반환하면 오류 체인에 일치하는 오류를 찾지 못한다. MyErr를 이용해서 확인해보자.

err := AFunctionThatReturnAnError()
var myErr MyErr
if errors.As(err, &myErr) {
    fmt.Println(myErr.Code)
}

즉, errors.Is 함수는 특정 에러가 wrapping error안에 있는 지 확인하기 위한 것이고, 때로는 사용자 정의 error 타입에서 두 에러 인스턴스가 동일한 지 검사하기 위해 사용한다. 즉, errors.Is는 두 에러가 동일한 지를 검사하는 로직이다. errors.As는 wrapping error안에 특정 error 타입이 있는 지 확인하고, 있다면 어떤 값을 가졌는 지 확인해주도록 한다. 즉, errors.As는 wrapping된 error를 꺼내주는 역할을 하는 것이다. 따라서 Unwrap을 쓰지 않아도 errors.Is, errors.As로 충분히 동일한 로직을 구현할 수 있다.

errors.As의 로직을 더 살펴보면 다음과 같다.


type MyErr struct {
	Codes []int
}

func (me MyErr) Error() string {
	return fmt.Sprintf("codes: %v", me.Codes)
}

func main() {
	var myErr MyErr
	myErr.Codes = append(myErr.Codes, 1000)
	err := fmt.Errorf("Wrapping Error: %w", myErr)

	var target MyErr
	fmt.Println(errors.As(err, &target)) // true
	fmt.Println(target.Codes)            // [1000]
	fmt.Println(target.Error())          //codes: [1000]
}

정리하자면 특정 인스턴스나 특정 값을 찾을 때는 errors.Is를 사용하고 특정 타입을 찾을 때는 errors.As를 사용하면 된다.

패닉과 복구

go는 go런타임이 다음에 무슨 일이 일어날 지 알 수 없는 상황에서 패닉을 발생시킨다. 이는 프로그래밍 오류나 환경적인 문제로부터 발생할 수 있다. 패닉이 발생하자마자 현재 함수는 즉시 종료되고 현재 함수에 연결된 모든 defer함수가 실행을 시작한다. defer가 완료되면 호출 함수에 연결된 defermain함수에 도달할 때까지 계속 실행된다. main에 도달하면 stack trace와 함께 프로그램이 종료된다.

직접 패닉을 생성할 수도 있다. 내장 함수 panic()은 어떠한 타입이든 하나의 파라미터를 받을 수 있는데, 주로 문자열을 넘겨주어 출력하도록 할 수 있다.

func doPanic(msg string) {
    panic(msg)
}

func main() {
    doPanic(os.Args[0])
}

다음의 결과를 볼 수 있다.

panic: /var/folders/xs/frlxcs_90_n2wc_7pygs16j40000gn/T/go-build1118341376/b001/exe/main

goroutine 1 [running]:
main.doPanic(...)
        /Users/gyu/Desktop/go-project/learning-go/main.go:6
main.main()
        /Users/gyu/Desktop/go-project/learning-go/main.go:10 +0x45

go는 패닉을 포착하여 보다 안정적인 종료를 제공하거나 종료를 방지할 수 있는 방법을 제공한다. 내장 함수 recover함수는 패닉을 확인하기 위한 defer내부에서 호출될 수 있다. 패닉이라면, 패닉에 할당된 값이 반환된다. 일단 recover가 발생하면 실행은 정상적으로 진행된다.

func doPanic(msg string) {
	panic(msg)
}

func main() {
	defer func() {
		if v := recover(); v != nil {
			fmt.Println(v) // hello world
		}
	}()
	doPanic("hello world")
}

recover를 사용하는 특정한 패턴이 있는데, 잠재적인 panic을 처리하기 위한 defer함수를 등록한다. if문 내에서 recover를 호출하고 nil이 아닌 값인지 확인한다. 일단 패닉이 발생하면 defer로 등록된 함수만 실행할 수 있기 때문에 recover는 반드시 defer내에 호출되어야 한다.

panic을 다른 언어의 Exception과 동일하게 취급하지 않도록 하자. 프로그램에서 panic이 발생했다는 것은 심각한 일이며, 이를 recover해도 되는 것인지에 대한 판단도 상황에 따라서 다르다. 대부분의 경우는 panic이 발생하면 recover를 시키지않고 프로그램을 종료시킨다. recover를 시키지않는 이뉴는 panic이 무엇 때문에 발생했는 지 정확히 처리하기 어렵기 때문이다.

recover이 쓰이는 유일한 경우가 있는데, 이는 서드 파티 라이브러리를 생성할 때 panic이 메인 코드에 영향을 주지않도록 recover하는 경우이다. 대표적으로 net/http 패키지에서 패닉이 발생해도 메인 코드에 영향을 주지 않고 서드 파티에서 조치가 완료된다. 물론, 현재는 Go팀이 실수한 것이 아니냐는 의견도 있다.

0개의 댓글