Published on

HTTP 헤더에서 캐시를 지원하는 방법

Authors
  • avatar
    Name
    이건창
    Twitter

Introduction

공부하게 된 계기

스프링 클라우드가 4.0 버전 부터 응답 데이터를 캐싱하는 동작을 지원하고 있어. cache-controlno-cache 로 설정 했을 때 304 not-modified 가 반환되는 모습을 봤고 응답 간에 헤더를 활용해 데이터의 라이프 사이클을 제어하는 모습이 신기했어.

그래서 cache 를 제어하기 위해 header 에 입력되는 값들을 확인해보기로 했어.

캐싱이란

캐시 목적은 처리율이 다른 시스템 사이의 병목 현상을 해결하기 위해서야. 운영체제에서는 CPU 읽기 속도와 디스크 읽기 속도 차이를 좁히기 위해 캐시 레이어인 메모리를 뒀어.

대부분 캐시 레이어는 빠른 영역보다는 크게 느린 영역보다는 적게 사용하고 있어. 그 이유는 가격이 비싸기 때문이야.

그럼 디스크 영역보다 적은 공간을 쓰는게 가능한 이유는 캐시가 참조 지역성을 띄기 때문이야.

참조 지역성은 조회하려는 데이터와 관계된 데이터가 다음에 조회하는 경우가 높아. 다음에 조회될 가능성이 높은 데이터를 미리 읽어 두게 돼.

응답을 캐싱하는 일이란

그 빠르고 느린 관계는 HTTP 통신 과정에서도 나타나. HTML 코드와 달리 이미지나 JS 파일이 느리게 도착한다면 사용자는 느린 응답을 받게 돼. 이런 불편감을 줄이기 위해 캐시 레이어를 두고 있어. HTTP 통신에서는 클라이언트에서 캐시 레이어를 두는 방법이 있고 서버에서 캐시 레이어를 두는 방법이 있어.

결국 두 개다 사용하는 경우가 많지만 분리해서 생각한다면 데이터의 라이프 사이클을 쉽게 파악해서 캐시로 인해 발생하는 문제를 해결 할 수 있어보여.

서버에서 캐싱하는 입장

서버 사이드에서 캐시를 관리하는 일은 CDN 처럼 활용하기 위해서라고 생각하면 돼. 사용하는 이유는 동일한 컨텐츠를 여러 클라이언트에게 전달하기 위해 사용하는 방법이야.

서버 처리율을 줄여준다는 장점은 있지만 클라이언트와의 통신 시간이 존재해 클라이언트에 저장하는 것보다 느리다는 단점이 있지.

클라이언트에서 캐싱하는 입장

클라이언트에서 캐싱하는 방법은 사용자 컴퓨터 내부에 데이터를 저장해 재활용하는 방법이야. 만약 캐시가 없다면 서버에게 데이터를 요청하게 돼.

서버에서 캐싱하는 방법보다 빠르른 장점이 있지만 사용자에 의해 데이터 생명 주기가 관리되기 떄문에 동작을 쉽사리 예측 할 수 없지. 만약 캐시를 저장하지 않는 사용자라면 서버에 많은 요청을 보내 느린 응답을 받을 수 없는 상황이 올 수 있어.

통신에서 캐싱을 활용하는 방법

통신에서 캐싱을 활용하는 방법은 cache-control을 사용하는 방법과 etag, modified-date 활용하는 방법이 있어. 통신에서 캐싱을 활용하기 위해 헤더에 어떠한 값을 저장하는 모습을 확인하고 그에 따른 단점도 파악해볼게.

cache-control

헤더에 cache-control를 정하면 자신이 갖고 있는 리소스를 클라이언트에 저장한 후 데이터가 적절한지 확인하게 된다. cache-control로 데이터를 제어하는 방법은 크게 세 가지로 나뉘어.

  • max-age=N : 캐시를 N초 만큼 활용하고 시간이 지나면 서버에 검증한다.
  • no-cache(== max-age=0) : 캐시는 저장하지만 서버에 지속적으로 검증한다.
  • no-store : 캐시에 절대 저장하지 않는다.

cache-control의 큰 단점은 리소스가 변경됐는지 여부를 파악하기 어렵다는 거야. 요청으로 전달한 값으로 데이터가 변경됐는지 확인이 필요한데 힌트가 적을수록 변경됐는지 여부를 파악하기 어려워.

if-none-match

cache-control 외에도 etag를 활용해 데이터 생명 주기를 관리 할 수 있어. if-none-match에 추가된 식별값으로 변경됐는지 확인하고 변경되지 않았으면 가지고 있던 리소스를 재활용 해.

etag의 큰 단점은 etag 상태도 관리 대상이라는 점이다. 여러 대의 서버를 활용해 제공한다면 etag 를 공유 할 수 있는 스토리지 공간이 필요해.

if-modified-science

if-modified-science는 언제 수정됐는지 여부로 데이터 생명 주기를 관리할 수 있어. 수정 날짜도 마찬가지로 공유할 수 있는 스토리지 공간이 필요해.

스프링 게이트웨이에서

cache-control

스프링 게이트웨이는 cache-control 를 활용해 캐시를 컨트롤 하고 있었어. no-cache 는 다음처럼 본문이 없는 데이터를 반환해 빠른 통신을 지향하고 있어.

public class SetStatusCodeAfterCacheExchangeMutator implements AfterCacheExchangeMutator {
	@Override
	public void accept(ServerWebExchange exchange, CachedResponse cachedResponse) {
        //...

		if (!CollectionUtils.isEmpty(cachedResponse.body()) && isRequestNoCache(requestHeaders)) {
            // no-cache 인 경우 304 반환
			response.setStatusCode(HttpStatus.NOT_MODIFIED);
		}
	}

	private boolean isRequestNoCache(HttpHeaders requestHeaders) {
		return requestHeaders.getCacheControl() != null && requestHeaders.getCacheControl().contains("no-cache");
	}
}

public class ResponseCacheManager {
    //...

	Mono<Void> processFromCache(ServerWebExchange exchange, String metadataKey, CachedResponse cachedResponse) {
		//...
		if (HttpStatus.NOT_MODIFIED.equals(response.getStatusCode())) {
            // 상태 코드가 304인 경우 본문이 없는 응답을 반환한다.
			return response.writeWith(Mono.empty());
		}
        //...
	}
    // ...
}

아직 게이트웨이에는 if-none-matchif-modified-science를 식별 할 수 있는 기능을 제공하고 있지 않는 듯 했지만 제공하고 있었어. 모든 헤더 값들을 비교하고 있었지.

public class CacheKeyGenerator {
	/* for testing */ static final List<KeyValueGenerator> DEFAULT_KEY_VALUE_GENERATORS = List.of(
			new UriKeyValueGenerator(), new HeaderKeyValueGenerator(HttpHeaders.AUTHORIZATION, KEY_SEPARATOR),
			new CookiesKeyValueGenerator(KEY_SEPARATOR));


	private Stream<KeyValueGenerator> getKeyValueGenerators(List<String> varyHeaders) {
		return Stream.concat(DEFAULT_KEY_VALUE_GENERATORS.stream(),
				varyHeaders.stream().sorted().map(header -> new HeaderKeyValueGenerator(header, ",")));
	}
}

없었다면 의도를 파악하고 나도 기여 할 수 있는건가?! 생각했지만 나에게 기회는 없었지 흑흑... 다른 방법으로도 기여 할 수 있을지 찾아봐야겠어.

참고 자료