let's go를 배워보자 6일차 - Displaying Dynamic Data

0

lets-go

목록 보기
6/15

Middleware

웹 사이트를 만들다보면 수많은 http request들에 사용하고 싶은 일부 공유되는 기능들이 있다. 가령, 매번 request를 로깅하고 싶다던가, handler에 request를 전달하기 전에 cache 를 체크하던가 모든 response를 compress하고 싶다던가..

이렇게 공유되는 기능들을 조직화하는 흔한 방법은 middleware로 이를 구성하는 것이다. middleware는 본질적으로, 우리의 application handler 앞, 뒤에 전달되는 reuqest에 대해 독립적으로 동작하는 self-contained code이다.

1. How Middleware Works

go web application은 ServeHTTP() 메서드들의 체인으로서 이루어져 ServeHTTP()를 누군가 부르면 다른 누군가는 또 다른 ServeHTTP()를 부르는 구조이다.

현재, 우리의 application은 우리의 서버가 새로운 HTTP request를 받을 때마다, servemux의 ServeHTTP()메서드가 호출된다. ServeHTTP() 메서드는 관련된 핸들러는 request URL path에 기반으로 하여 호출해주고, 핸들러의 ServeHTTP() 메서드를 호출해준다.

middleware의 기본적인 아이디어는 또 다른 handler를 특정 chain에 넣어주는 것이다. 이 middleware handler는 특정한 로직(가령 request를 로깅하는 기능)을 실행하고 나서, chain의 next 핸들러의 ServeHTTP() 메서드를 호출해준다.

사실상, 우리는 http.StripPrefix()라는 middleware를 static files를 제공하기 위해서 사용하였다. 이 middleware 덕분에 우리는 request URL path로 부터 특정한 prefix를 삭제하고 file server로 request를 전달할 수 있었다.

기본적인 middleware의 pattern은 다음과 닽다.

func myMiddleware(next http.Handler) http.Handler {
    fn := func(w http.ResponseWriter, r *http.Request) {
        // TODO: Execute our middleware logic here...
        next.ServeHTTP(w,r)
    }
    return http.HandlerFunc(fn)
}

위의 코드는 굉장히 간단해 보이지만, 꽤 머리를 써야하는 구석이 있다.

  • myMiddleware() 함수는 본질적으로 next 핸들러를 wrapping한다.
  • next핸들러를 closure 형성하기 위해 close 해버린 fn함수를 만든다. fn이 동작할 때, 이는 우리의 middleware 로직을 실행하고 next 핸들러에 ServeHTTP() 메서드를 호출함으로서 제어를 다음으로 넘긴다.
  • closure와 관계없이, fnfn이 만들어진 스코프에 있는 지역 변수에 접근가능하다. 이는 fn은 항상 next variable에 접근할 수 있다는 것이다.
  • 우리는 이 closure를 http.Handler로 변경한 뒤 http.HandleFunc() 어댑터를 사용하여 반환한다.

만약 위의 로직이 조금 어렵다면 간단하게 myMiddlewarenext handler를 chain안의 parameter로 가지는 함수로 받아들이면 된다. 이는 handler를 반환하고 이 handler는 일부 로직을 실행한 뒤 next handler를 실행한다.

이러한 패턴의 변형은 익명함수를 사용하여 myMiddleware middleware를 다시 정의하는 것이다. 다음과 같이 말이다.

func myMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // TODO: Execute our middleware logic here...
        next.ServeHTTP(w, r)
    })
}

이러한 패턴은 굉장히 많이 사용되고 있으며, third-party package들을 사용하거나 다른 이들이 만든 application들의 source code들을 살펴보면 많이 사용되는 것을 확인할 수 있을 것이다.

우리의 middleware가 handler들의 chain에 있어서 어디에 위치하는 가는 application의 behavior(행동)에 영향을 주기 때문에 굉장히 중요하다.

만약, 우리의 middleware의 위치가 servemux 체인의 앞이라면 application이 받는 모든 request들에 동작하게 된다.

myMiddleware -> servemux -> application handler

