[Next.js] 앱 성능 최적화를 하는 7가지 방법

windowook·2024년 12월 19일
post-thumbnail

🌱 머리말

여러분은 성능 최적화를 중요하게 생각하시나요?
아직 이 단계의 필요성을 체감하지 못한 분도 있을 것 같고, 이미 자신의 프로젝트에 성능 최적화를 완성해 본 경험이 있는 분들도 있겠죠. 오늘은 성능 최적화를 어떤 식으로 할 수 있는지 Next.js 환경에서의 방법을 얘기해보려고 합니다.

일반적으로 앱의 성능 최적화는 폰트, 이미지 다운로드 최적화와 렌더링 방식을 선택하는 작업, 코드 스플리팅, 캐시나 요청 횟수를 제한하는 로직, Critical Rendering Path를 고려한 메모이제이션 활용, Reflow를 일으키지 않도록 스타일 속성 변경 등의 작업이 필요합니다. 제가 개발했던 앱들에 최적화를 적용하면서 사용했던 방법들을 하나씩 적어보겠습니다.

🌱 이미지

Next.js에서 이미지를 사용할 때는 <Image/> 태그를 사용합니다.
Image는 기본적으로 자동 최적화 적용이 되어있는 상태입니다. Next.js에서 권장하는 방법이기도 합니다.

  • 포맷: Webp
  • 품질: 75 Default
  • 크기: 필요한 크기만 로드하여 불필요한 리소스 낭비 방지

그리고 Image는 기본적으로 Lazy Loading이 적용됩니다.
뷰포트에 이미지가 들어올 때만 로드하여 성능 향상과 초기 로드 시간을 단축하는데에 도움을 줍니다.

또, CDN과 통합되어있습니다. Vercel과 기본적으로 통합되어있는 상태라 빠른 이미지 제공이 가능합니다.

next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  webpack: config => {
    config.plugins.push(new CompressionPlugin());
    return config;
  },
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'www.youtube.com',
        port: '',
        pathname: '/embed/**',
      },
      {
        protocol: 'https',
        hostname: 'i.ytimg.com',
        port: '',
        pathname: '/vi/**',
      },
    ],
    localPatterns: [
      {
        pathname: '/images/**',
        search: '',
      },
    ],
  },
};

Next에서 외부 이미지를 연결하여 Image로 최적화된 로드를 하고 싶다면 위와 같이 config 내부에 도메인을 지정해주면 됩니다. 이게 기본적인 Image 태그의 성능이죠.

근데 여기서 Lighthouse를 이용해 성능 측정을 하면, 사이즈가 큰 이미지의 경우 Google Core Web Vitals 성능 지표 중 하나인 LCP(Largest Contentful Paint)에 안 좋은 영향을 끼칩니다. 페이지에서 가장 큰 텍스트 블록이나 이미지가 화면에 렌더링될 때까지 걸리는 시간을 측정하는 지표이므로, 사이즈가 크다면 완료 시간이 늦어지니 점수가 낮게 나오게 되죠.

priority 적용

이때 Lazy Loading이 기본적으로 적용되어 성능에 긍정적인 기여를 하는 것과 반대로 priority라는 Image 태그의 props를 설정해줍니다. priority={true} priority는 초기 렌더링 시 이미지를 즉시 로드하게 만들어줍니다. 이름에서 알 수 있듯이 렌더 우선 순위를 앞으로 당긴다고 생각하시면 됩니다. 쉽게 말하면 pre-rendering이죠.

결과적으로 LCP가 감소해 성능 점수도 같이 향상됩니다. 그렇다고 모든 Image에 priority를 남발하기 보다는 페이지에서 중요한 이미지이며 width * height의 크기를 줄이기 힘들 때 사용하면 좋습니다.

🌱 폰트

먼저 폰트에 대한 기본 개념에 대해서 얘기해보겠습니다.
폰트는 FOIT, FOUT인지에 따라 유저가 초반에 텍스트를 보는 시점이 달라집니다.

FOIT (Flash of Invisible Text)
웹 폰트가 다운로드 되기 전까지 해당 폰트가 적용된 텍스트를 보여주지 않습니다.
아무것도 보이지 않는 것입니다. 그래서 웹 페이지에 내용을 볼 수 없는 시간 동안은 그 영역만 비어보이는 문제가 있습니다.

