티스토리 뷰

반응형

Spring에서의 ETag

Spring에서도 ETag를 손쉽게 사용할 수 있도록 지원하고 있다.

그 중 리소스 캐싱에 대한 기능은 여러 API에서 공통으로 사용할 수 있는 기능이기에 Filter로서 지원하고 있다.

  • 충돌 피하기 (Lost update problem)에 대해서는 지원하지 않는 듯하다. 이에 대해서는 뒤에서 더 알아보자.

 

 

ShallowEtagHeaderFilter class에서 지원한다.

 

GitHub - spring-projects/spring-framework: Spring Framework

Spring Framework. Contribute to spring-projects/spring-framework development by creating an account on GitHub.

github.com

 

코드를 살펴보기 전에 몇 가지 알아두고 가자

 


캐싱과 성능?

‘리소스 캐싱’ 이름이 ShallowEtagHeaderFilter 에서 오해를 불러 일으킬 수 있는 부분이 있기에 먼저 이야기하고자 한다.

보통 ‘캐싱’이라고 하면 ‘빠른 응답’ 그리고 이를 통한 ‘더 좋은 성능’을 먼저 생각하게 된다.

 

하지만 ETag에서 말하는 리소스 캐싱은 클라이언트 측의 캐싱을 말하고, 서버 측이 하는 것은 특정 조건이 부합할 때 클라이언트의 캐시를 사용하라고 응답해주는 것 뿐이다.

즉, ShallowEtagHeaderFilter 는 서버 성능 자체에 영향을 주지 않는다. 대신, Network bandwidth를 줄일 수 있다는 이점이 있다.

 

왜인가?를 생각해보면, 그 프로세스를 생각해보면 이해하기 쉽다.

ETag를 비교하기 위해서는 Response가 필요하고, 그렇다보면 요청을 처리한 결과가 필요하다.

즉, ETag가 헤더에 들어왔던, 들어오지 않았던 간에 똑같은 비즈니스 로직을 하고 304를 내려주는 것 뿐이기에 성능상 이점은 없다.


SAFE - UNSAFE HTTP Method

ETag, If-Match, If-None-Match 로직은 HTTP Method가 무엇인가에 따라 분기/로직이 달라진다.

이 때 많이 나오는 것이 Safe / Unsafe HTTP method이다.

이 둘을 나누는 기준은 리소스의 변화(추가/삭제/수정 등)를 일으키는가? 라고 보면 될 것 같다. Restful API와 HTTP Method를 엮어 설명하는 글이 많은데, 그런 것들을 찾아보면 이해가 빠를 것 같다.

 

Safe HTTP Method라고 GET, HEAD 를 말한다.

Unsafe HTTP Method는 Safe HTTP Method를 제외한 HTTP Method를 의미한다. 보통 POST, PUT, DELETE, PATCH 등이 있다.

 


코드를 살펴보자

이제 코드를 좀 살펴보자.

응답을 반환할 때 ETag Filter 동작을 수행하게 되는데, 먼저 ETag를 수행할 필요가 있는지 확인한다. (코드)

protected boolean isEligibleForEtag(HttpServletRequest request, HttpServletResponse response,
        int responseStatusCode, InputStream inputStream) {

    if (!response.isCommitted() &&
            responseStatusCode >= 200 && responseStatusCode < 300 &&
            HttpMethod.GET.matches(request.getMethod())) {

        String cacheControl = response.getHeader(HttpHeaders.CACHE_CONTROL);
        return (cacheControl == null || !cacheControl.contains(DIRECTIVE_NO_STORE));
    }

    return false;
}

주석에서 알 수 있듯 다음과 같은 조건이면 ETag 사용이 가능하다.

  • Response status code가 200인 경우
  • GET 요청인 경우
  • Cache-Control header가 없거나 또는 있어도 no-store 이 아닌 경우
    • ETag도 Cache 방법 중 하나이므로 no-store 같은 Cache를 사용하지 않는 요청의 경우엔 사용하지 않도록 구현되어 있다.

 

만약 위 메서드에서 True가 반환되면 ETag 사용할 필요가 있다고 판단하고, ETag 값을 구하고 헤더에 추가하는 작업을 수행한다. (코드)

