Go 언어로 만든 REST에 ETag 캐시 적용하기

ETag는 Entity Tag의 줄임말이다. Entity라는 말이 생소할 수도 있는데 Entity는 HTTP 메시지(Messages)와 연관이 있다.

HTTP 메시지와 Entity

HTTP 메시지는 HTTP 통신상에서 웹 서버와 클라이언트가 서로 주고받는 것을 의미한다. 클라이언트가 웹 서버로 보내는 메시지를 요청 메시지(Request Messages)라고 부르며, 웹 서버가 요청에 의해 클라언트에게 보내는 메시지를 응답 메시지(Response Messages)라고 부른다.

출처: HTTP The Definitive Guide - 10 page

출처: HTTP The Definitive Guide - 10 page

Entity는 HTTP 메시지의 일부를 말하는데 메시지는 Entity를 감싸 만든다. 즉 메시지는 컨테이너로 Entity는 화물로 비유할 수 있다. 아래 그림은 HTTP 메시지에서 Entity 영역을 보여준다.

출처: HTTP The Definitive Guide - 342 page

출처: HTTP The Definitive Guide - 342 page

HTTP 응답 메시지의 헤더 값 중 Content-length 라는 것이 있는데 이 값은 엄밀하게 말하면 메시지의 크기가 아니라 Entity Body의 크기를 말하는 것이다.

ETag

그렇다면 ETag는 무엇일까? 모질라 사이트에서는 아래와 같이 설명한다.

The ETag(or entity tag) HTTP response header is an identifier for a specific version of a resource. - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag

ETag는 HTTP 응답 헤더로 리소스의 특정 버전에 대한 식별자라고 한다. 정의만 읽고서는 감이 오질 않을 것이다. 구체적인 쓰임새로 이해해 보자. ETag는 보통 캐시(Cache)로 사용한다. 이 때 함께 거론되는 것이 If-None-Match 요청 헤더이다. 아래 순차도(Sequence Diagram)를 보자.

출처 : https://devdojo.com/vnnvanhuong/demo-http-caching-with-etag

출처 : https://devdojo.com/vnnvanhuong/demo-http-caching-with-etag

  1. 클라이언트는 서버에 HTTP GET Method로 리소스(html, js, jpg 등)를 요청한다.
  2. 서버는 리소스와 함께 리소스의 버전을 ETag 헤더에 넣어 응답한다.
  3. 클라이언트는 전과 같은 리소스를 요청할 때 일전에 웹 서버로부터 받은 ETag를 If-None-Match에 넣어 요청한다.
  4. 서버는 If-None-Match의 값이 현재 리소스의 ETag 버전과 일치한다면 상태를 304(Not Modified)로 응답한다.

이 흐름에서 무엇이 캐싱 된다는 것일까? 위의 2번에서는 HTTP 응답 메시지에 Entity가 존재하지만 4번에서는 HTTP 응답 메시지에 Entity가 없다. 즉 Entity를 클라이언트가 캐싱 하는 것이다. Entity 캐싱을 이용하여 네트워크 비용을 아낄 수 있는 것이다. 이렇게 동작하는 것이 가능한 이유는 클라이언트가 2번에서 ETag 응답 헤더에 값이 존재하면 2번에서 받은 Entity를 클라이언트가 저장하고 있다가 4번에서 304(Not Modified)로 응답받으면 이를 재사용하기 때문이다. 그래서 보통 ETag는 HTTP GET 메서드에 사용한다.

왜 REST에서 ETag를 말하는가?

요즘은 보통 웹 애플리케이션을 프론트엔드와 벡엔드로 분리하여 개발한다.

일반적으로 프론트엔드는 정적인 리소스(js, css, html, png, svg 등)로서 웹 서버에 배포한다. 웹 서버로 Nginx를 많이 쓰는데 Nginx는 ETag 기능이 내장되어 있으며 기본적으로 ETag 기능이 활성화되어 있다. 따라서 개발자가 신경 쓰지 않더라도 ETag를 사용한다.

반면 벡엔드에서 만드는 REST API는 동적 리소스이다. 서버 로직으로 조건에 따라 다양한 Entity를 만들어 응답한다. 따라서 ETag를 기본적으로 사용하기에는 무리가 있으며 또 다른 문제는 API 성격에 따라 캐싱 전략이 달라진다는 것이다. 자바(Java) 언어에서는 주로 Spring 프레임워크를 사용하여 애플리케이션을 만드는데 ETag 기능이 프레임워크에 내장되어 있고 사용하려면 개발자가 직접 코드로 활성화해야 한다.

