배경
현재 프로젝트에서 Error 처리를 일관적이지 못한 방법으로 하고 있다는 생각이 들어서 글을 쓰게 되었습니다.
작년의 Golang 초기 사용 시점에는 모두 언어에 대한 이해도가 낮고 MVP 완성을 위해 빠르게 코드를 짰어야 해서 전체적으로 코드를 짠 사람의 코딩 Convention을 따라가게 되었습니다.
그때의 Error handling 사용의 형태를 유사하게 활용해 오다 보니 현재까지 같은 문제를 가지고 있는 Error handling을 가져오게 된 것이죠.
그래서 우리의 Error handling은 같은 문제들을 가지고 있습니다.
현재 프로젝트의 에러 처리 방식의 문제점
1. 에러 로그의 일관성이 부족합니다.
어떤 에러 로그는 최상위 API 실행시에만 몰아서 출력되고,
어떤 에러 로그는 최상위 API 실행시에 출력되기도 하고 여러 함수를 거치면서 또 출력하기도 합니다.
일관성이 부족하죠.
에러 로그 출력의 일관성을 위해서는 API Call 하는 위치에 출력되어야 한다고 생각을 하고 있습니다.
(다른 레벨의 로그의 경우에는 그냥 찍어두어도 상관없을지도 모르겠네요)
이런 방향성이 Golang Convention에도 권장하는 방법인지 혹은 다른 권장하는 방법이 있는지 검토해보려고합니다.
이 항목 외에도 다른 문제가 있습니다.
2. 에러 출력의 사용처를 분리 하지 않음.
로그용 에러와 Response용 에러를 혼용해서 쓴다는 문제도 있었습니다.
API 사용자는 알 필요 없는 Error 내역을 그대로 API의 메세지에 포함시키게 되는 경우도 있습니다.
다만, 설명은 API 사용자 입장에서 알 수 없거나, 불필요한 메세지가 포함되어있기도 합니다.
Golang Convention 확인 & 다른 회사 Style guide
Google의 Style Guide에서 error는 아래와 같은 특징을 가질 수 있다고합니다.
1. human에게 보여져 진단될 수 있는 형태의 정보.
2. maintainer에 의해 사용됨.
3. end user에 의해 이해됨.
에러 메세지는 log 메세지, error dump, UI로 렌더링된 정보로 보여질 수 있습니다.
에러를 위해서 아래를 결정 해야 한다고 합니다.
1. 에러를 생성할때 Structure를 만들지 결정
2. 에러 처리시에 Logging용 Error내용을 User가 알아야 에러와 마찬가지로 추가해야 함.
Error Structure 사용
만약 Error의 Caller가 어떤 에러인지 알고싶은 경우 string matching 같은 처리 방식을 써서는 안되고 programmatically 처리해야합니다.
이를 만족하는 가장 단순한 Error 구조체는 아래와 같은 형태를 띄고 있습니다.
type Animal string
var (
// ErrDuplicate occurs if this animal has already been seen.
ErrDuplicate = errors.New("duplicate")
// ErrMarsupial occurs because we're allergic to marsupials outside Australia.
// Sorry.
ErrMarsupial = errors.New("marsupials are not supported")
)
// Good:간단한 구현
func handlePet(...) {
switch err := process(an); err {
case ErrDuplicate:
return fmt.Errorf("feed %q: %v", an, err)
case ErrMarsupial:
// Try to recover with a friend instead.
alternate = an.BackupAnimal()
return handlePet(..., alternate, ...)
}
}
// Good: process()가 wrapped error를 리턴하는 경우에 error.is()를 사용
func handlePet(...) {
switch err := process(an); {
case errors.Is(err, ErrDuplicate):
return fmt.Errorf("feed %q: %v", an, err)
case errors.Is(err, ErrMarsupial):
// ...
}
}
// Bad: error를 문자열로 구별하는 방식은 좋지 않습니다.
func handlePet(...) {
err := process(an)
if regexp.MatchString(`duplicate`, err.Error()) {...}
if regexp.MatchString(`marsupial`, err.Error()) {...}
}
Error에 추가정보를 포함하는 방법
에러 값을 조금 더 쉽게 처리하기 위해서는 아래와 같이 처리하면 좋습니다.
// Good:
if err := os.Open("settings.txt"); err != nil {
return err
}
// Output:
//
// open settings.txt: no such file or directory
그리고 그 에러가 의미 있는 에러의 경우 아래와 같이 추가 정보를 담을 수 있습니다.
// Good:
if err := os.Open("settings.txt"); err != nil {
// 이 오류의 심각성을 전달합니다. 이 함수는 실패할 수 있는 파일 작업을 두 개 이상 수행할 수 있으므로
// 이러한 주석은 호출자에게 무엇이 잘못되었는지 명확하게 알려주는 역할을 할 수도 있습니다.
return fmt.Errorf("launch codes unavailable: %v", err)
}
// Output:
//
// launch codes unavailable: open settings.txt: no such file or directory
반면 아래와 같이 불필요 한 정보를 포함하는 경우는 지양해야합니다.
// Bad:
if err := os.Open("settings.txt"); err != nil {
return fmt.Errorf("could not open settings.txt: %w", err)
}
// Output:
//
// could not open settings.txt: open settings.txt: no such file or directory
전파되는 에러에 정보를 추가할때, error를 wrapping하거나 새로운 에러를 만드는 방법을 택합니다.
fmt.Errorf의 %w 키워드를 통해서 Caller가 원래의 에러를 접근할 수 있게 됩니다.
다만, 이런 에러 처리는 잘못 쓰여 질 수 있는데 Wrapping error는 API 까지 전달될 수 있으며 구현의 변경에 안전하지 않습니다.
만약 Caller가 errors.Unwrap이나 errors.Is를 쓰지 않을것으로 예상된다면, 이런 에러들을 노출 시키고 싶지 않은 경우 %w를 쓰지 않는게 좋습니다.
에러에 statuc code나 표준 값을 추가하는것은 좋습니다.
// Bad:
func (*FortuneTeller) SuggestFortune(context.Context, *pb.SuggestionRequest) (*pb.SuggestionResponse, error) {
// ...
if err != nil {
return nil, fmt.Errorf("couldn't find remote file: %w", err)
}
}
// Good:
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (*FortuneTeller) SuggestFortune(context.Context, *pb.SuggestionRequest) (*pb.SuggestionResponse, error) {
// ...
if err != nil {
// Or use fmt.Errorf with the %w verb if deliberately wrapping an
// error which the caller is meant to unwrap.
return nil, status.Errorf(codes.Internal, "couldn't find fortune database", status.ErrInternal)
}
}
Error에서 %w의 위치
%w 항상 error string의 끝에 위치하는것이 좋습니다.
Error Wrapping은 error chain을 형성합니다.
그리고 그 Error chain은 unwrap() error 메서드를 통해서 traverse 될 수 있습니다.
아래와 같이 예시를 비교해 볼 수 있습니다.
// Good:
err1 := fmt.Errorf("err1")
err2 := fmt.Errorf("err2: %w", err1)
err3 := fmt.Errorf("err3: %w", err2)
fmt.Println(err3) // err3: err2: err1
// err3은 new->old 체인을 가지며, new->old 순서로 에러를 출력합니다.
// Bad:
err1 := fmt.Errorf("err1")
err2 := fmt.Errorf("%w: err2", err1)
err3 := fmt.Errorf("%w: err3", err2)
fmt.Println(err3) // err1: err2: err3
// err3은 new->old 체인을 가지며, old->new 순서로 출력하여 혼란을 줍니다.
// Bad:
err1 := fmt.Errorf("err1")
err2 := fmt.Errorf("err2-1 %w err2-2", err1)
err3 := fmt.Errorf("err3-1 %w err3-2", err2)
fmt.Println(err3) // err3-1 err2-1 err1 err2-2 err3-2
// err은 new->old 체인을 가지지만, old->new 출력도, new->old 출력도 아닙니다.
Error logging
Function의 경우 caller에게 알리지 않고 에러를 외부 시스템에 알려줘야하는 경우가 있습니다.
Logging이 명백히 좋은 선택입니다.
Logging을 어떻게, 무엇을 해야할지가 중요합니다.
이에 대해서 살펴 보겠습니다.
1. 로그 메세지는 무엇이 잘못되었는지를 maintainer에게 명백히 알려줘서, 문제를 쉽게 인지할 수 있게 도와줘야합니다.
2. 중복을 피해야합니다. 에러를 return할때, 보통은 log를 찍지 않고 Caller에게 다루게 하는것이 좋습니다.
그래서 Caller는 해당 에러를 로깅할지 안할지를 정할 수 있습니다. 혹은 아래와 같은 패키지를 써서 rate-limit 로킹을 할수도 있습니다.
또 다른 선택지로는 recovery를 하거나 프로그램을 멈추도록 할수도 있습니다.
이 모든 경우에 Caller가 logspam을 제어할 수 있다는 의미입니다.
3. Personal data를 로깅하는데에는 조심 해야 합니다.
4. Error 레벨 로그 flush를 유발하며 이는 더 낮은 레벨 로깅보다 비싼 비용입니다. Warn과 Error 사이에서 고민 할 때 warn보다 훨씬 심각한 상황에서만 Error 로그를 출력 해야 합니다.
5. Google에서는 파일에 로깅을 하는것 보다 모니터링 시스템이 더 효과적으로 세팅되어있습니다. expvar라는 메트릭을 올려주는 패키지와 비슷한 역할을 합니다. 따라서, 이런 패키지 또한 고려하는것도 좋은 방법입니다.
Custom Verbosity Level
그리고 Custuom verbos\ity Level을 도입하는것도 좋은 방법입니다.
예를 들면
1. 약간의 추가 정보를 V(1)에 할당
2. Trace용 데이터를 V(2)에 할당
3. Dump용 큰 데이터를 V(3)에 할당
이런 방식으로 Verbosity level을 나눠서 관리할 수 있습니다.
// Good:
for _, sql := range queries {
log.V(1).Infof("Handling %v", sql)
if log.V(2) {
log.Infof("Handling %v", sql.Explain())
}
sql.Run(...)
}
// Bad:
// 로그가 출력되지 않고 sql.Explain()은 실행됩니다.
log.V(2).Infof("Handling %v", sql.Explain())
Golang을 창시한 Google에서 권장 하는 방식을 한번 살펴 보았습니다.
검토하게 된 배경에 적합한 내용도 있고 기존에 생각하지 못했던 접근도 있었어서 한번 정리해 둔 보람이 있는것 같네요.
다음 포스팅에서는 다른 Golang 프로젝트의 사례를 한번 확인 해보려고 합니다.
Reference
'백엔드 > Golang' 카테고리의 다른 글
Golang zero-value 알아보기 (0) | 2024.07.09 |
---|---|
[번역] Golang vs Spring boot native 성능 비교해보기 - Hello world 케이스 (0) | 2024.07.08 |
Golang init() 사용법 및 주의 사항 (0) | 2024.05.16 |
우리 프로젝트에서 Golang DB 처리 시에 GORM을 사용 해야 하는 이유 (0) | 2024.05.01 |
개발 및 IT 관련 포스팅을 작성 하는 블로그입니다.
IT 기술 및 개인 개발에 대한 내용을 작성하는 블로그입니다. 많은 분들과 소통하며 의견을 나누고 싶습니다.