조금 더 빠른 웹 앱을 위한 API Prefetching

이 글은 데이터를 웹 애플리케이션에서 최초 렌더링 이후 다음 페이지에 들어가기전에 미리 요청하고 캐시하는 방법을 다룹니다.

Nuxt.js의 Smart Prefetching 및 React Query를 통해 아이디어를 얻었습니다.코드는 Nuxt.js를 다루지만 일반적인 자바스크립트를 사용합니다

웹 기반 기술들은 더 빠른 사용자 경험을 사용자에게 제공하기 위해 여러가지 기술을 제공하고 있습니다.

대부분의 경우에 에셋 불러오는 과정을 최적화하여 첫번째 렌더링과 그 이후 페이지 렌더링을 최적화합니다.

간단한 브라우저 에셋 로딩 최적화 알아보기

현재 활용할 수 있는 브라우저가 웹페이지 또는 웹 앱을 빠르게 불러오는 기술을 간단히 소개합니다.

여기서 브라우저의 에셋은 보통 HTML, CSS, JavaScript, 이미지, 글꼴 등을 말합니다.

우선 이미지 로딩은 `<img src=”…” loading=”lazy”>` 을 이용하는 방법을 우선 적용하면 확실한 효과를 얻을 수 있습니다.

사용에게 보여지는 영역인 뷰포트에 이미지를 불러와야하는 시점에 다다를 때 이미지를 불러옵니다.

이런 로딩은 잠시 참을 시간을 벌지만진짜 빠르게 만들지는 않습니다

이 밖에 최초 렌더링하는 Placeholder 또는 Skeleton 이미지 또는 썸네일, 조금 더 나아가 BlurHash등을 이용해서 이미지 때문에 화면을 느리게 렌더링 하는 것을 막을 수 있습니다.

HTML과 CSS는 브라우저가 화면을 그리기위해 항상 앞서서 해석해야하는 요소이므로 HTML과 CSS의 크기를 줄이거나, 화면에서 꼭 필요한 내용만 불러오도록 만들어 빠르게 화면을 그릴 수 있습니다.

최근 자바스크립트는 보통의 경우에 다른 에셋들보다 용량이 큽니다. 웹앱이라면 더욱 커집니다. 용량이 커질수록 다운로드 받는데 긴 시간이 걸립니다.

브라우저는 HTML을 읽어나가면서 필요한 에셋을 불러오는데, 이때 `<head>` 태그에 큰 용량을 가지거나 불러오는데 시간이 걸리는 자바스크립트가 있다면 화면이 보이지 않는채로 한참을 기다려야 합니다. 때문에 대부분의 경우에 `<body>` 태그의 맨 마지막에 선언합니다.

추가적으로 최근 브라우저는 `<script>`태그에 `defer` 를 지원하여 자바스크립트를 백그라운드에서 다운로드합니다. 이 때문에 자바스크립트를 다운로드 받는 도중에 HTML 파싱을 멈추지 않습니다. 그리고 `DOMContentLoaded` 이벤트 발생 이전에 실행됩니다. 또한 `async` 속성은 `defer` 와 유사하지만 `DOMContentLoaded` 이벤트와 독립적으로 동작하기 때문에 페이지를 그리는 것과 동기화하는데에는 사용할 수 없습니다. 그리고 각 `async` 스크립트는 서로 독립적으로 동작하므로 유의해서 사용해야합니다.

자바스크립트를 불러오는 다른 방법으로 필요한 경우에 자바스크립트를 이용해서 다른 자바스크립트 파일을 `document`에 추가하는 방법으로 불러올 수도 있습니다. 이 방식은 `async` 와 비슷합니다.

Webpack, 그리고 Vite 등 에셋 빌드 도구

최근 프론트엔드 개발에서 `index.html` 을 직접 수정하는 경우가 많지 않습니다. `create-vite-app` 으로 만든 Vue.js 애플리케이션의 `index.html` 파일 내용은 다음과 같습니다

index.html (github.com)

최소한의 HTML만 가지고 있고 `<script type=”module” src=”/src/main.js”></script>` 를 이용해 이후 모든 렌더링을 처리합니다.

지난 몇년간 Webpack이 안쓰이는 곳이 없을 정도로 거의 모든 프론트엔드 개발의 중심에 있었습니다. Webpack의 경우에 개발 중에도 에셋 번들링을 하기 때문에 꽤 느렸고, Hot Module Reload를 이용하더라도 중요한 변경이 있는 경우에 다시 모든 에셋을 빌드해야 했습니다.

이 부분이 프론트엔드 개발하면서 기다리기 가장 괴로운 점이었습니다.

Vite. 새로운 프론트엔드 도구입니다.