FOUT (Flash of Unstyled Text)
웹 폰트가 다운로드 되기 전까지 해당 폰트가 적용된 텍스트에 브라우저가 시스템 폰트를 적용하여
텍스트를 보여주되, 로딩이 완료되면 웹 폰트로 스타일을 변경합니다. FOIT과 다르게 유저가 내용을 확인할 수 있지만 폰트 종류, 크기 등이 갑자기 변하는 것처럼 보여 유저에게 혼란을 줄 수 있습니다. 그래도 일반적으로 FOUT이 FOIT보다 많이 사용되는 폰트 렌더링 방식입니다. 끊기지 않는 UI/UX 플로우가 중요하기 때문이죠.

CSS의 font-display 속성은 폰트가 로딩되는 동안 브라우저에서 어떻게 처리할지를 결정하기 위해 사용하는 속성입니다.

@font-face {
  font-family: 'FreeFont';
  src: url('FreeFont.woff2') format('woff2');
  font-display: swap;
  font-weight: normal;
  font-style: normal;
}

위와 같은 방식은 FOUT을 적용했을 때의 CSS @font-face 코드입니다.
display에 설정할 수 있는 다른 값들과 효과에 대해서는 아래의 표를 참고해주시면 되겠습니다.

속성효과
auto브라우저가 기본적으로 적절한 로딩 전략을 선택
optional브라우저가 폰트 로딩에 대한 우선 순위를 설정
blockFOIT
swapFOUT
fallback웹 폰트가 로드되더라도 기본 대체 글꼴로 유지, 새로고침 후에 폰트 적용

폰트를 적용하는 방식에 관해서는 디자인 컨셉에 따라서 결정하면 됩니다.
그럼 최적화는 어떻게 하면 될까요?

preload, preconnect, dns-prefetch, next/font/local

preload

<link rel="preload" href="/test.woff2"as="font" type="font/woff2" crossorigin />

페이지 라우터의 _document, 앱 라우터의 layout 파일에 html > head 태그 내부에 위치시키는 link 태그입니다. preload를 사용하는 경우는 폰트의 경로가 로컬에 있을 경우, 서버에서 제공되는 정적 리소스일 경우 미리 로드하게 만드는 데에 사용합니다. 브라우저는 해당 폰트를 미리 다운로드하여 렌더링 시간을 단축하니 웹 폰트의 렌더링 block이 없으므로 페이지 렌더링 성능도 향상됩니다.

preconnect, dns-prefetch

@font-face {
  font-family: 'DungGeunMo';
  src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_six@1.2/DungGeunMo.woff') format('woff');
  font-weight: normal;
  font-style: normal;
  font-display: swap;
}

제가 한 프로젝트에서 메인으로 사용했던 무료 폰트인 둥근모 픽셀입니다.
현재 FOUT 방식으로 로드하고 있는 웹 폰트죠.
로컬이 아니라 CDN에서 다운로드하는 폰트의 리소스를 미리 로드하기 위해서는

  <head>
        <link rel="preconnect" href="https://fastly.jsdelivr.net" />
        <link rel="dns-prefetch" href="https://fastly.jsdelivr.net" />
  </head>

preconnect, dns-prefetch를 사용해야 합니다.
preconnect는 브라우저가 외부 리소스(CDN 등)에 미리 네트워크 연결을 설정하도록 지시합니다.
dns-prefetch는 브라우저가 지정된 도메인에 대해 DNS 조회를 미리 수행하도록 지시합니다.
둘을 설정함으로써 웹 폰트나 다른 외부 리소스가 로드될 때 초기 네트워크 설정 지연을 방지할 수 있습니다.

next/font/local

// app/layout.tsx

import localFont from 'next/font/local';

const dunggeunmo = localFont({
  src: '../public/fonts/DungGeunMo.woff2',
  display: 'swap',
  weight: '45 920',
  variable: '--font-dunggeunmo',
});

export default async function RootLayout({
  return (
	  <html lang="kr" className={`${pretendard.variable}${dunggeunmo.variable}`}></html>
  );
})

Next.js에서는 로컬에 저장된 폰트를 최적화할 수 있는 localFont라는 함수를 제공합니다.
이 함수는 Next.js 13부터 도입된 next/font 모듈의 일부로, 웹 폰트를 최적화하고 성능 및 SEO 향상에 도움을 줍니다. 사용하지 않는 폰트를 제거하고 필요한 글리프만 로딩하여 번들 크기를 줄입니다. 그리고 정적 최적화가 기본이어서 서버 사이드에서 사전 렌더링되어 초기 로딩 시간을 단축시킵니다. 결과적으로는 구글 폰트를 불러올 때보다 로딩 시간이 더 빨라 성능적으로 매우 좋습니다.

