무결성은 중요하며 이보다 중요한 것은 없다. 에러 처리는 그러한 무결성의 한 부분이다. 개발자가 매일 챙겨야 하는 부분이며, 작성하는 코드의 일부로 생각해야 한다. 먼저, 언어에서 제공하는 기본 에러 타입 구현의 동작에 대해 살펴보자.
package main
import "fmt"
http://golang.org/pkg/builtin/#error
이것은 언어 자체에 포함되어 있기에, 외부로 노출되지 않는 타입처럼 보인다. Error
라는 문자열 한 개를 반환하는 메서드 하나만 외부에서 접근이 가능하다. 에러 처리는 코드 테스트를 할 때에 항상 errer
인터페이스를 사용하기에 디커플링 되어 있다.
Go에서 에러는 단지 값일 뿐이며, 인터페이스 디커플링을 통해 그 값을 평가한다. 에러 처리를 디커플링 하는 것은 지속적인 변경이 코드 전반에 걸쳐 광범위한 영향을 야기하기 때문이다. 에러를 다룰 때 인터페이스를 최대한 이용하는 것이 중요하다.
type error interface {
Error() string
}
http://golang.org/src/pkg/errors/errors.go
error
패키지에 있는 구체적인 기본 타입이다. 타입도, 그 내부의 필드도 외부로 노출하지 않는다. 이 방법은 사용자의 에러 판정을 구조화 할 수 있는 충분한 컨텍스트를 제공한다.
호출 시 충분한 내용을 담아 에러 처리를 하여, 호출자가 에러 상황에 대해 의사결정을 할 수 있게 한다.
type errorString struct {
s string
}
http://golang.org/src/pkg/errors/errors.go
포인터 리시버를 이용하여 문자열을 반환한다. 사용자는 이 메소드를 호출하여 실패상황에 대한 문자열을 얻어낼 수 있다.
이 메소드는 에러에 대한 정보를 로깅하는 데 사용한다.
func (e *errorString) Error() string {
return e.s
}
http://golang.org/src/pkg/errors/errors.go
New
는 주어진 문자열에서 에러 인터페이스를 반환한다. 사용자가 New
를 호출하면, text로 넘어온 값을 포함하는 errorString
값이 생성된다. 해당 타입의 실체에 대한 주소를 반환하기 때문에, 사용자는 실제 에러 내용을 가리키는 errorString
을 통해 에러 인터페이스 값을 얻을 수 있다. 에러 처리는 이와 같은 방식으로 디커플링될 수 있다.
func New(text string) error {
return &errorString{text}
}
아래는 Go에서 에러를 다루는 전통적인 방식이다. webCall
함수를 호출하고 에러 인터페이스를 변수에 저장하는 예시를 관찰할 것이다.
nil
은 Go에서 특별한 값이다. error != nil
은 타입에 대한 실질적 값이 에러 인터페이스에 들어있는가 확인함을 의미한다. 에러가 nil
값이 아니면 실질적인 값이 저장된 것이기 때문이다. 값이 있는 경우에는 에러를 마주한 것이다.
이제 에러를 처리하고, 에러가 호출 스택 상위에서 누군가가 제어할 수 있도록 해야 하지 않겠는가? 이 부분은 나중에 다루기로 한다.
func main() {
if err := webCall(); err != nil {
fmt.Println(err)
return
}
fmt.Println("Life is good")
}
webCall
은 웹 요청을 처리한다
func webCall() error {
return New("Bad Request")
}
어떤 에러가 반환 되는지 알기 위해 에러 변수를 사용해본다.
package main
import (
"errors"
"fmt"
)
소스코드 파일의 최상단을 아래와 같이 작성한다. 명명 규칙 : 에러 변수 명명은 Err로 시작하도록 한다. 사용자들이 접근 가능하도록 (대문자로 작성하여) 노출한다.
아래는 지난 예시 파일에서 살펴 본 에러 인터페이스들에, 값을 할당한 것이다. 에러들에 대한 컨텍스트를 이 변수들이 자체적으로 포함한다. 이 방법은 사용자들이 기본 에러 타입과 그것의 필드에 대한 노출 없이 지속적으로 에러 처리를 사용할 수 있도록 디커플링한다.
요청에 문제가 있는 경우 ErrBadRequest
변수가 반환된다. 301/302가 반환되면 ErrPageMoved
변수가 반환된다.
var (
ErrBadRequest = errors.New("Bad Request")
ErrPageMoved = errors.New("Page Moved")
)
func main() {
if err := webCall(true); err != nil {
switch err {
case ErrBadRequest:
fmt.Println("Bad Request Occurred")
return
case ErrPageMoved:
fmt.Println("The Page moved")
return
default:
fmt.Println(err)
return
}
}
fmt.Println("Life is good")
}
webCall
은 웹 요청을 처리한다
func webCall(b bool) error {
if b {
return ErrBadRequest
}
return ErrPageMoved
}
Bad Request Occurred
컨텍스트를 표현하는 데 에러 인터페이스 값보다 더 많은 컨텍스트를 필요로 하는 경우도 있다. 예를 들어, 네트워킹 문제는 복잡할 수 있어, 에러 변수 만으로는 부족하다. 이럴 때에는 사용자 정의 타입을 통해 문제를 해결할 수 있다.
아래 2개 타입은 표준 라이브러리인 JSON 패키지 기반 사용자 정의 에러 정의 방법이다. 컨텍스트를 포함하는 타입이다.
http://golang.org/src/pkg/encoding/json/decode.go
package main
import (
"fmt"
"reflect"
)
UnmarshalTypeError
은 Go의 특정 타입으로 간주하기 어려운 JSON 값을 표현한다.
명명 규칙 : 타입 명명 시에는 접미사를 Error
로 한다.
type UnmarshalTypeError struct {
Value string // JSON value에 대한 설명이다
Type reflect.Type // 미리 선언할 수 없는 타입을 의미한다
}
UnmarshalTypeError
은 포인터 시맨틱(pointer semantics)을 이용하여 에러 인터페이스를 구현한다. 구현 할 때, 모든 필드를 에러 메시지에서 사용하는가 검증한다. 그렇지 않다면 문제가 발생할 수 있다. 사용자 정의 에러 타입에 필드를 추가 해두어도 아래 메서드(Error
)가 호출될 때 로그가 정상적으로 출력되지 않을 것이기 때문이다. 정말 필요할 때만 이렇게 사용하자.
func (e *UnmarshalTypeError) Error() string {
return "json: cannot unmarshal " + e.Value + " into Go value of type " + e.Type.String()
}
InvalidUnmarshalError
는 Unmarshal
함수에 유효하지 않은 매개변수가 들어왔음을 알린다. Unmarshal
의 매개변수로는 nil
이 아닌 포인터가 들어와야 한다. 실제 타입은 Unmarshal
함수가 값의 주소를 받지 않았을 때 반환값에 사용된다.
type InvalidUnmarshalError struct {
Type reflect.Type
}
InvalidUnmarshalError
은 에러 인터페이스를 구현한다.
func (e *InvalidUnmarshalError) Error() string {
if e.Type == nil {
return "json: Unmarshal(nil)"
}
if e.Type.Kind() != reflect.Ptr {
return "json: Unmarshal(non-pointer " + e.Type.String() + ")"
}
return "json: Unmarshal(nil " + e.Type.String() + ")"
}
Unmarshal
호출을 위해 user
타입을 사용한다.
type user struct {
Name int
}
func main() {
var u user
err := Unmarshal([]byte(`{"name":"bill"}`), u) // Run with a value and pointer.
if err != nil {
This is a special type assertion that only works on the switch.
switch e := err.(type) {
case *UnmarshalTypeError:
fmt.Printf("UnmarshalTypeError: Value[%s] Type[%v]\n", e.Value, e.Type)
case *InvalidUnmarshalError:
fmt.Printf("InvalidUnmarshalError: Type[%v]\n", e.Type)
default:
fmt.Println(err)
}
return
}
fmt.Println("Name:", u.Name)
}
Unmarshal
함수는 항상 실패할 언마셜을 실험한다. 매개변수 영역을 보면, 첫번째는 바이트 슬라이스이고, 두번째는 빈 인터페이스이다. 빈 인터페이스는 기본적으로 어떤 것도 의미하지 않으며, 함수를 통해 어떤 값이든 받을 수 있다. 아래에서는 리플렉션을 이용해 인터페이스에 저장된 값의 실제 타입을 알아내고, 그것이 포인터가 아니거나 nil
이 아닌지 확인할 것이다. 결과에 기초하여 다른 에러 타입을 반환한다.
func Unmarshal(data []byte, v interface{}) error {
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return &InvalidUnmarshalError{reflect.TypeOf(v)}
}
return &UnmarshalTypeError{"string", reflect.TypeOf(v)}
}
타입을 통해 컨텍스트를 처리할 때의 한 가지 흠이 있다. 이 경우에는, 구현된 타입에 접근하여 디커플링(decoupling)에서 멀어지게 된다. json
패키지를 작성한 개발자가 구현된 타입들을 변경하면, 예제 코드에도 연쇄적인 영향을 미치게 된다. 에러 인터페이스 디커플링의 보호를 받지 못한다.
이런 문제는 가끔 발생할 수 있다. 디커플링을 유지하는 다른 방법은 없을까? 기능을 통한 컨텍스트 처리를 살펴보자.
기능을 통한 컨텍스트 처리는 사용자 정의 오류를 마치 컨텍스트처럼 다룰 수 있게 해준다. 그리고 구현된 타입으로 단언하는 걸 막는다. 이를 통해 디커플링(decoupling)의 레벨에서 코드를 유지보수할 수 있게 된다.
package main
import (
"bufio"
"fmt"
"io"
"log"
"net"
)
client
는 하나의 연결성을 가진다.
type client struct {
name string
reader *bufio.Reader
}
TypeAsContext
는 net
패키지가 반환하는 여러 사용자 정의 오류들을 확인하는 방법을 보여준다.
func (c *client) TypeAsContext() {
for {
reader
인터페이스를 이용하여 네트워크를 읽는 것에서 코드를 분리할 수 있다.
line, err := c.reader.ReadString('\n')
if err != nil {
이 예제는 이전 예제와 마찬가지로 타입을 통해 컨텍스트를 처리한다. 여기서는 Temporary
라는 메서드가 중요하다. 이 메서드가 정상적으로 작동한다면 계속 작업을 수행하고 그렇지 않다면 멈춘 후에 다시 시작한다. 아래 모든 케이스는 오직 Temporary
만을 위한 것이다. 이게 왜 중요한가? 만약 타입 단언을 한다거나 구현된 타입의 내재된 기능만 요구한다면, 이것을 타입이 아니라 기능을 통해 처리하는 방식으로 바꿀 수 있다. 그렇기에 아래의 temporary
라는 사용자 정의 인터페이스를 만들 수 있다.
switch e := err.(type) {
case *net.OpError:
if !e.Temporary() {
log.Println("Temporary: Client leaving chat")
return
}
case *net.AddrError:
if !e.Temporary() {
log.Println("Temporary: Client leaving chat")
return
}
case *net.DNSConfigError:
if !e.Temporary() {
log.Println("Temporary: Client leaving return chat")
return
}
default:
if err == io.EOF {
log.Println("EOF: Client leaving chat")
return
}
log.Println("read-routine", err)
}
}
fmt.Println(line)
}
}
temporary
는 net 패키지에서 Temporary
라는 메서드가 반환되는 지 확인한다. 왜냐하면 그 중 Temporary
라는 메서드를 가진 구조체만 있으면 되기때문이다. 그러면 여전히 디커플링 단계에 있으며 계속 인터페이스 레벨에서 작업할 수 있다.
type temporary interface {
Temporary() bool
}
BehaviorAsContext
는 net
패키지가 반환할 지도 모르는 인터페이스를 어떻게 확인하는 지 보여준다.
func (c *client) BehaviorAsContext() {
for {
line, err := c.reader.ReadString('\n')
if err != nil {
switch e := err.(type) {
타입 단언을 통해 세가지 경우를 한가지로 줄일 수 있다: 이 구현 타입은 해당 인터페이스를 구현하고 있는 error
인터페이스를 가지고 있으며 해당 인터페이스를 정의하고 이용할 수 있다.
case temporary:
if !e.Temporary() {
log.Println("Temporary: Client leaving return chat")
return
}
default:
if err == io.EOF {
log.Println("EOF: Client leaving chat")
return
}
log.Println("read-routine", err)
}
}
fmt.Println(line)
}
}
Lesson:
Go의 암시적 형변환 덕분에, 원하는 메서드나 기능을 가진 인터페이스를 구현함으로 디커플링 단계에서 유지보수할 수 있고 타입 단언을 이용하는 switch
문법에서 구현 타입 대신에 사용할 수 있다.
package main
import "log"
customError
는 빈 구조체이다.
type customError struct{}
Error
는 error
인터페이스를 구현한다.
func (c *customError) Error() string {
return "Find the bug."
}
fail
함수는 둘 다 nil
값을 반환한다.
func fail() ([]byte, *customError) {
return nil, nil
}
func main() {
var err error
fail
을 호출하면 nil
값을 반환할 것이다. 하지만 error
인터페이스로써 반환하고 싶지만 customError
타입의 nil
값을 반환할 뿐이다. customError
타입은 이 소스 코드 안에서 만들어진 타입에 불과하다. 그렇기에 사용자 정의 타입을 직접 반환해서는 안되고 func fail() ([]byte, error)
처럼 인터페이스를 반환해야한다.
if _, err = fail(); err != nil {
log.Fatal("Why did this fail?")
}
log.Println("No Error")
}
오류 처리는 코드의 일부이며, 로깅으로 까지 이어진다. 로깅의 주목적은 디버그를 위한 것이다. 로그를 보고 대응이 가능한 것이라면 로그로 남긴다. 어떻게 실행되고 있는지 상황을 알려주는 것을 로그로 남긴다. 그 외의 것들은 노이즈나 다름 없으며, 대시보드의 지표로나 사용하면 된다. 예를 들자면, 소켓의 연결과 끊어짐을 로그로 남길 수는 있지만 딱히 대응을 해야 하거나 챙겨서 볼 필요는 없는 것이다.
여기 Dave Cheney가 작성한 errors
라는 패키지가 있다. 이 패키지는 오류를 간단하게 처리할 수 있게 도와주고 동시에 로그를 기록해준다. 아래 코드는 이 패키지가 코드를 어떻게 단순하게 만들어 주는 지 보여준다. 로깅의 양을 줄임으로, 힙(주로 Garbage Collection)에 대한 부담을 줄일 수 있다.
import (
"fmt"
"github.com/pkg/errors"
)
AppError
는 사용자 정의 에러 타입이다.
type AppError struct {
State int
}
AppError
는 error
인터페이스를 구현한다.
func (c *AppError) Error() string {
return fmt.Sprintf("App Error, State: %d", c.State)
}
func main() {
함수를 호출하고 오류를 검증한다. firstCall
은 secondCall
를 호출하고 secondCall
이 thirdCall
을 호출하면 결과로 AppError
가 반환된다. 호출 스택을 내려가다가 오류가 발생하는 thirdCall
에 도달한다. 이 곳이 오류가 발생한 근원지이다. 이 오류는 error
인터페이스에 담겨서 호출 스택을 거슬러 올라간다.
secondCall
로 돌아오면 error
인터페이스가 반환되며 그 내부엔 구현된 타입이 값으로 존재한다. secondCall
은 오류를 처리할 수 없으면 올려보내거나 직접 처리할 지 결정해야한다. secondCall
에서 오류를 처리하기로 결정한다면 로그로 남겨야할 책임이 주어지고 그렇지 않다면 이 책임은 호출 스택을 따라 거슬러 올라가게 된다. 하지만 호출 스택을 밀어 올린다더라도 컨텍스트를 잃지는 않는다. error
패키지가 들어올 시점이다. 이러한 오류를 새로운 문맥으로 래핑하거나 추가하여 새로운 인터페이스 값을 만든다. 이것은 코드 상의 호출 스택을 보여준다.
firstCall
은 오류를 처리하지는 않지만 래핑해서 위로 올려준다. main
에서 해당 오류를 처리하고 로그를 남기게 된다.
이 오류를 적절히 처리하기 위해서는 처음 발생한 오류, 래핑되지 않은 원시 오류에 대해 알 필요가 있다. Cause
메서드는 오류를 이러한 래핑으로부터 끌어내어 사용 가능한 모든 언어적 기술을 이용할 수 있게 해준다.
State
에 접근하는 것뿐만 아니라, 구현 타입으로 단언했더라도 %+v를 이용하여 전체 스택 트레이스(stack trace)를 추적할 수 있다.
타입을 통한 컨텍스트의 처리를 이용하여 사용자 정의 오류로 밝혀낸다.
if err := firstCall(10); err != nil {
switch v := errors.Cause(err).(type) {
case *AppError:
fmt.Println("Custom App Error:", v.State)
오류의 스택 트레이스(stack trace)를 보여준다.
fmt.Println("\nStack Trace\n********************************")
fmt.Printf("%+v\n", err)
fmt.Println("\nNo Trace\n********************************")
fmt.Printf("%v\n", err)
}
}
}
firstCall
은 secondCall
을 호출하고 오류를 래핑하여 반환한다.
func firstCall(i int) error {
if err := secondCall(i); err != nil {
return errors.Wrapf(err, "firstCall->secondCall(%d)", i)
}
return nil
}
secondCall
은 thirdCall
을 호출하고 오류를 래핑하여 반환한다.
func secondCall(i int) error {
if err := thirdCall(); err != nil {
return errors.Wrap(err, "secondCall->thirdCall()")
}
return nil
}
thirdCall
함수는 검사될 오류를 만든다.
func thirdCall() error {
return &AppError{99}
}
Custom App Error: 99
Stack Trace
********************************
App Error, State: 99
secondCall->thirdCall()
main.secondCall
/tmp/sandbox880380539/prog.go:74
main.firstCall
/tmp/sandbox880380539/prog.go:65
main.main
/tmp/sandbox880380539/prog.go:43
runtime.main
/usr/local/go-faketime/src/runtime/proc.go:203
runtime.goexit
/usr/local/go-faketime/src/runtime/asm_amd64.s:1373
firstCall->secondCall(10)
main.firstCall
/tmp/sandbox880380539/prog.go:66
main.main
/tmp/sandbox880380539/prog.go:43
runtime.main
/usr/local/go-faketime/src/runtime/proc.go:203
runtime.goexit
/usr/local/go-faketime/src/runtime/asm_amd64.s:1373
No Trace
********************************
firstCall->secondCall(10): secondCall->thirdCall(): App Error, State: 99