Gin 예외처리 — Part 2. 커스텀 예외처리 구현하기

이전 글에서는 Gin의 라이브러리를 활용하지 않고 예외처리를 구현할 시 발생할 수 있는 문제점에 대해 설명했습니다. 이제부터 Gin에서 제공하는 기능을 활용하여 예외처리를 수행하도록 하겠습니다.

Error Wrapping

Gin에서의 예외 처리

Gin Error() 함수에 의하면 Gin의 Context에 Error를 담은 후에 Middleware에서 처리하는 것을 권장하고 있습니다.

Error attaches an error to the current context. The error is pushed to a list of errors. It’s a good idea to call Error for each error that occurred during the resolution of a request. A middleware can be used to collect all the errors and push them to a database together, print a log, or append it in the HTTP response. Error will panic if err is nil.

라이브러리 코드를 보면Errors필드가 정의되어 있는데 errorMsg는[]*Error에러 리스트로 타입을 가지고 있습니다. 위의 설명대로 error의 리스트가 Context 내부에 구현되어 있는 것입니다.

// gin/context.go
type Context struct {
// ...
Errors errorMsgs
// ..
}

// gin/errors.go
type errorMsgs []*Error

즉,ctx.Error를 활용하여 입력받은 에러를 Gin의 에러로 감싼 후에 Context의 Errors 리스트에 넣게 됩니다.

// gin/context.go
// Error attaches an error to the current context. The error is pushed to a list of errors.// It's a good idea to call Error for each error that occurred during the resolution of a request.// A middleware can be used to collect all the errors and push them to a database together,
// print a log, or append it in the HTTP response.
// Error will panic if err is nil.
func (c *Context) Error(err error) *Error {
if err == nil {
panic("err is nil")
}

var parsedError *Error
ok := errors.As(err, &parsedError)
if !ok {
parsedError = &Error{
Err: err,
Type: ErrorTypePrivate,
}
}

c.Errors = append(c.Errors, parsedError)
return parsedError
}

Context 내부에 있는 Error 리스트는 Middleware에서 처리하게 됩니다.

Gin에서는 HandlerFunc를 slice로 구현된 HandlerChain이 있는데 이는 Gin이 각가지의 Handler를 Chain내에 넣고 처리됩니다.

type HandlersChain []HandlerFunc

그럼 오류를 어떻게 발생시키면 될까? HandlerChain 내에 있는 대기 중인(Pending) Handler를 호출하지 않도록 하면됩니다.

즉, Context를 Abort를 활용하여 이후 처리할 Handler가 없어지면서 종료시키는 것입니다.

Abort prevents pending handlers from being called. Note that this will not stop the current handler. Let’s say you have an authorization middleware that validates that the current request is authorized. If the authorization fails (ex: the password does not match), call Abort to ensure the remaining handlers for this request are not called.

프로젝트에 적용하기

절차

위의 내용을 가지고 실제로 적용해보겠습니다.
프로젝트 구성은 3계층으로 구성되어 있으며 커스텀 에러 감싸기는 서비스 로직에 수행하도록 구현했습니다.

  1. Service 계층에 Error Wrapping을 한다.
  2. Controller에 context 내에 있는 에러 리스트에 예외를 넣는다.
  3. Middleware에 Error Wrapping한 것을 Unwrapping 하면서 예외 타입을 학인한다.
  4. 커스텀 에러라면 WebCode에 따른 응답값을 반환한다.

그림으로 표현하자면 아래와 같습니다.

Custom error 구조도

코드

코드는 아래의 두 사이트를 참고했습니다.

Service Layer

Persistence Layer에서 얻은 에러 값을 직접 받은 후에 Service 계층에서 적절하게 NetError로 감쌌습니다.

// service
func (s *MemberService) CreateMember(ctx *gin.Context, req data.RegisterReq) (*ent.Member, *errorutils.NetError) {
// Check member Exist
existedMem, err := s.Store.GetMemberByEmail(ctx, req.Email)
if err != nil {
return nil, &errorutils.NetError{Code: codes.MemberInternalServerError, Err: err}
}
if ent.IsNotFound(err) {
mem, err2 := s.Store.Create(ctx, req)
if err2 != nil {
return nil, &errorutils.NetError{Code: codes.MemberCreationError, Err: err2}
}
return mem, nil
}
return existedMem, nil
}

Controller Layer

Controller는 Service 계층에서 감싼 커스텀 에러를 받은 후에 Context의 에러 리스트에 넣었습니다.

func (controller *MemberController) RegisterMember(ctx *gin.Context) {
req := data.RegisterReq{}
err := ctx.ShouldBindJSON(&req)
if err != nil {
_ = ctx.Error(errorutils.NewNetError(codes.MemberInvalidJson, err))
return
}

// Create member
mem, err2 := controller.service.CreateMember(ctx, req)
if err2 != nil {
// Service 계층에서 받은 에러를 Context 내 에러 리스트에 넣는다.
_ = ctx.Error(err2)
return
}

mid := data.MemberId{MemberId: mem.ID}
response.SuccessWith(ctx, codes.MemberCreationSuccess, mid)
}

Middleware

에러 응답값을 반환할 HandlerFunc를 구현했습니다.

  1. Next를 활용하여 대기 중인 핸들러를 실행시킨다.
  2. errors.As를 활용하여 Context 에러 리스트에 있는 에러가 커스텀 에러인지 확인한다. 정확히 말하자면 에러를 unwrapping하면서 커스텀 에러인지 확인한 후에 있다면 netError에 넣게 된다.
  3. WebCode를 활용하여 응답값을 얻은 후 AbortWithStatusJson 를 활용하여 Response json을 전송한다.
func ErrorHandler() gin.HandlerFunc {  
return func(ctx *gin.Context) {
// Pending 중인 핸들러 실행
ctx.Next()
// JSON이 두번 쓰이는 것을 대비해서 Body 확인
isBodyWritten := ctx.Writer.Written()
err := ctx.Errors.Last()

if err != nil {
// 커스텀 에러인지 확인
var netErr *errorutils.NetError
if errors.As(err, &netErr) {
code := netErr.GetCode()
statusCode := codes.GetStatus(code)
res := response.NewErrorResponse(code)

// Abort with the appropriate status code and response
if !isBodyWritten {
ctx.AbortWithStatusJSON(int(statusCode), res)
}
} else {
res := response.NewErrorResponse(codes.GlobalInternalServerError)
if !isBodyWritten {
ctx.AbortWithStatusJSON(http.StatusInternalServerError, res)
}
}

}
}
}

마무리

Go의 예외 매커니즘은 다른 언어와 달라서 틀을 잡는데 많은 시간을 사용했습니다. 공식 문서나 다른 사람들의 예제 코드를 분석하면서, Go스러움이 무엇인지 조금 배운 것 같습니다.

<hr><p>Gin 예외처리 — Part 2. 커스텀 예외처리 구현하기 was originally published in S0okJu Technology Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>