🌱 코드 스플리팅

코드 스플리팅은 웹 어플리케이션에서 필요한 코드만 로드하여 초기 로딩 속도를 향상시키고, 사용자 경험(UX)을 개선하는 기술입니다. Next.js는 기본적으로 자동 코드 스플리팅 기능을 제공하며 개발자가 이를 확장하거나 커스터마이징할 수 있는 다양한 방법도 지원합니다.

자동 코드 스플리팅

각 페이지에 필요한 코드만 번들링하여 로드합니다. 예를 들어 pages/home.jspages/about.js가 있다면 두 페이지는 서로 독립적으로 번들링됩니다. 사용자가 특정 페이지에 접근할 때 해당 페이지의 코드만 다운로드합니다.

자동 코드 스플리팅 기능으로 초기 로딩 속도가 빠릅니다. 또한 불필요한 자바스크립트 다운로드가 줄어들어 성능도 향상됩니다. 이는 UX의 품질 향상과도 직결됩니다.

동적 임포트 (Dynamic Import)

Next.js에서는 next/dynamic을 사용해서 컴포넌트를 동적으로 로드할 수 있습니다.
동적 임포트를 적용한 컴포넌트는 필요한 시점에만 로드되기 때문에 초기 로딩 시간이 줄어들고 성능이 향상됩니다.

import dynamic from 'next/dynamic';

const DynamicComponent = dynamic(() => import('../components/HeavyComponent'));

export default function Home() {
  return (
    <div>
      <h1>Home Page</h1>
      <DynamicComponent />
    </div>
  );
}

위와 같이 HeavyComponent를 동적으로 로드할 수 있습니다. 그리고 로드 방식을 설정할 수도 있습니다.

  • ssr: SSR을 사용할 것인지에 대한 여부를 설정합니다. false로 설정하면 CSR로 렌더링됩니다.
  • loading: fallback UI를 설정합니다. ex) <p>Loading...</p>

🌱 CRP를 고려한 CSS 개선

CRP는 Critical Rendeing Path, 브라우저가 HTML, CSS, 자바스크립트를 화면에 표시하는 과정을 의미합니다.
HTML/CSS → DOM/CSSOM → Render Tree → Layout → Painting의 과정을 거칩니다.

순수 CSS와 CSS 모듈, PostCSS는 모두 CSSOM 과정에서 스타일 계산이 이루어집니다. 따라서 성능상으로 보면 CSS-in-CSS 방식은 정적 파일로 로드되므로, 별도의 런타임 처리 없이 CSSOM 생성이 가능해 가장 유리해 보입니다. Tailwind CSS와 같은 CSS 프레임워크도 필요한 스타일을 미리 생성된 정적 CSS 파일에서 가져오거나 JIT 모드를 통해 빌드 시 필요한 클래스만 동적으로 생성하여 포함시킵니다.

한편 MUI, Styled Components와 같은 프레임워크 또한 많이 사용되고 있습니다. 이들은 CSS-in-JS 방식으로 분류됩니다. 정적인 CSS 파일을 로드하지 않고, 자바스크립트 코드에서 스타일을 정의하며 컴파일 후 브라우저가 파싱하는 과정에서 CSS가 동적으로 생성됩니다. 아래는 CSS-in-JS 방식의 프레임워크를 사용할 때 주의해야 할 점에 대해 제가 사용하며 겪었던 경험을 근거로 설명해보겠습니다.

MUI/sx props

MUI에서 제공하는 컴포넌트를 다수 활용했던 프로젝트를 예로 들겠습니다. 이 프로젝트는 크립토 서비스를 구현한 사례로, 차트 UI를 제외한 대부분의 컴포넌트를 MUI로부터 가져와 사용했습니다. 기능 개발과 스타일링 작업을 모두 완료한 뒤, Lighthouse를 이용해 렌더링 성능을 측정했는데, 모든 페이지가 60~70점대의 점수를 기록하는 충격적인 결과가 나왔습니다. 특히 네트워크 요청이 많지 않은 페이지에서도 요소가 눈에 띄게 느리게 렌더링되는 현상이 관찰되었습니다.

Lighthouse의 성능 분석 결과와 하단 메시지를 통해 몇 가지 주요 원인을 파악할 수 있었습니다. 그중 가장 큰 문제는 MUI에서 제공하는 sx props 시스템의 과도한 사용으로, CSS-in-JS 방식이 남용되었기 때문이었습니다.

MUI의 공식 문서에서는 sx props를 다음과 같이 설명하고 있습니다.

