오늘날 많은 웹서비스는 전통적인 Server Side Rendering 방식대신 정적 리소스와 동적 WAS 서버를 분리하는 Client Side Rendering 방식을 사용하고 있으며, 이와 동시에 Single Page Application 형태로 구축하고, 정적 리소스들을 CDN 형태로 서비스하는 경우가 많다.

 

이렇게 구성할 경우 정적 리소스를 엔드유저 근처에서 제공함으로써 Latency 를 낮추고 사용자 경험을 향상시키는 동시에 웹 서버의 부담을 확연히 줄일 수 있다.

또한 Client 와 Server 가 분리되게 된다면 보다 프론트엔드와 백엔드의 역할 구분이 분명해지고 인프라 구축 및 개발 환경 관리가 용이해진다.

 

단, 정적 리소스를 CDN 을 통해 제공하게 될 경우 Caching 옵션에 대해 주의할 필요가 있으며, 그 중에서도 가장 중요한 Cache-Control 헤더에 대해 간략히 기술해보고자 한다.

 

HTTP 헤더에 Cache-Control 헤더를 포함시키면 각 정적 리소스에 대해 헤더를 지정할 수 있고 각자의 캐싱 옵션을 제공할 수 있다. 이 옵션은 다음과 같이 구성되어진다.

위의 예시에서 public 은 모든 캐시가 응답의 사본을 저장한다는 것을 의미한다. 이 말은 CDN, Proxy 서버들이 모두 해당 리소스를 캐싱해도 된다는 것을 의미한다. 이 값을 private 으로 세팅하면 응답의 최종 수신자 (클라이언트 브라우저) 만 파일 사본을 저장할 수 있게 된다.

 

max-age 는 응답이 Fresh 한 것으로 간주되는 시간 단위(초) 를 정의한다. 서버의 컨텐츠가 새로 갱신된다면 200 응답과 함께 새 파일을 다운로드해서 이전 캐시를 Refresh 하고 캐싱 헤더를 유지한다.

서버 컨텐츠가 이미 Fresh 하다면 304 응답을 주며, 새로 파일을 다운로드하지 않는다.

 

그 외에 해당 헤더에 올 수 있는 중요한 다음과 같은 옵션들이 있다.

  • no-store : 이 옵션이 지정될 경우 응답은 절대 캐싱되지 않는다. 모든 요청은 서버를 히트하게 된다.
  • no-cache : 이 설정은 '캐싱하지 않음' 을 의미하지 않으며, 단순히 서버에서 유효성을 재검사하기 이전까지 캐시가 복사본을 제공하지 않음을 의미한다. 이 옵션은 Fresh 한 컨텐츠를 확인하는 가장 합리적인 방법이고, 동적인 HTML 페이지가 사용하기 적합하다.
  • must-revalidate : max-age 와 같이 사용해야하며, 유예기간이 있는 no-cache 옵션처럼 동작한다.
  • immutable : 클라이언트에게 파일은 절대 바뀌지 않음을 알린다

Cache-Control 을 적용한 실제 서비스별 모범 사례를 확인해보자.

 

(1) 온라인 뱅킹 서비스 - 금융 서비스는 트랜잭션과 계좌를 항시 최신으로 보여줘야하고, 어떤 스토리지에도 캐싱해서는 안된다.

(2) 실시간 기차 시간표 서비스 - 실시간성 업데이트가 필요하고, 데이터의 Freshness 가 가장 중요하다.

(3) FAQ 페이지 - 컨텐츠는 자주 업로드되지만 반영이 빠를 필요는 없으므로 페이지는 캐싱되어도 된다.

(4) 정적 CSS 또는 Javascript 번들 - 대부분 정적 파일들은 app.[fingerprint].js 와 같이 관리되며 자주 업데이트되어진다.

 

 

내용은 다음 페이지의 글을 참고하여 작성되었다. 중요한 부분 위주로 번역해서 정리해놓았으나, 웹서비스 바이탈을 설계 시에 고려할만한 명문이라고 생각한다.

 

https://csswizardry.com/2019/03/cache-control-for-civilians/

Dynamic Contents 와 Static Contents 의 차이는 명확하다.

용어가 생소하더라도 개념은 익히 알려진 내용일 것이다. 그럼에도 짚고 넘어가자면,

