Search

Go Fiber Error Handler 커스텀하기

주제
Backend
날짜
2024/01/23

Fiber의 Default Error Handler

Go fiber에서 기본적으로 제공해주는 error handler는 여러 제약이 많았다.
error를 string화 하여 평문으로 그대로 Response하기에 클라이언트에게 불필요한 정보를 과다하게 주거나 보기 불편하다.
DB error의 경우 column이 노출될 위험이 있다. (DB error를 막상 고의로 낼려고 하니 잘 안나서 column 이름에 오타를 내보았다)
"failed to find all wallet summary: Error 1054 (42S22): Unknown column 'statuss' in 'where clause'"
Plain Text
복사
그러기에 많은 개발자들이 Custom Error Handler를 만든다.
fiber 공식 문서에 Custom Error Handler를 만드는 방법에 대해 아주 간략하게 나와있다.
다만, 해당 내용만으로 개발자가 원하는 Custom Error Handler를 만들기 힘들기에 내가 Custom Error Handler를 만든 경험을 공유하고자 글을 적는다.

Custom Error Handler

먼저 내가 만들고 싶은 Error Handler에 대해 정의했다.
1.
Error는 Text가 아닌 JSON으로 response한다.
2.
Custom Error Code를 선언해 해당 error code를 기반으로 HTTP Status Code를 response한다.
3.
Zap을 이용해 Error Logging을 Kibana에서 보기 편하게 JSON 구조로 로깅한다.

Error 구조 정의

