API 서버에서의 오류 설계
원문 : https://zenn.dev/ysk1to/articles/dc76ad691606b1
【Go言語】APIサーバにおけるエラー設計
Go言語でAPIサーバを実装する際、エラー設計をどうするか検討してみた。 エラー時のレスポンス関数を定義 はじめに何も考えずに正常系と異常系の関数を作成すると、以下のようになる。 fu
zenn.dev
Go 언어로 API 서버를 구현할 때 오류 설계를 어떻게 해야 할지 고민해 보았다.
func ResponseJson(w http.ResponseWriter, data interface{}, httpStatus int) {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
w.WriteHeader(httpStatus)
if err := json.NewEncoder(w).Encode(data); err != nil {
panic(err)
}
}
func ResponseErrorJson(w http.ResponseWriter, httpStatus int, err error) {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
w.WriteHeader(httpStatus)
w.Write(err.Error())
}
하지만 이것만으로는 오류 발생 시 오류 내용을 그대로 돌려주기 때문에 사용자 친화적인 디자인이라고 할 수 없다.
오류 발생 시 응답 조정
사용자가 이해하기 쉬운 오류 메시지를 반환할 수 있도록, 그리고 프론트에서 오류 내용을 파악할 수 있도록 오류 시 응답을 확장한다.
type ErrorResponse struct {
ErrMessage string `json:"error_message"`
DevErrMessage string `json:"dev_error_message"`
}
func ResponseErrorJson(w http.ResponseWriter, httpStatus int, errMessage string, err error) {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
w.WriteHeader(httpStatus)
w.Write(ErrorResponse{errMessage, err.Error()}))
}
이렇게 하면 오류 발생 시 사용자 친화적인 메시지를 반환할 수 있다.
하지만 오류 발생 시 errMessage와 err의 정합성을 유지하기 위해 중복적인 처리가 전파된다.
예를 들어, controller에서 usecase, repository라고 호출하는 설계에서 아래의 repository에서 오류가 발생했다고 가정해보자.
func (r *UserRepositoryImpl) Find(ctx context.Context, id string) (*model.User, error) {
q := "SELECT id, last_name, first_name, email FROM users WHERE id = ?"
var row *sql.Row
tx := transaction.GetTransaction(ctx)
if tx != nil {
row = tx.QueryRow(q, id)
} else {
row = r.DB.QueryRow(q, id)
}
if row == nil {
return nil, nil
}
var u model.User
if err := ref.Scan(&u.Id, &u.LastName, &u.FirstName, &u.Email); err != nil {
return nil, err
}
return &u, nil
}
여기에 사용자에게 표시할 메시지를 추가해 보자.
func (r *UserRepositoryImpl) Find(ctx context.Context, id string) (*model.User, error, *string) {
q := "SELECT id, last_name, first_name, email FROM users WHERE id = ?"
var row *sql.Row
tx := transaction.GetTransaction(ctx)
if tx != nil {
row = tx.QueryRow(q, id)
} else {
row = r.DB.QueryRow(q, id)
}
if row == nil {
return nil, nil, nil
}
var u model.User
if err := ref.Scan(&u.Id, &u.LastName, &u.FirstName, &u.Email); err != nil {
return nil, err, "예기치 않은 오류가 발생했습니다"
}
return &u, nil, nil
}
추가로 repository를 호출하는 usecase는 다음과 같다.
func (i *UserInteractor) GetUser(ctx context.Context, userId string) (*UserOutput, *Status, error, *string) {
user, err, errMessage := i.r.Find(ctx, userId)
if err != nil {
return nil, &Status{Code: InternalServerError}, err, errMessage
}
output := adaptUserOutput(user)
return output, &Status{Code: OK}, nil, nil
}
본래 error만 있으면 좋은데, 아무 생각 없이 errMessage를 함께 반환하면 중복된 처리를 전파하게 된다.
독자적인 error 타입을 만들어 오류 메시지를 가지게 하기
앞서 언급한 문제를 해결하기 위해 독자적인 error 타입을 생성한다.
Go 언어의 error는 interface로 정의[1]되어 있기 때문에 Error() string 메서드를 정의하면 error로 취급할 수 있다.
이 특성을 활용하여 오류 메시지를 관리할 수 있는 독자적인 error를 다음과 같이 생성한다.
package myerror
import (
"fmt"
)
type ErrorCode int
const (
ErrorCodeInvalidArgument ErrorCode = iota
ErrorCodeNotFound
ErrorCodeServiceUnavailable
)
type MyError struct {
code ErrorCode
message string
stackTrace error
}
func (e *MyError) Error() string {
return e.stackTrace.Error()
}
func (e *MyError) Code() ErrorCode {
return e.code
}
func (e *MyError) Message() string {
return e.message
}
func New(c ErrorCode, e error) error {
return &MyError{
code: c,
stackTrace: e,
}
}
func Errorf(c ErrorCode, e error, format string, a ...interface{}) error {
return &MyError{
code: c,
stackTrace: e,
message: fmt.Sprintf(format, a...),
}
}
func ErrorMessage(err error) *string {
if me, ok := err.(*MyError); ok {
message := me.Message()
if 0 < len(message) {
return &message
}
code := me.Code()
switch code {
case ErrorCodeInvalidArgument:
message = "잘못된 파라미터가 감지되었습니다."
case ErrorCodeNotFound:
message = "지정된 데이터가 존재하지 않습니다."
default:
message = "예기치 않은 오류가 발생했습니다."
}
return &message
}
return nil
}
또한, 자체적으로 error를 처리하는 응답 함수는 다음과 같다.
func ResponseErrorJson(w http.ResponseWriter, statusCode int, err error) {
fmt.Printf("%+v", errors.WithStack(err))
res, err := json.Marshal(ErrorResponse{*myerror.ErrorMessage(err), err.Error()})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
w.WriteHeader(statusCode)
w.Write(res)
}
오류 발생을 감지한 경우
이 자체 오류를 이용하면 예시로 든 repository는 아래와 같이 된다.
func (r *UserRepositoryImpl) Find(ctx context.Context, id string) (*model.User, error) {
q := "SELECT id, last_name, first_name, email FROM users WHERE id = ?"
var row *sql.Row
tx := transaction.GetTransaction(ctx)
if tx != nil {
row = tx.QueryRow(q, id)
} else {
row = r.DB.QueryRow(q, id)
}
if row == nil {
return nil, nil
}
var u model.User
if err := ref.Scan(&u.Id, &u.LastName, &u.FirstName, &u.Email); err != nil {
// 오류 발생(자체 오류 반환)
return nil, myerror.New(myerror.ErrorCodeInternalServerError, err)
}
return &u, nil
}
오류를 감지했을 때 myerror로 error를 wrap시키면 된다.
이때 오류 코드별로 메시지를 미리 정의해 놓았으므로 해당 오류 코드를 설정하면 된다.
비즈니스 로직적으로 오류로 하는 경우
또한, 프로그램적으로 오류는 아니지만, 비즈니스 로직적으로 오류로 처리하고 싶다면 아래와 같이 설정한다.
if err != nil {
return nil, &Status{Code: InternalServerError}, err, errMessage
}
// user를 찾을 수 없는 경우 오류로 간주한다.
if user == nil {
return nil, &Status{Code: NotFound}, myerror.Errorf(myerror.ErrorCodeNotFound, xerror.Errorf("not found user."), "指定されたユーザが見つかりません。"
}
오류 코드에서 정의하는 메시지는 범용적인 메시지를 가정하고 있으므로, usecase에 맞는 메시지를 응답하고 싶다면 myerror.Errorf를 사용해야 한다.