프론트엔드 성능 개선
웹 프론트엔드에서 성능이라고 하면 페이지 로딩 속도, 렌더링 속도, 서버 부화 (SSR의 경우), 검색 엔진 최적화 등의 다양한 관점으로 볼 수 있습니다. 저는 이중 UI 성능에 해당되는 페이지 로딩 속도와 렌더링 속도를 개선하는 방법에 대해 다뤄보겠습니다.
UI 성능은 사용자 이탈에 큰 영향을 미치는 요소입니다. 실제로 로딩 속도가 3초가 넘어가면 30% 이상의 사용자가 이탈한다는 통계 또한 있습니다. 그렇기에 실무자들은 UI 성능을 개선하는데 많은 노력을 기울이고 있습니다. 그러나 성능 저하의 원인을 명확하게 파악하지 않고 작업을 진행한다면 아무런 효과가 없을 수 있습니다. 이번 포스팅에서는 제가 실제로 현업에서 UI 성능을 개선했던 방법과 이를 어떤 상황에서 적용했는지에 대해 다루겠습니다.
가장 흔히 알려진 방법: 렌더링 최적화
프론트엔드에서 흔히 성능 개선이라고 하면 떠오르는 것이며, 렌더링의 횟수를 필요한 만큼의 최소한으로 줄이는 방법입니다. React JS
의 경우 라이브러리 단에서 Virtual DOM
을 통해 렌더링을 누적하여 처리하기에 useMemo
, useCallback
, useEffect
의 dependency array
를 잘 설정해주거나, 배열의 key
를 잘 활용하는 것, React.memo
활용 등으로 불필요한 렌더링을 막아줄 수 있습니다.
또 다른 관점: Debouncing & Throttling
개발하는 서비스의 특성에 따라 다르겠지만, 저의 경우 렌더링 횟수를 줄이는 것 자체로 득을 보는 경우는 거의 없었습니다. 일반적인 경우 렌더링은 상당히 빠르게 일어나며, 개선되는 성능은 체감이 안되는 정도였습니다.
저는 에디터 계열의 웹페이지를 작업할 상황이 생겼고, 이러한 류의 프로그램에는 이벤트 처리에 상당히 무거운 작업이 일어나는 경우가 많습니다. 이 경우 이벤트를 누적하여 처리하는 방법을 생각해볼 수 있습니다. 대표적으로 Debouncing과 Throttling이 있습니다.
Debouncing
Debouncing은 연이어 발생하는 이벤트를 그룹화하여 마지막 이벤트가 발생한 후 일정 시간이 지난 후에 한 번만 이벤트를 발생시키는 방법입니다.
execute()
라는 함수가 있다고 가정하여 아래와 같이 onChange
이벤트가 발생할 때마다 execute
함수를 실행하는 코드가 있다고 가정해봅시다.
function App() {
const onChange = () => {
execute();
};
return <input onChange={onChange} />;
}
위의 코드대로라면 onChange
시마다 execute()
가 실행됩니다. execute()
함수가 무거운 작업을 수행한다면 연속적인 onChange
이벤트의 마지막에만 실행되도록 수정해볼 수 있습니다.
function App() {
const timeout = useRef<NodeJS.Timeout>();
const onChange = () => {
if (timeout.current) clearTimeout(timeout.current);
timeout.current = setTimeout(() => {
execute();
}, 500);
};
return <input onChange={onChange} />;
}
Debouncing이 적용된 위의 코드는 500ms동안 새로운 입력이 없을 시에만 execute()
가 실행됩니다. execute()
자체의 속도를 개선하지는 못했지만 적어도 모든 입력마다 무거운 작업이 실행되는 것은 막을 수 있습니다.
Throttling
Throttling은 연이어 발생하는 이벤트를 필터링하여 이벤트 발생을 일정 기간의 한번으로 제한하는 방법입니다. Debouncing과 비슷하지만, Debouncing은 마지막 이벤트가 발생한 후 일정 시간이 지난 후에 한 번만 이벤트를 발생시키는 것이고, Throttling은 일정 시간마다 이벤트를 발생시키는 것입니다.
function App() {
const timeout = useRef<NodeJS.Timeout>();
const onChange = () => {
if (timeout.current) return;
timeout.current = setTimeout(() => {
execute();
timeout.current = undefined;
}, 500);
};
return <input onChange={onChange} />;
}
유휴 상태를 확인하자
성능 저하의 원인이 꼭 JavaScript
의 실행 속도에만 있지는 않습니다. 이는 브라우저의 개발 도구의 성능 탭에서 Recording하여 확인할 수 있습니다.
위와 같이 나왔다면 성능 저하에 JavaScript
가 차지하는 비중은 11 밀리초밖에 되지 않습니다. 대부분은 유휴 상태, 즉 서버로부터 데이터를 받기 전에 대기하는 시간입니다. 이는 제가 당시에 개발하던 웹서비스에서도 마찬가지였습니다.
그렇다고 백엔드 측에서의 최적화에만 의존할 수는 없습니다. 순수 네트워크 속도에는 분명이 한계가 존재합니다. 프론트엔드에서도 이를 개선하는 방법은 분명히 존재합니다.
비동기 처리
여러 API를 호출하는 경우, 꼭 필요한 경우가 아니라면 순차적으로 호출하지 않고 병렬로 호출하는 방법이 있습니다.
// 순차적 호출
const data1 = await fetch("https://api.com/data1");
const data2 = await fetch("https://api.com/data2");
const data3 = await fetch("https://api.com/data3");
// 병렬 호출
const [data1, data2, data3] = await Promise.all([
fetch("https://api.com/data1"),
fetch("https://api.com/data2"),
fetch("https://api.com/data3"),
]);
Prefetching
사용자가 요청하기 전에 미리 필요한 데이터를 사전에 불러오는 방법입니다. API 호출에 활용될 수도 있지만, 아래와 같이 이미지, 폰트 등의 정적 파일에도 적용할 수 있습니다.
<!-- Font Prefetch -->
<link rel="preload" href="%PUBLIC_URL%/[Font 경로]" as="font" crossorigin />
<!-- Image Prefetch -->
<link rel="prefetch" href="%PUBLIC_URL%/[Image 경로]" />
저는 폰트 파일을 따로 두는 프로젝트에서 Prefetching을 적용해봤고, 페이지 자체 로딩 속도에는 차이가 없었지만, 폰트가 늦게 적용되는 문제를 해결할 수 있었습니다.
코드 스플리팅
코드 스플리팅은 번들 파일을 여러 개로 나누어 초기에 불러오는 .js
파일의 사이즈를 줄여주는 방법입니다. React JS
의 경우 React.lazy
와 Suspense
를 활용하여 코드 스플리팅을 쉽게 적용할 수 있습니다.
const LazyComponent = React.lazy(() => import("./LazyComponent"));
function App() {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</React.Suspense>
);
}
코드 스플리팅은 초기 로딩 속도를 개선하는데 도움을 줄 수 있습니다. 단, 너무 많은 코드 스플리팅은 초기 로딩 이후의 동작을 지연시킬 수 있으니 적절한 수준에서 적용해야 합니다.
브라우저 캐싱 활용
브라우저 캐싱은 보통 Cache-Control
, expire
등의 HTTP 헤더로 설정할 수 있습니다. 아무런 해더 설정이 안되어있다면 브라우저마다 기본 캐싱 정책이 존재하며, 이를 그대로 둬도 일반적으로는 큰 문제가 없습니다.
간혹 캐싱을 완전히 막아놓는 경우가 있습니다. 저의 경우가 그러했습니다. 당시의 담당자는 소스 코드를 수정하고 재배포하면 캐싱된 소스들이 브라우저에 그대로 남아 업데이트가 적용되지 않기에 막아놨다고 설명했습니다. 당시의 Nginx
설정은 아래와 같았습니다.
location / {
try_files $uri $uri/index.html =404;
error_page 404 /404/index.html;
expires -1;
add_header 'Cache-Control' 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
}
일부 정적 파일에 대해서는 캐싱을 허용해주는 방법을 생각해볼 수 있습니다. 당시 프로젝트의 경우 .js
와 .css
파일은 캐싱해줘도 괜찮다고 판단하여 아래와 같이 설정을 변경했습니다.
location / {
try_files $uri $uri/index.html =404;
error_page 404 /404/index.html;
add_header 'Cache-Control' 'no-store, no-cache, must-revalidate, max-age=0';
if ( $uri ~ \.js|\.css$ ) {
add_header 'Cache-Control' 'must-revalidate';
}
}
페이지 첫 접속때는 차이가 없지만, 다음 접속부터 속도가 눈에 띄게 개선되는 효과를 볼 수 있습니다.
실제로 저는 캐싱을 통해 가장 큰 성능적 이득을 체감했으며, 그 차이는 위와 같았습니다. 서비스 특성상 초기에 요청하는 API가 여럿 있기에 여전히 긴 유휴 상태가 존재하지만, .js
와 .css
캐싱만으로도 절반 가까이 줄어든 것을 볼 수 있었습니다.
SSR (Server Side Rendering)
방금 위에서 초기에 요청하는 API가 여럿 있다고 했습니다. 이러한 경우에는 SSR
을 활용하여 서버에서 초기 렌더링을 해주는 방법도 있습니다. 클라이언트에서 네트워크 통신이 많이 일어나면 서버와의 물리적 거리에 의해 유휴 상태가 길어질 수 있습니다. SSR
로 서버에서 초기 렌더링을 해주면 클라이언트에서의 네트워크 통신이 줄어들 수 있습니다.
단, SSR
은 서버 자원을 필요로 하기에 서버 부화를 고려해야 합니다. Next.js
의 경우 revalidate
기간을 주어 캐싱을 통해 서버 부화를 줄이는 방법 또한 고려해볼 수 있습니다. 저의 경우 SSR
을 활용할 수 있는 환경을 갖추지 못했기에 실제 효과는 확인해보지 못했습니다.
댓글남기기