Import On Interaction
요약: 중요하지 않은 리소스는 사용자 상호작용에 따라 필요해질 때 로드하도록 한다
우리가 개발하는 사이트에는 즉시 필요하지 않는 코드나 데이터들이 포함되어 있을 수 있다. 예를 들어 UI의 어떤 부분은 사용자가 클릭하거나 스크롤하여 보여지기 전 까지는 필요없을 수 있다. 이는 개발자가 퍼스트 파티(직접 개발한) 코드에 대해 적용할수도 있지만 비디오 플레이어나 채팅 위젯 등 메인 UI가 보이기 위해 클릭을 해야하는 서드파티 코드들에 적용할 수 있다.
이런 파싱 및 실행 비용이 높은 리소스들을 즉시 불러올 경우 메인 스레드를 블록할 수 있으며 사용자에게 중요한 영역이 상호작용 가능해지기까지의 시간을 지연시킬 수 있다. 이는 성능 주요 지표에 해당하는 First Input Delay, Total Blocking Time, Time to Interactive에 영향을 줄 수 있다. 이런 리소스들을 즉시 로드하는 대신, 아래의 상황에 로드하도록 할 수 있다.
- 사용자가 컴포넌트에 처음 클릭했을 때
- 스크롤하여 컴포넌트가 뷰포트에 들어왔을 때
- 또는 requestIdleCallback 을 통해 브라우저가 프로세스가 유휴 상태일 때
리소스를 불러오는 방법들을 고 수준에서 정리해 보자면:
- Eager - 리소스를 즉시 불러옴 (스크립트를 불러오는 일반적인 방법)
- Lazy (Route-based) - 특정 경로를 탐색할 때 로드한다
- Lazy (On interaction) - 사용자가 UI를 클릭했을 때 불러온다 (예를 들면 “채팅 시작하기”)
- Lazy (In viewport) - 사용자가 스크롤을 움직여 컴포넌트가 뷰포트에 들어올 때 로드한다
- Prefetch - 우선순위를 높여 로드하되, 중요한 리소스보다는 늦게 로드되도록 한다
- Preload - 중요 리소스로 지정하여 즉시 받도록 한다
참고: 퍼스트 파티 코드를 상호작용 발생 시 동적으로 로드하게 하는 것은. 상호작용 전에 리소스를 미리 불러올 수 없는 경우에만 적용해야 한다. 이 패턴은 주로 서드파티 코드들을 지연 로딩하는데 적용한다. 지연로딩을 시작하는 기준은 상호작용 시작 전까지 미루거나, 브라우저 프로세스가 유휴 상태일때까지 미루거나 기타 다른 기준들을 사용할 수 있다.
여러 상황에 일부 기능에 대한 코드를 지연 로딩하는 것에 대해서는 이 글에서 이어서 다룰 것이다. Google Docs를 사용했다면 아마 이 기능을 경험해보았을 것이다. Google Docs는 인터렉션 발생까지 스크립트 로딩을 지연시켜 초기 스크립트의 크기 중 500KB를 절약할 수 있었다.
이 패턴을 적용하기 좋은 또 다른 곳은 서드파티 위젯을 로드하는 곳이다.
파사드 UI를 활용하여 서드파티 모듈을 지연로딩하기
서드파티 코드를 불러오는 도중에는 해당 코드에 대해서 아직 완전한 제어를 할 수 없는 경우가 있다. 이 때 상호작용 시 로드 패턴을 적용하기 위한 방법 중 하나는 파사드 UI를 활용하는 방법이다. 파사드 UI는 실제 컴포넌트의 기본 동작만을 구현한 경량 컴포넌트 혹은 더 단순하게 스크린샷을 쓰는 단순한 “프리뷰” 혹은 “플레이스홀더”이다. Lighthouse팀에서 일할 때 떠올랐던 아이디어에 대한 용어였다
사용자가 “프리뷰”(파사드 UI) 를 클릭할 때 코드를 포함한 리소스를 불러오기 시작한다. 사용자는 해당 기능을 사용하기 위해 비용을 조금 더 사용해야 한다. 또 파사드 UI는 preconnect 속성을 이용해 마우스 호버 시점에 가져오게 할 수도 있다.
참고: 서드파티 리소스들은 전반적인 사이트 로딩 속도에 어떤 영향을 줄 지 고려하지 않고 코드베이스에 추가되곤 한다. 서드파티 스크립트를 동기적으로 로드하면 브라우저 파서를 블락하고 hydration을 지연시킬 수 있다. 가능하다면 3P(서드파티) 스크립트는 async나 defer (또는 다른 방법들)을 활용하여 1P(퍼스트파티) 코드들이 로드되는것을 방해하지 않도록 하자. 하지만 그 서드파티 코드들이 중요하다면 이 글에서 다루는 상호작용 시 로드 패턴과 같은 지연 로딩 패턴을 적용하는것을 추천한다.
비디오 플레이어 임베드
파사드 UI의 좋은 예시는 Paul Irish의 YouTube Lite Embed 이다. 해당 라이브러리는 YouTube 비디오 ID를 받아 최소한의 썸네일과 플레이 버튼을 렌더하는 커스텀 엘리먼트를 제공한다. 이 엘리먼트를 클릭하면 YouTube의 임베드 코드를 그때서야 로드하기 시작하고. 이는 사용자가 플레이버튼을 누르지 않으면 불필요한 리소스 비용을 소비하지 않아도 된 다는 이야기이다.
동일한 기법이 몇몇 구글 사이트에도 적용되어 있다. android.com에서는 YouTube의 임베드 코드를 즉시 로드하지 않고 썸네일과 플레이 버튼을 가진 컴포넌트를 렌더한 뒤 사용자가 클릭하면 풀 페이지 모달로 비디오를 보여주도록 개발되어 있다.
인증
앱을 개발하다 보면 클라이언트 사이드 JavaScript SDK를 이용하여 인증을 지원해야 하는 경우가 있다. 이 때 사용자가 로그인 페이지에 진입하지 않아도 이를 로드하게 하면 무거운 JS를 다운받게 되며 그에 따른 비용이 사용된다. 이런 인증 SDK들을 사용자가 로그인 버튼을 누를 때 다운로드 받게 하여 메인 스레드를 블록하지 않도록 하자.
채팅 위젯
Calibre앱은 위에서 소개한 파사드 UI와 유사한 기법을 적용하여 고객상담 라이브 채팅의 성능을 30%이상 개선할 수 있었다. HTML과 CSS만 포함한 “가짜” 라이브 챗 버튼을 구현하였고 이를 클릭하면 고객상담 채팅 번들을 다운로드하도록 구현하였다.
Postmark는 사용자가 가끔 사용하는 도움 채팅 기능을 페이지 로드 즉시 로드하고 있었다. 해당 위젯의 스크립트는 314KB에 육박했으며 웹 사이트의 번들보다 크기가 컸다. 사용자 경험을 개선하기 위해 이 위젯을 HTML과 CSS만 포함한 가짜 엘리먼트로 대체하고, 실제로 클릭이 이뤄졌을 때 로드하도록 하였다. 이는 TTI를 7.7초에서 3.7초로 감소시켰다.
기타 사례
Ne-digital 사이트는 “맨 위로”버튼의 기능을 위헤 서드파티 라이브러리를 사용하고 있었다. 이를 즉시 로드하지 않고. 사용자가 버튼을 클릭할 때 로드하여 스크롤을 맨 위로 올리도록 하여 ~7KB 정도를 절약할 수 있었다.
handleScrollToTop() {
import('react-scroll').then(scroll => {
scroll.animateScroll.scrollToTop({
})
}
상호작용 시 불러오기는 어떻게 구현할 수 있지?
바닐라 자바스크립트
자바스크립트에서는 dynamic import()를 사용하면 모듈을 지연로딩할 수 있으며 프로미스를 반환한다. 제대로 적용하기만 하면 꽤 효과를 볼 수 있다. 아래 예제는 사용자가 버튼을 클릭했을 때 lodash의 sortby를 로드하여 사용하고 있다.
const btn = document.querySelector('button')
btn.addEventListener('click', e => {
e.preventDefault()
import('lodash.sortby')
.then(module => module.default)
.then(sortInput()) // use the imported dependency
.catch(err => {
console.log(err)
})
})
dynamic import를 사용할 수 없었거나 사용하기 애매한 경우 스크립트 태그 자체를 동적으로 페이지에 포함하는 방법이 있다. 이를 위해 프로미스 기반 스크립트 로더를 사용할 수 있다. (프로미스 기반 스크립트 로더의 전체 구현에서는 로그인 파사드의 예시도 구현되어 있다.)
const loginBtn = document.querySelector('#login')
loginBtn.addEventListener('click', () => {
const loader = new scriptLoader()
loader
.load(['//apis.google.com/js/client:platform.js?onload=showLoginScreen'])
.then(({ length }) => {
console.log(`${length} scripts loaded!`)
})
})
React
<MessageList>
, <MessageInput>
, <EmojiPicker>
로 구성된 채팅 앱이 있다고 가정해 보자 (emoji-mart를 사용했다. minified 하고 gzipped 되면 98KB정도의 크기이다). 페이지 진입과 동시에 모든 컴포넌트를 로드할 수 있다.
import MessageList from './MessageList';
import MessageInput from './MessageInput';
import EmojiPicker from './EmojiPicker';
const Channel = () => {
...
return (
<div>
<MessageList />
<MessageInput />
{emojiPickerOpen && <EmojiPicker />}
</div>
);
};
코드 스플리팅을 통해 전체 로드를 분리시키는것은 꽤 간단하다. React.lazy
는 React앱 내의 컴포넌트 레벨에서 dynamic import를 적용해 코드를 나눌 수 있게 해 준다. 개발자는 로딩에 관련된 상태만 신경쓰고 이를 Suspense
컴포넌트와 잘 연결해주기만 하면 된다.
import React, { lazy, Suspense } from 'react';
import MessageList from './MessageList';
import MessageInput from './MessageInput';
const EmojiPicker = lazy(
() => import('./EmojiPicker')
);
const Channel = () => {
...
return (
<div>
<MessageList />
<MessageInput />
{emojiPickerOpen && (
<Suspense fallback={<div>Loading...</div>}>
<EmojiPicker />
</Suspense>
)}
</div>
);
};
또 앱 초기화 때 전체를 로드하게 하지 말고, 이 방식을 <MessageInput>
이 클릭되었을 때 로드되도록 적용할수도 있다.
import React, { useState, createElement } from 'react'
import MessageList from './MessageList'
import MessageInput from './MessageInput'
import ErrorBoundary from './ErrorBoundary'
const Channel = () => {
const [emojiPickerEl, setEmojiPickerEl] = useState(null)
const openEmojiPicker = () => {
import(/* webpackChunkName: "emoji-picker" */ './EmojiPicker')
.then(module => module.default)
.then(emojiPicker => {
setEmojiPickerEl(createElement(emojiPicker))
})
}
const closeEmojiPickerHandler = () => {
setEmojiPickerEl(null)
}
return (
<ErrorBoundary>
<div>
<MessageList />
<MessageInput onClick={openEmojiPicker} />
{emojiPickerEl}
</div>
</ErrorBoundary>
)
}
Vue
뷰에서는 상호작용 시 불러오기를 다양한 방법으로 적용할 수 있다. EmojiPicker
를 동적으로 불러오도록 하기 위해 이를 다음처럼 동적 로딩 함수로 감쌀 수 있다. () => import("./EmojiPicker")
. 이렇게 하면 뷰는 렌더해야할 때 컴포넌트를 지연로딩하도록 동작한다.
또 사용자 인터렉션 발생 시에 로드하도록 하려면 피커의 부모 엘리먼트에 v-if
디렉티브를 사용하면 된다. 사용자가 버튼을 클릭하면 조건문의 값을 만족시켜 동적으로 불러온 후 렌더링하도록 하는 것이다.
<template>
<div>
<button @click="show = true">Load Emoji Picker</button>
<div v-if="show">
<emojipicker></emojipicker>
</div>
</div>
</template>
<script>
export default {
data: () => ({ show: false }),
components: {
Emojipicker: () => import('./Emojipicker'),
},
}
</script>
상호작용 시 불러오기 패턴은 Angular를 포함하여 동적 로딩을 지원하는 프레임웍에서는 대부분 적용할 수 있다.
퍼스트 파티 코드에 상호작용 시 불러오기를 적용하여 점진적 로딩 구현하기
상호작용 시 코드를 불러오는 패턴은 구글이 항공권 예약이나 사진 앱과 같은 규모가 큰 앱에 점진적 로딩을 적용하는데 있어 매우 중요하다. 이를 설명하기 위해 이전 글에서 소개했던 Shubhie Panicker의 예제에 대하여 이야기 해 보자.
사용자가 인도의 뭄바이를 여행할 때 묵을 호텔의 가격을 구글에서 본다고 생각해 보자. 이 상호작용을 위한 리소스들은 당연히 필요하겠지만 아직 장소를 정하지 않았다면 지도 기능을 위한 HTML/CSS/JS는 아직 필요가 없을 것이다.
이 때 리소스를 다운로드하는 시나리오를 간단하게 하기 위해. 구글 호텔이 단순하게 클라이언트 측 렌더링 (CSR)을 적용했다고 하자. 화면을 출력하기 위해 모든 코드를 다운로드받아야 한다: 한번의 렌더링을 위해 HTML, JS, CSS를 모두 받아야 한다. 그러나 이는 사용자로 하여금 화면에 무언가 보이기 까지 너무 오랜 시간을 기다리게 할 수 있다. JS 와 CSS의 대부분은 필요하지 않을 수 있다.
다음으로는 이를 서버측 렌더링 (SSR)으로 구현했다고 생각해 보자. 사용자가 뭔가를 더 빨리 볼 수 있도록 해 주었다. 하지만 추가적인 데이터 다운로드와 클라이언트 측에서 hydration이 끝나기 전 까지 사용자는 아무런 상호작용을 할 수 없다.
SSR이 그나마 좀 낫지만, 사용자는 일종의 불쾌한 골짜기를 느낄 수 있다. 페이지가 렌더되어 준비가 끝난것 처럼 보이지만 어느것도 탭할 수 없다. 때때로 이는 사용자가 클릭을 여러번 시도하다가 안되는 것을 깨닫고 포기하는 상황에 이를 수 있다.
위의 구글 호텔 예제로 돌아가서 UI을 조금 더 자세히 들여다보면. 사용자가 원하는 호텔을 찾기 위해 “more filters”를 클릭하면 관련된 코드를 다운로드하기 시작하는 것을 알 수 있다.
초기에는 필수적인 코드만 다운로드하도록 하고 이후에 유저의 인터렉션을 보며 추가적으로 다운로드하게 한다.
이 로딩 시나리오를 조금 더 자세히 들여다 보자.
상호작용 기반 불러오기 패턴에는 아래의 여러 중요한 측면들이 있다.
- 최초에는 최소한의 코드만을 다운로드하게 하여 페이지를 빨리 볼 수 있게 한다.
- 그 다음, 사용자의 인터렉션에 적합한 추가 코드를 다운로드하도록 한다. 예를 들어 “검색조건 더 보기” 같은 것이 있다.
- 이로 인해 사용자가 필요로 하지 않는 여러 기능들은 위한 코드들이 페이지 렌더를 지연시키지 않도록 하는것이다.
너무 빨리 클릭하여 지연로딩 시작이 안되는 문제는 어떻게 해결할 수 있을까?
구글 팀의 프레임웍 스택에는 프레임웍 코드가 실행되기도 전에 사용자가 너무 빨리 클릭을 하더라도 이를 추적할 수 있게 해 주는 매우 경량의 이벤트 라이브러리를 사용하고 있다. 이는 아래 두 가지를 위해 활용하고 있다.
- 사용자 인터렉션에 의한 컴포넌트의 다운로드를 트리거한다
- 사용자 인터렉션을 잘 가지고 있다가 프레임웍이 실행되었을 때 릴레이 해 준다
컴포넌트 코드를 로드해야 할 지 판단할 수 있는 다른 항목으로는:
- 유휴 시간 이후의 기간
- 사용자가 관련된 UI, Button 등에 마우스오버할 때
- 브라우저의 상태 기반 (네트워크 속도, 데이터 세이버 모드 등)
데이터에도 적용이 가능할까?
초기에 받는 데이터는 SSR HTML이나 스트리밍을 통해서 페이지에 렌더링 될 것이다. 그 이후 사용자 인터렉션에 의해 어떤 컴포넌트와 함께 다운로드될 수 있다.
CSS와 JS함수들이 그랬던 것 처럼 데이터도 동적으로 불러올 수 있다. 동적 로딩되는 컴포넌트는 자체적으로 어떤 데이터를 불러와야 하는지 알고 있다. 이 과정은 그냥 요청을 보내는 것이나 다름없다.
이런 컴포넌트 내의 데이터 요청 코드는 빌드 타임에 이미 결정되어 있을 것이다. 웹 앱은 이를 처리할 때 컴포넌트와 필요 데이터를 동시에 받도록 할 수 있다. 이는 라우팅 경로에 따라 코드를 나누는 것 보다 컴포넌트 기준으로 코드를 나누는 것과 같다.
위 예제에 대하여 조금 더 자세히 알고 싶다면 Elevating the Web Platform with the JavaScript Community를 참고하기 바란다.
트레이드오프
비용이 높은 코드들을 사용자의 인터렉션 발생 시 다운로드하도록 리펙토링하면 페이지의 초기 로딩 속도를 최적화할 수 있지만, 단점이 없는 것은 아니다.
사용자가 클릭한 뒤 스크립트를 다운로드 받는 시간이 오래 걸리면?
위의 구글 호텔 예제에서 코드를 잘게 나누는 것은 코드와 데이터를 다운로드 받고 실행하는 시간을 줄여줄 수 있다. 하지만 의존하는 모듈이 많고 네트워크 속도가 느린 환경을 고려해보아야 한다.
이런 상황을 피하기 위해서는 중요한 리소스들을 받은 직후 이런 리소스를 prefetch하는것을 고려할 수 있다. 이를 측정하여 앱이 실제로 어느정도의 성능을 보이는지 확인해보는것이 좋다.
사용자 인터렉션이 없어도 동작해야 하는 경우라면?
또 다른 트레이드오프는 사용자 인터렉션 없이는 기능을 제공할 수 없다는 점이다. 비디오 플레이어 임베드 예제에서 이 경우 자동 재생 기능을 이용할 수 없다. 어떤 기능이 중요하다면 이 기능을 다운로드 하기 위해 서드파티 모듈을 스크롤에 따라 지연로딩 하는 등 다른 방법을 사용해야 한다.
상호작용 임베드를 정적인 형태로 교체하는 사례
이 글에서 상호작용 시 불러오기 패턴과 점진적 로딩에 대해 다루었다. 하지만 임베드 되는 컨텐츠를 아예 정적으로 만드는 것에 대해서도 이야기 해볼 필요가 있다.
소셜 미디어 포스트와 같이 페이지 초기 렌더링 시 뷰포트에 즉시 임베드되어 보여야 하는 경우가 있다. 거기에 임베드 되는 영역이 2-3MB씩이나 되는 코드를 가져오는 경우에는 꽤 문제가 될 수 있다. 임베드 되는 컨텐츠는 거의 즉시 렌더링 되어야 하다 보니. 지연로딩과 파사드 UI를 적용하기도 애매한 부분이 있다.
이 때 성능 최적화를 위해 임베드 되는 컨텐츠를 생긴게 똑같은 정적 컨텐츠로 변경하고. 인터렉티브가 가능한 원본 링크를 거는 방법을 선택할 수 있다. 빌드 타임에는 데이터가 템플릿을 통해 정적인 HTML로 만들어진다.
이 방법은 @wongmjane이 소셜 미디어 임베드를 사용하기 위해 블로그에 적용한 방식이다. 이 방법을 적용하여 임베드 코드가 페이지 내에 적용될 때 겪는 페이지 로딩 성능 문제와 누적 레이아웃 이동 문제를 해결했다.
이 방법은 성능에 유리한 부분이 있지만 추가 작업을 해야 하니 적용할 때 이 부분을 고려해야 한다.
결론
퍼스트 파티 코드들 (직접 작성한 코드들)은 모던 웹에서 상호작용 가능하기까지의 준비 시간에 영향을 준다. 메인 스레드를 지속적으로 많이 사용하는 퍼스트 파티 코드나 서드파티 리소스들이 이를 더욱 더 지연시킬 수 있다.
일반적으로 document의 head에 동기적으로 서드파티 스크립트를 포함시키지 않도록 하여 퍼스트 파티 코드가 로드되는 것을 서드파티가 블록하지 않도록 한다. 이 글에서 다룬 상호작용 시 불러오기 패턴은 이런 중요하지 않은 코드들을 지연로딩하여 사용자가 UI를 다룰 때 조금 더 쾌적하도록 해 줄 수 있다.
Shubhie Panicker, Connor Clark, Patrick Hulce, Anton Karlovskiy 및 Adam Raine의 의견에 특별히 감사드립니다.