최근 새로운 프로젝트를 진행하며, Lighthouse CI와 Github-actions를 이용하여 어플리케이션의 성능 검사를 하고자 하면서 어플리케이션의 성능을 최대치로 끌어올릴 수 있는 방법은 어떠한 것들이 있을까에 대한 관심이 많아졌다.
Next.js를 만든 vercel은 commerce라는 베스트 프렉티스 어플리케이션 오픈 소스를 공개하고 있다
위 오픈 소스의 성능이 굉장히 뛰어나기 때문에, 나 또한 위 소스를 많이 참고하고 정독하면서 어떤 방식으로 코드를 작성했길래 퍼포먼스가 이렇게 뛰어날 수 있을까에 대해 많은 탐구를 하게 되었다.
그 중 가장 눈에 뛰는 부분 중 하나가 이번 포스팅에서 다루고자 하는 dynamic imports와 lazy-load에 관한 것이었다. 자 그럼 이제 시작해보자.
코드 분할 및 스마트 로딩 전략으로 Next.js 앱 속도를 높이는 방법.
출처 : Milica Mihajlija , vercel/with-dynamic-import
// import Puppy from "../components/Puppy";
import dynamic from "next/dynamic";
// ...
const Puppy = dynamic(import("../components/Puppy"));
앱을 처음 로드하면 index.js만 다운로드, Puppy 컴포넌트에 대한 코드가 포함되어 있지 않기 때문에 사이즈가 0.5KB 더 작다 (37.9KB에서 37.4KB로 감소).
실제 적용 시, 구성 요소가 훨씬 더 큰 경우가 많으며, 컴포넌트를 필요 시 lazyload를 활용하면 초기 JavaScript 페이로드를 수백 킬로바이트까지 줄일 수 있다.
리소스를 lazyload할 때 지연이 있는 경우에 대비하여 로드 표시기를 제공하는 것이 좋으며 Next.js에서, dynamic() 함수에 추가 인수를 제공하여 이러한 작업을 수행할 수 있다.
const Puppy = dynamic(() => import("../components/Puppy"), {
loading: () => <p>Loading...</p>
});
클라이언트 측에서만 구성 요소를 렌더링해야 하는 경우(예: 채팅 위젯) ssr 옵션을 false로 설정하여 이러한 작업을 수행할 수 있다.
const Puppy = dynamic(() => import("../components/Puppy"), {
ssr: false,
});
다음은 vercel에서 제시한 dynamic imports에 대한 example 코드
import { useState } from 'react'
import Header from '../components/Header'
import dynamic from 'next/dynamic'
const DynamicComponent1 = dynamic(() => import('../components/hello1'))
const DynamicComponent2WithCustomLoading = dynamic(
() => import('../components/hello2'),
{ loading: () => <p>Loading caused by client page transition ...</p> }
)
const DynamicComponent3WithNoSSR = dynamic(
() => import('../components/hello3'),
{ loading: () => <p>Loading ...</p>, ssr: false }
)
const DynamicComponent4 = dynamic(() => import('../components/hello4'))
const DynamicComponent5 = dynamic(() => import('../components/hello5'))
const names = ['Tim', 'Joe', 'Bel', 'Max', 'Lee']
const IndexPage = () => {
const [showMore, setShowMore] = useState(false)
const [falsyField] = useState(false)
const [results, setResults] = useState()
return (
<div>
<Header />
{/* Load immediately, but in a separate bundle */}
<DynamicComponent1 />
{/* Show a progress indicator while loading */}
<DynamicComponent2WithCustomLoading />
{/* Load only on the client side */}
<DynamicComponent3WithNoSSR />
{/* This component will never be loaded */}
{falsyField && <DynamicComponent4 />}
{/* Load on demand */}
{showMore && <DynamicComponent5 />}
<button onClick={() => setShowMore(!showMore)}>Toggle Show More</button>
{/* Load library on demand 요청 시 라이브러리 로드 */}
<div style={{ marginTop: '1rem' }}>
<input
type="text"
placeholder="Search"
onChange={async (e) => {
const { value } = e.currentTarget
// Dynamically load fuse.js
const Fuse = (await import('fuse.js')).default
const fuse = new Fuse(names)
setResults(fuse.search(value))
}}
/>
<pre>Results: {JSON.stringify(results, null, 2)}</pre>
</div>
</div>
)
}
export default IndexPage
다음은 Next.js에서 만든 commerce 어플리케이션의 코드 중 레이아웃에 관한 한 부분 코드이다.
import cn from 'clsx'
import s from './Layout.module.css'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/router'
import { CommerceProvider } from '@framework'
import LoginView from '@components/auth/LoginView'
import { useUI } from '@components/ui/context'
import { Navbar, Footer } from '@components/common'
import ShippingView from '@components/checkout/ShippingView'
import CartSidebarView from '@components/cart/CartSidebarView'
import { useAcceptCookies } from '@lib/hooks/useAcceptCookies'
import { Sidebar, Button, LoadingDots } from '@components/ui'
import PaymentMethodView from '@components/checkout/PaymentMethodView'
import CheckoutSidebarView from '@components/checkout/CheckoutSidebarView'
import { CheckoutProvider } from '@components/checkout/context'
import { MenuSidebarView } from '@components/common/UserNav'
import type { Page } from '@commerce/types/page'
import type { Category } from '@commerce/types/site'
import type { Link as LinkProps } from '../UserNav/MenuSidebarView'
const Loading = () => (
<div className="w-80 h-80 flex items-center text-center justify-center p-3">
<LoadingDots />
</div>
)
const dynamicProps = {
loading: Loading,
}
const SignUpView = dynamic(() => import('@components/auth/SignUpView'), {
...dynamicProps,
})
const ForgotPassword = dynamic(
() => import('@components/auth/ForgotPassword'),
{
...dynamicProps,
}
)
const FeatureBar = dynamic(() => import('@components/common/FeatureBar'), {
...dynamicProps,
})
const Modal = dynamic(() => import('@components/ui/Modal'), {
...dynamicProps,
ssr: false,
})
interface Props {
pageProps: {
pages?: Page[]
categories: Category[]
}
}
const ModalView: React.FC<{ modalView: string; closeModal(): any }> = ({
modalView,
closeModal,
}) => {
return (
<Modal onClose={closeModal}>
{modalView === 'LOGIN_VIEW' && <LoginView />}
{modalView === 'SIGNUP_VIEW' && <SignUpView />}
{modalView === 'FORGOT_VIEW' && <ForgotPassword />}
</Modal>
)
}
const ModalUI: React.FC = () => {
const { displayModal, closeModal, modalView } = useUI()
return displayModal ? (
<ModalView modalView={modalView} closeModal={closeModal} />
) : null
}
const SidebarView: React.FC<{
sidebarView: string
closeSidebar(): any
links: LinkProps[]
}> = ({ sidebarView, closeSidebar, links }) => {
return (
<Sidebar onClose={closeSidebar}>
{sidebarView === 'CART_VIEW' && <CartSidebarView />}
{sidebarView === 'SHIPPING_VIEW' && <ShippingView />}
{sidebarView === 'PAYMENT_VIEW' && <PaymentMethodView />}
{sidebarView === 'CHECKOUT_VIEW' && <CheckoutSidebarView />}
{sidebarView === 'MOBILE_MENU_VIEW' && <MenuSidebarView links={links} />}
</Sidebar>
)
}
const SidebarUI: React.FC<{ links: LinkProps[] }> = ({ links }) => {
const { displaySidebar, closeSidebar, sidebarView } = useUI()
return displaySidebar ? (
<SidebarView
links={links}
sidebarView={sidebarView}
closeSidebar={closeSidebar}
/>
) : null
}
const Layout: React.FC<Props> = ({
children,
pageProps: { categories = [], ...pageProps },
}) => {
const { acceptedCookies, onAcceptCookies } = useAcceptCookies()
const { locale = 'en-US' } = useRouter()
const navBarlinks = categories.slice(0, 2).map((c) => ({
label: c.name,
href: `/search/${c.slug}`,
}))
return (
<CommerceProvider locale={locale}>
<div className={cn(s.root)}>
<Navbar links={navBarlinks} />
<main className="fit">{children}</main>
<Footer pages={pageProps.pages} />
<ModalUI />
<CheckoutProvider>
<SidebarUI links={navBarlinks} />
</CheckoutProvider>
<FeatureBar
title="This site uses cookies to improve your experience. By clicking, you agree to our Privacy Policy."
hide={acceptedCookies}
action={
<Button className="mx-5" onClick={() => onAcceptCookies()}>
Accept cookies
</Button>
}
/>
</div>
</CommerceProvider>
)
}
export default Layout