본문으로 건너뛰기

JavaScript 개발자를 위한 코드 캐싱

· 약 12분
[Leszek Swirski](https://twitter.com/leszekswirski), 캐시 스매셔

코드 캐싱(바이트코드 캐싱이라고도 함)은 브라우저에서 중요한 최적화 기술입니다. 이것은 파싱 + 컴파일 결과를 캐싱하여 자주 접속하는 웹사이트의 시작 시간을 줄입니다. 대부분의 인기 브라우저에서 어떤 형태로든 코드 캐싱을 구현하고 있으며, Chrome도 예외는 아닙니다. 실제로 우리는 Chrome과 V8이 컴파일된 코드를 어떻게 캐싱하는지에 대해 작성하고, 개선하며, 설명한 바 있습니다.

이 블로그 게시물에서는 웹사이트의 시작 성능을 향상시키기 위해 코드 캐싱을 최적으로 사용하고자 하는 JS 개발자를 위한 몇 가지 조언을 제공합니다. 이 조언은 Chrome/V8의 캐싱 구현에 중점을 두고 있지만, 대부분 다른 브라우저의 코드 캐싱 구현에도 적용할 수 있을 가능성이 높습니다.

코드 캐싱 요약

다른 블로그 게시물과 프레젠테이션에서 코드 캐싱 구현에 대해 더 자세히 다루고 있지만, 작동 방식을 간략히 요약하는 것도 가치가 있습니다. Chrome은 V8 컴파일 코드(클래식 스크립트와 모듈 스크립트 둘 다)를 위한 두 가지 수준의 캐싱을 제공합니다: V8에서 관리하는 저비용 “최선의 노력” 인메모리 캐시(Isolate 캐시)와 디스크에 직렬화된 완전한 캐시입니다.

Isolate 캐시는 동일한 V8 Isolate(즉, 동일한 프로세스, 대략 “같은 탭에서 탐색할 때 같은 웹사이트의 페이지”)에서 컴파일된 스크립트에 대해 작동합니다. 이는 가능하면 빠르고 최소화되도록 노력하며, 이미 사용 가능한 데이터를 사용하면서 실행하며, 낮은 적중률과 프로세스 간 캐싱 부족이라는 비용을 한계로 합니다.

  1. V8이 스크립트를 컴파일하면 해당 컴파일된 바이트코드는 해시 테이블(V8 힙)에 저장되고, 해당 스크립트의 소스 코드로 키가 지정됩니다.
  2. Chrome이 V8에 다른 스크립트를 컴파일하도록 요청하면, V8은 먼저 해당 스크립트의 소스 코드가 해시 테이블의 항목과 일치하는지 확인합니다. 만약 그렇다면 기존 바이트코드를 반환합니다.

이 캐시는 빠르고 거의 무료로 제공되며, 실제 환경에서 80%의 적중률을 기록합니다.

디스크 기반 코드 캐시는 Chrome(구체적으로는 Blink)에서 관리하며, Isolate 캐시가 채우지 못하는 격차를 메웁니다: 프로세스 간 및 여러 Chrome 세션 간의 코드 캐시 공유입니다. 이는 웹에서 받은 데이터를 캐싱하고 만료시키는 기존의 HTTP 리소스 캐시를 활용합니다.

  1. JS 파일이 처음 요청될 때(즉, 콜드 런), Chrome은 이를 다운로드하여 V8에 전달하여 컴파일합니다. 또한 해당 파일을 브라우저의 디스크 캐시에 저장합니다.
  2. JS 파일이 두 번째로 요청될 때(즉, 웜 런), Chrome은 브라우저 캐시에서 파일을 가져와 다시 V8에 전달하여 컴파일합니다. 하지만 이번에는 컴파일된 코드가 직렬화되고, 메타데이터로 캐시된 스크립트 파일에 첨부됩니다.
  3. 세 번째 요청 시(즉, 핫 런), Chrome은 파일과 파일의 메타데이터를 캐시에서 가져와 V8에 전달합니다. V8은 메타데이터를 역직렬화하여 컴파일 단계를 건너뜁니다.

요약하면:

코드 캐싱은 콜드, 웜, 핫 런으로 나뉘며, 웜 런에서는 인메모리 캐시를, 핫 런에서는 디스크 캐시를 사용합니다.

이 설명에 기반하여, 웹사이트에서 코드 캐싱을 최적으로 사용하는 방법에 대한 최고의 팁을 제공합니다.

팁 1: 아무것도 하지 마세요

이상적으로, JS 개발자로서 코드 캐싱을 개선하기 위해 할 수 있는 가장 좋은 일은 “아무것도 하지 않는 것”입니다. 이것은 사실 두 가지를 의미합니다: 소극적으로 아무것도 하지 않는 것과 적극적으로 아무것도 하지 않는 것입니다.

코드 캐싱은 결국 브라우저 구현 세부사항입니다. 휴리스틱을 기반으로 한 데이터/공간 트레이드오프 성능 최적화로, 구현과 휴리스틱이 정기적으로 변경될 수 있습니다(실제로 변경됩니다!). V8 엔지니어로서 우리는 변화하는 웹 환경에서 이러한 휴리스틱이 최대한 효과적으로 적용되도록 최선을 다하고 있으며, 현재 코드 캐싱 구현 세부사항을 지나치게 최적화하면, 이러한 세부사항이 변경되었을 때 몇몇 릴리즈 이후에 실망할 수 있습니다. 또한, 다른 JavaScript 엔진은 코드 캐싱 구현을 위한 서로 다른 휴리스틱을 가질 가능성이 높습니다. 그래서 많은 측면에서, 코드가 캐싱되도록 하는 최고의 조언은 JS 작성 조언과 비슷합니다: 깨끗하고 관습적인 코드를 작성하면, 어떻게 우리가 그것을 캐싱할지 최선을 다하겠습니다.

아무것도 하지 않는 수동적인 방법 외에도, 적극적으로 아무것도 하지 않으려는 노력을 기울여야 합니다. 캐싱은 본질적으로 변경 사항이 없다는 것을 전제로 하므로 아무 것도 하지 않는 것이 캐시된 데이터가 유지되도록 하는 최선의 방법입니다. 적극적으로 아무것도 하지 않는 몇 가지 방법이 있습니다.

코드를 변경하지 않기

이것은 명백할 수 있지만 명시적으로 언급할 가치가 있습니다 — 새로운 코드를 배포할 때 그 코드는 아직 캐시되지 않습니다. 브라우저가 스크립트 URL에 대한 HTTP 요청을 보낼 때, 해당 URL에 대한 마지막 요청 날짜를 포함할 수 있으며 서버가 파일이 변경되지 않았음을 알고 있다면 304 Not Modified 응답을 보내 코드 캐시를 뜨겁게 유지합니다. 그렇지 않으면 200 OK 응답이 캐시된 리소스를 업데이트하고 코드 캐시를 비워 차가운 실행 상태로 되돌립니다.

최신 코드 변경 사항을 즉시 푸시하려는 유혹을 받을 수 있습니다, 특히 특정 변경 사항의 영향을 측정하고 싶을 때는 더욱 그렇습니다. 하지만 캐시를 위해 코드를 그대로 두거나 적어도 가능한 한 덜 업데이트하는 것이 훨씬 좋습니다. ≤ x 배포 제한을 주 단위로 설정하는 것을 고려하세요. 여기에서 x는 캐싱과 오래된 정보 사이의 균형을 조정하기 위한 슬라이더입니다.

URL을 변경하지 않기

코드 캐시는 (현재로서는) 스크립트 URL과 연결되어 있는데, 이는 실제 스크립트 내용을 읽지 않고도 쉽게 조회할 수 있기 때문입니다. 이는 스크립트의 URL(쿼리 매개변수를 포함하여)을 변경하면 새로운 리소스 항목이 리소스 캐시에 생성되고, 해당 항목이 차가운 캐시로 들어간다는 것을 의미합니다.

물론 이것은 캐시를 강제로 비우는 데도 사용될 수 있지만 이는 구현상의 세부 사항일 뿐입니다. 언젠가는 캐시를 소스 URL 대신 소스 텍스트로 연결하기로 결정할 수도 있으며, 그러면 이 조언은 더 이상 유효하지 않을 것입니다.

실행 동작을 변경하지 않기

코드 캐시 구현에 최근 추가된 최적화 중 하나는 컴파일된 코드를 실행 후 직렬화하는 것입니다. 이는 초기에 컴파일되지 않고 실행 도중에만 컴파일되는 게으르게 컴파일된 함수들을 잡아내기 위함입니다.

이 최적화는 스크립트가 실행될 때마다 동일한 코드, 또는 적어도 동일한 함수를 실행할 때 가장 효과적으로 작동합니다. 예를 들어 A/B 테스트가 런타임 결정에 따라 달라지는 경우 문제가 될 수 있습니다:

if (Math.random() > 0.5) {
A();
} else {
B();
}

이 경우 따뜻한 실행에서 A() 또는 B()만 컴파일되고 실행되어 코드 캐시에 들어가지만, 이후 실행에서는 둘 중 하나가 실행될 수 있습니다. 대신 실행을 결정론적으로 유지하려고 노력하여 캐시된 경로를 유지하세요.

팁 2: 무엇인가를 하기

분명히 '아무것도 하지 말라'는 조언은 수동적이거나 적극적이든 간에 별로 만족스럽지 않습니다. 그래서 '아무것도 하지 않는 것' 외에도, 현재의 휴리스틱과 구현을 감안하여 할 수 있는 몇 가지 작업이 있습니다. 그러나 휴리스틱은 변할 수 있다는 점, 이 조언도 변할 수 있다는 점, 프로파일링 외에는 대체할 수 있는 것이 없다는 점을 반드시 기억하세요.

라이브러리를 사용하는 코드와 분리하기

코드 캐싱은 큰 규모의 스크립트 단위로 이루어지므로, 스크립트의 일부라도 변경되면 전체 스크립트의 캐시가 무효화됩니다. 배포 코드가 안정적인 부분과 변경되는 부분, 예를 들면 라이브러리와 비즈니스 로직 코드가 하나의 스크립트에 들어 있다면 비즈니스 로직 코드 변경이 라이브러리 코드 캐시를 무효화시킵니다.

대신 안정적인 라이브러리 코드를 별도의 스크립트로 분리하고 따로 포함시킬 수 있습니다. 그러면 라이브러리 코드는 한 번 캐시되어 비즈니스 로직이 변경되더라도 계속 캐시로 유지됩니다.

이런 방법은 라이브러리가 웹사이트의 서로 다른 페이지에서 공유되는 경우 추가적인 이점을 제공합니다: 코드 캐시가 스크립트에 부착되어 있기 때문에 라이브러리의 코드 캐시도 페이지들 간에 공유됩니다.

라이브러리를 사용하는 코드와 병합하기

코드 캐싱은 각 스크립트가 실행된 후 이루어지므로, 해당 스크립트의 코드 캐시는 스크립트가 실행을 완료할 때 컴파일된 함수만 포함합니다. 이것은 라이브러리 코드에 몇 가지 중요한 결과를 미칩니다:

  1. 코드 캐시는 이전 스크립트의 함수를 포함하지 않습니다.
  2. 코드 캐시는 이후 스크립트에 의해 호출된 게으르게 컴파일된 함수를 포함하지 않습니다.

특히, 라이브러리가 완전히 게으르게 컴파일된 함수로 구성되어 있다면, 이후에 사용되더라도 해당 함수들은 캐시되지 않습니다.

이 문제에 대한 한 가지 해결책은 라이브러리와 사용법을 단일 스크립트로 병합하여 코드 캐싱에서 라이브러리의 어떤 부분이 사용되었는지를 "볼 수" 있도록 하는 것입니다. 불행히도, 이 방법은 위에서 언급한 권장 사항과 정반대입니다. 모든 스크립트 JS를 단일 대규모 번들로 병합하기보다는, 여러 개의 작은 스크립트로 나누는 것이 코드 캐싱 외에도 (예: 여러 네트워크 요청, 스트리밍 컴파일, 페이지 상호작용성 등) 더 유익한 경우가 많기 때문에 이를 권장하지 않습니다.

IIFE 휴리스틱 활용하기

스크립트 실행이 완료될 때까지 컴파일된 함수만이 코드 캐싱에 포함되므로, 나중에 실행되더라도 캐싱되지 않는 함수 종류가 많습니다. 이벤트 핸들러 (심지어 onload도 포함), 프라미스 체인, 사용되지 않는 라이브러리 함수, 그리고 </script>가 나올 때까지 호출되지 않고 지연 컴파일된 다른 모든 것들은 계속 지연 상태로 남고 캐싱되지 않습니다.

이러한 함수들을 강제로 캐싱하려면 컴파일을 강제해야 하며, 컴파일을 강제하는 일반적인 방법 중 하나는 IIFE 휴리스틱을 사용하는 것입니다. IIFE(즉시 호출 함수 식)는 함수가 생성된 직후 바로 호출되는 패턴입니다:

(function foo() {
// …
})();

IIFE는 즉시 호출되기 때문에 대부분의 자바스크립트 엔진은 이를 감지하고 즉시 컴파일하여 지연된 컴파일 후 전체 컴파일의 비용을 피하려고 합니다. IIFE를 조기에 감지하기 위한 다양한 휴리스틱이 존재하며, 가장 일반적인 방법은 function 키워드 앞에 (를 사용하는 것입니다.

이 휴리스틱이 조기에 적용되므로 함수가 실제로 바로 호출되지 않더라도 컴파일을 트리거합니다:

const foo = function() {
// 지연됨
};
const bar = (function() {
// 즉시 컴파일됨
});

이는 코드 캐싱에 포함되어야 하는 함수가 괄호로 감싸짐으로써 강제로 추가될 수 있음을 의미합니다. 하지만 이러한 힌트를 잘못 적용하면 초기 실행 시간이 느려질 수 있으며, 일반적으로 이는 휴리스틱의 남용에 해당하므로 필요한 경우가 아니면 이를 피하는 것이 좋습니다.

작은 파일을 함께 그룹화하기

Chrome은 현재 1 KiB 이상의 소스 코드 크기를 코드 캐싱의 최소 크기로 설정하고 있습니다. 따라서 크기가 작은 스크립트는 오버헤드가 이점보다 크다고 판단되어 전혀 캐싱되지 않습니다.

이러한 작은 스크립트가 많은 웹사이트의 경우, 오버헤드 계산이 더 이상 동일하게 적용되지 않을 수 있습니다. 최소 코드 크기를 초과하도록 스크립트를 병합하는 것과 더불어 일반적으로 스크립트 오버헤드를 줄이는 방향으로 고려해 볼 수 있습니다.

인라인 스크립트를 피하기

HTML에 인라인된 소스의 스크립트 태그는 관련된 외부 소스 파일이 없으므로 위의 메커니즘으로 캐싱될 수 없습니다. Chrome은 HTML 문서의 리소스에 캐시를 연결하여 인라인 스크립트를 캐싱하려고 시도하지만, 이러한 캐시는 전체 HTML 문서가 변경되지 않을 때에만 유효하며, 페이지 간에 공유되지 않습니다.

따라서 코드 캐싱의 혜택을 받을 수 있는 비중요한 스크립트에는 HTML에 인라인 삽입을 피하고 외부 파일로 포함하는 것이 좋습니다.

서비스 워커 캐싱 사용하기

서비스 워커는 페이지 리소스에 대한 네트워크 요청을 가로채는 코드를 작성할 수 있는 메커니즘입니다. 특히 리소스 중 일부를 로컬 캐시로 구축하고 요청이 있을 때 캐시에서 해당 리소스를 제공할 수 있도록 합니다. 이는 특히 PWA와 같은, 오프라인에서도 계속 작동하려는 페이지에 유용합니다.

서비스 워커를 사용하는 사이트의 일반적인 예는 주요 스크립트 파일에서 서비스 워커를 등록하는 것입니다:

// main.mjs
navigator.serviceWorker.register('/sw.js');

그리고 서비스 워커는 설치(캐시 생성) 및 데이터 가져오기(캐시에서 잠재적으로 리소스 제공)의 이벤트 핸들러를 추가합니다.

// sw.js
self.addEventListener('install', (event) => {
async function buildCache() {
const cache = await caches.open(cacheName);
return cache.addAll([
'/main.css',
'/main.mjs',
'/offline.html',
]);
}
event.waitUntil(buildCache());
});

self.addEventListener('fetch', (event) => {
async function cachedFetch(event) {
const cache = await caches.open(cacheName);
let response = await cache.match(event.request);
if (response) return response;
response = await fetch(event.request);
cache.put(event.request, response.clone());
return response;
}
event.respondWith(cachedFetch(event));
});

이러한 캐시는 캐시된 JS 리소스를 포함할 수 있습니다. 그러나 우리는 이러한 캐시에 대해 약간 다른 휴리스틱을 사용하며, 이는 다른 가정을 할 수 있기 때문입니다. 서비스 워커 캐시는 할당량 관리 저장 공간 규칙을 따르기 때문에 더 오래 유지될 가능성이 크며, 캐싱의 이점이 더 커질 것입니다. 게다가 로드 전에 미리 캐싱된 리소스가 더 중요하게 간주될 수 있습니다.

서비스 워커 설치 이벤트 중에 리소스가 서비스 워커 캐시에 추가될 때 가장 큰 휴리스틱 차이가 발생합니다. 위 예는 이러한 사용 사례를 보여줍니다. 이 경우 코드는 리소스가 서비스 워커 캐시에 투입될 때 즉시 캐시됩니다. 추가로, 이러한 스크립트에 대해서는 "완전한" 코드 캐시를 생성합니다 - 이제 함수들을 지연적으로 컴파일하지 않고, 대신 _모든 것_을 컴파일하여 캐시에 배치합니다. 이는 빠르고 예측 가능한 성능을 제공하며 실행 순서 의존성이 없다는 장점이 있지만, 메모리 사용이 증가하는 비용이 있습니다.

JS 리소스가 서비스 워커 설치 이벤트 외부에서 Cache API를 통해 저장될 경우 코드 캐시는 즉시 생성되지 않습니다. 대신, 서비스 워커가 캐시에서 해당 응답을 제공하면 코드 캐시는 첫 번째 로드에서 "일반"으로 생성됩니다. 이 코드 캐시는 두 번째 로드에서 사용할 수 있게 됩니다 - 일반적인 코드 캐싱 시나리오보다 한 로드 빠릅니다. 리소스는 Cache API가 설치 이벤트 외부에서 저장될 수 있으며, 이는 fetch 이벤트에서 리소스를 "점진적으로" 캐싱하거나 Cache API가 서비스 워커 대신 메인 윈도우에서 업데이트되는 경우에 발생합니다.

참고로, 미리 캐시된 "완전한" 코드 캐시는 스크립트가 실행될 페이지가 UTF-8 인코딩을 사용할 것이라고 가정합니다. 페이지가 다른 인코딩을 사용하게 되면 코드 캐시는 삭제되고 "일반" 코드 캐시로 교체됩니다.

추가로, 미리 캐시된 "완전한" 코드 캐시는 페이지가 스크립트를 클래식 JS 스크립트로 로드할 것이라 가정합니다. 페이지가 ES 모듈로 로드하게 되면 코드 캐시는 삭제되고 "일반" 코드 캐시로 교체됩니다.

추적

위의 제안 중 어떤 것도 웹 앱을 반드시 빠르게 만들 것을 보장하지는 않습니다. 안타깝게도 현재 DevTools에서는 코드 캐싱 정보를 제공하지 않으므로, 웹 앱의 스크립트 중 어느 것이 코드 캐싱을 사용하는지 알아내는 가장 확실한 방법은 약간 더 낮은 수준의 chrome://tracing을 사용하는 것입니다.

chrome://tracing은 Chrome의 특정 기간 동안 도구 계측 추적을 기록하며, 생성된 추적 시각화는 다음과 같은 모습입니다:

따뜻한 캐시 실행 기록이 포함된 chrome://tracing UI

추적은 다른 탭, 창 및 확장 기능을 포함하여 브라우저 전체의 행동을 기록하므로, 확장 기능을 비활성화하고 다른 브라우저 탭을 열지 않은 상태의 깨끗한 사용자 프로필에서 실행하면 가장 효과적입니다:

# 확장 기능이 비활성화된 깨끗한 사용자 프로필로 Chrome 브라우저 세션 시작
google-chrome --user-data-dir="$(mktemp -d)" --disable-extensions

추적을 수집할 때 추적할 카테고리를 선택해야 합니다. 대부분의 경우 “웹 개발자” 카테고리 세트를 선택할 수 있지만, 카테고리를 수동으로 선택할 수도 있습니다. 코드 캐싱에 중요한 카테고리는 v8입니다.

v8 카테고리로 추적을 기록한 후, 추적에서 v8.compile 요소를 찾으세요. (또는 추적 UI의 검색 창에 v8.compile을 입력할 수 있습니다.) 이는 컴파일된 파일과 컴파일 메타데이터를 나열합니다.

스크립트의 첫 번째 실행에는 코드 캐싱에 대한 정보가 없습니다 — 이는 해당 스크립트가 캐시 데이터를 생성하거나 소비하지 않았음을 의미합니다.

따뜻한 실행에서는 각 스크립트에 대해 두 개의 v8.compile 엔트리가 있습니다: 하나는 실제 컴파일에 대한 것이고 (위와 같음), 다른 하나는 캐시를 생성하기 위한 것입니다. 후자는 cacheProduceOptionsproducedCacheSize 메타데이터를 포함하고 있다는 점에서 인식할 수 있습니다.

뜨거운 실행에서는 캐시를 소비하기 위한 v8.compile 엔트리를 볼 수 있으며, 메타데이터는 cacheConsumeOptionsconsumedCacheSize를 포함합니다. 모든 크기는 바이트 단위로 표현됩니다.

결론

대부분의 개발자들에게 코드 캐싱은 “그냥 작동”해야 합니다. 캐시가 아무 변화 없이 유지될 때 가장 잘 작동하며, 버전에 따라 변경될 수 있는 휴리스틱으로 작동합니다. 그럼에도 불구하고 코드 캐싱은 사용할 수 있는 행동과 피할 수 있는 제한 사항이 있으며, chrome://tracing을 사용하여 신중하게 분석하면 웹 앱에서 캐시 사용을 조정하고 최적화하는 데 도움이 될 수 있습니다.