Next.js로 개발을 경험하며 React의 단점을 Next.js가 해결해주는데 왜 React가 계속해서 사용되는거지?? 라는 생각을 하게 되었습니다.
그럼 반대로 React의 단점을 해결할려는 노력은 해봤는가를 생각해보니 없었습니다. 그래서 저는 지난 프로젝트를 통해 React의 최대 단점인 초기 로딩 속도 문제를 발견하고 문제를 개선했던 과정들을 작성 해보겠습니다.
React를 사용하는 CSR 환경은 큰 번들 파일을 사용자의 브라우저에 초기에 전부 다운로드해야 하기 때문에, 사용자가 앱을 처음 방문할 때 긴 로딩 시간을 겪게 됩니다.
또한 이미지, 폰트 등의 리소스가 최적화되지 않아 파일 크기가 불필요하게 크다면 로딩 시간이 길어질 수 있습니다.
우선 개선하기 전 성능 측정을 해봤습니다.
초기 다운로드 용량이 21.3MB 정도로 측정됩니다.
빌드 시간도 7.01s가 측정되고 로고 빌드 용량도 gzip 기준으로 약 160.17 kB가 측정 됩니다.
라이트하우스의 FCP는 7.9s LCP는 8.1, Speed Index는 10.6s로 측정됩니다.
라이트하우스의 측정 시간을 보니 SpeedIndex에서 초기 페이지가 보여지기까지 얼마나 오래걸리는지 확인할 수 있습니다.
라이트하우스에서 경고한 내용을 살펴보면 첫 화면을 그리는 데 방해가 되는 자원들을 최적화할 것을 권장합니다.
페이지 로딩 초기 단계에서 필수적이지 않은 JS와 CSS 파일들이 페이지 렌더링을 지연시키고 있다는 것을 의미하고 FCP, LCP에 영향을 준다고 표시됩니다.
실제로 하나의 번들 파일이 초기에 모두 로드되는 것이 React의 초기 속도를 저하하는 큰 이유 중 하나입니다.
저는 코드 스플리팅을 통해 코드를 여러 개의 파일로 나누어 사용자가 특정 부분에 접근할 때만 해당 코드를 로드할 수 있도록 함으로써 초기 로딩 시간을 줄여보았습니다.
React에서는 lazy import 기능을 활용하여 페이지를 필요한 시점에 js파일을 다운로드 할 수 있습니다.
lazy import를 통해 모달 같은 사용자가 사용호작용 시 보여지는 경우에도 적용할 수 있습니다.
const DetailPage = lazy(() => import('@/Pages/DetailPage'));
const HomePage = lazy(() => import('@/Pages/HomePage'));
const NotFoundPage = lazy(() => import('@/Pages/NotFoundPage'));
const LoginPage = lazy(() => import('@/Pages/LoginPage'));
const SignUpPage = lazy(() => import('@/Pages/SignUpPage'));
const DirectMessagePage = lazy(() => import('@/Pages/DirectMessagePage'));
const ProfilePage = lazy(() => import('@/Pages/ProfilePage'));
const AddChannelModal = lazy(() => import('@/Pages/HomePage/AddChannelModal'));
const ModalRouter = lazy(() => import('./ModalRouter'));
lazy import를 적용 후에 Suspense를 활용하여 해당 페이지 로딩 중 fallback을 통해 다른 화면을 보여주도록 했습니다.
const RouterManager = () => {
return (
<Suspense
fallback={
<div>
<Spinner />
</div>
}
>
<Routes>
<Route
path="/"
element={<HomePage />}
/>
</Routes>
</Suspense>
);
};
lazy import를 통해 이전에는 초기 페이지에서 모든 번들 파일을 다운로드 하지만 사용이 필요한 번들 파일 만 다운로드 할 수 있습니다.
페이지 기준 코드 스플리팅 후 빌드 결과를 보면 이전에 하나의 번들 파일이 페이지 별로 여러 파일로 분리되어 있는 것을 확인할 수 있습니다. 하지만 이상하게 메인 index 번들 파일이 310.24 kB를 차지하고 있습니다.
원인을 찾아보니 라이브러리 같은 외부 모듈들이 모두 메인 번들 파일이 들어가있었습니다. 이렇게 되면 코드 스플리팅을 통해 분리하는 효과를 기대하기 힘들다고 생각되었습니다.
vite에서 빌드 Rollup 시 수동으로 옵션을 설정하여 외부 모듈 또한 vendor 디텍토리 기준으로 분리했습니다.
vite.config.ts에서 아래 코드를 추가해주었습니다.
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
const module = id.split('node_modules/').pop().split('/')[0];
return `vendor/${module}`;
}
},
},
},
},
외부 모듈또한 분리 후 빌드를 해보면 기존 index 파일에 뭉쳐져있던 외부 모듈들이 각각 분리되어 필요한 페이지 접근시에만 불러올 수 있습니다.
트리 쉐이킹은 번들링 과정에서 불필요한 코드(사용되지 않는 모듈)를 식별하고 제거하는 기법입니다.
빌드 과정에서 의문이 생겼습니다. lodash 라이브러리가 저 만큼이나 차지한다고?? 그래서 npm i rollup-plugin-visualizer 라이브러리를 통해 시각화를 했습니다.
확실히 이상하게 loadsh 모듈이 번들 파일보다 큰 이상한 문제가 있었고 구글링을 통해 import 방식 때문에 lodash의 전체를 불러온느 문제를 발견했습니다.
import { debounce } from 'lodash';
// 아래로 변경
import debounce from 'lodash/debounce';
저는 import 방식을 변경하여 lodash의 필요한 모듈만 불러오도록 했습니다.
결과적으로 lodash 모듈을 71.81 KB -> 2.48 KB 만큼이나 줄였습니다.
먼저 네트워크 탭을 살펴보니 폰트 파일을 3개나 불러오고 있었습니다. 그 중 2개의 폰트는 실제로 사용하고 있지 않은 폰트였습니다. 용량도 671KB, 591KB를 차지하고 있었습니다.
코드를 살펴보니 head 태그 내부와 font-face 2곳에서 CDN을 통해 폰트를 불러오고 있었습니다. 실제로 사용되는 폰트는 font-face 부분으로 head 태그 내부 폰트 로드를 제거했습니다.
CDN vs Local
라이트하우스에서 Google Fonts CDN과 JSDelivr CDN을 사용하여 폰트와 CSS 파일을 로드하는 데 상당한 시간이 소요되고 있음을 알 수 있습니다.
CDN을 통해 외부에서 폰트를 받는 것 보다 Local에 폰트를 저장하는 방법이 더 빠르다고 합니다. CDN 링크를 사용하는 방법은 간단하지만 CDN 서비스에 문제가 생기거나 인터넷 연결이 느린 경우 영향 폰트 로딩 시간이 지연될 수 있습니다.
기존 CDN에서 다운받은 폰트를 local에 다운 받아 사용하도록 수정했습니다.
woff2 형식 사용하기
폰트 파일의 형식은 4가지가 있습니다. (EOT, TTF, WOFF, WOFF2) 형식마다 용량이 다르고, 브라우저 지원 상황도 다릅니다.
WOFF와 WOFF2는 거의 모든 브라우저에서 사용할 수 있습니다. WOFF2 형식은 모든 브라우저에서 사용할 수 있고 용량이 가장 작기 때문에 WOFF2 확장자를 선택했습니다.
FOUT 적용
브라우저 종류에 따라 폰트 최적화 하는 방법이 다릅니다. IE 계열 브라우저에서는 FOUT 방법을 채택하고 최신 브라우저에서는 FOIT 방식을 채택하고 있습니다.
FOUT(Flash Of Unstyled Text) : FOUT는 웹 폰트가 로딩되는 동안 대체 폰트로 표시된 텍스트가 잠시 보였다가 웹 폰트 로딩이 완료되면 원래의 웹 폰트로 전환되는 현상입니다.
FOIT(Flash Of Invisible Text) : FOIT는 웹 폰트가 로딩되는 동안 텍스트가 전혀 보이지 않는 현상을 말합니다. 웹 폰트가 아직 로드되지 않았을 때, 브라우저가 텍스트 렌더링을 일시적으로 차단하고, 폰트 로딩이 완료될 때까지 텍스트를 숨깁니다.
FOUT를 적용하면 폰트가 로드되기전 대체 기본 폰트로 보여질 수 있지만 폰트가 로드되면
레이아웃이 변경되어 텍스트가 움직이는 현상이 발생합니다.
저는 빠른 초기 로딩 최적화가 목적이기에 font-display: swap을 통해 브라우저가 항상 FOUT 현상을 적용하도록 했습니다.
폰트에서는 static, variable 폰트가 있습니다. static 정적 폰트는 딱 정해진 폰트 스타일이지만 varibale 가변 폰트는 하나의 파일로 다양한 두께, 폭, 스타일을 조절할 수 있어, 리소스 사용을 최적화할 수 있습니다.
만약 가변 폰트로 여러 스타일을 사용한다면 해당 스타일에 맞는 폰트들을 모두 다운 받아야합니다.
하지만 가변 폰트를 사용한다면 하나의 폰트 파일로 여러 스타일을 지정할 수 있습니다.
가변 폰트는 모든 브라우저에서 완벽하게 지원되지 않을 수 있지만 대부분의 현대 브라우저에서는 지원됩니다.
만약 사용자 브라우저에 해당 폰트가 설치되어있다면 굳이 폰트를 로드할 필요가 없습니다. 그래서 local 설정을 통해 브라우저에 폰트가 있다면 바로 사용하도록 설정했습니다.
서브셋 폰트(Subset font)란 폰트 파일에서 불필요한 글자들을 제거하고 사용할 글자만 남겨둔 폰트 입니다.
한글에서 노란색 부분을 사실상 사용하지 않는 부분입니다. 서브셋 폰트는 이러한 불필요한 텍스트를 제거하기 때문에 용량이 줄어들 수 있습니다.
서브셋 폰트 변경 참조 사이트를 참고하여 서브셋 폰트를 제작하였습니다.
서브셋 폰트를 적용하여 2,010KB -> 214KB 만큼 용량을 개선했습니다. woff 형식도 가져온 김에 브라우저가 woff2를 지원하지 않을 경우 woff를 적용하도록 수정했습니다.
첫 빌드 시 2개의 정적 이미지 용량이 상당히 컸습니다. 실제로 페이지 접근 시 저 용량많큼 로드시간이 소요되기 때문에 반드시 줄여야하는 항목이였습니다.
우선 https://compressor.io/ 해당 사이트에서 이미지 파일의 용량을 대폭 줄이고 https://convertio.co/kr/download/723bd6c9674ff02cd360098184f79d86ead5b4/ 해당 사이트에서 이미지의 확장자를 기존의 PNG에서 WebP로 변경하여 더욱 최적화했습니다.
WebP는 PNG, JPEG, GIF 등 기존 포맷에 비해 우수한 압축률을 지원합니다. WebP보다 AVIF 형식이 더 압축률이 좋지만 AVIF보다 WebP 형식이 더 많은 브라우저에 호환되기에 WebP 형식을 선택했습니다.
빌드 시 이미지 용량을 보면 눈에 29.75 KB -> 8.64 KB, 346.43 KB -> 24.80 KB 엄청나게 용량이 줄었습니다.
WebP 형식은 인터넷 익스플로러 빼고, 웬만한 브라우저는 거의 다 지원한다고 합니다. 하지만 만약 익스플로러 브라우저까지 고려하신다면 picture 태그를 활용하여 호환성에 따른 확장자 분기 처리를 하여 해결할 수 있습니다.
const HeaderLogo = () => {
const { setTab, setPrev } = useTabStore();
const { setCurrentChannelId } = useChannelStore();
const { isMobileSize } = useResize();
return (
<StyledContainer>
<Link to="/">
<picture>
<source
srcSet={logoWebp}
type="image/webp"
/>
<StyledLogo
onClick={() => {
setTab('home');
setPrev('home');
if (!isMobileSize) setCurrentChannelId('');
}}
src={logo}
alt="logo"
/>
</picture>
</Link>
</StyledContainer>
);
};
picture 태그를 사용함으로 우선적으로 WebP 형식을 사용하고 호환되지 않으면 PNG 형식을 사용합니다.
초기 렌더링 때 사용자 목록을 불러와 보여지는 컴포넌트 입니다. 여기서 많은 이미지가 사용되는데 실제적으로 보여지는 부분 이미지만 로드하고 보여지지 않는 부분은 로드하지 않으면 초기 속도를 높일 수 있습니다.
lazy loading 방법에는 img 태그에 loading = "lazy"가 있지만 이는 브라우저 마다 호환성이 달라서 추천하지 않습니다.
그래서 저는 react-intersection-observer 라이브러리와 같이 화면에 보여줬을 때만 load 하도록 구현했습니다.
const [loadedSrc, setLoadedSrc] = useState('');
const { ref: inViewRef, inView } = useInView({
triggerOnce: true,
rootMargin: '50px 0px',
});
if (inView && !loadedSrc) {
setLoadedSrc(
src ||
'https://user-images.githubusercontent.com/17202261/101670093-195d9180-3a96-11eb-9bd4-9f31cbe44aea.png',
);
}
img 태그의 src에 초기에는 아무런 값을 넣지 않다가 onView가 true가 되면 그때 받아온 src를 적용하면서 lazy loading을 적용합니다.
lazy loading 적용 전
lazy loading 적용 후
logo 이미지와 api 호출을 제외하고 11개 -> 7개로 초기에 불러오는 이미지를 줄였습니다. 하지만 이미지 lazy loading 이 초기에 빈 src가 적용되어 SEO에는 안 좋다고 하네요.
폰트 CDN 요청을 줄였지만 여전히 라이트하우스에서는 CDN 요청에 많은 시간이 걸린다고 표시하고 있었습니다.
그래서 네트워크 탭을 열고 확인해보니 구글 아이콘 CDN을 통해 필요한 리소스를 초기에 다운 받는데 용량도 크고 시간도 많이 소요되고 있습니다. (SVG로 아이콘 사용할 껄)
그래서 조금이나마 빠르게 로드하기 위해 preload 옵션을 추가했습니다.
preload는 브라우저에게 해당 리소스를 다른 리소스보다 빨리 로딩하도록 알려주는 역할입니다. 중요도가 높은 자원(폰트, 이미지, 스크립트 파일 등)을 의도적으로 먼저 로딩할 때 사용하면 최적화에 매우 좋습니다.
preload 설정할 때 반드시 as와 crossorigin을 사용하여 2번 요청을 방지해야 합니다.
확실히 preload 설정 후에 574ms -> 188ms 로 측정되는 걸 보니 개선된 것 같습니다.
다운로드 용량이 21.3MB -> 13.8MB 정도 개선되었습니다.
빌드 타임도 7.01 s -> 4.51 s 만큼이나 개선되었습니다.
gzip 기준으로 빌드 용량이 약 160.17 kB -> 112.65 kB로 개선되었습니다.
FCP는 7.9s -> 0.9s
LCP는 8.1s -> 1.6s
Speed Index는 10.6s -> 1.2s
라이트하우스을 기준으로 초기 로드 관련 수치가 얼마나 개선되었는지 확실하게 보입니다.
처음으로 성능을 최적화 해봤습니다. 작업한 만큼 결과가 눈에 보이니 최적화 부분도 재미가 있는 것 같습니다.
성능을 개선해나가면서 왜 초기 로드가 느려지는지 이해하게 되었습니다. 처음에는 코드 스플리팅만 적용하면 개선될 부분인 줄 알았지만 생각보다 폰트, 이미지, 외부 모듈 관련해서 용량을 줄이거나, 확장자 포맷 등 전략을 통해 최대한 적은 용량으로 빨리 로드되도록 개선하는 것이 매우 중요했습니다.
또한 개선된 웹 서비스를 사용해보면 확실히 사용자 입장에서 빠른 로드로 인해 더 좋은 경험을 느낄 수 있는 것 같습니다.
개선된 수치를 정리하자면 아래와 같습니다.
네트워크 load : 1.99 s -> 547 ms
빌드 시간 : 7.01 s -> 4.51 s
gzip 기준 빌드 용량 : 160.17 kB -> 112.65 kB
FCP : 7.9s -> 0.9s
LCP : 8.1s -> 1.6s
Speed Index : 10.6s -> 1.2s
마지막으로 서버에서 받아오는 이미지도 최적화를 하고 싶은데 이는 서버에서 작업이 더 맞다고 봐서 프론트에서 할 수 있는 최적화를 해봤습니다.
구글링 중에 인혁님 블로그를 발견해서 반가운 마음에 글을 읽다가
개선 과정과 방법이 너무 상세하게 정리가 잘 되어 있어서 감탄하고 갑니다!
저도 Next.js만으로 React의 단점들을 개선할 수 있어서 편리하다고 생각했었는데
나중에 이 글을 참고해서 React CSR 방식의 성능 최적화도 도전해봐야겠어요..! 좋은 글 감사합니다 👍