이러한 경우는 모든 reuqest를 log해야하는 경우에 적용된다.

다른 경우는, 우리의 middleware로 특정한 application handler를 wrapping함으로서 servemux 체인의 뒤에 위치해두는 것이다. 이는 우리의 middleware가 특정한 routes에 동작하도록 할 수 있다. (모든 request가 아니라, 특정 request에만 영향을 미친다는 것이다.)

servemux -> myMiddleware -> application handler

이는 authorization middleware를 사용할 때와 비슷한데, 인증 권한을 필요로 하는 특정 routes에 관해서 auth middleware를 요청하는 것이다.

2. Setting Security Headers

이제 middleware를 사용해보도록 하자. 우리의 middleware를 만들어서 자동으로 모든 응답 response에 추가적인 헤더를 넣어주도록 하자.

X-Frame-Options: deny
X-XSS-Protection: 1; mode=block

위 헤더는 web browser가 XSS와 Clickjacking 공격을 막는데 도움이 되는, 일부 추가적인 security measures를 구현하도록 지시하는 것이다. 별 다른 이유가 없다면 위 헤더를 추가하는 것이 좋다.

새로운 middleware.go 파일을 만들도록 하자

touch /cmd/web/middleware.go

파일을 열고 secureHeaders() 함수를 추가하여 이전 chapter에 소개된 패턴을 사용해보자.

  • cmd/web/middleware.go
package main

import "net/http"

func secureHeaders(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("X-XSS-Protection", "1; mode=block")
		w.Header().Set("X-Frame-Options", "deny")
		next.ServeHTTP(w, r)
	})
}

우리는 이러한 middleware가 우리가 받은 모든 request에 실행되기를 원하므로, 우리는 우리의 servemux 앞에 해당 middleware를 실행하도록 하자.

secureHeaders -> servemux -> application handler

우리는 다음과 같은 흐름으로 control flow가 만들어지기를 원하는 것이다.

secureHeaders middleware함수를 우리의 servemux에 wrapping하여 위의 chain대로 구현해보도록 하자. routes.go 파일에 가서 우리가 원하는 바를 위해 수정해주도록 하자.

  • cmd/web/routes.go
package main

import "net/http"

// Update the signature for the routes() method so that it returns a
// http.Handler instead of *http.ServeMux.
func (app *application) routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/", app.home)
	mux.HandleFunc("/snippet", app.showSnippet)
	mux.HandleFunc("/snippet/create", app.createSnippet)

	fileServer := http.FileServer(http.Dir("./ui/static/"))
	mux.Handle("/static/", http.StripPrefix("/static", fileServer))
	// Pass the servemux as the 'next' parameter to the secureHeaders middleware.
	// Because secureHeaders is just a function, and the function returns a
	// http.Handler we don't need to do anything else.
	return secureHeaders(mux)
}

routes 함수의 반환 타입을 *http.ServeMux에서 http.Handler으로 바꾸도록 하자. 어차피 http.ServeMux 자체가 ServeHTTP 메서드를 가지고 있기 때문에 http.Handler 인터페이스를 반환해도 문제가 없다.

이제 secureHeaders middleware의 next 파라미터로 mux를 넘겨주도록 하자.

실행해본다음 응답 헤더를 확인해보자.

curl localhost:4000 -I

HTTP/1.1 200 OK
X-Frame-Options: deny
X-Xss-Protection: 1; mode=block
Date: Tue, 03 Jan 2023 05:10:16 GMT
Content-Length: 1316
Content-Type: text/html; charset=utf-8

우리가 원하는 대로 응답 헤더에 X-Frame-Options, X-Xss-Protection가 잘 설정된 것을 확인할 수 있다.

추가적으로 마지막 chain의 마지막 handler가 반환할 때를 알아야 하는 것은 꽤나 중요한 일인데, control이 chain의 반대 방향으로 흐르기 때문이다. 그래서 우리의 코드는 현실적으로 다음과 같이 흐른다.

secureHeaders -> servemux -> application handler -> servemux -> secureHeaders

