The proposal to enhance Go's HTTP router

Ben Hoyt

Go HTTP 라우터 개선 제안

원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기

Go 표준 라이브러리에는 예전부터 견고하고 프로덕션 수준의 HTTP 서버가 포함되어 있었다. 하지만 내장 요청 라우터인 http.ServeMux는 너무 미니멀해서 직접 라우팅 코드를 작성해야 하는 경우가 많다.

특히 HTTP 메서드 기반 매칭(예를 들어 GETPOST를 구분하는 기능)을 지원하지 않고, /users/{user}/settings 같은 와일드카드 경로도 지원하지 않는다. 이 두 기능은 거의 모든 REST 스타일 API 서버에 필요하다.

물론 이런 기능은 직접 구현할 수도 있다. 이전에 Go에서 HTTP 라우팅을 다루는 다양한 방법에 대해 쓴 적이 있는데, 더 발전된 라우팅을 제공하는 훌륭한 서드파티 패키지들이 여럿 있고, 서드파티 라이브러리 없이도 30줄 정도의 코드만으로 비슷한 기능을 추가할 수 있다.

하지만 이런 우회 방법이나 서드파티 패키지가 머지않아 필요 없어질지도 모른다. HTTP 메서드와 와일드카드 경로를 매칭할 수 있도록 ServeMux를 개선하자는 진행 중인 제안이 있으며, 레퍼런스 구현까지 함께 나와 있다.

이 제안과 그에 앞선 논의는 Google Go 팀의 Jonathan Amsterdam이 주도하고 있다. Jonathan은 표준 라이브러리에 구조적 로깅을 추가하자는 제안을 성공적으로 이끈 인물로, 그가 만든 log/slog 패키지는 Go 1.21(2023년 8월 출시 예정)에 포함될 예정이다.

어떤 모습일까

현재 /users/{user}/settings 경로로 들어오는 GET 요청을 처리하려면 다음과 같이 보일러플레이트 코드를 잔뜩 작성해야 한다(실제로는 서드파티 라이브러리를 쓰게 될 가능성이 크지만):

mux.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
    if r.Method != "GET" {
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }
    remainder := r.URL.Path[len("/users/"):]
    userId, subPath, _ := strings.Cut(remainder, "/")
    switch subPath {
    case "settings":
        fmt.Fprintf(w, "user %s", userId)
    // cases for other sub-paths could go here
    default:
        http.NotFound(w, r)
    }
})

제안이 받아들여지면 이렇게 쓸 수 있게 된다:

mux.HandleFunc("GET /users/{user}/settings", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "user %s", r.PathValue("user"))
})

훨씬 깔끔하다!

다른 인기 라우터들이 사용하는 문법과도 매우 유사하다:

// github.com/go-chi/chi
router.Get("/users/{user}/settings", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "user %s", chi.URLParam(r, "slug"))
})

// github.com/gorilla/mux
router.HandleFunc("/users/{user}/settings", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "user %s", mux.Vars(r)["user"])
}).Methods("GET")

// github.com/bmizerany/pat
router.Get("/users/:user/settings", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "user %s", r.URL.Query().Get(":user"))
}))

// github.com/gin-gonic/gin
router.GET("/users/:user/settings", func(c *gin.Context) {
    fmt.Fprintf(w, "user %s", c.Param("user"))
})

이 제안에서 흥미로운 결정 중 하나는 ServeMux에 새로운 메서드를 추가하지 않는다는 점이다. 대신 기존 HandleHandleFunc 메서드를 확장해 메서드 접두사와 {wildcard} 경로 세그먼트를 허용하는 방식이다.

새 메서드를 추가하지 않으려는 의도는 이해하지만, 이 결정에 대해서는 확신이 서지 않는다. 안타깝게도 기존 버전의 ServeMuxHandle("GET /foo", h) 같은 패턴을 그냥 받아들인다. 그러면 개선된 ServeMux용으로 작성된 코드가 이전 버전의 Go에서도 컴파일되고 겉보기에는 정상적으로 실행되는 것처럼 보이지만, 실제로는 어떤 라우트도 매칭되지 않을 것이다. 오류가 발생하기 쉬운 부분이다. 나라면 HandleMatch / HandleMatchFuncRoute / RouteFunc 같은 새로운 메서드를 추가했을 것 같다.