Static Contents 는 유저/지역 등 어떤 기준을 막론하고 같은 데이터, 즉 정적 데이터를 말하고,

Dynamic Contents 는 유저/지역 또는 어떤 기준에 대하 다를 수 있는, 동적 데이터를 말한다.

 

쉽게 이해하자면 변수와 상수의 차이 정도로 보면 쉽다.

 

너무나도 간단한 개념이지만, 캐싱의 관점에서는 또 다르게 적용될 수 있다.

Static Contents 의 캐싱은 간단하다. 단순히 변하지않는 정적 데이터를 캐싱해주면 된다.

일반적으로 API 등에서 Meta data 를 캐싱하는 경우 In memory 에 캐싱하거나 혹은 설정값으로 관리하는 경우가 많다.

Meta data 가 아닌 종류의 리소스들이라면 CDN 이라는 훌륭한 솔루션이 있고, Cache-invalidation 정책만 조절하여 관리해준다.

 

반면 Dynamic Contents 의 캐싱은 조금 다르다. 계속 변하는 컨텐츠이기 때문에 자체만으로는 캐싱이 불가능하다.

가령 "Wellcome Home" 과 "Wellcome Tom" 이라는 두 종류의 웹페이지가 있다고 해보자. 앞선 페이지는 Static web page 이고 다음 페이지는 Dynamic web page 이다.

Static web page 의 캐싱은 간단하며, 단순히 해당 페이지(컨텐츠)를 저장하지만, Dynamic web page 의 경우 동적 요소를 따로 분리해서 로직으로 저장해주고(web page 의 경우 javascript 객체로 매핑시켜줄 수 있겠다.) static contents 만 캐싱해서 응답을 재구성하다.

 

CDN 및 캐시 솔루션 중에는 Dynamic Contents 에 대한 캐싱을 서비스해주려는 노력들이 꽤 있다.

가령 AWS 의 CloudFront 같은 경우 별도의 Backbone Network 를 구성해서 오리진 서버(비즈니스 로직이 처리될 서버)까지의 Latency 를 줄이고 Region 을 확장하는 방식으로 노력을 하고 있다.

 

 

 

Cache 알고리즘 중에 가장 유명한 알고리즘 중 하나로 LRU 알고리즘 이라는 것이 있다.

 

LRU 알고리즘이란 Least Recently Used Algorithm 의 약자로, 캐시에서 메모리를 다루기 위해 사용되는 알고리즘이다.

 

캐시가 사용하는 리소스의 양은 제한되어 있고, 캐시는 제한된 리소스 내에서 데이터를 빠르게 저장하고 접근할 수 있어야 한다.

이를 위해 LRU 알고리즘은 메모리 상에서 가장 최근에 사용된 적이 없는 캐시의 메모리부터 대체하며 새로운 데이터로 갱신시켜준다.

 

알고리즘의 주요 동작은 다음과 같다.

 

 

가장 최근에 사용된 항목은 리스트의 맨 앞에 위치하고 가장 최근에 사용되지않은 항목 순서대로 리스트에서 제거된다.

LRU 알고리즘의 구현은 위의 그림에서도 볼 수 있듯이 Linked List 를 이용한 Queue 로 이루어지고, 접근의 성능 개선을 위해 Map 을 같이 사용한다.

 

다음 예제코드를 참조하자.

 

public class LRUCacheImpl {

	private class ListNode {
		private int key;
		private int val;
		private ListNode prev;
		private ListNode next;

		public ListNode(int key, int val) {
			this.key = key;
			this.val = val;
			this.prev = null;
			this.next = null;
		}
	}

	private Map<Integer, ListNode> nodeMap;
	private int capacity;
	private ListNode head;
	private ListNode tail;

	public LRUCacheImpl(int capacity) {
		this.nodeMap = new HashMap<>();
		this.capacity = capacity;
		head = new ListNode(0, 0);
		tail = new ListNode(0, 0);
		head.next = tail;
		tail.prev = head;
	}

	private void remove(ListNode node) {
		node.prev.next = node.next;
		node.next.prev = node.prev;
		nodeMap.remove(node.key);
	}