그래서 다음과 같이 next.ServeHTTP 메서드 앞에 쓰이는 로직은 chain의 ->(아래 방향)으로 실행이되고, next.ServeHTTp 메서드 뒤에 쓰이는 로직은 chain의 back(반대 방향)으로 실행되는 것이다.

func myMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Any code here will execute on the way down the chain.
        next.ServeHTTP(w, r)
        // Any code here will execute on the way back up the chain. 
    })
}

만약, 우리의 middleware function에서 next.ServeHTTP 메서드 call 앞에 return을 하였다면 chain의 upstream(위 방향 즉, 반대 방향)으로 제어가 넘어간다.

이를 early returns라고 하는데, 가령 오직 특정한 check에 성공한 유저만 다음 chain의 실행이 넘어가도록 하기를 원하는 authentication middleware라면 이러한 early returns use-case를 사용할 수 있다. 즉, 자신이 원하는 사용자가 아니라면 미리 return을 하도록 하여 다음 chain으로 넘어가지 못하도록 하는 것이다.

func myMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    // If the user isn't authorized send a 403 Forbidden status and
    // return to stop executing the chain.
    if !isAuthorized(r) {
        w.WriteHeader(http.StatusForbidden)
        return
    }
    // Otherwise, call the next handler in the chain.
    next.ServeHTTP(w, r)
    })
}

isAuthorized가 false라면 return을 먼저 실행시켜 next 핸들러를 실행하지 못하게 하는 것이다.

3. Request Logging

이제는 HTTP request를 log하기위한 일부 middleware를 추가하도록 하자. 특히, user의 IP정보와 URL, method를 먼저 기록하기 위한 inforamtion logger를 사용하도록 하자.

middleware.go 파일에 logRequest() 메서드를 추가해보자.

func (app *application) logRequest(next http.Handler) http.Handler {
	return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
		app.infoLog.Printf("%s - %s %s %s", req.RemoteAddr, req.Proto, req.Method, req.URL.RequestURI())
		next.ServeHTTP(res, req)
	})
}

우리의 middleware가 application의 메서드로 구현된 것을 확인하자. 이전에 우리가 만든 secureHeaders과 동일한 함수 시그니처를 가지고 있지만 application에 대한 메서드로 만들어 logger에 대한 의존성을 주입받은 것이다.

routes.go 파일을 수정하여 모든 request에 대하여 logRequest middleware가 첫번째로 실행되도록 하자. control flow는 다음과 같다.

logRequest <-> secureHeaders <-> application handler
  • cmd/web/routes.go
package main

import "net/http"

func (app *application) routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/", app.home)
	mux.HandleFunc("/snippet", app.showSnippet)
	mux.HandleFunc("/snippet/create", app.createSnippet)

	fileServer := http.FileServer(http.Dir("./ui/static/"))
	mux.Handle("/static/", http.StripPrefix("/static", fileServer))
	// Wrap the existing chain with the logRequest middleware.
	return app.logRequest(secureHeaders(mux))
}

이제 프로그램을 실행하여 middleware가 잘 실행되는 지를 확인해보자.

curl localhost:4000 -I

INFO    2023/01/03 15:15:20 127.0.0.1:54210 - HTTP/1.1 HEAD /

4. Panic Recovery

go application에서 code가 panic이 나버리면 application에서 곧 바로 terminated를 시켜버린다.

그러나, 우리의 web application은 더욱 정교한데, go의 http server는 어떠한 panic이든 그 영향을 http request를 제공하기 위한 gorouitne 자체에 격리시켜버린다. (모든 request들은 http server 내부에서 goroutine을 생성하여 handler로 처리하는 것이다.)

구체적으로 우리의 server에 대한 panic은 server error log에 stack trace를 log해준다. 영향을 받은 goroutine에 대한 stack은 풀어주고, 기저의 http connection을 닫아준다. 그러나 이러한 로직으로 인해 application이 종료되진 않는다. 그래서, 중요하게도 개발자가 만든 handler에서 발생하는 어떠한 panic이든 server를 down시키진 않는다.