제안서에는 두 패턴이 겹칠 때 우선순위를 어떻게 처리하는지에 대한 긴 설명이 있지만, 결국 간단한 규칙 하나로 요약된다. “두 패턴이 겹친다면(공통으로 처리하는 요청이 있다면), 더 구체적인 패턴이 우선한다.”

예를 들어 /users/(/users/*와 매칭되는 패턴)와 /users/{user} 패턴을 함께 등록했을 때, /users/ben 요청이 들어오면 더 구체적인 두 번째 패턴이 매칭된다. 이는 기존 ServeMux에서 호스트가 지정된 패턴이 호스트가 없는 패턴보다 우선하는 방식과 유사하다.

URL 끝을 매칭하는 와일드카드

이 제안은 URL의 끝에만 매칭되는 “특수 와일드카드”인 {$}도 추가한다. 이는 홈페이지처럼 정확히 하나의 경로에만 매칭시키고 싶은 라우트에 주로 유용하다. 현재는 이게 의외로 번거로운데, /로 끝나는 패턴은 / 하위의 모든 경로와 매칭되고, 이는 / 단독 패턴에도 동일하게 적용되기 때문이다.

그래서 현재는 홈페이지에만 매칭시키려면 이렇게 해야 한다:

mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    if r.URL.Path != "/" { // ensure path is exactly "/"
        http.NotFound(w, r)
        return
    }
    serveHomepage(w, r)
})
mux.HandleFunc("/users", serveUsers)

이 방식은 번거롭고, 경로 검사를 빼먹으면 / 아래에 모든 것이 포함되므로 다른 URL에 대해서도 찾을 수 없음 페이지를 내보내는 대신 홈페이지가 서빙되는 결과가 된다.

새 제안에서는 훨씬 간단해진다:

mux.HandleFunc("/{$}", serveHomepage)
mux.HandleFunc("/users", serveUsers)

레퍼런스 구현

Jonathan은 개선된 ServeMux의 샘플 구현을 github.com/jba/muxpatterns 패키지에 작성해 두었다. 별도 패키지라 http.Request 타입 자체를 바꿀 수 없다는 점만 다를 뿐이며, 따라서 경로 값은 request.PathValue("name") 대신 mux.PathValue(request, "name") 형태로 가져와야 한다.

나는 내 go-routing 저장소에 PR을 올려 muxpatterns를 사용한 위젯 API 버전을 추가했다. chi 버전과 매우 유사하고, 단순하고 읽기 쉽다:

r.HandleFunc("GET /{$}", home)
r.HandleFunc("GET /contact", contact)
r.HandleFunc("GET /api/widgets", apiGetWidgets)
r.HandleFunc("POST /api/widgets", apiCreateWidget)
r.HandleFunc("POST /api/widgets/{slug}", apiUpdateWidget)
r.HandleFunc("POST /api/widgets/{slug}/parts", apiCreateWidgetPart)
r.HandleFunc("POST /api/widgets/{slug}/parts/{id}/update", apiUpdateWidgetPart)
r.HandleFunc("POST /api/widgets/{slug}/parts/{id}/delete", apiDeleteWidgetPart)
r.HandleFunc("GET /{slug}", widgetGet)
r.HandleFunc("GET /{slug}/admin", widgetAdmin)
r.HandleFunc("POST /{slug}/image", widgetImage)

사실 처음 테스트했을 때 레퍼런스 구현에서 몇 가지 사소한 버그를 발견했지만, 이미 수정되었다.

결론

기존 HandleHandleFunc 메서드를 확장하는 방식에 대해서는 다소 유보적인 입장이지만, 이런 제안이 논의되고 있다는 점 자체는 매우 반갑다. Jonathan이 제안에 들인 공과 log/slog에서의 성과, 그리고 커뮤니티의 긍정적인 반응을 고려하면 제안이 받아들여질 가능성은 높아 보인다.

이 기능이 표준 라이브러리에 들어오면 정말 좋을 것이다. 내가 개발했던 거의 모든 웹사이트와 REST 스타일 API에 이 기능이 필요했기 때문이다. Go 표준 라이브러리만으로도 이미 많은 것을 할 수 있지만, 이 개선이 이루어지면 서드파티 라우터의 필요성은 거의 완전히 사라질 것이다.

아마 Go 1.22(2024년 2월 출시 예정)에 포함되어도 전혀 놀랍지 않을 것이다. 두고 봐야겠지만 말이다!

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글