	private void insertToHead(ListNode node) {
		this.head.next.prev = node;
		node.next = this.head.next;
		node.prev = this.head;
		this.head.next = node;
		nodeMap.put(node.key, node);
	}

	public int get(int key) {
		if (!nodeMap.containsKey(key)) {
			return -1;
		}
		ListNode node = nodeMap.get(key);
		remove(node);
		insertToHead(node);
		return node.val;
	}

	public void put(int key, int value) {
		ListNode newNode = new ListNode(key, value);
		if (nodeMap.containsKey(key)) {
			ListNode oldNode = nodeMap.get(key);
			remove(oldNode);
		} else {
			if (nodeMap.size() >= capacity) {
				ListNode tailNode = tail.prev;
				remove(tailNode);
			}
		}
		insertToHead(newNode);
	}
}

 

여기서 테크닉적으로 중요한 부분은, Linked List 처리의 효율성과 코딩상의 이점을 위해 Head 와 Tail 을 단순히 포인터로만 둔 상태에서 중간에 노드들을 배치시키는 더블 링크드리스트 형태를 취한다는 점이다.

즉, Head 가 가리키는 head.next 값이 실제 리스트의 첫 원소가 되고, Tail 이 가리키는 Tail.prev 값이 실제 리스트의 마지막 원소가 된다.

 

이렇게 Linked List 와 Map 을 이용해서 구현 시, Get 연산에 O(1), Put 연산에도 O(1) 의 성능을 가져올 수 있다.

 

쉬우면서도 효과적인 알고리즘이자 자료구조이므로 잘 익혀두자.

 

 



Cache 가 저장된 데이터를 Flush 시키는 전략은 캐시 서버의 유지에 있어서 중요한 부분이다.


가령 캐시에 저장된 데이터가 실제 데이터와 동기화가 안되어 잘못된 값이 참조된다거나, 이전 데이터가 만료되지 않는 경우,

혹은 저장된 정보인데 Key 값이 Mismatch 나거나 불필요하게 Flush 되어 Cache hit-ratio 가 낮아진다면 이는 전체 서버 구조에 영향을 미치게 된다.


다음은 Cache 서버를 사용할 때 유의해야할 몇가지 Cache 의 Flushing 전략 들을 정리해보았다.


주로 사용되는 캐시인 Redis 위주로 정리가 되어있으며, Cache 자체에 대한 범용적인 정리이지만 예시는 Redis 위주이며 캐시의 종류에 따라 차이가 있을 수 있다.



1. Cache 의 Flushing 은 일반적으로 Active 한 방법과 Passive 한 방법이 있음을 이해하자.


 : Active 한 방법은 별도의 요청에 따라 Cache 를 Flush 시키는 방법이고, Passive 한 방법은 자체에 Expire Time 을 이용해서 외부에서 별도 요청없이 캐시를 비우게끔 만드는 전략이다.

 

 만약 Active Flushing 을 하지 않는다면, 해당 캐시는 수동적으로만 갱신되므로 실시간적인 데이터 동기화 및 캐싱이 불가능하다.

 Passive Flushing 을 하지 않는다면, Active 한 방식으로 Flushing 되지않는 데이터는 영구히 캐시 서버에 남아서 공간을 차지하게 되며 이는 큰 비용이 될 수 있다.


 반대로 지나치게 Active 한 Flushing 이 많아진다면, Cache 는 Read 요청 대비 Write 요청의 비율이 높아져 쓰는 효율이 없어지게 되고, Passive Flushing 도 지나치게 짧은 단위로 Flushing 이 일어난다면 Cache hit ratio 가 줄어들 것이므로 설계에 유의가 필요하다.

 

 일반적으로 API 의 설계에 맞춰 Active Flushing 타이밍을 정확히 맞추고, Passive Flushing 을 위한 Expire Time 의 설정은 선험적 지식 또는 벤치마킹 등에 의해 측정되어 설정되게 된다.

 