if (isEligibleForEtag(request, wrapper, wrapper.getStatus(), wrapper.getContentInputStream())) {
    String eTag = wrapper.getHeader(HttpHeaders.ETAG);
    if (!StringUtils.hasText(eTag)) {
        eTag = generateETagHeaderValue(wrapper.getContentInputStream(), this.writeWeakETag);
        rawResponse.setHeader(HttpHeaders.ETAG, eTag);
    }
    if (new ServletWebRequest(request, rawResponse).checkNotModified(eTag)) {
        return;
    }
}

로직 상에서 이미 ETag를 헤더에 추가하는 처리를 해놓았을 수 있으니, ETag 값이 없는 경우에만 ETag를 만들고 헤더에 추가하는 작업을 수행한다.

  • generateETagHeaderValue 에서는 entity body 값(wrapper.getContentInputStream())을 이용하여 MD5 hash를 생성한다. (코드)
  • writeWeakETag parameter는 Strong validation을 사용할지, Weak validation을 사용할지에 대한 설정 값이다. default는 false이며 이는 Strong validation을 사용한다는 것을 의미한다. 이에 대한 자세한 내용은 이전 글에서도 다뤘다. 그래도 한번 더 MDN 문서 를 참조하자.

 

ETag를 만들었으면 요청에 들어온 값과 비교를 해야한다. 이는 new ServletWebRequest(request, rawResponse).checkNotModified(eTag) 에서 이루어진다.

ServletWebRequest.checkNotModified() 를 좀 더 살펴보자. (코드)

@Override
public boolean checkNotModified(String etag) {
    return checkNotModified(etag, -1);
}

@Override
public boolean checkNotModified(@Nullable String eTag, long lastModifiedTimestamp) {
    HttpServletResponse response = getResponse();
    if (this.notModified || (response != null && HttpStatus.OK.value() != response.getStatus())) {
        return this.notModified;
    }

    // 1) If-Match
    if (validateIfMatch(eTag)) {
        updateResponseStateChanging(eTag, lastModifiedTimestamp);
        return this.notModified;
    }
    // 2) If-Unmodified-Since
    else if (validateIfUnmodifiedSince(lastModifiedTimestamp)) {
        updateResponseStateChanging(eTag, lastModifiedTimestamp);
        return this.notModified;
    }
    // 3) If-None-Match
    if (!validateIfNoneMatch(eTag)) {
        // 4) If-Modified-Since
        validateIfModifiedSince(lastModifiedTimestamp);
    }
    updateResponseIdempotent(eTag, lastModifiedTimestamp);
    return this.notModified;
}

위에서 언급한 If-Match, If-None-Match 헤더 값 유무에 따라 로직이 변경되게 된다.

  • 이러한 헤더들을 조건부 헤더라고 한다. 위에서 설명하지 않은 나머지 조건부 헤더에 대한 내용은 링크를 참고하자.
  • 위에 정의된대로 조건부 헤더 절차 순서(order)는 RFC 9110에 정의되어있다. Spring에서도 이에 따라 구현하였다.

 

ETag와 직접적으로 연관된 1번과 3번을 살펴보자.

1) If-Match

  • 충돌 피하기를 위해 확인/구현되어 있는 부분
  • validateIfMatch(eTag) 에서 아래 요소를 확인한다.
    • Safe HTTP Method인 경우, 진행하지 않는다. → 충돌 피하기는 리소스 변경/생성 등 변화가 있는 경우에만 사용되기에
    • If-Match 헤더 값이 존재하면 현재 etag와 If-Match 헤더 값의 etag를 비교한다. 같지 않으면 412 PRECONDITION_FAILED를 반환한다.

 

3) If-None-Match

  • 리소스 캐싱을 위해 확인/구현되어 있는 부분
  • validateIfNoneMatch(eTag) 에서 If-None-Match header 값이 있는지 비교 후 etag와 값이 같은지 확인한다. 값이 같으면, notModified property를 true로 할당한다.
    • If-Modified-Since 부분은 여기서 무시하자. If-None-Match 헤더 값이 없는 경우에만 동작하는 부분이다.
    • notModified property는 ServletWebRequest 의 private property로 변경되었는지 여부를 의미한다.
  • updateResponseIdempotent 메서드에서 notModified property가 true인 경우, 리소스가 변경되지 않았다는 것이므로 304 NOT_MODIFIED 를 반환한다.
    • 만약 Safe HTTP Method가 아닌 경우에는 412 PRECONDITION_FAILED 를 반환한다. UNSAFE method는 허용하지 않음을 의미한다.
    • ref) RFC 9110 - Section 13.1.2
  • notModified property 값이 어떻든 간에 Safe HTTP Method인 경우 Resource caching(ETag 헤더 추가)을 해서 결과를 반환한다.

 


