HTTP 캐싱

디우·2022년 9월 29일
0

HTTP 를 활용하여 사이트의 성능을 개선하기 위한 방법 중 하나는 HTTP 캐싱이다.
HTTP 를 제대로 활용하기 위해 HTTP 캐싱에 대해서 공부하고, 학습 테스트를 진행한 내용을 정리한다.

추석과 더불어 vlog 영상 촬영 및 제작, 그리고 편집을 진행하게 되어 레벨4에서 학습한 내용에 대한 정리가 많이 밀리게 되었고, 늦게나마 정리하려고 한다.
vlog 촬영한 내용이 우아한tech 유튜브에 업로드 되고 나면 관련 블로그글도 포스팅해볼까한다.


HTTP 캐시란?

HTTP 캐시는 특정 요청의 응답을 저장하고, 이를 재사용하는 것을 이야기한다.
위키백과에서는 다음과 같이 정리하고 있다.

웹 캐시 또는 HTTP 캐시는 서버 지연을 줄이기 위해 웹 페이지, 이미지, 기타 유형의 웹 멀티미디어 등의 웹 문서들을 임시 저장하기 위한 정보기술

즉, 우리는 HTTP 캐시를 통해서 클라이언트는 서버에 직접 요청을 보낼 필요가 없으므로 클라이언트 입장에서는 응답을 빠르게 받을 수 있고, 서버 입장에서는 트래픽을 줄일 수 있다는 이점이 있다.

그럼 요청에 대한 응답은 어디에 어떻게 저장하는 것일까? 우선 캐시는 요청 URL 을 기준으로 각 응답을 저장하는 형식이다. 예를 들어 http://woowacoursre.com/example 이라는 URL 에 대해서 그에 맞는 응답을 저장하는 식이다.
또한 일반적으로 HTTP 캐시는 HTTP GET 메소드에 대한 응답만을 캐싱하며 다른 메소드들은 제외된다. 그 이유는 우리가 쉽게 유추가 가능하다. POST 와 같은 메소드들은 매 요청마다 응답이 다를 수 있다. 즉, 멱등성을 보장해줄 수 없기 때문에 GET 메소드에 대해서만 지원해준다.
다음으로 캐시는 어디에 저장되는 것일까? 간단하게 클라이언트 측에 저장할 수도 있고 서버측에 저장할 수도 있다. (캐시의 종류에서 더 자세하게)

캐시의 종류

HTTP 캐시는 크게 2가지 유형이 존재한다.
private cacheshared cache(Proxy or Managed) 가 존재한다.

private cache 는 웹 브라우저(ex. 크롬)에 저장되는 캐시이다. 그리고 이러한 private cache 는 사용자 각 개인 PC의 브라우저에 저장되는 것이기 때문에 다른 사용자들은 접근을 할 수 없다. 따라서 사용자의 개인화된 응답을 저장할 수 있게 된다. (물론 shared cache 에도 사용자 고유 식별 값을 통해서 1대1 매핑되는 개인화된 데이터를 저장 가능하다고 생각한다.)
private cache 에서 주의할 점은 서버 응답에 Authorization 헤더가 포함되어 있으면 private cache 에 저장되지 않는다.

클라이언트와 서버 사이에 존재하는 Proxy Server 는 client 와 서버 사이에 존재하는 서버라고 이해하면 되는데, 이 proxy cache 는 개발자가 직접 제어할 수는 없기 때문에 HTTP Cache 를 어떻게 처리하라고 헤더를 통해서만 지시할 뿐이다.

Reverse Proxy 또는 CDN 에 저장되어 관리되는 캐시를 Managed Cache라고 한다. 그리고 이 경우에는 이름에서 알 수 있다시피 개발자가 관리(manage) 할 수 있다.

캐시 유효기간

캐시에는 유효기간이라는 개념이 존재한다. 서버에서 최신 응답을 내려두기 전까지는 캐시해둔 응답을 써야 앞서 언급한 캐시를 사용하는 이점을 누릴 수 있게 된다. 그런데 여기서 문제가 발생한다. 현재 캐시해 둔 응답과 서버가 실제로 가지고 있는 응답의 불일치가 발생할 수 있다. 예를 들어 보자. 서버에서 특정 요청에 대해서 100이라는 응답을 처음에 내려주었고, 이를 캐시해두었다고 하자. 그런데 그 다음 날은 101 이라는 응답으로 서버측 응답이 갱신되었고, 그 다음 날은 102 로 변경되었다. 그렇다면 캐시해둔 값은 100인데, 실제 서버쪽 응답은 102 이므로 두 값은 일치하지 않게 된다. 그렇다면 캐시를 언제까지 활용할 수 있을까? 언제까지 캐시가 유효(fresh)할까?