그런데, 만약 panic이 우리의 handler에서 발생하면 어떻게 우리가 볼 수 있는가?

home handler에 일부러 panic을 만들어보도록 하자.

  • cmd/web/handlers.go
func (app *application) home(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path != "/" {
		app.notFound(w)
		return
	}

	panic("oops! something went wrong") // Deliberate panic

	s, err := app.snippets.Latest()
	if err != nil {
		app.serverError(w, err)
		return
	}

	app.render(w, r, "home.page.tmpl", &templateData{
		Snippets: s,
	})
}

서버를 실행하고 http request를 보내보자.

curl localhost:4000 -I

curl: (52) Empty reply from server

안타깝게도, panic이 발생하면 go가 기저의 http connection을 끊어버리기 때문에 빈 응답을 받게 된다.

이는 user에게 그닥 좋은 경험이 되지못한다. 적절한 응답인 500 Internal Server Error 상태를 반환하는 것이 더 좋은 방법이다.

적절한 방ㅂ버은 middleware를 하나 만들어서 panic은 recover하고 우리의 app.serverError() helper method를 실행시켜주는 것이다. 이를 위해서 우리는 panic 이후 stack이 풀릴 때, deferred function들은 항상 호출된다는 사실을 이용할 수 있다.

우리의 middleware.go 파일에 다음의 코드를 추가해보자.

  • cmd/web/middleware.go
func (app *application) recoverPanic(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Create a deferred function (which will always be run in the event
		// of a panic as Go unwinds the stack).
		defer func() {
			// Use the builtin recover function to check if there has been a
			// panic or not. If there has...
			if err := recover(); err != nil {
				// Set a "Connection: close" header on the response.
				w.Header().Set("Connection", "close")
				// Call the app.serverError helper method to return a 500
				// Internal Server response.
				app.serverError(w, fmt.Errorf("%s", err))
			}
		}()
		next.ServeHTTP(w, r)
	})
}

여기에는 설명하기 좋은 두 가지 자세한 사항이 있다.

  1. 응답 헤더에 Connection: Close를 설정하는 것은 go의 http server가 자동적으로 응답을 전송한 후 현재의 connection을 닫도록 만들도록 하는 트리거로서 작동한다.
  2. 빌트인 함수인 recover()interface{} 타입을 가지는 반환값을 내놓는다. 그리고 그 값은 string또는 error 또는 다른 것일 수 있는데, panic()에 파라미터로 전달된 무엇이든지 된다. 우리의 case에서는 panic("oops! something went wrong")으로 "oops! something went wrong"을 넘겼기 때문에 이를 fmt.Errorf를 통해 error로 normalize할 수 있다. 즉, fmt.Errorf 함수로 error 객체를 만들고, interface{} 값의 기본적인 문자적 표현을 error에 집어넣는 것이다. 그리고 해당 error를 app.serverError() helper method로 넘기는 것이다.

routes.go 파일에 우리의 middleware를 사용해보도록 하자. 그러기 위해서 chain의 가장 맨 앞에 middleware를 두어서 실행되도록 한다. 이렇게 함으로서 모든 하위 subsequent middleware와 handlers에 발생하는 panics들을 커버할 수 있도록 한다.

package main

import "net/http"

func (app *application) routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/", app.home)
	mux.HandleFunc("/snippet", app.showSnippet)
	mux.HandleFunc("/snippet/create", app.createSnippet)

	fileServer := http.FileServer(http.Dir("./ui/static/"))
	mux.Handle("/static/", http.StripPrefix("/static", fileServer))
	// Wrap the existing chain with the logRequest middleware.
	return app.recoverPanic(app.logRequest(secureHeaders(mux)))
}

다시 서버를 실행하여 패닉이 발생하는 요청을 보내보자.

curl localhost:4000 -I

HTTP/1.1 500 Internal Server Error
Connection: close
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
X-Frame-Options: deny
X-Xss-Protection: 1; mode=block
Date: Tue, 03 Jan 2023 07:02:46 GMT
Content-Length: 22