ETag 비교는 어떻게 진행되고 있을까?

matchRequestedETags 메서드를 통해 비교한다. (코드 링크)

private boolean matchRequestedETags(Enumeration<String> requestedETags, @Nullable String eTag, boolean weakCompare) {
    eTag = padEtagIfNecessary(eTag);
    while (requestedETags.hasMoreElements()) {
        // Compare weak/strong ETags as per https://datatracker.ietf.org/doc/html/rfc9110#section-8.8.3
        Matcher eTagMatcher = ETAG_HEADER_VALUE_PATTERN.matcher(requestedETags.nextElement());
        while (eTagMatcher.find()) {
            // only consider "lost updates" checks for unsafe HTTP methods
            if ("*".equals(eTagMatcher.group()) && StringUtils.hasLength(eTag)
                    && !SAFE_METHODS.contains(getRequest().getMethod())) {
                return false;
            }
            if (weakCompare) {
                if (eTagWeakMatch(eTag, eTagMatcher.group(1))) {
                    return false;
                }
            }
            else {
                if (eTagStrongMatch(eTag, eTagMatcher.group(1))) {
                    return false;
                }
            }
        }
    }
    return true;
}

Matcher를 통해 비교하는 부분인데, ETag 형태를 띄고 같은 값을 가졌는가를 확인한다.

 

weak, strong match를 다른 메서드를 통해 진행하는 것을 볼 수 있는데, 저 메서드들의 내부 구현을 살펴보면 사실 W/ prefix가 있나? 차이 정도 밖에 없다.

  • Weak/Strong ETag는 전 글에서 설명했는데, 전체 리소스 비교하는 강한 검사를 할 것인가? 아니면 필요한 부분에 대한 검사만 할 것인가? 차이를 의미한다.

 

여기서 알 수 있는건 ShallowEtagHeaderFilter 의 경우, Weak/Strong 비교에 차이가 없다는 것이다. 해당 Filter에서 Weak ETag를 사용하겠다 한들 MD5 알고리즘으로 전체 리소스가 해싱된다. 단순히 W/ 가 붙는 차이밖에 없는 것이다.

  • 만약 캐싱 성능을 높이기 위해 리소스의 필요한 부분만 비교하는 ETag를 생성하고 싶다면 Custom Filter를 만들어 구성해야 한다.

 


충돌 피하기

충돌 피하기 (Lost update problem)에 대해서는 따로 Filter/Inteceptor를 제공하고 있지 않다.

  • 내가 찾아본 바로는 그렇다… 있으려나….🤩

 

그냥 그 없는 이유에 대해서 생각해봤는데

  • 현재 상태의 리소스를 구한 뒤 ETag를 생성한다.
  • If-Match header 의 값과 ETag 값을 비교한다.
  • 값이 동일한 경우, 리소스 생성/수정 등의 Persistent한 작업을 수행한다.

위와 같은 로직에서 ETag 값을 구하는 과정, 리소스 생성/수정 등의 Persistent한 작업 이 Endpoint마다 공통되게 뺄 수 없는 부분(횡단 관심사)이 아니기 때문이라고 결론지었다.

 

즉, Filter/Interceptor에서 구현해야하는 부분이 아니라는 것이다.

그래도 이대로 넘어가면 아쉬우니 직접 구현해보았다.

  • 관련된 코드는 github에 있다.
 

GitHub - KimDoubleB/spring-learning: 🌱 •̀.̫•́✧ 

🌱 •̀.̫•́✧ . Contribute to KimDoubleB/spring-learning development by creating an account on GitHub.

github.com

 

먼저 직접 구현한 부분이다.

