go는 함수의 마지막 반환값으로 error
타입의 값을 반환하여 오류를 처리한다. 만약, 함수가 정상적으로 동작하였다면 error
에 nil
을 넣어 전달하면 된다.
새로운 오류는 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
변수인 err
를 if
문으로 확인해주어야 한다.
error
는 단일 메서드를 저으이하는 내장 인터페이스로 정의되어있다.
type error interface {
Error() string
}
해당 인터페이스를 구현하는 모든 것은 error
인터페이스로 받을 수 있기 때문에 오류로 간주된다. 또한, error
가 인터페이스이기 때문에 오류가 발생하지 않았다는 것을 나타내기 위해서 함수에서 nil
을 반환해야하는 것이다.
go에서 Exception
을 발생시키는 대신에 error
을 반환하는 이유는 두 가지로 볼 수 있다.
Exception
의 경우 handling하지 않은 예외의 경우 심가한 crush를 발생시키거나, 코드가 미완된 상태에서 구동시켜 디버깅을 어렵게 한다.error
를 함수에서 반환하게 되면, 변수로 받게되고 go에서의 변수는 모두 사용해야만 한다. 따라서, error
를 받은 변수를 반드시 처리할 수 밖에 없다.물론, 함수에서 반환된 값들 중 어떤 것들은 생략하여 받을 수도 있다. _
과 같이 말이다. 이것은 error
도 통용되는 사실이다. 다만 error
를 명시적으로 무시하는 것은 되도록 피해야만하는 경우이라는 것을 확인해두도록 하자.
추가적으로 error
를 반환하는 방식은 발생하는 error
가 어떤 것들이 있는 지 코드를 더욱 가독성있게 만들 수 있고, 정상적으로 코드가 실행되는 루트를 파악하기 쉽게해준다. 단순히 코드의 길이가 길어진다해서 디버깅이 더 어려워지고, 유지 보수가 편한 것은 아니기 때문이다.
go의 표준 라이브러리는 문자열로 오류를 생성하는 두 가지 방법을 제공한다.
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
}
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
}
센티넬 오류는 특정 에러 값을 사용하여 에러 상태를 나타내는 것인데, 어떤 오류는 단순히 무시하거나, 다른 경우의 수로 변경하여 처리할 수 없는 것들이 있다. 즉, 더 이상 현 문제를 처리할 수 없고 지속할 수 없는 상태를 표현하는 에러가 있는데 이를 센티넬 오류라고 한다. 재밌는 것은 실제로 발생하지 않는 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.NewReader
에 zip
파일 포맷이 아닌 값이 들어가면 더 이상 실행할 수 없고 지속할 수 없는 상태이기 때문에 센티넬 에러를 반환하고, 이 때의 센티넬 에러가 바로 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 StatusErr
을 var genErr *StatusErr
로 선언하면 되지 않는가? 어차피 pointer
의 제로값은 nil
이니까라고 생각할 수 있다. var genErr *StatusErr
로 바꾸어도 똑같은 결과가 나올 것이다.
왜냐하면 error
는 interface
고 interface
의 nil
판정은 단순히 구조체가 nil
을 가진다고 nil
을 반환하는 것이 아니라, 타입과 값 두 가지 특성에 의해 결정된다는 것을 알고 있다. var genErr *StatusErr
의 경우는 타입이 *StatusErr
라는 것을 명확히 알 수 있고, 단순히 값만 없는 것이기 때문에 nil
이 아니다. 인터페이스가 nil
이기 위해서는 그냥 nil
이어야 하는 것이지 구조체가 nil
이라고 해서 nil
을 반환하는 것은 아니다.
오류가 코드를 통해 전달될 때, 해당 오류에 추가적인 정보를 추가하려고 하는 경우가 있다. 원래의 오류에 추가 정보를 넣다보면 원래 오류 정보가 사라지거나 훼손되는 경우가 았다. 이런 경우없이 추가 정보를 추가하면서 오류를 유지하는 것을 오류 래핑(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
로 생성하면 된다.
오류 래핑을 하다보면 원래의 오류에 대한 정보를 읽거나 원치 않는 문제가 발생할 때가 있다. 가령, 센티널 오류가 래핑되면 ==
을 사용해도 확인이 안되며 래핑된 사용자 지정 오류와 일치시키기 위해 타입 단언이나 타입 스위치를 사용할 수도 없다. go는 이러한 문제를 해결하기 위해 errors
패키지에 Is
와 As
를 제공한다.
반환된 오류나 래핑된 모든 오류를 센티널 오류 인스턴스와 일치하는 지 확인하기 위해서 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
가 완료되면 호출 함수에 연결된 defer
가 main
함수에 도달할 때까지 계속 실행된다. 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팀이 실수한 것이 아니냐는 의견도 있다.