자. 이제부터 Go 언어로 간단하게 REST를 만들어 보고 ETag를 직접 구현해 보자.

REST

Go 언어에서 가장 많이 사용되는 웹 프레임워크인 Gin으로 상품 목록을 반환하는 REST를 만들어보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main
import (
  "github.com/gin-gonic/gin"
  "net/http"
)
func main() {
  r := gin.Default()
  r.GET("/products", getProducts)
  r.Run() // listen and serve on 0.0.0.0:8080
}
func getProducts(c *gin.Context) {
  products := []map[string]any{
    {
      "id":        1,
      "name":      "큰 잔",
      "listPrice": 1000,
    },
    {
      "id":        2,
      "name":      "작은 잔",
      "listPrice": 2000,
    },
  }
  c.JSON(http.StatusOK, products)
}

코드를 실행하면 8080 포트로 웹 서버가 동작한다. 웹 브라우저에 아래 주소를 입력하면 만들어진 REST를 확인할 수 있다.

http://localhost:8080/products

Untitled

Gin Middleware

REST가 만들어졌으니 이제는 ETag 기능을 만들어볼 차례다. 그런데 어떻게 구현해야 할까?

앞서 ETag 동작 메커니즘을 보면 HTTP 응답 Entity를 버전닝을 해야 하고 요청 헤더에 If-None-Match가 있다면 현재 Entity 버전과 비교하여 응답 코드를 304(Not Modified) 반환해야 한다. 이런 코드를 getProducts 함수에 구현할 수도 있겠지만 재사용 문제가 발생한다. 소프트웨어 설계에서 말하는 관심사의 분리(Separation of concerns)가 필요한 시점이다.

Gin은 미들웨어(Middleware) 라는 것이 있다. 미들웨어는 HTTP 요청과 응답에 사이에 끼우는 훅(Hook) 같은 기능으로 예를 들면 모든 요청을 기록하는데 쓸 수 있다.

출처 : https://subscription.packtpub.com/book/application-development/9781788294287/3/ch03lvl1sec23/what-is-middleware

출처 : https://subscription.packtpub.com/book/application-development/9781788294287/3/ch03lvl1sec23/what-is-middleware

말보다는 코드다. 미들웨어는 함수로 구현하는데 아래는 요청 URL과 응답 상태를 로그를 남기는 간단한 미들웨어 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func main() {
  // ...
  r.GET("/products", LoggingMiddleware(), getProducts)
}
func getProducts(c *gin.Context) {
  fmt.Println("getProducts...")
  // ...
  c.JSON(http.StatusOK, products)
}
func LoggingMiddleware() gin.HandlerFunc {
  return func(c *gin.Context) {
    fmt.Println(fmt.Sprintf("request url : %s", c.Request.URL.RequestURI()))
    c.Next()
    fmt.Println(fmt.Sprintf("response status : %v", c.Writer.Status()))
  }
}

실행 결과는 아래와 같다.

Untitled

위 로그에서 보이듯이 요청과 응답 사이에 미들웨어가 위치하여 동작한다는 것을 확인할 수 있다. c.Next() 함수는 LoggingMiddleware에 다음에 연결된 함수로 실행 흐름을 넘기는 코드이다. 다음 함수는 REST가 될 수도 있고 다른 미들웨어가 될 수도 있다. 그래서 미들웨어는 체인으로 구성할 수 있다.

예제 코드에서 확인할 수 있듯이 미들웨어는 HTTP 요청과 응답에 관여한다. ETag 기능을 미들웨어로 구현한다면 관심사를 분리가 가능하며 다른 REST에서 사용할 수 있어 재사용도 가능해진다.

HTTP ETag Cache Middleware

본격적으로 ETag 미들웨어를 만들어 보자.

먼저 미들웨어에서 Entity 버전닝(태깅)을 위해 HTTP 응답 Entity Body를 가져와야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func main() {
  // ...
  r.GET("/products", HttpEtagCache(), getProducts)
}
func HttpEtagCache() gin.HandlerFunc {
  return func(c *gin.Context) {
    w := &responseBodyWriter{body: &bytes.Buffer{}, ResponseWriter: c.Writer}
    c.Writer = w
    c.Next()
                fmt.Println(fmt.Sprintf("Response Entity body : %s", w.body.String()))
  }
}
type responseBodyWriter struct {
  gin.ResponseWriter
  body *bytes.Buffer
}
func (r responseBodyWriter) Write(b []byte) (int, error) {
  return r.body.Write(b)
}