@PostMapping("/lost-update")
public ResponseEntity<ResponseData> saveData(@RequestHeader(HttpHeaders.IF_MATCH) String ifMatchHeaderValue,
                                             @RequestBody Request request) {
    // check data is changed
    if (ifMatchHeaderValue != null) {
        var currentData = etagService.getSomeData(request);
        var currentEtag = EtagUtils.generateETagHeaderValue(currentData);
        if (!ifMatchHeaderValue.equals(currentEtag)) {
            log.info("Lost update - originalEtag {}, currentEtag {}", ifMatchHeaderValue, currentEtag);
            return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
        }
    }

    log.info("Save data: {}", request);
    var savedData = etagService.saveSomeData(request);
    return ResponseEntity.status(HttpStatus.OK.value())
        .header(HttpHeaders.ETAG, EtagUtils.generateETagHeaderValue(savedData))
        .body(savedData);
}
  • If-Match 헤더가 존재하지 않으면, 리소스 변경 작업을 수행한다.
  • 있다면, 현재 리소스 상태를 가져와 ETag 값을 구한다.
  • If-Match ETag 값과 현재 상태의 ETag 값을 비교하고, 같지 않다면 412를 반환한다.

 

필요한 부분마다 위처럼 구현하려니 가독성도 떨어지고 유지보수도 힘들 것 같다는 생각이 든다.

앞서 배웠던 ServletWebRequest.checkNotModified() 를 사용해보자.

@PostMapping("/lost-update/spring")
public void saveDataUsingSpring(HttpServletRequest httpServletRequest,
                                HttpServletResponse httpServletResponse,
                                @RequestBody Request requestData) throws IOException {
    var currentData = etagService.getSomeData(requestData);
    var currentEtag = EtagUtils.generateETagHeaderValue(currentData);

    boolean isModified = new ServletWebRequest(httpServletRequest, httpServletResponse)
        .checkNotModified(currentEtag);

    if (!isModified) {
        log.info("Save requestData: {}", requestData);
        var savedData = etagService.saveSomeData(requestData);
        String resultData = objectMapper.writeValueAsString(savedData);
        httpServletResponse.setStatus(HttpStatus.OK.value());
        httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
        httpServletResponse.getWriter().write(resultData);
    }
}
  • ETag를 검사하고 결과 응답을 설정하는 부분을 ServletWebRequest 내부에서 일어나도록 넘겼다.
  • httpServletResponse을 이용해야되다보니 직접 설정해야 되는 부분이 있는데, Method로 잘 분기해놓는다면 그나마 나을 것 같다.

 

참고) 관련되어 테스트 코드도 작성해두었다.

 

GitHub - KimDoubleB/spring-learning: 🌱 •̀.̫•́✧ 

🌱 •̀.̫•́✧ . Contribute to KimDoubleB/spring-learning development by creating an account on GitHub.

github.com

 


결론과 후기

이렇게 ETag가 무엇인지, Spring에서 어떻게 다룰 수 있는지 간략하게 정리해보았다.

이제 리소스 캐싱/충돌 피하기가 필요한 곳에서는 ETag와 조건부 헤더들을 통해 최적화된 요청/응답 처리를 할 수 있도록 구현할 수 있을 것이다.

 

생각보다 간단할 줄 알았는데 ETag 헤더만 보면 안되고, 조건부 헤더들과 Cache-Control 헤더도 같이 참고해야되는 등 파보면 파볼수록 복잡해져서 당황했었다. 그러다보니 사실 찾아보고 기록한건 더 많은 내용들이었지만, 장황해지고 이해되기 어려운 글이 될 것 같아 여기서 마무리한다.

 

그나저나 최근 HTTP를 조금씩 살펴보고 있는데 정말 방대한 것 같다.

이 방대한 것들을 완벽하게 외우진 못하더라도 어떤 기능들이 있고, 해당 기능들이 어떤 프로세스로 진행되는지만 알아둬도, 적재적소에 잘 활용할 수 있을 것이라는 생각이 든다.

또한, RFC 문서를 처음으로 직접 읽어보았는데 왠만한 기술 블로그보다 자세해서 놀랐다. 키워드를 검색하다보면 기술 블로그만 읽고 넘어가는 경우가 있는데, HTTP/Web 관련해서 만큼은 RFC를 먼저 살펴보아도 나쁘지 않을 것 같다.

 

Spring 내부 코드로도 살펴보았는데 생각보다 깔끔하게 작성되어있지 않았고(의문감 드는 코드가 많았다), RFC 스펙과 다르게 구현된 부분도 발견할 수 있었다. 이 부분은 추후 Issue를 통해 확인해보려고 한다.

무조건 구현되어있고, 유명한 프로젝트니 사용하자라는 시각도 위험한 것 같다는 것을 느꼈다 (맨날 파볼 순 없지만, 그래도 가끔 사용하는 부분을 살펴보는 건 좋은 습관인 것 같다).

320x100
반응형
댓글
반응형
250x250
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
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
글 보관함