2. Cache Refresh / Expire reset 기준을 확인하는 것이 중요하다.


 : 사용하는 캐시 서버 및 설정에 따라 다를 수 있지만, Cache Refresh 의 기준을 확인하는 것은 중요하다. 

 가령 Redis 의 경우 Cache hit Read 기준으로 Expire reset 이 발생하기 때문에, 계속적으로 Cache hit 이 발생하면 자동으로 Expire 는 되지않는다.

 단순 기능이라면 큰 문제가 안되겠지만 캐시를 바라보는 경우가 많을 경우 문제가 생기기 쉬운 상황이므로 잘 체크해주어야 한다.


 즉, 지속적으로 Cache-hit 이 발생하는 한, Active 한 방식의 Flushing 이 아닌 Passive Flushing 을 기대하는 건 잘못된 방식이다.

 개인적으로 QA 프로세스에서 Caching 문제가 발생하고 있었는데, 이 부분을 간과해서 문제가 해결되지 않은 버전이 Live 에 포함될 뻔 한 일이 있었다.



3. 사용하는 캐시 서버의 Expire 로직을 이해하자.


 : Cache 를 사용할 때 캐시 서버의 로직을 파악하고 있는 것이 중요하다. 중요한 것은 사용하는 Cache 서버가 어떻게 내부적으로 동작하냐는 것이다.

 가령 Redis 의 경우 일정한 주기를 갖고 랜덤하게 key 들을 뽑아서 expire 을 검사하고 expiration rate 가 높을 경우 다시 일정 주기로 검사하는 루틴을 지닌다.

 이러한 구조를 이해하는 것은 서버 설정하는 데 있어서 기본이 되며 보다 나은 환경을 구축하는 데 도움을 준다.




캐시에 대한 현업에서 이슈들로 겪으면서 가장 중요했던 기본 내용들만 정리해보았다.


실제 이슈와 그에 대한 트러블 슈팅은 따로 포스팅에 정리하도록 하겠다.






캐시란 데이터를 임시로 저장해두는 장소를 말한다. 임시로 저장하여 사용하는 데이터의 종류는 제한이 없기 때문에 작게는 메모리에서 크게는 Logic 혹은 그 이상을 저장할 수 있다.


Cache 의 목적은 로직을 처리하는 데 있어서 빠른 접근성을 제공하는 것이며, 단순히 수동적으로 보관하는 것에 그치지 않고 이를 응용해서 작업의 결과를 저장함으로써 해당 로직의 불필요한 수행을 줄여주는 능동적인 역할까지 수행한다.


캐시가 될 수 있는 것은 일반적으로 로컬 메모리부터 별도의 디스크 볼륨까지 다양하지만 Cache 로 사용하기 위한 가장 중요한 요건은 데이터로의 접근성이다.


Cache 에 대한 접근성은 어떤 경우에도 로직 상에서 원하는 데이터에 직접 접근하거나 만들어내는 비용보다 저렴해야 한다. 그래야만 캐시로서의 의의가 있는 것이다.


그렇기 때문에 Cache 는 접근성이 빠른 공간(Space)에 빠른 자료구조를 사용한다.


캐시서버로 이용되는 서버들은 I/O 에 최적화된 공간이 사용되며 당연히 이에 대한 접근성에 있어서 효율적인 REST 등의 방법을 사용한다. 


컴퓨터 내에서 사용되는 캐시는 RAM보다 빠른 L1,L2 레지스터를 캐시로 사용하고, 프로그램 내에서 구현된 Software Cache 라면 접근에 용이한 Map 과 같은 자료구조에 인메모리(Inmemory)로 저장한다.


다음은 캐시를 이해하는 데 중요한 용어들이다.


 - origin : origin 혹은 origin server 는 캐시에 저장할 실 데이터가 존재하는 공간이다. 웹 캐시라면 DB 서버일 수도 있고, SW 내에서라면 파일 혹은 실행 함수 그 자체일 수도 있다.


 - cache expire : 프로세스 내에서 사용하는 인메모리 캐시나 영구히 상주해야하는 정보를 가진 캐시가 아니라면 Cache 는 Expire Date 를 갖고 있으며 해당 시간이 지나면 상한(Stale) 상태가 된다.


 - cache freshness : 캐시가 만료되지 않은 경우를 fresh 한 캐시라 하고 만료된 경우 stale cache 라 한다.


 - cache hit : 참조하려는 데이터가 캐시에 존재할 때 해당 캐시를 조회하는 걸 Cache hit 이라 한다.


 - cache miss : 참조하려는 데이터가 캐시에 존재 하지 않는 경우


 - cache hit ratio : 적중률로 전체 참조 횟수 대비 Cache hit 된 비율을 의미한다. 실질적으로 캐시의 설계는 Cache hit Ratio 를 높이는 데 초점을 둔다.