The sx prop is a shortcut for defining custom style that has access to the theme.

즉, 테마에 접근 가능한 사용자 정의 스타일을 정의하기 위한 단축키입니다.
sx props를 사용하면 정해진 포맷에 따라 스타일 프로퍼티를 지정하여 간단히 적용할 수 있습니다. 예를 들어, HTML 태그 내부에서 스타일을 바로 선언할 수 있어 별도의 CSS 파일을 작성할 필요가 없습니다. 이 점은 문법이 간단하고 접근성이 높아 Tailwind CSS와 유사한 개발자 경험을 제공합니다. 아래는 공식 페이지의 예시 코드입니다.

	<Box
     sx={{
     color: 'success.dark',
     display: 'inline',
     fontWeight: 'medium',
     mx: 0.5,
     }}
   	>

하지만, sx props는 CSS-in-JS 방식으로 동작하기 때문에 성능 상의 한계가 존재합니다.
CSS-in-JS는 컴포넌트 렌더링 시점에 자바스크립트 코드로 스타일을 생성하고 이를 DOM에 삽입합니다. 그 후 브라우저는 해당 스타일을 기반으로 다시 CSSOM(CSS Object Model)을 생성하여 렌더 트리에 스타일을 추가합니다. 이 과정이 렌더링 중에 반복적으로 발생하면, 브라우저에서 불필요한 계산이 빈번하게 이루어지며 렌더링 오버헤드가 발생하게 됩니다.

프로젝트에서 sx를 과도하게 사용한 결과 페이지 내 다수의 컴포넌트에서 이러한 과정이 중복적이고 빈번하게 실행되었고 렌더링 성능에 심각한 영향을 미쳤습니다. Lighthouse 점수가 낮아진 주된 이유는 이러한 렌더링 오버헤드 때문이었으며, 이는 UX에 직접적으로 부정적인 영향을 주었습니다.

우선으로 HTML 태그와 Tailwind CSS로 동일한 UI를 구현할 수 있는 부분은 모두 대체하였습니다.
Tailwind는 CSS가 정적으로 계산되기 때문에 렌더링 오버헤드를 줄이면서도 빠른 스타일 적용이 가능합니다.

차선으로는 MUI System의 styled를 사용했습니다. styled는 Styled Components와 거의 동일하며, 여전히 CSS-in-JS 방식으로 동작하지만 sx props와 달리 스타일이 컴포넌트 정의 시점에 한 번만 계산됩니다. 즉 컴포넌트가 렌더링될 때마다 스타일을 다시 계산하지 않으며, 정의된 스타일은 재사용 가능한 사용자 정의 컴포넌트로 활용할 수 있습니다. 이로 인해 성능과 재사용성 측면에서 sx props에 비해 월등히 효율적입니다.

다만, sx props를 완전히 배제하지는 않았습니다. MUI 컴포넌트에 기본 제공되는 props가 개발자가 원하는 옵션을 제공하지 않을 때, 일회성으로 스타일을 적용해야 하는 경우에는 sx props를 사용하면 여전히 유용한 도구가 될 수 있습니다. 따라서 프로젝트의 요구사항과 사용 목적에 따라 sx와 styled를 적절히 병행하여 사용하는 것이 중요합니다.

🌱 렌더링 방식 변경

Next.js에서는 데이터 페칭 메서드를 사용하지 않는 페이지 컴포넌트는 CSR입니다.
데이터 페칭 메서드를 사용하면 렌더링 방식이 변경됩니다.
(해당 되는 내용은 Pages Router를 사용할 때입니다. App Router 방식에서는 'use server', 'use client'를 사용하여 서버 컴포넌트와 클라이언트 컴포넌트를 명확히 구분하는데 이때도 명시하지 않은 컴포넌트는 기본적으로 CSR입니다.)

  • getServerSideProps: SSR
  • getStaticProps : SSG, ISR

공통점은 모두 서버 사이드에서 HTML을 생성한다는 것입니다. 하지만 차이점이 있죠.

getServerSideProps는 요청이 들어올 때마다 서버에서 HTML을 생성합니다.
항상 최신 데이터를 제공하지만, 서버 요청마다 HTML을 다시 생성하기 때문에 초기 로딩 시간이 길어질 수 있습니다. 실시간 데이터가 필요하거나 사용자별 데이터를 렌더링해야 할 때 적합합니다.

