수많은 사용자가 사용하는 웹 사이트가 있다고 가정해보자. 이러한 웹 사이트는 수많은 유저들에 의해 다양한 요청이 서버에 도착할 것이고, 서버는 이에 맞는 응답을 네트워크를 통해 전달할 것이다. 네트워크의 제한된 대역폭에 비해 출력량이 많아진다면 당연하게도 네트워크 지연이 발생하게 된다. 즉 대역폭 병목현상이 발생하게 된다.
물론 이벤트성 페이지에서 12시 정각에 상품 구매창이 열린다던지, 대학 수강신청과 같이 한 순간에 대량의 유저가 몰리는 경우와 같이 어쩔수 없이 지연이 발생하게 되는 상황은 지연이 발생할 수 밖에 없는 과정을 인정하고 이에 대한 해결책을 강구해야할 것이다. (이벤트 페이지의 구매창 클릭 버튼을 맨 밑에 위치시키는 등으로 급격하게 너무 쉽게 사용자가 몰리는 상황을 예방하는 방법도 있겠다.)
하지만 이러한 특수한 경우가 아니라, 단순히 검색을 위해 input 창에 키워드를 입력하는 과정이나, 단순히 목록 페이지에서 페이지내이션을 이동하거나, 그저 유저가 페이지를 이동할 뿐인 상황에서도 같은 요청을 반복하게 되어 출력이 많아져 생긴 병목현상이라면, 이는 캐싱을 통해 어느정도 해결할 수 있다.
이번 포스팅에서는 캐시에 대한 간략한 설명과 이를 이용한 data fatching hook 인 swr 에 대해서 살펴보도록 하자.
HTTP 캐시 (Cache)
컴퓨터에서 캐시(Cache) 는 일정 데이터를 임시로 저장하는 고속 처리 스토리지 계층을 의미한다. 캐시의 데이터는 일반적으로 RAM 과 같이 빠르게 처리할 수 있는 하드웨어에 저장이 된다. 영구적으로 저장되는 데이터와는 다르게 캐시는 일정한 기간동안 저장되어있다.
캐시의 필요성을 예시를 통해 이해해보자. 브라우저에서 이미지를 서버에 요청한 뒤, 응답으로 메시지와 함께 이미지를 받아온다고 가정해보자. 메시지가 대략적으로 0.1mb 정도이며 이미지가 1mb 라고 한다면 응답마다 총 1.1mb 의 대역폭이 필요해진다. 이미지와 텍스트가 변화가 없는데도 매번 요청을 할 때마다(즉 페이지를 그냥 읽는) 1.1mb 씩 계속해서 데이터를 받아와야 하기에 로딩 속도의 저하를 가져오게 된다. 인터넷 네트워크는 기본적으로 매우 느린편이기에 데이터가 변경되지 않는 상황에서 계속 네트워크를 통해 데이터를 다운 받는것은 너무 비효율적이다. 이럴 때 데이터 캐시가 필요해진다.
캐시 중에서 웹 브라우저에서 활용하는 캐시를 HTTP 캐시라 하는데, 최초 서버 요청 시 HTTP header 부분에서 cache-control 가 포함되어있음을 확인할 수 있다.
HTTP/1.1 200 OK
Content-Type: image/jpeg
cache-control: max-age=60
Content-Length: 34012
앞선 예시의 1.1mb 의 데이터가 캐시화 되어있다면, cache-control 은 이 데이터가 얼마나 캐시화 되어있는지를 나타낸다. 즉 위에서는 60초 동안 캐시가 유효하다 라고 해석할 수 있다.
웹 브라우저에는 캐시 저장소가 있으며, 이 응답 결과를 캐시 저장소에 저장한다. 두번째 요청에서는 먼저 요청 보내기 전에 캐시저장소를 살펴보고 캐시에 저장되어있다면 그대로 사용하게 된다. 즉, 캐시 가능 시간동안은 네트워크를 사용하지 않아도 되는 것이다. 웹 사이트의 여러 유저가 여러 군데에서 캐시를 활용한다면 기하급수적으로 서버 요청량을 줄일 수 있을 테니, 이전보다 더 쾌적한 브라우저 로딩 경험을 얻을 수 있을 것이다.
일반적으로 캐시는 유효 기간이 만료되면, 다시 서버에 요청을 보내 응답을 받아야 한다. 유효 기간동안 데이터가 변경되었을 수 있기 때문이다. 이러한 특징때문에 기존의 캐시 데이터를 신선하지 않은(Stale) 캐시라 하며, 새로 받아온 캐시 데이터를 신선한(fresh) 캐시 라고 표현한다. 데이터가 변경이 되었다면 당연하게도 업데이트를 해야하지만, 데이터가 그냥 그대로라면 역시나 의미없는 데이터 갱신을 하게 된다. 이를 해결할 방법을 찾아야 한다.
검증 헤더와 조건부 요청
캐시의 유효 시간이 초과해서 서버에서 다시 요청을 하는 경우 기존 데이터를 변경해야 하는 상황과, 기존 데이터를 변경하지 않아도 되는 상황이 발생하게 된다. 변경해야 한다면 당연히 해야하는데, 변경하지 않아도 되는 상황이라면 이전 캐시를 그대로 계속 활용하는것이 더 효율적일 것이다. 이를 위해 검증 헤더가 사용이 된다.
HTTP/1.1 200 OK
Content-type: image/jpeg
cache-control: max-age=60
Last-Modified: 2020년 11월 10일 10:00:00 (실제로는 한글 아니다)
...
새로운 header 가 추가되었다. Last-Modified!
최초 요청 시 위 헤더를 받게 되는데, 이 속성을 통해 언제 최종적으로 파일이 업데이트 되었는지 확인할 수 있다. 캐시저장소에 이를 같이 저장하고, 유효 기간이 지난 후 다시 요청을 보낼 때, 요청 헤더에 아래처럼 속성을 추가해서 전송한다.
GET /star.jpg
if-modified-since: 2020년 11월 10일 10:00:00
의미 그대로 이해하면 된다. '혹시 업데이트 날짜가 이 날짜인가요?' 라고 같이 전송하게 되는데, 만일 서버측에서 데이터 변화가 없다면 요청 if-modified-since 의 날짜가 Last-Modified 와 동일할 것이다. 동일하다면 서버에서는 응답 시 데이터가 들어갈 body 부분을 제외하고 헤더만 전송하게 된다.
HTTp/1.1 304 Not Modified
Content-Type: Image/jpeg
cache-control: max-age=60
Last-Modified: 2020년...
이를 통해 브라우저는 캐시 안 데이터를 다시 활용해도 되겠다고 판단하게 된다.
검증 헤더의 경우 Last-Modified 만 있는것은 아니다. 오히려 이 방식은 한계점을 가지고 있다. Last-Modified 의 경우 시간으로 표현하기에 1초 미만 단위로의 캐시 조정은 불가능 하다. 또한 날짜 기반의 한계점이 있는데, 예를 들어서 a 라는 데이터를 b 라는 데이터로 변경하였다고 하면 분명 변경한 날짜가 업데이트 될 것이다. 그런데 브라우저 요청이 있기 전 다시 b 에서 a 로 업데이트 했다고 해보자. 그러면 역시나 업데이트이니 날짜가 변경이 될 것이다. 하지만 데이터는 a 그대로 이다! 브라우저는 날짜가 변경되었냐고 물어볼 것이며, 실제로 날짜가 변경되었으니 서버에서는 a 라는 데이터를 재전송하게 된다.
이러한 문제를 해결하려면 ETag 방식을 사용하면 된다.
ETag는 Entity Tag 의 약자로서, 캐시용 데이터 이름에 임의의 고유한 ETag 를 포함시켜 이 ETag 가 변경되면 업데이트가 된 것이라고 판단하게 하는 것이다. 예를 들어서 버전이라고 한다면 'v.10' 이 최초에 붙어있었고, 이 이름이 'v.20' 이 되면 업데이트를 하면 된다.
HTTP/1.1 200 OK
Content-Type: Image/jpeg
cache-control: max-age=60
ETag: "aaaaa"
...
최초로 응답을 받을 시 ETag 가 함께 오고 되고, 이를 같이 저장한 다음, 유효 기간이 지났을 때 서버에게 아래처럼 요청을 보내게 된다.
GET /star.jpg
If-None-Match: 'aaaaa'
서버가 가진 ETag 가 위와 동일하다면 이전처럼 304 코드를 전송하여 변경사항이 없음을 알린다. 만약 다르다면 200을 전송하면서 업데이트 된 데이터를 함께 전송하게 된다. 날짜가 아니기에 위 예제처럼 a 에서 b 로 변경후 다시 a 로 변경했다 하더라도 ETag를 변경시키지 않는다면 304 코드를 전송하게 될 것이다.
캐시화를 하고 싶지 않은 데이터의 경우
어떤 데이터는 항시 최신화를 하여 보여줘야 하는 경우가 있다. 혹은 민감한 정보가 포함되어있어 캐시 저장소에 저장하면 안되는 경우도 있을 수 있다. 위와 같은 상황들에 대응하기 위해서 Cache-control 의 캐시 지시어를 사용한다.
브라우저에서 서버에 요청을 보낼 때 Cache-Control 에 지시어를 넣어서 전달할 수 있다. (이처럼 Cache-Control 의 경우 응답과 요청 모두에서 사용된다.)
위에서 캐시 설정에 사용된 max-age 는 말그대로 캐시의 유효 기간을 설정하는 지시어이며, 나머지 지시어는 다음과 같다.
no cache
캐시를 허용하지 않는 지시어처럼 보이지만 아니다. 캐시를 사용해도 된다만, 반드시 원(Origin) 서버의 데이터 검증을 받고 사용하라는 의미이다. 갑자기 원 서버가 나와서 햇갈릴 수 있겠다. 이 부분은 뒤 프록시 서버를 설명할 때 같이 하겠다. 여하튼 서버의 검증이 보장된다면 캐시를 사용해도 된다는 의미이다.
no store
이 지시어가 실제 캐시로 데이터를 저장하지 않겠다는 지시어이다. 보통 민감한 데이터가 있어서 저장을 할 수 없을 경우 사용하게 된다.
프록시(Proxy) 캐시
위에서 no cache 를 설명할 때 원 서버에 대해서 이야기 했었다. 원 서버는 실제 데이터가 존재하는 서버라고 생각하면 된다. 이러한 원 서버에 직접 접근하는 것은 자칫 속도 저하를 유발할 수 있다.
한국에 있는 유저가 미국 유명 유튜버의 동영상을 감상한다고 생각해보자. 이는 한국에서 동영상에 접근하기 위해 요청을보내게 되고 이 요청은 미국의 원 서버에서 받게된 후 다시 한국으로 응답을 해주게 된다. 바로 알 수 있겠지만 물리적인 거리가 상당하기 때문에 실제 응답 속도가 느릴 수 밖에 없다. 처음은 기다린다고 해도 매번 미국 유튜버의 영상을 볼 때마다 원 서버의 응답을 기다려야 한다면 영상을 보기도 전에 지쳐버릴 수 있겠다.
하지만 실제 영상을 요청하면 생각보다 빨리 데이터 응답이 오게 되는데, 이것이 가능한 이유 역시 캐시에 있다. 한국과 미국의 물리적 거리 사이, 다른 한국 지역에 프록시 캐시 서버를 통해 캐시화 된 동영상을 받을 수 있다. 즉 다른 누군가가 이미 미국 유튜버의 동영상을 시청하여 한국의 프록시 캐시 서버에 캐시저장소에 저장해놓은 상태인 것이다. 그렇기 때문에 한국 유저는 그냥 캐시화된 동영상을 한국 서버에서 바로 응답받아 시청할 수 있게 된 것이다. 당연하게도 원 서버에 직접 접근하는 것 보다 훨씬 빠른 응답속도를 기대할 수 있다. (만일 정말 아무도 안보는 영상을 시청하게 된다면, 원 서버에 직접 접근하는 첫번째 사용자가 될 것이다..)
프록시 서버의 캐시는 public cache(Shared Cache) 라고도 한다. 다른 사용자들에게 접근이 허용된 캐시이다. 반면 privte cache 의 경우 오직 해당 사용자만을 위한 캐시이며 기본값이다.
캐시 무효화 전략 중 Stale-While-Revalidate
지금까지 간략하게 캐시에 대해서 살펴 보았고, 어째서 캐시를 사용해야 하는지, 그리고 캐시가 만료될 경우 ETag 를 통해 다시 재검증을 거칠 수 있다는 사실을 알게 되었다.
기본적인 흐름은 다음과 같다.
- max-age 동안에는 재검증 없이, 캐시 저장소에서 데이터를 사용한다.
- max-age 이후 유효 기간이 지났다면, 다시 서버에서 재검증 과정을 거치게 된다.
- 만일 데이터의 변화가 있다면 다시 데이터를 받아오고, 아니라면 캐시를 갱신한다.
캐시화 된 데이터를 사용하는것은 사용자 경험을 크게 향상시킬 수 있다. 하지만 캐시 유효 기간이 지난 시점에서 다시 요청시 기존 캐시를 그대로 사용할지, 아니면 새롭게 데이터를 받아와서 그 데이터로 렌더를 해야할지 Loding 시간이 생겨버린다. 어떠한 데이터로 사용할지를 결정하는데는 서버에 달려있기 때문이다.
만일 max-age 가 짧다면 데이터의 최신화는 확실하게 보장이 되겠지만, 즉시 데이터를 렌더해야 하는 경우에는 캐시의 장점이 활용되지 않을 수 있다. 반대로 max-age 를 길게 형성하여 즉시성을 확보할 순 있지만 최신화에 대한 보장을 받을 수 없을 수 있다. 이러한 상황에서 어느정도의 타협점을 위한 비표준 단계의 HTTP Cache-Control 지시어인 stale-while-revalidate 캐싱 전략이 고안되었다.
Cache-Control: max-age=100, stale-while-revalidate=50
max-age 에 대한 캐시의 작용은 기존과 같다. 기간 내라면 캐시 저장소에서 그대로 활용하여 사용된다.
만일 max-age 이상의 기간으로서 기존같으면 서버의 재검증이 필요한 경우라면, 이때 swr 의 기간이 중요해진다. 위 예제를 통해서 살펴보자면, 100~150초 사이의 재 요청이라면, swr 의 캐싱전략이 작동하게 된다.
- 100~150초 사이라면 stale-while-revalidate 의 전략에 따라, 아직 재검증은 되지 않았지만 우선은 기존에 저장된 캐시 데이터를 그대로 사용한다
- 그 다음 다음 요청이 오기 전 백그라운드에서 서버에 재검증 요청을 통해 기존 캐시의 최신화를 확인한다
- 그대로라면 그대로 캐시시간만 갱신시키고, 데이터가 변화되었다면 기존 캐시를 새로운 데이터로 갱신한다
- 다음 요청이 오게 되면 기존 max-age 가 갱신되었으니, 다시 갱신된 캐시를 그대로 사용한다.
만약 150초 이후에 요청이 서버에 들어온다면, 그냥 캐시는 무시하고 기존처럼 재검증을 거쳐서 다시 캐시를 갱신한다. 즉, 150초 전까지는 즉시성을 유지하면서 캐시 갱신을 이후 진행하며, 150초 이후는 그냥 바로 검증에 돌입해서 최신화를 신경써준다.
아래 사진은 max-age 가 1초이며, stale-while-revalidate 가 59초인 예시에 대한 설명이다.
캐싱 전략을 활용한 react hook - SWR
react-query 와 같이 위 캐싱 전략을 활용하는 data-fetching 라이브러리로 SWR 이 있다. 말 그대로 Stale-while-revalidate 의 줄임말이다.
SWR 은 재검증 과정 중 사용자에게 기존 캐시 데이터를 보여줌으로서 좀 더 유저 친화적인 웹사이트를 구성하는데 도움을 주는 라이브러리로서, 데이터를 캐시화하여 불필요한 네트워크 대역폭을 감소시키고, 리엑트 내 클라이언트 server state 의 재검증을 손 쉽게 할 수 있도록 도와준다. 기존에는 이러한 캐싱 작업을 직접 개발자가 다 구현했어야 했는데.. 이제 SWR 이나 React-query 를 사용하면 쉽게 캐싱 시킬 수 있다.
기본적인 SWR 의 사용법은 공식문서에 자세하게 나와있다.
https://swr.vercel.app/ko/docs/getting-started
시작하기 – SWR
SWR is a React Hooks library for data fetching. SWR first returns the data from cache (stale), then sends the fetch request (revalidate), and finally comes with the up-to-date data again.
swr.vercel.app
사용법은 어렵지 않으니 추후 포스팅에서 mutate 를 설명할 때 한번 더 사용법에 대해서 설명하겠다.
SWR 은 크게 data 를 받아오고, 못 받아올 시 error, 처음 데이터를 가져올 때의 isLoading, 데이터를 재 검증할 때의 isValidating 이 기본 구조라 생각하면 된다. SWR 이 작동하는 흐름은 다음과 같다
처음에는 당연히 캐시화 된 데이터가 없으니 data = undefined 이다. isLoading 은 데이터가 아직 없고, 받아오는 중일 때 True 가 된다. 프론트단에서는 이 기간동안 다른 화면을 렌더해야할 것이다.
이후 서버로 부터 data 인 value 를 받아오게 되어 렌더하게 된다. 이 다음부터가 기존 react 방식과의 차이점인데, 캐시화 된 value 에 재검증이 필요하다면 isValidating 이 True 가 되면서 재 검증이 진행이 된다. 재검증 기간에는 기존 캐시인 value 가 랜더가 된 상황이다. 만약 데이터가 new value 로 변했다면, 리렌더링을 통해 new value 를 보여주게 된다. 아니라면 기존 value 가 유지가 된다. 즉 사용자는 어떠한 로딩창 없이 최신 데이터를 확인할 수 있게 된다.
SWR 의 특징이라면 인자로 들어가는 key 를 공유한다는 점에 있다. 즉, 같은 key 라면 캐시 데이터를 공유한다. 이는 여러 컴포넌트에서 동일한 데이터를 사용한다고 가정했을 때, Context 나 Props, useSelector 없이도 최신화된 데이터를 사용할 수 있다는 의미이다. 특정 a,b,c 컴포넌트가 같은 key 를 가진 캐시 데이터를 공유한다고 하였을 때, 만일 a 컴포넌트에서 캐시 데이터에 변화를 주었을 때 나머지 컴포넌트에서도 동일하게 갱신이 일어나게 된다. 성능적으로도 좋은데, 여러 컴포넌트라고 해도 같은 key 라면 단 한번만 서버와의 요청이 들어가게 된다.
만일 key 가 다른 경우 아래 그림처럼 isLoading 과정을 거치는 서버 요청이 일어나야 한다
다른 key 는 캐시 데이터가 다르니 당연한 결과이다. 만일 그럼에도 기존 value(1) 을 사용하고 싶다면, KeepPreviousData 속성을 true 로 적용해주면 된다
초기 렌더 화면을 fallback 을 통해서 대신 렌더해줄 순 있다. 다만 리엑트는 데이터 라이브러리에서 Suspense 의 사용을 권장하지 않고 있어서 보류하자.
실제 공식문서의 예시 코드와 화면을 살펴보자
function Stock() {
const { data, isLoading, isValidating } = useSWR(STOCK_API, fetcher, {
refreshInterval: 3000
});
// If it's still loading the initial data, there is nothing to display.
// We return a skeleton here.
if (isLoading) return <div className="skeleton" />;
// Otherwise, display the data and a spinner that indicates a background
// revalidation.
return (
<>
<div>${data}</div>
{isValidating ? <div className="spinner" /> : null}
</>
);
}
훅 useSWR 을 통해서 data, isLoading, isValidating 을 불러오고 있다. 속성으로 refreshInterval 은 3초로 되어있으며 3초마다 재 갱신을 하겠다는 속성값이다.
만약 처음 데이터를 요청하는 경우라면 skeleton UI 가 발동을 할 것이다. 그리고 이제 검증이라면 spinner 가 기존 데이터 옆에서 돌아가게 될 것이다. 실제 동작하는 화면이다
재검증 단계에서 기존 218 달러가 그대로 렌더되고 있다. 사용자는 큰 화면 변화 없이 최신화된 데이터를 볼 수 있게 된다.
정리해보면..
지금까지 캐시가 필요한 이유와, HTTP 캐시 설정, 비표준단계의 Stale-While-Revalidate, 그리고 이를 활용한 리엑트 라이브러리 SWR 에 관하여 간략하게 정리해보았다.
SWR 에는 데이터 fetching 에 대한 다양한 기능들을 제공하고 있고, 위 포스팅에서 다 다룰 수 없기에 공식문서의 링크를 남기었으며, 사용법 보다는 어떠한 과정으로 SWR 이 동작하게 되는지에 대해서 살펴보는 시간을 가졌다. 실제 사용했었던 과정은 이후 포스팅에서 다루고자 한다.
처음에는 사용방법에 대한 포스팅을 계획하고 있었으나, 직접 사용해보면서 여러 오류들과 여러 뻘짓(?) 들을 반복하면서, 좀 더 정확하게 SWR 의 동작과정을 이해하고 싶었고, 이번 포스팅을 통해 나 자신 역시 기존에 의아했던 부분들이 많이 해소된 느낌이다.
아쉬운점은 아직 React-query 를 사용해보지 못한 점인데, 나중에 사용해보면서 왜 사람들이 SWR 보다 React-query 를 더 선호하는지도 알아보고 싶다.
https://web.dev/stale-while-revalidate/
stale-while-revalidate로 최신 상태 유지
stale-while-revalidate는 개발자가 캐시된 콘텐츠를 즉시 로드하는 즉시성과 캐시된 콘텐츠에 대한 업데이트가 향후에 사용되도록 보장하는 최신성 간의 균형을 유지하는 데 도움이 됩니다.
web.dev
https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching
HTTP caching - HTTP | MDN
The HTTP cache stores a response associated with a request and reuses the stored response for subsequent requests.
developer.mozilla.org
'Programing > React' 카테고리의 다른 글
ReFlow 를 방지하는 Intersection Observer, 이를 활용한 Hook (1) | 2023.05.05 |
---|---|
useEffect을 사용하기 전 생각해봐야 할 상황들 (1) | 2023.03.12 |
Redux의 비동기 처리 (feat. middleware chaining) (1) | 2023.02.25 |
[번역] useState 에 await 을 적용시킨 커스텀훅을 구현하기 (0) | 2023.02.07 |
Redux를 간략하게 구현해보자 (0) | 2023.02.01 |