다음은 캐시의 동작에 대한 정책들이다.


 Cache Read : 


 - Cache-aside 방식 : 데이터를 참조 하기 전에 참조하고자 하는 값이 캐시에 존재하는지 확인한다. 여기서 값을 직접 비교하기 보다는 키를 이용해서 캐시에 접근한다. 

 Cache에 존재한다면 Cache에서 데이터를 가져온다. 만약 Cache에 존재하지 않는다면 origin에서 데이터를 가져오고 이를 캐시에 저장한다.


 - RT/WT/Write back 방식 : 캐시를 Main Data Source 로 사용하기 때문에 캐시에서만 데이터를 조회한다.

 RT/WT 방식(Read Through / Write Through) 은 Read Scalability 가 가장 뛰어나다.



 Cache Write :


 - Cache-aside 방식 : 캐시를 Application Level 에서 직접 갱신시켜준다. 개발자가 Flow 를 이해하고 Update / Evict 시켜줘야 하며, 그렇지 않으면 Cache 데이터와 DB 데이터가 불일치하는 Stale 현상이 발생한다.


 - Read Through / Write Through 방식 : 데이터의 쓰기시 캐시와 실제 저장공간의 데이터 둘다 최신화 시키는 작업이다. 

 캐시를 메인 Database 로 사용하는게 특징적이며, 캐시에 데이터를 먼저 업데이트하고 캐시에서 Main Database 를 즉시 갱신시킨다.

 양쪽의 데이터를 동일하게 유지할 수 있지만, 쓰기 시에 추가 부하가 생긴다는 단점이 있다.


 - Write Back 방식 : 데이터의 쓰기시 캐시의 데이터만 최신화 하고 해당 Cache 를 Evict 시켜놓는다. 이 후 RT/WT 방식처럼 캐시 값을 마킹된 기준으로 origin 으로 직접 반영(Write) 하는데, 캐시가 별도의 큐를 이용해서 Database Source를 비동기로 Update 시켜준다.

 Write Performance 와 DB Scalability 에 있어서 가장 뛰어나다.

 쓰기 작업이 Cache 에서만 발생하지만, Cache 가 만료되는 시점까지 Origin 에 Write Failure 가 발생한다면 데이터를 영구 손실할 위험이 존재한다. 



 Cache Replacement


  웹 캐시의 경우 자동 expire 하거나 명시적으로 cache 를 지워주는 동작을 해주지만, 캐시를 Scheduling 에 사용하는 컴퓨터나 알고리즘의 경우 Replicement Policy 를 갖는다.



 다음은 몇가지 대표적인 알고리즘들이다.


  - FIFO(First In First Out) : 오래된 캐시를 먼저 비우고 새로운 캐시를 추가하는 방식이다.


  - LIFO(Last In First Out) : 가장 최근에 반영된 캐시가 먼저 지워진다.


  - LRU(Least Recently Used) : 가장 최근에 사용되지 않는 순서대로 캐시를 교체한다. 가장 오랫동안 사용되지 않은 캐시가 삭제되며 일반적으로 사용되는 방식이다.


  - MRU(Most Recently Used) : 가장 최근에 많이 사용되는 순서대로 캐시를 교체한다. 휘발성 메모리를 이용해야 하는 특수한 상황에 사용된다.


  - Random : 말그대로 랜덤으로 캐시를 교체한다.


 운영체제를 배웠다면 페이지 교체 알고리즘이 Cache Replacement 정책을 사용한다는 것을 알 수 있을 것이다.



본 포스팅에서는 넓은 범위의 Cache 의 정의와 목적, 정책들에 대해 정리해보았다.


이론적인 부분이고 웹 캐시와는 조금 다르기도 하지만 중요한 기본 개념은 잘 숙지해두자.


참조 : 

https://en.wikipedia.org/wiki/Cache_replacement_policies

https://codeahoy.com/2017/08/11/caching-strategies-and-how-to-choose-the-right-one/