getStaticProps는 빌드 시 HTML을 생성하여 정적 파일로 제공되기 때문에 초기 로딩 속도가 매우 빠릅니다. 하지만 정적 데이터만 제공되므로, 동적인 데이터 갱신이 필요할 경우 revalidate 옵션을 사용해 ISR을 활용할 수 있습니다. 이를 통해 지정된 시간 간격으로 페이지를 재생성하여 최신 데이터를 제공할 수 있습니다.

제가 개발한 미스터 크립(Mr.cryp)에서는 한 페이지 컴포넌트가 원래 getServerSideProps를 사용하고 있었습니다. API 라우트를 호출하여 응답받은 데이터를 반환했는데, 해당 데이터는 실시간 변동이 없는 데이터였습니다. 따라서 컴포넌트가 렌더링될 때마다 데이터를 새로 가져오는 방식은 비효율적이었으며, 이를 getStaticPropsrevalidate를 설정하여 데이터를 반환하는 방식으로 변경했습니다.

변경 후 성능 측정을 진행한 결과, 해당 페이지의 FCP(First Contentful Paint)가 0.9초에서 0.2초로 단축되었고, SI(Speed Index) 또한 3.6초에서 0.4초로 크게 향상되었습니다. 각각 4.5배와 9배의 성능 개선을 이루었습니다. 적절한 렌더링 방식을 선택하는 것이 성능 최적화에 얼마나 중요한지를 보여줍니다.

🌱 네트워크 최적화(캐싱, 쓰로틀링/디바운스)

캐싱

리액트 쿼리를 활용하여 서버로부터 응답받은 데이터를 캐싱합니다.

 queryKey: ['쿼리 키'],
 queryFn: async () => {콜백 함수()},
 enabled: 활성화 조건
 staleTime: 1000 * 60 * 3,
 gcTime: 1000 * 60 * 5,

위와 같은 파라미터를 useQuery 훅을 사용할 때 넘겨줍니다.
각 파라미터에 대한 자세한 설명은 Tanstack Query 공식 문서를 보면 알 수 있습니다.
여기서는 캐싱을 함으로써 얻을 수 있는 성능적인 이득에 대해서만 설명하겠습니다.

네트워크 요청 감소

캐싱을 통해 이미 서버에서 가져온 데이터를 로컬 스토리지(메모리)에 저장함으로써,
동일한 데이터에 대해 중복된 네트워크 요청을 방지합니다.
이로 인해 네트워크 트래픽이 줄어들고 서버 부하가 감소하여 전반적인 성능 향상이 이루어집니다.

빠른 데이터 접근

staleTime을 활용해 데이터가 일정 시간 동안은 신선한 상태로 간주되도록 설정할 수 있습니다.
이로 인해 서버 데이터를 자주 갱신할 필요가 없어 효율적인 상태 관리가 가능하며, 사용자가 변화를 느끼지 못하도록 매끄러운 경험을 제공합니다.

데이터 일관성 유지

서버 데이터를 자주 갱신할 필요가 없어 효율적인 상태 관리가 가능하며, 사용자가 변화를 느끼지 못하도록 매끄러운 경험을 제공합니다.

필요한 시점에만 데이터 갱신

enabled 조건을 사용하면 특정 조건이 충족될 때만 데이터를 가져오도록 설정할 수 있습니다.
이를 통해 불필요한 데이터 갱신을 최소화하고, 애플리케이션 성능을 최적화할 수 있습니다.

메모리 관리 및 효율성

gcTime(가비지 컬렉션 시간)을 설정하면, 일정 시간 동안 사용되지 않은 데이터는 메모리에서 제거됩니다. 이를 통해 캐시 데이터가 무한히 쌓이지 않도록 방지하며, 애플리케이션의 메모리 사용량을 최적화합니다.

위와 같은 주요 혜택을 얻을 수 있기 때문에 서버와의 요청 응답을 주고 받는다면 캐싱은 반필수적으로 사용하는 것이 좋습니다.

쓰로틀링/디바운스

쓰로틀링과 디바운스는 모두 서버로의 요청을 줄여 부하를 감소시키고, 결과적으로 비용을 절감하는 데 활용되는 기술입니다. 이 부분에 대한 자세한 내용에 대해서는 제가 예전에 올렸던 포스트를 참고해주시면 좋을 것 같습니다.

[JS] 쓰로틀링 & 디바운스
https://velog.io/@windowook/JS-쓰로틀링-디바운스


성능 최적화는 애플리케이션의 완벽함을 더하는 필수적인 과정입니다. 멋진 UI와 좋은 성능을 가진 애플리케이션... 얼마나 좋나요ㅎㅎ

profile
안녕하세요

0개의 댓글