우아한테크코스 내부의 쿠폰 문화 (커피챗과 유사한)를 온라인으로 가져가고 있는 꼭꼭 프로젝트는 코치와 크루, 크루와 크루 간의 대면 만남을 유도하고자 쉬운 쿠폰의 관리, 사용을 목적으로 프로덕트를 개발하고 있습니다.
안녕하세요. 우아한테크코스에서 프론트엔드 개발자로 꼭꼭 프로젝트에 참여중인 준찌라고합니다. 저희는 이번 프로젝트에서 성능 최적화를 위해 정적 리소스를 캐싱하고자 하였습니다. 이 포스팅은 왜 캐싱을 적용하게 되었는지 부터 어떻게 진행하였는지에 대해 정리하고 있습니다. 재미있게 봐주세요!! (본문은 딱딱체로 진행되니 부담을 느끼시더라도 참으십시오.)
최초 개발 당시 정적 리소스 캐싱 기능을 도입하지 않았었다. 이유는 기능 개발에 비해 니즈가 적었기 때문이었는데, MVP가 출시된 이후 성능 최적화, 특히 리소스 캐싱에 대한 니즈가 생겨 몇가지 조사에 나섰다.
다음은 1차 릴리즈 이후의 사이트 네트워크 탭의 모습이다. (최초 요청이 아닌, 요청 이후의 재 요청을 보낸 상황 - 새로 고침 등을 통해)
응답 헤더를 살펴보면 리소스 응답 헤더에 아무런 캐시 설정도 되어 있지 않은 모습을 확인할 수 있다. 브라우저의 인메모리 캐시에서 받아오는 슬랙 서버의 프로필 이미지와는 다르게 assets
으로 관리하고 있는 coffee.png
는 브라우저의 캐시서버에서 가져 오는 것이 아닌 원본 서버에서 가져오고 있는 모습 또한 확인할 수 있었다. (시간 차이를 보라)
캡처 할 때 First View의 영역은 빼고 이미지를 촬영하여 제대로 된 이미지를 첨부하지는 못하였으나, WebPageTest에서의 First View (최초 요청) - Repeat View(재요청)의 성능 분석 점수의 차이도 미비하였다. (캐싱이 되어 있더라면 브라우저의 영역 혹은 CDN 서버에서 빠르게 캐시 데이터를 받아올 수 있게 되니 보통 캐시 설정이 되어 있는 경우 Repeat View의 LCP 영역의 점수가 개선된다.)
우리는 5.279초나 걸리는 LCP의 속도를 줄이는 것을 목표로 캐싱 정책을 수립, 적용하고자 하였다.
client
-> nginx
(front-web-server) 중간의 프록시 서버, CDN 서버가 존재하지 않는 단순한 구조로 인프라가 설계되어 있었음.이러한 환경이었기에 CDN 캐싱과 Proxy Server에서의 캐싱은 크게 고려하지 않았다. CDN 서버를 두고 이 서버에 캐시 데이터를 보관하는 형식으로는 스택 제한으로 하지 못하였고, 또 다른 EC2 서버에 nginx를 하나를 더 켜두고 이를 프록시 서버로 활용하면서 캐시 데이터를 보관 응답해주는 방식은 오히려 복잡도가 커질 것으로 판단되었다. (원본 서버에 포트포워딩을 수행하며 접근하는 비용 + 캐시 데이터를 저장하고 삭제하는 스크립트가 작성되어야 함 + 프록시 서버와 원본 서버의 거리적 차이가 없다.)
그렇기에 브라우저의 캐시만을 백분 활용해보기로 결정하고 캐싱 정책 수립 및 적용을 진행하였다.
캐시
캐시 버스팅
LCP
CDN 캐시와 브라우저 캐시
다시 부드럽체를 사용합니다. 어색해하지 마세요.
어떻게 적용했는지에 앞서 캐시를 적용하고 난 결과 정보는 webpagetest에서 얻었습니다. (3G Fast - EC2 Paris - First And Repeat View) 이유는 Repeat View에 대한 정보가 필요했기 때문. 캐싱을 적용하고 난 결과를 확인하고자 한다면 최초 요청에 대한 성능 분석이 아닌 최초 요청 이후 재요청에 대한 성능 분석을 확인할 수 있어야 한다. 이를 위한 최적의 도구는 webpagetest라 생각하여 해당 서비스를 이용하였습니다.
우선 알아두어야 할 것은 브라우저의 캐시는 날려버리기 쉽지 않다는 것입니다. 사용자가 강력 새로고침을 수행하거나, 우리가 구식의 캐시를 날려버리는 작업을 해야하므로 이번 캐싱 작업엔 이 점을 신경쓰며 진행하였습니다.
css in js를 활용하는 환경에서 크게 정적 리소스 캐시 기능을 도입하고자 한 영역은 js
, index.html
, assets
영역이었습니다. 각 영역 별로 리소스의 성격이 다르다고 판단한 우리는 다음과 같이 캐시 정책을 수립하였습니다.(이유포함)
js : 번들링된 결과물. 우리들의 프로덕션 코드 max-age:1y, 캐시 버스팅 적용
자바스크립트 결과물은 자주 변할 수 있습니다. 그렇기 때문에, 배포할 때마다 사용자들은 캐시 리소스가 아닌 새로운 리소스를 받아볼 수 있어야합니다.
이러한 목적이 있더라면 방법은 두 가지 입니다. 하나는 캐시 버스팅을 수행하여 브라우저의 캐시를 무효화 시키는 방법, 두번째는 cache-control
지시자의 값을 no-cache 혹은 max-age:0
으로 두어 매번 서버로 재검증 요청을 보내도록 하는 방법이 있습니다. (여기서 max-age에 시간을 두는 것은 고려하지 않았습니다. 그 시간보다 빠른 시간 내에 신규 버전이 등장하게 된다면 사용자는 그 리소스를 바라볼 수 없습니다. 브라우저의 캐시 데이터는 날려버리기 쉽지 않습니다.)
저희는 두 번째 방식 보다는 첫 번째 방식으로 js 파일의 브라우저 캐싱을 적용하였습니다. 두 번째 방식의 경우 사용자가 리로드 할 때 마다 재검증 요청을 보낼 것이므로 이는 첫 번째 방식에 비해 비효율적일 것이라 판단하여 전자의 방식을 택하였습니다.
index.html: 마크업 문서, main resource / max-age:0
마크업 문서의 경우 역시 자주 변할 수 있는 성격의 리소스라 생각하였습니다. (프로덕션 코드의 수정은 언제든지 발생할 수 있는 영역이니) 그렇기에 js 번들 파일의 경우와 동일한 방식을 통해 브라우저 캐싱 기능을 구현할 수 있습니다.
저희는 마크업 문서에 대해선 두 번째 방식을 택해보았습니다. 이유는 캐시 버스팅을 수행하기 애매하다고 판단하였기 때문입니다. 해시 값으로 이름을 설정해버리면 사용자의 요청에 내려주어야 하는 리소스의 이름이 변경되는 것이다. 서버 단에서의 처리 혹은 클라이언트가 알아서 URL에 접근할 수 있게 하는 장치가 필요한데 이를 구현하는 것은 캐싱 기능을 구현하여 성능을 개선한다라는 목표에는 맞지 않는다고 판단하였습니다. 그렇기에 메인 리소스에 대해서는 재검증 요청을 매번 보내도록 cache-control
헤더의 지시문을 주는 방식인 첫 번째 방식을 택하였습니다.
assets: image, font 등이 해당됩니다. max-age:1y, 캐시 버스팅 적용
이 리소스의 경우 자주 변하지 않는다고 판단하였습니다. 그렇기에 유효기간을 길게 두었습니다. 하지만 1년 내에 변경사항이 발생할 수도 있으므로, 이러한 상황에 대응할 수 있게 캐시 버스팅을 적용하였습니다.
캐싱 정책을 정리해보자면 다음과 같습니다.
js 리소스는 유효기간 1년의 캐시 버스팅을 적용하여 리소스를 갱신
index.html 리소스는 유효기간 0을 두어 서버로 재검증 요청을 매번 보내 리소스를 갱신
assets 리소스는 유효기간 1년에 캐시 버스팅을 적용하여 리소스를 갱신
각각 저희는 위 정책을 다음과 같이 구현하였습니다.
// webpack.common.js
// ...
output: {
path: path.join(__dirname, '..', 'build'),
publicPath: '/',
// 번들 결과물의 이름에 해시 값을 두어 번들 결과물을 이전 결과물과 다른 파일로 만든다. 유니크하게 만듭니다.
filename: 'bundle.[hash].js',
clean: true,
},
// ...
location ~* \.(?:css|js)$ {
root /home/ubuntu/build;
index index.html;
# 유효기간은 1년으로 설정합니다.
expires 1y;
add_header X-Proxy-Cache $upstream_cache_status;
#gzip on;
gzip_static on;
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/javascript text/xml application/xml application/rss+xml application/atom+xml application/rdf+xml;
}
위와 같이 진행하여 번들 결과물을 유니크하게 만들면, 브라우저 단에서 캐시 데이터를 갱신할 수 있습니다. 브라우저는 이름을 기준으로 서로 다른 파일로 인지하기에 이전 캐시 데이터와는 별개의 데이터로 인지합니다. 따라서 이전 데이터는 메모리에 남겠지만, 여전히 사용자는 새로운 파일을 바라볼 수 있습니다.
nginx 단에서 응답해줄 때의 응답 헤더에 지시문을 추가해주었습니다.
file-loader를 활용해 img, font 파일들을 번들링하고, 이를 유니크한 네임으로 만들어 번들 결과물에 삽입합니다. 이로서 js와 똑같은 효과를 기대할 수 있게 되었습니다.
{
test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif|webp|webm|mp4)$/i,
type: 'asset/resource',
},
기존에는 file-loader(asset-modules)
가 아닌 webpack copy plugin
을 활용해 assets
들의 자원들을 번들 결과물로 옮겨 주었습니다. 하지만 webpack copy plugin
자체는 파일의 이름을 변경할 수 있도록 장치를 심어둘 수 있었지만, 코드 단에서 이를 불러오는 코드를 작성하기란 쉽지 않다고 판단하였습니다. 또한 불러오는 모듈을 기준으로 리소스를 빌드 폴더에 삽입하는 것이 아닌 모든 파일을 옮겨넣는 형태이기에 사용되지 않는 모듈이 존재하는 경우 비효율적이라 판단하였습니다.
file-loader(asset-modules)
는 import
문을 기준으로 모듈이 불러와지고 있다면 정적 결과물에 삽입하는 구조입니다. 이 과정에서 이름을 해시 값으로 변경할 수 있고 실제 코드 단에서도 불러오는 모듈을 통해 해시 값이 되어버린 리소스를 바라볼 수 있게 설정할 수 있습니다. (file-loader(asset-modules)
를 도입하게 된 이유가 생겨 좋았슴다. 그리고 현재 기준으로 file-loader(asset-modules)
는 웹팩5에 내장되었습니다.)
결과는 다음과 같습니다. 보셔야 하는 부분은 Repeat View에서의 LCP 시간
과 Repeat View와 First View의 LCP 차이
두 가지 입니다.
첫 번째 페이지 로드시에는 LCP가 5.6초가 걸리는 반면 두 번째 페이지 로드시(리소스들이 캐시가 된 이 후)에는 LCP 0.4초 인것을 확인할 수 있었습니다.
file-loader(asset-modules)를 사용해야 하는 이유에 대해 깨닫게 되었습니다.
브라우저의 캐시를 활용하는 방법을 공부하다.
리소스의 성격을 분석해보고 실제로 정책을 구상해보았다.
HTTP Caching
Cache-Control 헤더에 명시적으로 유효기간을 넣어주는게 좋다. (지정하지 않으면 휴리스틱 캐싱을 수행하는데 오락가락한다.)
이거 글 좋네요. 도움되네요 :D