https://gomguard.tistory.com/115

https://onecellboy.tistory.com/260

https://dzone.com/articles/using-read-through-amp-write-through-in-distribute



Cache 는 서버의 동작을 이해하는 데 있어서 빼놓을 수 없는 부분이며, 서버의 부하를 줄여주고 서버가 가진 능력을 최대한으로 활용할 수 있게 해줄 뿐만 아니라, 클라이언트에도 중요한 역할을 한다.


흔히 말하는 Cache 의 종류로는 Redis 나 Memcached 를 사용하며 대부분 인메모리 형태로 서버의 값을 저장하고, 필요할 때에 해당 값을 반환함으로써 서버의 작업 공수를 줄여준다.


Spring Framework 는 프레임워크 레벨에서 캐시의 추상화를 지원해준다.


Cache의 추상화란, 흔히 캐시를 사용할 때 작업이 필요한 부분에 대한 인터페이스를 제공해준다는 뜻이다.


가령, 웹서버에 캐싱 기능을 적용하기 위해서는 다음과 같은 캐싱의 기본 로직이 탑제되어야 한다.


(1) Memory 혹은 원격 캐시에 연결된 객체를 생성한다. (이를 Cache Manager라 한다.)


(2) 캐시의 값을 불러온다.


(3) 캐시에 값이 존재하지 않는다면 캐싱할 값을 일정한 기준을 갖고 등록한다.


(4) 등록된 캐시값에 대해 조회가 가능하다.


(5) 필요할 때에 캐시의 값을 불러오고 적당한 때에 캐시를 업데이트 한다.


위의 단계들은 기본적으로 캐시가 가져야할 역할이며, 위의 역할 정도는 수행할 수 있어야 서버측에서 "캐시" 로써 동작한다고 할 수 있다.


Spring 에서 위와 같은 단계는 다음 Annotation 들로 대체될 수 있다.


Cache 의 생성 : Spring 의 Cache Configuration 참조


Cache Key Value 의 등록 : @Cacheable



@Cacheable(value="user")
public List<User> getUserListFromDB() {
return selectUserListFromDB();
}

@Cacheable(value="user", key="#uid", condition="#result!=null")
public User getUserFromDB(String uid) {
return selectUserFromDB(uid);
}


@Cacheable 어노테이션과 함께 저장할 캐시의 이름을 value에 명시하고, key 값을 지정하면 해당 결과값을 설정된 Cache에 캐싱할 수 있다.


값은 캐싱될 뿐 아니라, 다시 해당 함수로 접근 시 캐싱된 값이 있다면 내부 함수를 수행하지 않는다.


condition 은 해당 캐시에 적용 시 어떤 항목들에 대해 캐싱하거나 캐싱하지 않을 지를 결정할 수 있다.



Cache Key Value 의 삭제 : @CacheEvict



@CacheEvict(value="user", key="#user.uid", beforeInvocation=false)
public void putUserToDB(User user) {
insertUserToDB(user);
}


@CacheEvict 어노테이션을 이용하면 해당 캐시 이름과 Key 에 저장되어 있는 Cache Value 를 제거할 수 있다. 

이 때 종종 사용되는 옵션으로 beforeInvocation 옵션이 있는데, 이 옵션을 true 로 지정하면 함수가 시작되기 전에 캐시를 비우는 작업을 수행한다.


Cache 의 갱신 : @CachePut


@CachePut 어노테이션은 값이 변경되었을 경우에만 해당 캐시를 비운다.


여러 개의 Caching 동작에 대해 : @Caching



@Caching(evict = {
@CacheEvict(value="user", key="#user.uid")),
@CacheEvict(value="userGroup", key="#user.groupNo")
})
public void addNewUserToDB(User user) {
insertUserToDB(user);
insertUserGroupToDB(user.getUserGroup());
}


Cache 에 대한 여러 동작을 수행하고자 할 때에는 @Caching 어노테이션을 사용한다.



Spring 의 Cache 어노테이션은 내부적으로 SPEL(Spring Expression Language) 라는 문법을 사용한다.

위의 간단한 예시만으로도 사용하는 데 큰 지장은 없을 것이다.




 많은 어플리케이션들에서 사용되는 캐시 로직과 마찬가지로 Web browser 역시 캐시를 사용한다.


