Spring Boot

AOP를 활용한 브라우저 캐시_2_ETag & 인터셉터 (실패)

99duuk 2024. 9. 10. 23:14

초기접근

일단 지난 주에 공부했던 AOP로 어노테이션을 만들어서 간단하게 

Cache-Control: public, max-age=300 → 서버가 브라우저에 300초(5분) 동안 응답을 캐시하라고 지시. 

를 설정해보았다.

@GetMapping("/api/data")
@CacheControl(maxAge = 3600)
public ResponseEntity<Data> getData() {
    // ...
}

200 대신 304 응답이 오는 걸 확인했고, 일단 캐시가 되긴 했다 

 

  • 수백 개의 컨트롤러에 일일이 어노테이션을 추가해야 하는 번거로움
  • 캐시 정책의 일관성 유지가 어려움
  • 유지보수의 어려움

 

모든 컨트롤러에 캐싱 처리하려고 했기 때문에 어노테이션을 모든 컨트롤러마다 달아주기 귀찮아서

방법을 떠올리다 인터셉터로 처리해버리는 방안을 생각했다. 


모든 응답에 대해 일괄적으로 캐시 헤더를 추가할 수 있는 인터셉터 방식을 시도했다.

 

일단 데이터 일관성 유지를 위해... ETag를 찾아봤다.

 

ETag(Entity Tag)는 웹 서버와 브라우저 간의 캐시 유효성을 검사하는 데 사용되는 HTTP 응답 헤더로..

리소스의 특정 버전에 대한 식별자 역할을 한다......

 

  • 서버가 리소스에 대해 생성한 고유한 문자열
  • 리소스 내용이 변경될 때마다 ETag도 변경됨
  • 캐시된 리소스가 최신 상태인지 효율적으로 확인
  • 불필요한 데이터 전송 방지

 

 

a) 서버 응답 - 리소스와 함께 ETag 값을 전송

ETag: "686897696a7c876b7e"

 

 

b) 클라이언트 요청 - 이후 요청 시 'If-None-Match' 헤더에 ETag 값을 포함

If-None-Match: "686897696a7c876b7e"

 

c) 서버 검증

 

  • ETag 일치 시: 304 Not Modified 응답 (콘텐츠 변경 없음)
  • ETag 불일치 시: 200 OK와 함께 새로운 리소스 및 ETag 전송

 


@Log4j2
@Configuration
public class CacheControlInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (request.getMethod().equals("GET") && !(response instanceof ContentCachingResponseWrapper)) {
            request.setAttribute("original-response", response);
            response = new ContentCachingResponseWrapper(response);
            request.setAttribute("wrapped-response", response);
        }
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        if (request.getMethod().equals("GET")) {
            ContentCachingResponseWrapper responseWrapper = (ContentCachingResponseWrapper) request.getAttribute("wrapped-response");
            if (responseWrapper != null) {
                byte[] responseArray = responseWrapper.getContentAsByteArray();
                if (responseArray.length > 0) {
                    String responseBody = new String(responseArray, StandardCharsets.UTF_8);
                    log.info("Response body (first 100 chars): {}", responseBody.substring(0, Math.min(responseBody.length(), 100)));

                    String etag = generateETag(responseBody);
                    log.info("Generated ETag: {}", etag);

                    response.setHeader("ETag", etag);
                    String ifNoneMatch = request.getHeader(HttpHeaders.IF_NONE_MATCH);
                    if (etag.equals(ifNoneMatch)) {
                        response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
                        return;
                    }

                    responseWrapper.copyBodyToResponse();
                } else {
                    log.warn("Response body is empty");
                }

                response.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache, must-revalidate, max-age=0");
            }
        }
    }

    private String generateETag(String content) throws Exception {
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        byte[] hash = md.digest(content.getBytes(StandardCharsets.UTF_8));
        return "W/\"" + Base64.getEncoder().encodeToString(hash) + "\"";
    }
}

대충 이런 식으로 몇시간동안 수정했다가 뭐했다가 돌려놨다가 해보았다.

 

서버의 응답을 클라이언트로 전송하기 전에 잡아서

-> 해시화 한 뒤 -> ETag로 만들고 
응답 헤더에 있던 기존 ETag와 비교해서 변경사항 있으면 새로 전송하고, 없으면 헤더만 전송

 

흐름을 생각하고 짰는데 

ETag가 계속 똑같이 만들어졌다. 문제는 Response body를 못잡아서 빈값이 해시화 되고 있었다. 

 

 

삽질하다가 ..

preHandler, PostHandler, AfterCompletion 이런거 잘못 쓰고 있었고..

애초에 내가 생각했던 응답 바디를 인터셉트한다는 것 자체가 

대충 응답 전송하기 전에 잠깐 복사해서 저장해두고 그걸로 어째저째 한다는 거라 

응답이 커지면 메모리에 부담 될 수도 있고... 어쩌고 저쩌고 문제가 이만저만이 아니라 

인터셉터를 활용한 방법은 잘못된 접근이라는 결론을 내렸다. 

(인터셉터와 필터 중 고민하다가 인터셉터 방식을 선택했는데 차라리 처음부터 필터 방식으로 처리했다면 그냥 됐을 것 같기도 하다..)


+ 브라우저의 네트워크 탭을 한참을 들여다보다가 어떤 파일이나 요청은 설정 하지도 않았는데 이미 304로 캐시되고 있었다.

"Spring Boot에서는 기본적으로 정적 리소스에 대한 ETag를 생성을 자동으로 처리한다."

ResourceHttpRequestHandler 라는 컴포넌트를 통해 이루어지는데 
이 핸들러는 

 

 

  • ETag 생성: 정적 파일의 내용을 기반으로 ETag를 자동으로 생성
  • Last-Modified 헤더 설정: 파일의 최종 수정 시간을 기반으로 설정
  • Cache-Control 헤더 설정: 기본적인 캐시 정책을 적용

따라서 네트워크 탭에서 정적 리소스들이 304 (Not Modified) 상태를 보이는 것은 이 기본 기능 덕분이다.

클라이언트가 리소스를 요청할 때 이전에 받은 ETag를 함께 보내면, 서버는 이를 확인하고 변경이 없을 경우 304 응답을 반환한다.

이런 상황에서는 정적 리소스에 대한 추가적인 ETag 처리를 위해 별도의 필터를 구현할 필요가 없다. Spring Boot가 이미 효율적으로 처리하고 있기 때문이다.

 

 

그래서 처리해야할 부분은

 

  • 동적 컨텐츠(API 응답 등)에 대한 ETag 처리
  • 필요한 경우 정적 리소스의 캐싱 정책 커스터마이징

이다.