서버에서 응답 헤더에 언제까지 캐싱해도 되는지를 표시해주는 방법으로 위와 같은 문제를 해결한다.

HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Date: Wed, 28 Sep 2022 00:00:00 GMT
Cache-Control: max-age=604800

body ....

위의 HTTP 응답 메시지를 보자. 헤더 부분에 보면 Date 헤더가 있는데, 이는 응답을 생성한 시간을 의미한다. 그리고 Cache-Contorl 헤더는 캐싱을 제어하는 지시문 역할을 한다. max-age 라고 해서 604800 라는 값을 주고 있는데, 이는 캐시가 어느정도의 시간동안 유효한지를 나타내고 있다고 볼 수 있다. 단위는 초이므로 일주일 동안 캐시가 유효하다 라고 볼 수 있다. 이외에도 Cache-Control 에는 다양한 캐시 제어 지시문을 삽입할 수 있는데, 예를 들어 캐시를 재사용할 때마다 원본 서버에 응답의 유효성을 검사해야함을 나타내는 no-cache 와 같은 것들이 존재한다.

위의 응답의 경우에는 일주일 동안, 즉 캐시가 유효한 동안 재사용할 수 있게 된다.

재검증

유효 기간이 다 된 캐시는 어떻게 처리를 할까? 계속해서 재사용해도 문제가 되지 않을까?
유효 기간이 다 된 캐시를 계속해서 사용해도 무방한지를 서버에 검증하게 되는 절차를 밟는다. 그리고 이를 유효성 검증(validation) 또는 재검증(revalidation) 이라고 하며 조건부 요청(conditional request) 를 통해서 재검증 절차가 이루어지게 된다.
방법은 크게 2가지 인데, If-Modified-SinceETag/If-None-Math 이다.

If-Modified-Since 는 캐싱해 둔 응답의 Last-Modified 헤더에 있는 날짜 정보를 이용하는 방법이다.
클라이언트 측에서 조건부 요청을 보낼 때, If-Modifed-Since 에 앞서 캐싱 해둔 응답의 Last-Modified 헤더의 날짜를 실어서 서버로 요청을 보내게 된다. 만약 해당 날짜 이후로 변경이 있는 경우 새로운 값으로 다시 캐싱해서 동기화를 맞춰주는 작업을 수행해주어야하므로 새로운 응답을 받고 이를 캐시하게 된다. 만약 If-Modified-Since 이후로 변경되지 않았다고 하면 서버는 304 Not Modified 응답을 내려주게 된다.

두번째 방법인 ETag/If-None-Match 는 태그 번호를 부여하는 방식이다.
앞선 If-Modified-Since 의 경우에는 날짜가 초 단위이기 때문에 밀리세컨드 단위에 대해서는 제대로된 동기화를 해주지 못할 수 있다.
ETag/If-None-Match 방식은 클랑이언트가 서버로 요청을 보내면 서버에서 응답을 내려주면서 ETag 라는 헤더에 어떤 캐시값을 함께 담아서 준다. 그리고 이후 클라이언트에서 캐시의 유효시간이 만료된 이후에 If-None-Match 헤더에 앞선 해시값을 담은 조건부 요청을 보내면 서버에서는 이 해시값과의 match 여부를 판단해서 갱신이 필요없다면 304 응답을 내려주고, 필요하다면 새로운 응답 body 를 담아서 보내주게 된다.

Force Revalidation

앞서 예시로 짧게 등장했던 Cache-Contorlno-cache 를 사용하여 항상 재검증을 수행하게 하는 방법이 있다. 이는 항상 최신화를 시키고 싶을 때 사용한다. 이렇게 되면 클라이언트는 항상 서버에 재검증 요청을 보내서 최신화 상태인지를 확인하는 절차를 수행하게 된다.
(이전에는 Cache-Control: max-age=0, must-revalidate 로 썻었는데, 제대로 된 처리를 수행하지 못해서 HTTP 1.1 버전부터는 no-cache 를 사용한다.)
그리고 캐시에 대해서 별도의 설정을 해주지 않는다면 Cache-Control: no-cache 는 기본 설정이다. 이를 명시해주지 않으면 브라우저 등에서 임의로 캐싱을 하게 되고, 이는 우리가 의도하지 않은 캐싱이 발생하는 것이다. 따라서 우리가 새로운 응답으로 갱신을 하더라도 이것이 제대로 반영되지 않을 수 있다. (종종 cmd + shift + r, 을 통해서 강력 새로고침을 수행해야 그 때서야 원하는 응답을 받을 때가 있는데, 이럴 때 no-cache 를 해준다면 문제가 해결될 수 있다.)