우리가 원하는 대로 Connection: close라는 헤더와 함께 Internal Server Error 메시지와 상태코드 500이 전달되었다. 이제 panic이 발생해도 에러 페이지를 제공할 수 있으며, http connection도 원하는 때에 close시킬 수 있게 된 것이다.

이제 의도적으로 만든 panic을 없애도록 하자.

  • cmd/web/handlers.go
func (app *application) home(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path != "/" {
		app.notFound(w)
		return
	}

	s, err := app.snippets.Latest()
	if err != nil {
		app.serverError(w, err)
		return
	}

	app.render(w, r, "home.page.tmpl", &templateData{
		Snippets: s,
	})
}

추가적으로 우리의 middleware는 오직 같은 goroutine에서 발생하는 panic만을 처리한다는 점을 알아두자.

가령, 만약 또 다른 goroutine을 spin하는 handler를 가진다면, 이 부수적인 goroutine에서 발생하는 어떠한 panic도 recoverPanic() middleware에 의해서 recovered되지 않게되고, go http server에서 동작하는 recovery panic도 동작하지 않는다. 이는 우리의 application을 down시키고 server를 종료시킨다.

그래서 만약, 추가적인 goroutine을 web application에서 spinning한다면 panic의 문제가 발생할 수 있으므로, 이 안(해당 goroutine)에서 panic을 어떻게 처리할 지에 대해서 확신해야한다. 가령 다음과 같이 말이다.

func myHandler(w http.ResponseWriter, r *http.Request) {
    go func() {
        defer func() {
            if err := recover(); err != nil {
                log.Println(fmt.Errorf("%s\n%s", err ,debug.Stack()))
            }
        }()

        doSomeBackgroundProceessing()
    }()

    w.Write([]byte("ok"))
}

5. Composable Middleware Chains

justinas/alice package를 통해서 우리의 middleware/handler chain들 구성하는 데 도움이 되도록 해보자.

반드시 사용할 필요는 없지만, 해당 패키지는 composable, resuable한 middleware chain들을 만들어주는데 큰 도움을 준다. 그리고 이는 application을 발전시키는데 큰 도움을 주며 route에 더욱 복잡한 기능들을 쉽게 제공한다. 해당 패키지 자체는 또한 굉장히 작고 가벼우며 코드가 간결하다.

다음과 같이 우리의 handler chain을 구성할 수 있다.

return myMiddleware1(myMiddleware2(myMiddleware3(myHandler)))

해당 코드를 다음과 같이 구성할 수 있다.

return alice.New(myMiddleware1, myMiddleware2, myMiddleware3).Then(myHandler)

해당 패키지의 가장 강력한 기능은 middleware chain을 마치 변수처럼 쓸 수 있다는 것이다.

myChain := alice.new(myMiddlewareOne, myMiddlewareTwo)
myOtherChain := myChain.Append(myMiddleware3)
return myOtherChain.Then(myHandler)

다음의 패키지를 사용하고 싶다면 go get으로 설치하면 된다.

go get github.com/justinas/alice@v1

이제 routes.go 파일에 justinas/alice 패키지를 다음과 같이 적용해보도록 하자.

  • routes.go
package main

import (
	"net/http"

	"github.com/justinas/alice"
)

func (app *application) routes() http.Handler {
	// Create a middleware chain containing our 'standard' middleware
	// which will be used for every request our application receives.
	standardMiddleware := alice.New(app.recoverPanic, app.logRequest, secureHeaders)

	mux := http.NewServeMux()
	mux.HandleFunc("/", app.home)
	mux.HandleFunc("/snippet", app.showSnippet)
	mux.HandleFunc("/snippet/create", app.createSnippet)

	fileServer := http.FileServer(http.Dir("./ui/static/"))
	mux.Handle("/static/", http.StripPrefix("/static", fileServer))
	// Return the 'standard' middleware chain followed by the servemux.
	return standardMiddleware.Then(mux)
}

서버를 다시 실행하여 요청을 보내보면 이전과 동일하게 실행될 것이다.

0개의 댓글