Gin 예외처리 — Part 1. 프로젝트 코드로 살펴보는 예외처리 문제점

Go와 예외처리

일반적인 Go의 예외처리 방법

Go에서는 함수에서 반환된 에러 객체(error)로 처리합니다. 다행히도 multi-return이 가능하기에 에러 반환을 더욱 수월하게 해줄 수 있습니다.

f, err := Sqrt(-1)
if err != nil {
fmt.Println(err)
}

try ~ catch가 없는 이유

공식 문서에 의하면 try ~ catch는 난해한 코드를 생성하며, 개발자에게 너무많은 일반적인 예외를 처리하도록 장려합니다.

We believe that coupling exceptions to a control structure, as in the try-catch-finally idiom, results in convoluted code. It also tends to encourage programmers to label too many ordinary errors, such as failing to open a file, as exceptional.

다른 언어처럼 try ~ catch 를 어렴풋이 구현할 수 있습니다. 바로 중간에 실행의 흐름을 끊는panic함수를 사용하는 것입니다.
반대로 생각하자면 모든 에러들을 panic으로 처리해야 할까? 라고 생각하면 좋은 선택지는 아니라고 생각합니다.
이런 이유로 Go는 시의적절하기 예외처리할 수 있도록 error를 반환하는 방식으로 처리합니다.

Error Wrapping

Error Wrapping이란 쉽게 말하자면 error 객체를 감싸는 또다른 구조체를 만드는 것입니다.
Go에서는 에러처리할 때 Error 객체를 넘겨줍니다. 물론 일반 에러 객체를 넘겨줄 수 있지만 개발자가 직접 만든 에러를 만들어서 넘겨줄 수 있습니다.

gin에서의 Error를 확인하겠습니다. gin의 Error 내에 필드로 error가 존재합니다. 아래 코드와 같은 과정을 Error Wrapping이라고 보면 됩니다.

// Error represents a error's specification.
type Error struct {
Err error
Type ErrorType
Meta any
}

ctx.Error를 실행했는데 의도치 않게 errors.As가 적절하게 실행되지 않는다고 가정하겠습니다.error.As는 Error Type을 확인하는 함수인데, 만약에 타입이 적절하지 않는다면, 입력한 error을 감싼 Error를 반환하게 됩니다.

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
}

원본 에러(error)는 Unwrap()함수를 통해 얻을 수 있습니다.

// Unwrap returns the wrapped error, to allow interoperability with errors.Is(), errors.As() and errors.Unwrap()  
func (msg *Error) Unwrap() error {
return msg.Err
}

Error의 구조를 정리하면 아래와 같습니다.

Error 구조

프로젝트 코드의 문제점

Gin Context의 잘못된 활용

공식 문서에서 말하는 Context는 데드라인, 취소 시그널, API에 대한 경계값을 가지는 값으로 정의합니다. 초반에는 조건에 따라 실행이 중단될 수 있다는 것으로 이해했습니다.

gin은 자체적인 Context를 가지고 있으며, context를 중단시킬 수 있는 여러 함수들이 존재합니다. gin에서 제공하는 context를 활용하여 Service Layer에서 커스텀 에러 타입으로 반환하도록 구현해보겠습니다.

func (controller *MemberController) RegisterMember(ctx *gin.Context, req request.RegisterReq) {
req = request.RegisterReq{}
err := ctx.ShouldBindJSON(req)
// ...
// Create member
err2 := controller.service.CreateMember(ctx, req)
if err2 != nil {
errorutils.ErrorFunc(ctx, err2)
return
}
webutils.Success(ctx)
}

커스텀 에러 타입을 자세히 보면, 자체적으로 제작한 에러 코드와 error을 담을 Err 필드가 존재합니다.

type Error struct {
// Code is a custom error codes
ErrorType ErrorType
// Err is a error string
Err error
// Description is a human-friendly message.
Description string
}

애플리케이션에 오류 발생시 현재 실행을 멈추고, 응답값을 보내는 ErrorFunc 함수도 만들었습니다.

func ErrorFunc(ctx *gin.Context, err *Error) {
res := getCode(err.ErrorType)

ctx.AbortWithStatusJSON(res.Code, res)
return
}

공식 문서 에 의하면AbortWithStatusJSON에는 내부적으로 Context를 중단시킬 수 있는Abort 함수를 사용합니다. 구체적으로 Abort 함수는 현재의 handler는 그대로 남지만, 그 이후의 handler를 처리하지 않겠다는 것이다. 즉, Abort()를 실행한 이후에도 남은 코드가 실행되는 것입니다.

Gin Error 미사용

공식 문서에 의하면 Gin은 자신들의 Error를 사용하는 것을 권장하며, middleware가 이를 처리하여 오류 response를 처리하라고 명시되어 있습니다.

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.

즉, 오류가 발생할때마다 gin의 Context에서 제공해주는 Error로 감싸며, Middleware에 있는 Handler가 이를 순차적으로 처리해야 한다는 것입니다.

2부에서는

지금까지는 내가 만들었던 예외처리에는 어떠한 문제점이 있는지 확인해봤습니다.

2부에서는 위에서 설명한 잘못된 에러처리를 공식문서에서 제시한 올바른 에러처리를 구현해보겠습니다.

<hr><p>Gin 예외처리 — Part 1. 프로젝트 코드로 살펴보는 예외처리 문제점 was originally published in S0okJu Technology Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>