캐시 무효화(Cache Busting)

보통 캐시는 js 파일이나 css 와 같이 정적 리소스에 대해서 캐싱을 하는 것이 일반적이다. 그리고 이런 경우에는 우리가 새로운 정적 리소스를 배포하기 전에는 파일이 변경되지 않으므로 최대한 오래 캐시해두는 것이 이득이다. 예를 들어 style.css 를 배포했고, 이후 다음 배포까지는 이를 쭉 사용한다고 하자. 그러면 다음 새로운 css 파일로 업데이트 되어 배포되기 전까지는 style.css 를 계속해서 캐시해두는 것이 캐시를 최대로 활용하는 것이 되는 것이다. 그리고 새롭게 배포했을 때는 바로 캐시를 최신화해주어야 한다. 따라서 파일의 이름을 단순히 style.css 로 사용하는 것이 아니라 버전을 추가해서 style-v1.css 와 같이 쓰는 등의 방식이다. 하지만 버저닝을 하는 경우 버전을 일일이 기억해야 하기 때문에 해쉬값을 함께 활용하는 것이 일반적이다.
이렇게 되면 리소스가 변경된 것으로 인식하기 때문에 캐시를 최대한 길게(보통 1년) 사용할 수 있고, 배포가 되어 갱신되는 경우 새로운 리소스로 인식해 이를 다시 캐싱하게 되는 것이다.

학습 테스트

학습 테스트 진행한 저장소

휴리스틱 캐싱 제거

브라우저는 Cache-Control이 없어도 휴리스틱 캐싱 을 통해서 암묵적인 캐싱을 진행한다.
하지만 의도하지 않은 캐싱이 진행되는 것이다. 의도하지 않은 캐싱을 막기 위해 모든 응답 헤더에 Cache-Control: no-cache 를 명시해줄 수 있다.
또한 개인 사용자의 정보를 유출하는 것을 막기 위해 private 설정도 추가해보자.

    @Test
    void testNoCachePrivate() {
        final var response = webTestClient
                .get()
                .uri("/")
                .exchange()
                .expectStatus().isOk()
                .expectHeader()
                .cacheControl(CacheControl.noCache().cachePrivate())
                .expectBody(String.class)
                .returnResult();

        log.info("response body\n{}", response.getResponseBody());
    }

위의 테스트 코드가 통과하면 된다.

public class CacheInterceptor implements HandlerInterceptor {

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
                            @Nullable ModelAndView modelAndView) {
        final String cacheControl = CacheControl
                .noCache()
                .cachePrivate()
                .getHeaderValue();

        response.addHeader(CACHE_CONTROL, cacheControl);
    }
}

위와 같이 Interceptor 의 postHandle() 메소드를 이용하여 컨트롤러를 통해 요청을 처리한 이후 해당 메소드가 호출되도록 한다. 그리고 응답 헤더에 CacheControl 을 추가해주는데 이 때, noCache() 와 cachePrivate() 메소드를 통해서 Cache-Control: no-cache, private 을 추가해준다.

HTTP Compression 설정

HTTP 응답을 압축하면 웹 사이트의 성능을 높여줄 수 있고, 스프링 부트에서는 이를 위해 gzip 과 같은 HTTP 압축 알고리즘을 제공한다. Enable HTTP Response Compression

application.yml 에 아래와 같은 설정을 추가해준다.
min-response-size 는 기본 2KB 이기 때문에 작은 크기의 html 파일은 압축되지 않는다. 따라서 10으로 설정해서 테스트해보았다.

server:
  compression:
    enabled: true
    min-response-size: 10

ETag/If-None-Match 적용

ETag와 If-None-Match 를 이용해서 HTTP 를 캐싱할 수 있다. Spring MVC 에서는 ShallowEtagHeaderFilter 클래스를 제공해주며 해당 필터를 이용해서 ETag를 적용해볼 수 있다.

@Configuration
public class EtagFilterConfiguration {

    private final ResourceVersion version;

    public EtagFilterConfiguration(final ResourceVersion version) {
        this.version = version;
    }