위의 코드를 실행하면 getProducts 함수가 반환한 Entity Body를 확인할 수 있다.

Untitled

두 번째로 할 일은 ETag 응답 헤더의 값으로 사용할 Entity의 버전을 만드는 일이다. 버전닝은 Entity body를 MD5Hash 하여 만든다. MD5 알고리즘은 문자열의 길이를 줄이는(Digest) 용도로 사용하는데 같은 입력값이면 같은 값이 나온다.

1
2
3
// MD5 예시
MD5("The quick brown fox jumps over the lazy dog") = 9e107d9d372bb6826bd81d3542a419d6 
MD5("The quick brown fox jumps over the lazy dog.") = e4d909c290d0fb1ca068ffaddf22cbd0

이 원리를 이용하여 Entity의 버전을 만들어보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import (
  "bytes"
  "crypto/md5"
  "encoding/hex"
  "fmt"
  "github.com/gin-contrib/cors"
  "github.com/gin-gonic/gin"
  "net/http"
)
// ...
func HttpEtagCache() gin.HandlerFunc {
  return func(c *gin.Context) {
    w := &responseBodyWriter{body: &bytes.Buffer{}, ResponseWriter: c.Writer}
    c.Writer = w
    c.Next()
    fmt.Println(fmt.Sprintf("Response Entity body : %s", w.body.String()))
    eTag := generateMD5Hash(w.body.String())
    w.ResponseWriter.Header().Set("ETag", eTag)
  }
}
func generateMD5Hash(text string) string {
  hash := md5.Sum([]byte(text))
  return hex.EncodeToString(hash[:])
}

위의 코드를 브라우저에서 확인해 보면 아래와 같이 ETag 응답 헤더를 볼 수 있다.

Untitled

그런데 무언인가 이상한 게 있다. 응답 Entity가 없다! 그 이유는 미들웨어에서 Entity body를 가져오기 위해 별도의 responseBodyWriter 를 사용했기 때문이다. 아래와 같이 코드를 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func HttpEtagCache() gin.HandlerFunc {
  return func(c *gin.Context) {
    w := &responseBodyWriter{body: &bytes.Buffer{}, ResponseWriter: c.Writer}
    c.Writer = w
    c.Next()
    fmt.Println(fmt.Sprintf("Response Entity body : %s", w.body.String()))
    eTag := generateMD5Hash(w.body.String())
    w.ResponseWriter.Header().Set("ETag", eTag)
    // 코드 추가
    _, err := w.ResponseWriter.Write(w.body.Bytes())
    if err != nil {
      panic(err)
    }
  }
}

다시 확인해 보면 아래와 같이 ETag와 Entity body를 확인할 수 있다.

Untitled

이제 마지막으로 만일 HTTP 요청 헤더 중 If-None-Match 값이 있다면 ETag 값과 비교해서 같다면 Entity body를 없이 HTTP 응답 상태를 304(Not Modified)로 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main
import (
  "bytes"
  "crypto/md5"
  "encoding/hex"
  "github.com/gin-gonic/gin"
  "net/http"
)
func HttpEtagCache() gin.HandlerFunc {
  return func(c *gin.Context) {
    w := &responseBodyWriter{body: &bytes.Buffer{}, ResponseWriter: c.Writer}
    c.Writer = w
    c.Next()
    eTag := generateMD5Hash(w.body.String())
    w.ResponseWriter.Header().Set("ETag", eTag)
    // 요청 헤더에 If-None-Match 존재한다면
    if len(c.Request.Header.Get("If-None-Match")) > 0 {
      // If-None-Match 값과 eTag 값을 비교
      if eTag == c.Request.Header.Get("If-None-Match") {
        // eTag 값이 같다면 응답 상태를 304(Not Modified)로 설정
        // 네트워크 비용을 줄이기 위해 Entity Body를 반환하지 않음.
        c.Writer.Header().Set("Cache-Control", "max-age=120")
        c.Status(http.StatusNotModified)
        return
      }
    }
    _, err := w.ResponseWriter.Write(w.body.Bytes())
    if err != nil {
      panic(err)
    }
  }
}