type Error struct { Code errorCode // string, ex) 400.000 Err error Data interface{} }
Go
복사
Code: 사전에 정의된 Custom Error Code.
Err: Golang 기본 error type.
Data: 필요한 경우 부가 정보를 포함한다.

Error Response 구조 정의

type ErrorResponse struct { Code string `json:"code"` Message string `json:"message"` Data interface{} `json:"data"` }
Go
복사
4xx, 5xx Status Code가 응답되며, 추가적인 error code와 message도 포함된다.
Property
Type
Description
Note
code
string
에러 코드
Custom Error Code. HTTP Status Code + xxx(000을 시작으로 3자리 숫자)
message
string
에러 메시지
Client 개발자를 위한 지정된 에러 메시지.
data
object
추가적인 데이터
optional

Respone Eample

// data가 없는 경우 { "code": "400.000", "message": "Bad Request" "data": null } // data가 있는 경우 { "code": "400.001", "message": "Email or token is not valid", "data": { "try_count": 3 } } // error detail을 전달해야하는 경우 data에 포함 (주로 validator에서 발생) { "code": "400.007", "message": "Invalid parameter provided", "data": { "error": "1 error occurred:\n\t* Key: 'GetWalletListParam.ProductGroupType' Error:Field validation for 'ProductGroupType' failed on the 'required' tag\n\n" } }
JSON
복사

Error Log 구조 정의

기존에 Fiber에 Zap을 사용하기 위한 공식 라이브러리 fiberzap을 이용해 로깅을 하고 있었다.
하지만 fiberzap 경우 Error를 단순히 String으로 로깅을 하고 있었다.
case "error": if chainErr != nil { fields = append(fields, zap.String("error", chainErr.Error())) }
Go
복사
이 경우 에러가 만약 Object일 경우 가독성이 좋지 않으며, 또한 Kibana에서 로그를 찾거나 분류할 때 여러 불편함이 있다.
그렇기에 가장 먼저 에러를 JSON 형식으로 로깅할 수 있도록 에러 추출 코드와 fiberzap 라이브러리 코드를 수정했다.
type ErrorLog struct { Code string `json:"code"` Message string `json:"message"` Error string `json:"error"` Data interface{} `json:"data"` } func (e *Error) Error() string { log := ErrorLog{ Code: string(e.Code), Message: ErrorMessage()(e.Code), Error: e.Err.Error(), Data: e.Data, } jsonBytes, _ := json.Marshal(log) return string(jsonBytes) }
Go
복사
Custom Error Log Struct
case "error": if chainErr != nil { var log exception.ErrorLog if err = json.Unmarshal([]byte(chainErr.Error()), &log); err != nil { fields = append(fields, zap.String("error", chainErr.Error())) } else { fields = append(fields, zap.Reflect("error", log)) } }
Go
복사
Fiberzap Library
그 결과, 사전에 정의한 Error Struct로 에러가 반환될 경우 JSON 형식으로 변환하여 로깅한다.
그 이외의 에러의 경우 기존과 마찬가지로 String으로 로깅을 한다.

Error Handler 개발

이제 본격적으로 Error Handler를 개발한다.
Error Handler는 Middleware의 일종으로 가장 밑단에서부터 올라온 에러를 식별하여 정제 후 response한다.
가장 밑단이란 MVC의 경우 Repository에서 발생한 에러의 경우 Repository → Service → Controller → Error Handler 까지 와서 처리된다.
가장 먼저 반환된 에러가 사전의 정의한 Error Struct와 아닐 경우에 대해 따로 처리하도록 했다.
Custom Error가 아닐 경우 Custom Error 형식에 맞춰 변환 후 반환하도록 한다.
var customError *exception.Error if errors.As(err, &customError) { errorCode := extractStatusCode(customError, fiber.StatusInternalServerError) return errorCode, exception.GenerateErrorResponse(*customError) } var fiberErr *fiber.Error if errors.As(err, &fiberErr) { return fiberErr.Code, fiber.Map{ "code": fiberErr.Code, "error": fiberErr.Message, "data": fiber.Map{ "error": fiberErr.Error(), }, } }
Go
복사
만약 사전에 정의한 Error Struct(Custom Error)일 경우 Custom Error Code에서 HTTP Status Code만을 분리해 HTTP 전송시에 사용한다.
// extractStatusCode extracts the status code from a custom error. func extractStatusCode(customError *exception.Error, defaultErrorCode int) int { if parts := strings.Split(string(customError.Code), "."); len(parts) > 0 { if code, err := strconv.Atoi(parts[0]); err == nil { return code } } return defaultErrorCode }
Go
복사

Error Handler 전체 코드

// ErrorHandler handles errors for the Fiber application. func ErrorHandler(c *fiber.Ctx, err error) error { c.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON) errorCode, errorResponse := determineErrorResponse(err) return c.Status(errorCode).JSON(errorResponse) } // determineErrorResponse determines the appropriate HTTP status code and error response based on the error type. func determineErrorResponse(err error) (int, interface{}) { var customError *exception.Error if errors.As(err, &customError) { errorCode := extractStatusCode(customError, fiber.StatusInternalServerError) return errorCode, exception.GenerateErrorResponse(*customError) } var fiberErr *fiber.Error if errors.As(err, &fiberErr) { return fiberErr.Code, fiber.Map{ "code": fiberErr.Code, "error": fiberErr.Message, "data": fiber.Map{ "error": fiberErr.Error(), }, } } return fiber.StatusInternalServerError, fiber.Map{ "code": fiber.StatusInternalServerError, "error": "Internal Server Error", "data": fiber.Map{ "error": err.Error(), }, } } // extractStatusCode extracts the status code from a custom error. func extractStatusCode(customError *exception.Error, defaultErrorCode int) int { if parts := strings.Split(string(customError.Code), "."); len(parts) > 0 { if code, err := strconv.Atoi(parts[0]); err == nil { return code } } return defaultErrorCode }
Go
복사
Custom Error Handler

Error Handler를 Fiber에 등록

app := fiber.New(fiber.Config{ AppName: "go-boilerplate", ServerHeader: "go-boilerplate", Prefork: false, // This will spawn multiple Go processes listening on the same port. CaseSensitive: true, // When disabled, /Foo and /foo are treated the same. StrictRouting: true, // When disabled, the router treats /foo and /foo/ as the same. UnescapePath: true, // url decoded path, ctx.Params(%key%) ErrorHandler: exception.ErrorHandler, })
Go
복사

Error Wrap

cockroachdb/errors

기존에는 cockroachdb/errors 라이브러리를 이용해 error를 wrapping 했다.
return errors.WithStack(err)
return errors.Wrap(err, “message”)
하지만 이럴 경우 HTTP Status Code를 첨부할 수 없었다.
그래서 errors 라이브러리를 좀 더 조사해보니 exthttp라는 package가 있고 여기서 HTTP Status Code를 함께 Wrap할 수 있는 함수를 발견했다.
// withHTTPCode is our wrapper type. type withHTTPCode struct { cause error code int } // WrapWithHTTPCode adds a HTTP code to an existing error. func WrapWithHTTPCode(err error, code int) error { if err == nil { return nil } return &withHTTPCode{cause: err, code: code} } // GetHTTPCode retrieves the HTTP code from a stack of causes. func GetHTTPCode(err error, defaultCode int) int { if v, ok := markers.If(err, func(err error) (interface{}, bool) { if w, ok := err.(*withHTTPCode); ok { return w.code, true } return nil, false }); ok { return v.(int) } return defaultCode }
Go
복사
다만 해당 함수를 사용할 경우 사전에 정의한 Status Code 형식에는 대응되지 않는 문제점과 Error 구조를 Custom할 수 없는 문제점이 있다.
Custom Status Code: 400.000
Custom Error Struct
type Error struct { Code errorCode // string, ex) 400.000 Err error Data interface{} }
Go
복사

Custom Error Wrap

그렇기에 자체적으로 간단한 Wrap 함수를 만들었다.
func Wrap(code errcode.ErrorCode, err error) error { if err == nil || (reflect.ValueOf(err).Kind() == reflect.Ptr && reflect.ValueOf(err).IsNil()) { return nil } return &Error{ Code: code, Err: err, } } func WithData(code errcode.ErrorCode, err error, data interface{}) error { if err == nil || (reflect.ValueOf(err).Kind() == reflect.Ptr && reflect.ValueOf(err).IsNil()) { return nil } return &Error{ Code: code, Err: err, Data: data, } }
Go
복사
에러가 발생한 곳에서 exception.Wrap()을 통해 에러를 return한다.
한번 Wrap한 에러는 그 뒤에 cockroachdb/errors 라이브러리를 통해 errors.WithStack(err)을 이용할 수 있다.
error의 내용이 필요한 경우 마지막에 data field에 fiber.Map{"error": err.Error()}를 통해 error 내용을 첨부 할 수 있다.
data를 첨부할 경우 WithData()를 사용한다.
return exception.WithData(storeErr.BadRequest, errors.WithStack(err), data)
Go
복사

예시

// Service Layer func (s tempService) Test() error { err := errors.New("test error") // Custom Error Wrap return exception.Wrap(storeErr.BadRequest, errors.WithStack(err), fiber.Map{"error": err.Error()}) } // Controller Layer func (ctrl tempController) Test(c *fiber.Ctx) error { err := ctrl.service.Test() if err != nil { // cockroachdb/errors return errors.WithStack(err) } return c.SendStatus(http.StatusOK) } // response { "code": "400.000", "message": "Bad Request", "data": { "error": "test error" } }
Go
복사

주의 사항

한번 Wrap한 에러를 다시 Wrap한 후 Response시 이전 에러 내용이 덮어씌어지면서 사라진다.
이와 별개로 Log에서는 모든 에러가 찍힌다.
Reponse가 이전에 Wrap한 BadRequest가 아닌 마지막에 Wrap한 InternalServerError를 기준으로 나가는 것을 확인하실 수 있다.
// Service Layer func (s tempService) Test() error { err := errors.New("test error") return exception.Wrap(storeErr.BadRequest, errors.WithStack(err), fiber.Map{"error": err.Error()}) } // Controller Layer func (ctrl tempController) Test(c *fiber.Ctx) error { err := ctrl.service.Test() if err != nil { return exception.Wrap(storeErr.InternalServerError, err, nil) } return c.SendStatus(http.StatusOK) } // response { "code": "500.000", "message": "Internal Server Error", "data": null }
Go
복사
Error Log
2번 Wrap되었기에 error안의 error field에 400.000 error가 들어가 있는 것을 확인할 수 있다.
만약 Wrap하지 않은 에러를 return할 경우 500.000이 아닌 500 에러를 response한다.
// Controller Layer func (ctrl tempController) Test(c *fiber.Ctx) error { err = errors.New("test") if err != nil { return errors.WithStack(err) } return c.SendStatus(http.StatusOK) } // Response { "code": 500, "data": { "error": "test" }, "error": "Internal Server Error" }
Go
복사
전체 프로젝트는 아래 링크에서 확인할 수 있다.
errors
cockroachdb