    @Bean
    public FilterRegistrationBean<ShallowEtagHeaderFilter> shallowEtagHeaderFilter() {
        FilterRegistrationBean<ShallowEtagHeaderFilter> filterRegistrationBean
                = new FilterRegistrationBean<>(new ShallowEtagHeaderFilter());
        filterRegistrationBean.addUrlPatterns("/etag", PREFIX_STATIC_RESOURCES + "/" + version.getVersion() + "/*");

        return filterRegistrationBean;
    }
}
    @Test
    void testETag() {
        final var response = webTestClient
                .get()
                .uri("/etag")
                .exchange()
                .expectStatus().isOk()
                .expectHeader().exists(HttpHeaders.ETAG)
                .expectBody(String.class).returnResult();

        log.info("response body\n{}", response.getResponseBody());
    }

캐시 무효화(Cache Busting)

캐시는 URL에 따라 리소스를 구분해서 진행되므로 리소스가 업데이트될 때 URL을 변경하면 간단하게 캐시를 제거할 수 있다.
캐시 무효화는 이러한 방법을 이용하여 리소스가 업데이트 될 때 URL을 변경하여 응답을 장시간 캐시하게 만드는 방법이다.
이를 위해서 정적 리소스 파일에 대해서 max-age 를 최대치인 1년으로 설정하고 ETag 를 설정해준다. 그리고 리소스에 변경사항이 생기면 캐시가 제거되도록 URL에 버전을 함께 명시해준다.

    @Test
    void testCacheBustingOfStaticResources() {
        final var uri = String.format("%s/%s/js/index.js", PREFIX_STATIC_RESOURCES, version.getVersion());

        final var response = webTestClient
                .get()
                .uri(uri)
                .exchange()
                .expectStatus().isOk()
                .expectHeader().exists(HttpHeaders.ETAG)
                .expectHeader().cacheControl(CacheControl.maxAge(Duration.ofDays(365)).cachePublic())
                .expectBody(String.class).returnResult();

        log.info("response body\n{}", response.getResponseBody());

        final var etag = response.getResponseHeaders().getETag();

        webTestClient.get()
                .uri(uri)
                .header(HttpHeaders.IF_NONE_MATCH, etag)
                .exchange()
                .expectStatus()
                .isNotModified();
    }
@Configuration
public class CacheBustingWebConfig implements WebMvcConfigurer {

    public static final String PREFIX_STATIC_RESOURCES = "/resources";
    private static final int ONE_YEAR = 31_536_000;

    private final ResourceVersion version;

    @Autowired
    public CacheBustingWebConfig(ResourceVersion version) {
        this.version = version;
    }

    @Override
    public void addResourceHandlers(final ResourceHandlerRegistry registry) {
        registry.addResourceHandler(PREFIX_STATIC_RESOURCES + "/" + version.getVersion() + "/**")
                .addResourceLocations("classpath:/static/")
                .setCacheControl(CacheControl.maxAge(ONE_YEAR, TimeUnit.SECONDS).cachePublic());
    }
}

CacheBustirngWebConfigaddResourceHandlers() 메소드에서 registry 에 버전을 추가하고 캐싱을 추가해준다.

CacheWebConfig 에서는 버전이 포함된 요청은 제외해준다.

@Configuration
public class CacheWebConfig implements WebMvcConfigurer {

    private final ResourceVersion version;

    public CacheWebConfig(final ResourceVersion version) {
        this.version = version;
    }

    @Override
    public void addInterceptors(final InterceptorRegistry registry) {
        registry.addInterceptor(new CacheInterceptor())
                .excludePathPatterns(PREFIX_STATIC_RESOURCES + "/" + version.getVersion() + "/**");
    }
}

EtagFilterConfiguration 에서는 "/etag" 경로 뿐 아니라 버전을 포함한 경로도 Filter를 거칠 수 있도록 수정해준다.

@Configuration
public class EtagFilterConfiguration {

    private final ResourceVersion version;

    public EtagFilterConfiguration(final ResourceVersion version) {
        this.version = version;
    }

    @Bean
    public FilterRegistrationBean<ShallowEtagHeaderFilter> shallowEtagHeaderFilter() {
        FilterRegistrationBean<ShallowEtagHeaderFilter> filterRegistrationBean
                = new FilterRegistrationBean<>(new ShallowEtagHeaderFilter());
        filterRegistrationBean.addUrlPatterns("/etag", PREFIX_STATIC_RESOURCES + "/" + version.getVersion() + "/*");

        return filterRegistrationBean;
    }
}
profile
꾸준함에서 의미를 찾자!

0개의 댓글