완성한 전체 코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package main
import (
  "bytes"
  "crypto/md5"
  "encoding/hex"
  "fmt"
  "github.com/gin-gonic/gin"
  "net/http"
)
func main() {
  r := gin.Default()
  r.GET("/products", HttpEtagCache(), getProducts)
  r.Run() // listen and serve on 0.0.0.0:8080
}
func getProducts(c *gin.Context) {
  products := []map[string]any{
    {
      "id":        1,
      "name":      "큰 잔",
      "listPrice": 1000,
    },
    {
      "id":        2,
      "name":      "작은 잔",
      "listPrice": 2000,
    },
  }
  c.JSON(http.StatusOK, products)
}
func HttpEtagCache() gin.HandlerFunc {
  return func(c *gin.Context) {
    w := &responseBodyWriter{body: &bytes.Buffer{}, ResponseWriter: c.Writer}
    c.Writer = w
    c.Next()
    eTag := generateMD5Hash(w.body.String())
    w.ResponseWriter.Header().Set("ETag", eTag)
    if len(c.Request.Header.Get("If-None-Match")) > 0 {
      if eTag == c.Request.Header.Get("If-None-Match") {
        c.Writer.Header().Set("Cache-Control", "max-age=120")
        c.Status(http.StatusNotModified)
        return
      }
    }
    _, err := w.ResponseWriter.Write(w.body.Bytes())
    if err != nil {
      panic(err)
    }
  }
}
func generateMD5Hash(text string) string {
  hash := md5.Sum([]byte(text))
  return hex.EncodeToString(hash[:])
}
type responseBodyWriter struct {
  gin.ResponseWriter
  body *bytes.Buffer
}
func (r responseBodyWriter) Write(b []byte) (int, error) {
  return r.body.Write(b)
}

ETag 테스트

웹 브라우저로 테스트 해보자. 웹 브라우저에서 최초로 요청을 하면 Etag를 반환한다.

Untitled

웹 브라우저를 새로고침하면 두 번째 요청 시에는 웹 브라우저는 ETag 값을 If-None-Match에 넣어 요청했으며 응답으로 304를 받았다.

Untitled

웹 브라우저는 응답 Entity body가 없을 텐데 어떻게 Entity body를 표시했을까? 앞서 언급했듯이 웹 브라우저는 HTTP 응답 헤더의 ETag가 있으면 Entity를 로컬 저장소에 저장한다. 그리고 304 응답을 받으면 로컬에 저장된 Entity를 사용한다. 아래는 로컬에 저장(캐싱)된 Entity를 보여 준다.(Mac 구글 크롬 브라우저 기준)

Untitled

Untitled

Cache-Control

앞서 설명하지 않고 지나간 부분이 있다. 바로 Cache-Control 헤더이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ...
func HttpEtagCache() gin.HandlerFunc {
  return func(c *gin.Context) {
    // ...
    if len(c.Request.Header.Get("If-None-Match")) > 0 {
      if eTag == c.Request.Header.Get("If-None-Match") {
        c.Writer.Header().Set("Cache-Control", "max-age=120")
        c.Status(http.StatusNotModified)
        return
      }
    }
    // ...
  }
}

위의 코드를 보면 Cache-Control 헤더에 값으로 지시자(Directives)로 max-age를 사용했다.

max-age The max-age=N response directive indicates that the response remains fresh until N seconds after the response is generated. - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control

max-age는 뒤의 숫자는 초를 의미한다. 그래서 Cache-Control: max-age=120의미는 120초 동안 캐시를 유지해 달라는 것이다. 예를 들어 하루에 3번 집계를 하여 통계를 제공하는 API가 있다고 가정해 보자. 이 API를 한번 호출한 후에 몇 시간이 지나도 같은 데이터를 반환할 것이다. 이 경우 max-age는 데이터가 변경되지 않는 시간만큼 설정해야 할 것이다. 반면 CRUD(Create, Read, Update and Delete) 에서 데이터를 생성 후 바로 생성된 데이터를 조회할 때에는 mag-age를 0으로 설정해야 의도치 않는 결과를 받지 않는다.

이처럼 REST에 따라 max-age 값이 다르게 해야 하기 때문에 REST마다 max-age를 다르게 설정할 수 있도록 미들웨어를 아래와 같이 수정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func main() {
  // ...
  r.GET("/products", HttpEtagCache(120), getProducts)
}
func HttpEtagCache(maxAge uint) gin.HandlerFunc {
  return func(c *gin.Context) {
    // ...
    c.Next()
    if len(c.Request.Header.Get("If-None-Match")) > 0 {
      if eTag == c.Request.Header.Get("If-None-Match") {
        c.Writer.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", maxAge))
        c.Status(http.StatusNotModified)
        return
      }
    }
    // ...
  }
}

GitHub

지금까지 만든 HttpEtagCache 미들웨어는 Go 모듈로 만들어 오픈소스로 공개하였다. 상세한 내용은 아래 링크를 참조하라.

https://github.com/bettercode-oss/gin-middleware-etag


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.