[Go] - golang 에러처리에 대한 고민과 opaque 에러로 똑똑하게 처리하기
프로그램은 여러 가지 원인으로 인해서 의도하지 않은 방향으로 동작하거나, 종료될 수 있습니다.
따라서 어떤 언어를 선택하든간에 로깅과 에러핸들링, 테스팅은 가장 기본적이며 가장 먼저 체화해야 한다고 생각합니다.
다양한 언어에서 오류/예외를 처리할 때 Opaque(불투명) error handling을 채택하곤 합니다.
그렇다면 golang에서는 왜 Opaque error handling을 선택해야 하며, Opaque erro handling이란 무엇인가 알아보겠습니다.
Opaque Type (불투명한 타입)
- 불투명한 반환 타입이 있는 함수 또는 메서드는 반환값의 타입 정보를 가립니다.
- 함수의 반환 타입으로 구체적인 타입을 제공하는 대신에 반환값은 지원되는 프로토콜 측면에서 설명됩니다.
- 반환값의 기본 타입이 비공개로 유지될 수 있으므로 모듈과 모듈을 호출하는 코드 사이의 경계에서 타입 정보를 숨기는 것이 유용합니다.
- 타입이 프로토콜 타입인 값을 반환하는 것과 달리 불투명한 타입은 타입 정체성을 보존합니다. ➡️컴파일러는 타입 정보에 접근할 수 있지만 모듈의 클라이언트는 그럴 수 없습니다.
- 제네릭과는 반대되는 개념이라고 생각할 수 있습니다.
Error를 처리하는 3가지 방식
첫 번째 : sentinel error (나쁜 방식)
알고리즘 속에서 종료 조건을 나타내거나, 더 이상 처리가 불가능한 상황을 나타내는 값을 sentinel value라고 합니다. 예를 들면 다음 코드의 -1은 sentinel value 입니다.
int find(int arr[], size_t len, int val)
{
for (int i = 0; i < len; i++)
if (arr[i] == val)
return i;
return -1; // not found
}
func exam(){
err := find(arr,arrLen,val);
if err == -1{
//에러처리
}
}
sentinel errors로 error를 처리할 경우, 함수를 호출한 쪽에서 if error == 특정값
연산을 통해서 에러를 처리합니다.
이 "특정값"이 다양한 문제를 일으킵니다.
필연적으로 호출자는 error를 검사하기위해 "특정값"을 import해야합니다.
즉, package간에 의존성이 생깁니다.
또는 이 sentinel error를 처리하는 로직 속에 error를 반환하는 interface를 정의했다면, 그 interface의 구현체들은 모두 그 error 값만 을 반환하는 제약이 생깁니다.
두 번째: error types(나쁜 방식)
error를 특정 구조체로 만들어, error가 추가적인 context 정보를 포함하게 하는 방식입니다.
type MyError struct {
Msg string
File string
Line int
}
sentinel error와 다르게 일반적인 error에 다른 context정보를 wrapping하여 error를 처리할 수 있습니다.
하지만 error type 방식도 sentinel error가 가졌던 문제점을 그대로 가지고 있습니다.
즉, error를 처리하는 모든 패키지는 정의된 MyError 패키지에 대한 의존성을 갖게 됩니다.
세 번째: opaque error(좋은 방식)
사용하면서 정말 좋다는 것을 몸으로 체감한 에러 핸들링 방식입니다.
타입이 아니라 행위(behavior)에 대해 error assertion 을 하자.
어떤 경우에는 에러 처리에 대해 이분법적 접근만으로는 충분하지가 않습니다.
예를 들면, 만약 내 process 의 바깥 세상과의 상호 작용(network를 통한 동작같은)에서는, 호출자가 작업을 재수행하는 것이 좋은지를 판단하기 위해 error 를 조사해야할 경우가 있습니다. (만약 error 발생 시 network 재연결을 원한다거나 등)
다음은 그 예시입니다.
type temporary interface {
Temporary() bool
}
// IsTemporary returns true if err is temporary.
func IsTemporary(err error) bool {
te, ok := err.(temporary)
return ok && te.Temporary()
}
IsTemporary
함수는 주어진 오류err
이 임시적인지 여부를 확인합니다.err.(temporary)
는err
이temporary
인터페이스를 구현하는 경우 해당 인터페이스 타입으로 변환합니다. 반환된te
변수에는Temporary()
메서드가 포함됩니다.ok
변수는 타입 변환이 성공적으로 이루어졌는지 여부를 나타내는 부울 값입니다.- 최종적으로 함수는
ok
가true
이고,te.Temporary()
가true
일 때에만true
를 반환합니다. 이는 오류가temporary
인터페이스를 구현하면서 동시에Temporary()
메서드가true
를 반환하는 경우에만true
를 반환하도록 하는 것입니다.
즉, IsTemporary에 발생한 error를 넣었을 때 true가 반환된다면 이는 일시적 에러를 의미하며 추가적인 동작을 수행할 수 있게됩니다.
여기서 중요한 점은 error를 정의하는 패키지를 import 할 필요없이 구현될 수 있다는 것입니다.
단지 error 를 검사만 하지 말고, 우아하게 처리하자.
또한 opaque error는 error가 발생하면 그대로 흘려보냅니다. 다만, error에 특정 Context를 추가하며 마치 stacktrace와 같은 느낌을 줄 수 있습니다.
go는 [github.com/pkg/errors](https://godoc.org/github.com/pkg/errors)
패키지의 Wrap()과 Cause()함수로 Error에 Context를 추가하거나, 복원할 수 있습니다.
🤔예시
func getIPAddresses(url string) ([]string, error) {
// Using LookupHost to lookup IP addresses of domain
ips, err := net.LookupHost(url)
if err != nil {
//LookupHost 에서 발생한 err에 "LookupHost failed"라는 context가 추가되었다.
return nil, errors.Wrap(err, "LookupHost failed : ")
}
err:= invalidUrl(url)
if err != nil {
//invalidUrl 에서 발생한 err에 "Invalid Url"라는 context가 추가되었다.
return nil, errors.Wrap(err, "Invalid Url : ")
}
return ips, nil
}
func findAndSaveNameServerInfo(ns []string, outputDB *sql.DB, searchId int) error {
var nameserverIPs []string
var err error
for _, nameserver := range ns {
IPs, err := getIPAddresses(nameserver)
if err != nil {
// getIPAddresses에서 발생한 err에 네임서버 정보 context가 추가되었다.
errors.Wrap(err, "\n--->Error for Nameservers : "+nameserver+err.Error()+"\n")
continue
}
.
.
}
}
func main(){
.
.
err = findAndSaveNameServerInfo(ns, outputDB, url.URLId)
if err != nil {
logger.Warn("Problem with find and save NS : " + err.Error())
}
.
.
}
opaque 에러를 구현한 위 예시를 보겠습니다.
만약 getIpAddresses에서 에러가 난다면, errors에는 어느 url에서 에러가 발생되었는지가 중첩되어 쌓이게 됩니다. 그리고 이후 ns에 대한 for문이 끝나게 되면, main함수는 에러가 발견된 네임서버리스트의 모든 네임서버에 대한 에러를 출력합니다.
따라서 해당 메시지를 통해서 어떤 위치에서 어떤 에러가 발생했는지 한 눈에 확인할 수 있습니다.
🚨주의할 점
에러는 한번만 출력하자.
• 에러 로깅은 parent에게 맡기는게 원칙. 만약 자식 메서드에서 모두 에러를 출력한다면 에러 로그가 굉장히 중복되고 더러워질 것이다.
// ❌
if err := nil {
log.Println("failed to get user id: %d", userID)
return err
}
// ✅
if err := nil {
return err
}
결론
opaque error를 통해서 error를 처리하는 것을 지향합니다.
만약 중간에 error를 검사해야한다면, error의 값이 아닌, 행위 구현자로 error를 검사하는 것이 좋습니다.
errors.Wrap을 통해 error에 context 정보를 추가합니다.(중간에 error를 점검할 필요가 있다면, errors.Cause로 복원한다.)
error 또한 public API의 일부이므로, 이를 명심하여 최대한 의존성을 줄이는 방향으로 코드를 작성하는 습관을 들이는 것이 좋습니다.
reference