2021년에는 Vite `/vit/ 비트 로 발음` 를 이용한 개발이 주목받고 있습니다. 유명한 React.js, Vue.js를 지원하며 개발 중에 더욱 빠른 에셋 번들링과 Hot Module Replacement를 제공하여 웹팩 기반 앱을 개발할 때보다 개발 경험이 매우 좋아졌습니다.

Webpack에서 제공하던 코드 분할 (Code Splitting)은 Vite에도 동일하게 지원됩니다. 코드분할을 통해 필요한 에셋을 필요한 상황에서 불러올 수 있기 때문에 더욱 빠른 사용자 경험을 제공할 수 있습니다.

사용자의 다음 페이지 에셋 미리 불러오기

빠른 사용자 경험은 언제나 필요한 것만 미리 불러오는 것으로부터 시작합니다.

에셋의 크기를 줄이고, 필요한 페이지에서 필요한 에셋만 불러왔다면, 더 빠른 페이지를 위해 다음에 불러올 수 있는 페이지의 에셋을 미리 불러올 수도 있습니다.

구글의 quicklink는 사용자가 브라우저에서 아무것도 하지 않고 있는 시간에 화면 안에 있는 링크들을 미리 불러옵니다. 다음 페이지를 요청할 때 미리 가져온(prefetch) 에셋을 사용하여 빠른 페이지 전환을 가능하게 합니다. instant.page 도 마찬가지로 동작합니다.

Nuxt.js는 “Smart Prefetching” 을 지원합니다 `<nuxt-link />` 가 브라우저의 뷰포트에 들어왔을 때 다음 페이지에 필요한 에셋을 불러옵니다. 다른 방식이 사용자의 동작이 멈추거나, 링크 등에 마우스를 올리는 타이밍에 하는 방식을 선택했다면, Nuxt.js 는 조금 더 공격적으로 화면에 들어오자마자 요청합니다.

Nuxt.js Smart Prefetching⚡️

에셋을 미리 불러온다면, 데이터는?

이제까지 에셋을 먼저 불러오는 방식으로 빠른 페이지를 제공하는 방식을 알아봤다면, 이제 데이터는 미리 가져올 수 있지 않을까 생각해봅니다.

Nuxt.js를 사용하지만 독립적인 메소드를 이용하므로 어떤 자바스크립트 라이브러리, 프레임워크 또는 순수 자바스크립트를 사용하더라도 적용해볼 수 있을 것 입니다.

데이터를 웹 서버에 요청해야 하므로 운영에 사용하기 위해서는 웹 서버, 데이터베이스 캐시 등을 우선 고려해야합니다. 만약 서버사이드 캐시 최적화가 되어 있다면 적은 부하와 함께 빠른 사용자 경험을 제공할 수 있을 것 입니다.

API Prefetch 데모

https://nuxt-prefetch-api-demo.vercel.app/ 는 데이터를 웹페이지에 들어가기 전에 미리 캐시하는 예제입니다. Nuxt.js로 만들었으며 express를 이용하여 임의로 400ms 이상 딜레이를 주어 약간은 느린 웹 애플리케이션을 시뮬레이션합니다.

API 미리 불러오기(API Prefetch)는 다음 과정으로 진행됩니다

간단한 캐시 전략

최초 페이지 진입 ~ 렌더링 및 API 요청에는 API 미리 불러오기가 적용되지 않습니다. 전적으로 에셋 최적화와 서버 성능에 달려 있습니다. 위 데모는 매우 느린 400ms 이상의 응답 시간을 가진 API 를 가지고 있으므로 최초 렌더링에는 시간이 한참 걸립니다. 개발자도구에서 캐시를 끄고 Fast 3G 속도로 불러오면 600ms, Slow 3G로 불러오면 2초 이상의 시간이 소요되는 것을 확인할 수 있습니다.

예제의 좌측 사이드바에서 DASHBOARD를 누르면 마찬가지로 Slow 3G 네트워크에서 2초 정도 시간이 걸립니다. 조회 용도로만 사용하는 페이지이기 때문에 다른 페이지에 갔다 온 다음에 다시 돌아와도 마찬가지로 2초가 걸립니다.

매번 이와 같이 느린 페이지를 불러온다면 사용하는 사람이 너무 괴로울 것 입니다. 화면은 빠르게 불러오겠지만 API를 이용해서 가져와야하는 데이터를 늦게 가져오기 때문에 실제로 사용자가 사용하기에 너무나 사용성이 떨어집니다.

변화가 크지 않고 조회를 위해 사용하는 페이지라면, 잠시 메모리에 저장한 다음 다시 돌아왔을 때 이 데이터를 쓸 수 있습니다.

API 미리 불러오기의 캐시 전략의 전체 코드입니다.

위 방법은 어떤 자바스크립트 라이브러리, 프레임워크에서 사용할 수 있습니다. Nuxt.js에서는 다음과 같이 요청합니다. `@nuxt/composition-api` 의 useFetch 를 이용합니다.

