HTTPエラー処理の簡素化 + RFC7807
개요
Go 언어에서 http api를 생성할 때, 가장 귀찮은 건 에러 처리입니다. 대표적으로 이런 코드가 있습니다.
1func(w http.ResponseWriter, r *http.Request) {
2 err := doSomething()
3 if err != nil {
4 http.Error(w, err.Error(), http.StatusInternalServerError)
5 log.Printf("error occurred: %v", err)
6 return
7 }
8 // ...
9}
API가 몇개 되지 않는 다면, 이런 식으로 작성해도 딱히 불편한 건 없을 겁니다. 다만, API 수가 늘어나고 내부 로직이 복잡해질 수록 세가지가 거슬리게 됩니다.
- 적절한 에러 코드 반환
- 많은 결과 로그 작성 수
- 명확한 에러 메시지 전송
본론
적절한 에러 코드 반환
물론 1번, 적절한 에러 코드 반환은 제 개인적인 불만 사항이긴 합니다. 숙련된 개발자라면 적절한 코드를 찾아서 매번 잘 찾아 넣을 겁니다만, 저도 그렇고 아직 미숙한 개발자들은 로직이 복잡해지고, 회수가 많아질 수록 적합한 에러 코드를 규칙적으로 쓰는 것에 어려움을 겪을 수 있습니다. 이에 대해 여러 방법이 있을 거고, 가장 대표적으로 미리 API 로직 흐름을 설계한 후 적절한 에러를 반환하도록 코드를 작성하는 것이 있을 겁니다. 그렇게 하십시오
하지만 이는 IDE(혹은 Language Server)의 도움을 받는 인간 개발자에게 최적의 방법으로 보이진 않습니다. 또한 REST API 자체가 에러 코드에 담긴 의미를 최대한 활용하는 만큼, 또 다른 방식을 제안할 수 있을 겁니다. HttpError라는 에러(error) 인터페이스 구현체를 새로 만들어, StatusCode와 Message를 저장하게 합니다. 그리고 다음과 같은 헬퍼 함수를 제공합니다.
1err := httperror.BadRequest("wrong format")
BadRequest 헬퍼 함수는 StatusCode로 400, Message를 인자로 받은 값으로 설정한 HttpError를 반환할 겁니다. 이 외에도 당연히 NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired 등의 헬퍼 함수를 자동 완성 기능으로 조회 및 추가할 수 있을 겁니다. 이는 준비된 설계서를 매번 확인하는 것보다 빠르며, 매번 숫자로 에러 코드를 입력하는 것보다 안정적일 겁니다。 http.StatusCode 상수에 다 있다구요? 쉿
많은 결과 로그 작성 수
에러 발생 시 당연히 로그를 남기게 됩니다. API가 호출되고, 요청이 성공했는지, 실패했는지에 대해 로그를 남길 때에 시작부터 모든 예상 종료 지점에 로그를 남기는 건 작성할 코드 수가 많아집니다. 이를 핸들러 자체를 한번 감싸면서, 중앙에서 관리할 수 있게 됩니다.
다음은 chi 라우터를 감싸는 예시입니다.
1package chiwrap
2
3import (
4 "errors"
5 "net/http"
6
7 "github.com/go-chi/chi/v5"
8
9 "github.com/gosuda/httpwrap/httperror"
10)
11
12type Router struct {
13 router chi.Router
14 errCallback func(err error)
15}
16
17func NewRouter(errCallback func(err error)) *Router {
18 if errCallback == nil {
19 errCallback = func(err error) {}
20 }
21 return &Router{
22 router: chi.NewRouter(),
23 errCallback: errCallback,
24 }
25}
26
27type HandlerFunc func(writer http.ResponseWriter, request *http.Request) error
28
29func (r *Router) Get(pattern string, handler HandlerFunc) {
30 r.router.Get(pattern, func(writer http.ResponseWriter, request *http.Request) {
31 if err := handler(writer, request); err != nil {
32 he := &httperror.HttpError{}
33 switch errors.As(err, &he) {
34 case true:
35 http.Error(writer, he.Message, he.Code)
36 case false:
37 http.Error(writer, err.Error(), http.StatusInternalServerError)
38 }
39 r.errCallback(err)
40 }
41 })
42}
라우터 구조체는 chi.Router를 내부에 가지고 있어서, chi.Router의 기능을 그대로 사용하게 구성됩니다。 Get 메서드를 보시면, 방금 위에서 제안드린 헬퍼 함수가 반환하는 HttpError 구조체가 반환되었는지 체크 후 적절히 반환하고, error일 경우엔 일괄적으로 에러 콜백 함수로 전달하게 됩니다. 이 콜백은 생성자를 통해 입력 받습니다.
다음은 이 패키지를 활용하여 작성한 코드입니다.
1package main
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log"
9 "net/http"
10
11 "github.com/gosuda/httpwrap/httperror"
12 "github.com/gosuda/httpwrap/wrapper/chiwrap"
13)
14
15func main() {
16 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
17 defer cancel()
18
19 r := chiwrap.NewRouter(func(err error) {
20 log.Printf("Router log test: Error occured: %v", err)
21 })
22 r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
23 name := request.URL.Query().Get("name")
24 if name == "" {
25 return httperror.BadRequest("name is required")
26 }
27
28 writer.Write([]byte("Hello " + name))
29 return nil
30 })
31
32 svr := http.Server{
33 Addr: ":8080",
34 Handler: r,
35 }
36 go func() {
37 if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
38 log.Fatalf("Failed to start server: %v", err)
39 }
40 }()
41
42 <-ctx.Done()
43 svr.Shutdown(context.Background())
44}
어떤가요? 단순히 HttpError만 헬퍼 함수로 만들어 반환하면, 상위 스코프에서 적절한 에러 코드와 메시지로 응답을 돌려주면서 구현 서비스 마다 적절한 로그를 남길 수 있도록 콜백을 등록하여 처리할 수 있습니다. 추가적으로 필요하다면 확장해서, RequestID 등을 사용하여 상세한 로깅이 가능할 겁니다.
명확한 에러 메시지 전송
이를 위한 문서로 RFC7807이 있습니다. RFC7807은 주로 다음과 같은 요소를 정의하여 사용합니다.
type: エラータイプを識別するURI。主にエラーについて説明するドキュメントです。title: どのようなエラーであるかについての簡潔な説明です。status: HTTP Status Codeと同じです。detail: そのエラーについて人間が読める詳細な説明です。instance: エラーが発生したURIです。例えばGET /user/infoでエラーが発生した場合、/user/infoがその値になります。extensions: JSON Object形式で構成される、エラーを説明するための補助的な要素です。- 例えば、
BadRequestの場合にはユーザーの入力が含まれることがあります。 - あるいは
TooManyRequestの場合には、最も最近のリクエスト時点が含まれることもあります。
- 例えば、
これを簡単に使用するために、HttpErrorと同じ場所であるhttperrorパッケージに新しいファイルを生成し、RFC7807Error構造体を生成し、メソッドチェインパターンで生成できるようにします。
1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
2 return &RFC7807Error{
3 Type: "about:blank", // Default type as per RFC7807
4 Title: title,
5 Status: status,
6 Detail: detail,
7 }
8}
9
10func BadRequestProblem(detail string, title ...string) *RFC7807Error {
11 t := "Bad Request"
12 if len(title) > 0 && title[0] != "" {
13 t = title[0]
14 }
15 return NewRFC7807Error(http.StatusBadRequest, t, detail)
16}
17
18func (p *RFC7807Error) WithType(typeURI string) *RFC7807Error { ... }
19func (p *RFC7807Error) WithInstance(instance string) *RFC7807Error { ... }
20func (p *RFC7807Error) WithExtension(key string, value interface{}) *RFC7807Error { ... }
Typeの"about:blank"はデフォルト値です。これは存在しないページを意味します。以下は不正なリクエストに対するエラー生成の例です。
1problem := httperror.BadRequestProblem("invalid user id format", "Bad User Input")
2
3problem = problem.WithType("https://example.com/errors/validation")
4 .WithInstance("/api/users/abc")
5 .WithExtension("invalid_field", "user_id")
6 .WithExtension("expected_format", "numeric")
簡単なメソッドチェインにより、ユーザーに対する構造化されたエラーメッセージを生成できます。また、先に記述した中央化されたルーターを利用するために、以下のメソッドをサポートできます。
1func (p *RFC7807Error) ToHttpError() *HttpError {
2 jsonBytes, err := json.Marshal(p)
3 if err != nil {
4 // If marshaling fails, fall back to just using the detail
5 return New(p.Status, p.Detail)
6 }
7 return New(p.Status, string(jsonBytes))
8}
これをそのまま使用して上記の例を修正するとこのようになります。
1package main
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log"
9 "net/http"
10
11 "github.com/gosuda/httpwrap/httperror"
12 "github.com/gosuda/httpwrap/wrapper/chiwrap"
13)
14
15func main() {
16 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
17 defer cancel()
18
19 r := chiwrap.NewRouter(func(err error) {
20 log.Printf("Router log test: Error occured: %v", err)
21 })
22 r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
23 name := request.URL.Query().Get("name")
24 if name == "" {
25 return httperror.BadRequestProblem("name is required", "Bad User Input").
26 WithType("https://example.com/errors/validation").
27 WithInstance("/api/echo").
28 WithExtension("invalid_field", "name").
29 WithExtension("expected_format", "string").
30 WithExtension("actual_value", name).
31 ToHttpError()
32 }
33
34 writer.Write([]byte("Hello " + name))
35 return nil
36 })
37
38 svr := http.Server{
39 Addr: ":8080",
40 Handler: r,
41 }
42 go func() {
43 if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
44 log.Fatalf("Failed to start server: %v", err)
45 }
46 }()
47
48 <-ctx.Done()
49 svr.Shutdown(context.Background())
50}
결론
このように中央化されたルーターを使用してエラーを処理すると、毎回エラーコードを確認し、適切なエラーメッセージを作成する負担を軽減できます。また、RFC7807を活用して構造化されたエラーメッセージを提供することで、クライアントがエラーを理解し処理するのに役立ちます。これらの方法により、Go言語で作成されたHTTP APIのエラー処理をより簡単かつ一貫性のあるものにできます。
当該記事のコードはgosuda/httpwrapリポジトリで確認できます。