웹브라우저에서의 캐시 사용은 단순히 웹페이지의 빠른 로딩을 가능하게 할 뿐 아니라, 이미 저장된 리소스의 경우

서버측에 불필요한 재요청을 하지 않는 방법을 통해 네트워크 비용의 절감도 가져올 수 있다.


이는 아주 중요한 부분으로 리소스를 캐시했다가 재활용함은 트래픽 절감과 클라이언트 측에서의 반응성 향상 및 서버의 부하 감소라는 이점까지도 가져올 수 있다.


그렇다면 웹브라우저는 어떤 항목을 어떻게 캐시하는 것일까?


HTTP Spec 에 의하면, 모든 HTTP Request / Response 는 이러한 캐싱 동작과 관련한 설정을 Header 에 담을 수 있다.

주로 사용되는 HTTP Response Header 로는 Cache-Control 과 ETag 가 있다.


(1) Cache-Control


Cache-Control 헤더는 어떻게, 얼마나 오래 응답을 브라우저가 캐싱하면 좋을지를 브라우저에게 알려준다.


Cache-Control 헤더는 브라우저 또는 다른 캐시가 얼마나 오래 어떤 방식으로 캐싱을 할지를 정의한다. 


Web browser 가 서버에 리소스를 첫 요청할 때, 브라우저는 반환되는 리소스를 캐시에 저장한다. 

Cache-Control 헤더는 몇가지 서로다른 Pair 를 Parameter 를 가질 수 있다.


 - no-cache / no-store : no-cache 파라미터는 브라우저가 캐시를 사용하지 않고 무조건 서버에서 리소스를 받아오게끔 한다.

 하지만 여전히 ETags 헤더를 체크하기 때문에, ETags 헤더의 조건에 맞는다면 서버에 직접 요청하지 않고 캐시에서 리소스를 가져온다.

 no-store 파라미터는 ETags 에 상관없이 Cache 를 사용하지 않고 모든 리소스를 다운받도록 하는 옵션이다.


 - public / private : public 은 리소스가 공개적으로 캐싱될 수 있음을 말하고 private 은 유저마다 리소스에 대한 캐싱을 하도록 한다.

 private 옵션은 특히 캐시에 개인정보가 담길 경우 중요하다.


 - max-age : max-age 는 캐시의 유효시간을 의미한다. 초단위로 입력된다.


(2) ETag


ETag 헤더는 캐시된 리소스가 마지막으로 캐시된 이후에 변했는지를 체크해주는 헤더이다.

전체 리소스를 재다운로드하는 대신, 수정된 부분을 체크하고, Same Resource 는 재다운로드하지 않는다.

ETag 는 서버에서 리소스에 대해 할당하는 Random String 으로 할당이 되며, 이값을 비교함으로써 revision 을 체크한다.

이 유효성 검사 토큰을 사용하면 리소스가 변경되지 않은 경우 이므로 추가 데이터 전송 요청을 전송하지 않는다.


ETag 값은 다시 서버에 전송해야하며, 서버는 리소스의 토큰값과 비교해서 변경되지 않은 경우 304 Not Modified 응답을 반환한다.



이를 바탕으로 브라우저 캐싱 동작을 정리해보자.




웹브라우저는 서버의 응답값을 바탕으로 재사용가능한 Response 인지 확인하고, Validation 과 Cache 의 성질, Expiration Time 에 따라 캐시 정책을 결정한다.

이에 대해 구글은 위와 같은 명확한 형태의 Decision Tree 를 제공한다.


이외에도 웹에서 사용되는 캐시 로직을 좀 더 이해하기 위해선 서버사이드의 Cache 로직 역시 이해할 필요가 있다.


캐시 정책에 있어서 왕도는 없으며, 트래픽 패턴, 데이터 유형 및 서비스 종류에 따라 알맞게 설계하는 것이 중요하다.

그 중에서도 최적의 캐시 Lifetime 의 사용, Resource Validation, 캐시 계층 구조의 결정은 반드시 고려되어야 한다.




좀 더 자세한 자료는 다음을 참고한다.

[https://thesocietea.org/2016/05/how-browser-caching-works/]

[https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching?hl=ko]


+ Recent posts