한 페이지에서 데이터를 미리 불러왔다면, 다른 페이지에 다녀 오더라도 TTL (Time To Live) 기간 동안에는 캐시를 이용합니다.

https://nuxt-prefetch-api-demo.vercel.app/ 에서 대시보드 화면을 불러온 이후에 TODOS 페이지로 이동한 후 다시 대시보드로 이동해보세요.

순서는 보면 다음과 같습니다

로그와 함께 살펴보세요

// 최초 페이지 API 요청 시작
[🚀][todos] START FETCH FROM REMOTE
// TODOS API 요청 완료
[🛑][todos] DONE FETCH FROM REMOTE
// 대시보드 페이지 API 요청 시작
[🚀][dashboard] START FETCH FROM REMOTE
// 불러오는 도중 todos 캐시 삭제됨
[🗑][todos] CLEAR by TTL
// 대시보드 페이지 API 요청 완료
[🛑][dashboard] DONE FETCH FROM REMOTE
// 다시 todos 페이지로 이동
[🚀][todos] START FETCH FROM REMOTE
// 다시 대시보드 페이지로 이동하였고, 캐시를 이용함
[📤][dashboard] HIT CACHE
// 대시보드 페이지로 들어오는 도중 todos API 요청 완료
[🛑][todos] DONE FETCH FROM REMOTE
// 잠시 후 dashboard 캐시 제거됨
[🗑][dashboard] CLEAR by TTL
// 잠시 후 todos 캐시 제거됨
[🗑][todos] CLEAR by TTL

API Prefetch 없이 사용하기

API Prefetch 와 함께 사용하기

마우스 이벤트와 함께 사용할 수 도 있습니다. https://nuxt-prefetch-api-demo.vercel.app/ 데모 페이지의 맨 위에 있는 항목 “delectus aut autem THIS LINK DOES NOT USE PREFETCH API” 는 마우스를 올렸을 때 API 미리 요청하지 않습니다.

이 항목외의 모든 항목들은 마우스를 올린 후 Debounce delay 이후에 캐시에 데이터를 미리 요청해둡니다.

데이터를 미리 요청하지 않으면 Loading 후에 데이터를 그립니다.

하지만 데이터를 화면에 들어가기 전에 미리 요청하면 화면에 들어갔을 때 필요한 내용을 바로 볼 수 있습니다.

마우스 이벤트의 경우에는 반드시 Debounce를 해야합니다. 그렇지 않으면 마우스를 움직일 때마다 서버에 요청을 하기 때문에 큰 부하를 줄 수 있습니다.

이 방식은 다음 페이지에 필요한 내용을 아래와 같이 개선하는 과정을 말합니다.

일반적인 웹 앱에서 다음페이지로 이동할 때 첫번째 페이지 렌더링이 완료되고 다음페이지로 넘어간 다음 새로 에셋, 데이터를 요청합니다.

Nuxt.js의 Smart Prefetching을 이용하면 첫번째 페이지에서 미리 다음페이지에서 사용할 에셋을 불러옵니다.

그리고 다음 페이지로 넘어간 다음 데이터를 요청합니다. 첫번째보다 화면을 그리는 소요시간은 줄어들었지만 데이터 요청이 완료되어야 사용자가 온전히 사용할 수 있습니다.

마지막 방식은 이전 페이지에 들어가기 전에 이미 화면을 그리기 위한 에셋과 데이터가 완전히 불러졌기때문에 다음페이지로 이동했을 때 바로 화면을 볼 수 있습니다.

이 방법은 매우 빠르지만 한번 API를 요청한 이후라면 브라우저에서 캐시한 데이터를 사용하기 때문에 캐시 정책에 따라서 차이가 크지 않을 수 있습니다. 데모 앱에서 개발자 도구를 열어 여러가지 상황을 테스트해보세요

더 개선한다면

데모 앱은 API 요청이 여러번 일어나도 모두 서버에 요청합니다. 요청을 Promise Queue에 넣거나 Debounce하여 여러번 요청되지 않도록 하면 한 페이지에서 API를 요청하기 시작한 후 다음 페이지로 이동하는 과정에서 데이터 요청을 더 하지 않도록 막을 수 있습니다.

그리고 사용자의 행동 패턴을 파악하여 필요한 링크에만 적절한 이벤트로 API Prefetch를 적용할 수 있습니다. 어쩌면 hotjar같은 사용자 행동 분석 서비스가 필요할 수 있습니다.

다음 내용을 더 알아보세요

- web.dev Fast load times
- Introducing Smart Prefetching
- Google quicklink
- Instant Page
- Turbo
- Critical Rendering Path
- BlurHash
- Webpack code splitting
- Vite
- Dynamic & Async Components
- Blitz
- React Query
- React Query: It’s Time to Break up with your “Global State”! –Tanner Linsley
- swr
- swrv